kopia lustrzana https://github.com/meshtastic/Meshtastic-Android
				
				
				
			feat(bluetooth): expose and display bluetooth signal strength (RSSI) (#3235)
Signed-off-by: James Rich <2199651+jamesarich@users.noreply.github.com>pull/3237/head
							rodzic
							
								
									00e9be0919
								
							
						
					
					
						commit
						92202e3ebf
					
				| 
						 | 
				
			
			@ -50,7 +50,6 @@ fun NavGraphBuilder.connectionsGraph(navController: NavHostController) {
 | 
			
		|||
                        restoreState = true
 | 
			
		||||
                    }
 | 
			
		||||
                },
 | 
			
		||||
                onNavigateToSettings = { navController.navigate(SettingsRoutes.Settings()) },
 | 
			
		||||
                onNavigateToNodeDetails = { navController.navigate(NodesRoutes.NodeDetailGraph(it)) },
 | 
			
		||||
                onConfigNavigate = { route -> navController.navigate(route) },
 | 
			
		||||
            )
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -36,6 +36,8 @@ import dagger.assisted.AssistedInject
 | 
			
		|||
import kotlinx.coroutines.CancellationException
 | 
			
		||||
import kotlinx.coroutines.Job
 | 
			
		||||
import kotlinx.coroutines.delay
 | 
			
		||||
import kotlinx.coroutines.flow.MutableStateFlow
 | 
			
		||||
import kotlinx.coroutines.flow.StateFlow
 | 
			
		||||
import org.meshtastic.core.model.util.anonymize
 | 
			
		||||
import java.lang.reflect.Method
 | 
			
		||||
import java.util.UUID
 | 
			
		||||
| 
						 | 
				
			
			@ -137,6 +139,42 @@ constructor(
 | 
			
		|||
 | 
			
		||||
    private lateinit var fromNum: BluetoothGattCharacteristic
 | 
			
		||||
 | 
			
		||||
    // RSSI flow & polling job (null when unavailable / disconnected)
 | 
			
		||||
    private val _rssiFlow = MutableStateFlow<Int?>(null)
 | 
			
		||||
    val rssiFlow: StateFlow<Int?> = _rssiFlow
 | 
			
		||||
 | 
			
		||||
    @Volatile private var rssiPollingJob: Job? = null
 | 
			
		||||
 | 
			
		||||
    // Start polling RSSI every 5 seconds (immediate first read)
 | 
			
		||||
    @Suppress("MagicNumber", "LoopWithTooManyJumpStatements")
 | 
			
		||||
    private fun startRssiPolling() {
 | 
			
		||||
        rssiPollingJob?.cancel()
 | 
			
		||||
        val s = safe ?: return
 | 
			
		||||
        // Immediate read for faster UI update
 | 
			
		||||
        s.asyncReadRemoteRssi { first -> first.getOrNull()?.let { _rssiFlow.value = it } }
 | 
			
		||||
        rssiPollingJob =
 | 
			
		||||
            service.serviceScope.handledLaunch {
 | 
			
		||||
                while (true) {
 | 
			
		||||
                    try {
 | 
			
		||||
                        delay(5000)
 | 
			
		||||
                        if (safe == null) break
 | 
			
		||||
                        safe?.asyncReadRemoteRssi { res -> res.getOrNull()?.let { _rssiFlow.value = it } }
 | 
			
		||||
                    } catch (ex: CancellationException) {
 | 
			
		||||
                        break
 | 
			
		||||
                    } catch (ex: Exception) {
 | 
			
		||||
                        debug("RSSI polling error: ${ex.message}")
 | 
			
		||||
                    }
 | 
			
		||||
                }
 | 
			
		||||
            }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    // Stop polling and clear current value
 | 
			
		||||
    private fun stopRssiPolling() {
 | 
			
		||||
        rssiPollingJob?.cancel()
 | 
			
		||||
        rssiPollingJob = null
 | 
			
		||||
        _rssiFlow.value = null
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * With the new rev2 api, our first send is to start the configure readbacks. In that case, rather than waiting for
 | 
			
		||||
     * FromNum notifies - we try to just aggressively read all of the responses.
 | 
			
		||||
| 
						 | 
				
			
			@ -201,6 +239,7 @@ constructor(
 | 
			
		|||
 | 
			
		||||
    /** We had some problem, schedule a reconnection attempt (if one isn't already queued) */
 | 
			
		||||
    private fun scheduleReconnect(reason: String) {
 | 
			
		||||
        stopRssiPolling()
 | 
			
		||||
        if (reconnectJob == null) {
 | 
			
		||||
            warn("Scheduling reconnect because $reason")
 | 
			
		||||
            reconnectJob = service.serviceScope.handledLaunch { retryDueToException() }
 | 
			
		||||
| 
						 | 
				
			
			@ -391,6 +430,7 @@ constructor(
 | 
			
		|||
 | 
			
		||||
        service.serviceScope.handledLaunch {
 | 
			
		||||
            info("Connected to radio!")
 | 
			
		||||
            startRssiPolling()
 | 
			
		||||
 | 
			
		||||
            if (
 | 
			
		||||
                needForceRefresh
 | 
			
		||||
| 
						 | 
				
			
			@ -431,6 +471,7 @@ constructor(
 | 
			
		|||
 | 
			
		||||
    override fun close() {
 | 
			
		||||
        reconnectJob?.cancel() // Cancel any queued reconnect attempts
 | 
			
		||||
        stopRssiPolling()
 | 
			
		||||
 | 
			
		||||
        if (safe != null) {
 | 
			
		||||
            info("Closing BluetoothInterface")
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -97,6 +97,10 @@ constructor(
 | 
			
		|||
 | 
			
		||||
    private var radioIf: IRadioInterface = NopInterface("")
 | 
			
		||||
 | 
			
		||||
    // Expose current bluetooth RSSI (null if not connected or not BLE)
 | 
			
		||||
    private val _bluetoothRssi = MutableStateFlow<Int?>(null)
 | 
			
		||||
    val bluetoothRssi: StateFlow<Int?> = _bluetoothRssi.asStateFlow()
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * true if we have started our interface
 | 
			
		||||
     *
 | 
			
		||||
| 
						 | 
				
			
			@ -254,6 +258,13 @@ constructor(
 | 
			
		|||
                }
 | 
			
		||||
 | 
			
		||||
                radioIf = interfaceFactory.createInterface(address)
 | 
			
		||||
 | 
			
		||||
                // If the new interface is bluetooth, collect its RSSI flow
 | 
			
		||||
                if (radioIf is BluetoothInterface) {
 | 
			
		||||
                    (radioIf as BluetoothInterface).rssiFlow.onEach { _bluetoothRssi.emit(it) }.launchIn(serviceScope)
 | 
			
		||||
                } else {
 | 
			
		||||
                    _bluetoothRssi.value = null
 | 
			
		||||
                }
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
| 
						 | 
				
			
			@ -280,6 +291,7 @@ constructor(
 | 
			
		|||
        if (r !is NopInterface) {
 | 
			
		||||
            onDisconnect(isPermanent = true) // Tell any clients we are now offline
 | 
			
		||||
        }
 | 
			
		||||
        _bluetoothRssi.value = null
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -325,6 +325,7 @@ class MeshService :
 | 
			
		|||
        serviceScope.handledLaunch { radioInterfaceService.connect() }
 | 
			
		||||
        radioInterfaceService.connectionState.onEach(::onRadioConnectionState).launchIn(serviceScope)
 | 
			
		||||
        radioInterfaceService.receivedData.onEach(::onReceiveFromRadio).launchIn(serviceScope)
 | 
			
		||||
        radioInterfaceService.bluetoothRssi.onEach { serviceRepository.setBluetoothRssi(it) }.launchIn(serviceScope)
 | 
			
		||||
        radioConfigRepository.localConfigFlow.onEach { localConfig = it }.launchIn(serviceScope)
 | 
			
		||||
        radioConfigRepository.moduleConfigFlow.onEach { moduleConfig = it }.launchIn(serviceScope)
 | 
			
		||||
        radioConfigRepository.channelSetFlow.onEach { channelSet = it }.launchIn(serviceScope)
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -317,6 +317,11 @@ class SafeBluetooth(private val context: Context, private val device: BluetoothD
 | 
			
		|||
            override fun onDescriptorRead(gatt: BluetoothGatt, descriptor: BluetoothGattDescriptor, status: Int) {
 | 
			
		||||
                completeWork(status, descriptor)
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            // Added: callback for remote RSSI reads
 | 
			
		||||
            override fun onReadRemoteRssi(gatt: BluetoothGatt, rssi: Int, status: Int) {
 | 
			
		||||
                completeWork(status, rssi)
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
    // To test loss of BLE faults we can randomly fail a certain % of all work items.  We
 | 
			
		||||
| 
						 | 
				
			
			@ -654,6 +659,14 @@ class SafeBluetooth(private val context: Context, private val device: BluetoothD
 | 
			
		|||
    fun asyncWriteDescriptor(c: BluetoothGattDescriptor, cb: (Result<BluetoothGattDescriptor>) -> Unit) =
 | 
			
		||||
        queueWriteDescriptor(c, CallbackContinuation(cb))
 | 
			
		||||
 | 
			
		||||
    // Added: Support reading remote RSSI
 | 
			
		||||
    private fun queueReadRemoteRssi(cont: Continuation<Int>, timeout: Long = 0) =
 | 
			
		||||
        queueWork("readRSSI", cont, timeout) { gatt?.readRemoteRssi() ?: false }
 | 
			
		||||
 | 
			
		||||
    fun asyncReadRemoteRssi(cb: (Result<Int>) -> Unit) = queueReadRemoteRssi(CallbackContinuation(cb))
 | 
			
		||||
 | 
			
		||||
    fun readRemoteRssi(timeout: Long = timeoutMsec): Int = makeSync { queueReadRemoteRssi(it, timeout) }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Some old androids have a bug where calling disconnect doesn't guarantee that the onConnectionStateChange callback
 | 
			
		||||
     * gets called but the only safe way to call gatt.close is from that callback. So we set a flag once we start
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -50,6 +50,15 @@ class ServiceRepository @Inject constructor() : Logging {
 | 
			
		|||
        _connectionState.value = connectionState
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    // Current bluetooth link RSSI (dBm). Null if not connected or not a bluetooth interface.
 | 
			
		||||
    private val _bluetoothRssi = MutableStateFlow<Int?>(null)
 | 
			
		||||
    val bluetoothRssi: StateFlow<Int?>
 | 
			
		||||
        get() = _bluetoothRssi
 | 
			
		||||
 | 
			
		||||
    fun setBluetoothRssi(rssi: Int?) {
 | 
			
		||||
        _bluetoothRssi.value = rssi
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    private val _clientNotification = MutableStateFlow<MeshProtos.ClientNotification?>(null)
 | 
			
		||||
    val clientNotification: StateFlow<MeshProtos.ClientNotification?>
 | 
			
		||||
        get() = _clientNotification
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -103,7 +103,6 @@ fun ConnectionsScreen(
 | 
			
		|||
    scanModel: BTScanModel = hiltViewModel(),
 | 
			
		||||
    radioConfigViewModel: RadioConfigViewModel = hiltViewModel(),
 | 
			
		||||
    onClickNodeChip: (Int) -> Unit,
 | 
			
		||||
    onNavigateToSettings: () -> Unit,
 | 
			
		||||
    onNavigateToNodeDetails: (Int) -> Unit,
 | 
			
		||||
    onConfigNavigate: (Route) -> Unit,
 | 
			
		||||
) {
 | 
			
		||||
| 
						 | 
				
			
			@ -120,6 +119,7 @@ fun ConnectionsScreen(
 | 
			
		|||
    val selectedDevice by scanModel.selectedNotNullFlow.collectAsStateWithLifecycle()
 | 
			
		||||
    val bluetoothState by connectionsViewModel.bluetoothState.collectAsStateWithLifecycle()
 | 
			
		||||
    val regionUnset = config.lora.region == ConfigProtos.Config.LoRaConfig.RegionCode.UNSET
 | 
			
		||||
    val bluetoothRssi by connectionsViewModel.bluetoothRssi.collectAsStateWithLifecycle()
 | 
			
		||||
 | 
			
		||||
    val bleDevices by scanModel.bleDevicesForUi.collectAsStateWithLifecycle()
 | 
			
		||||
    val discoveredTcpDevices by scanModel.discoveredTcpDevicesForUi.collectAsStateWithLifecycle()
 | 
			
		||||
| 
						 | 
				
			
			@ -222,8 +222,8 @@ fun ConnectionsScreen(
 | 
			
		|||
                                        node = node,
 | 
			
		||||
                                        onNavigateToNodeDetails = onNavigateToNodeDetails,
 | 
			
		||||
                                        onSetShowSharedContact = { showSharedContact = it },
 | 
			
		||||
                                        onNavigateToSettings = onNavigateToSettings,
 | 
			
		||||
                                        onClickDisconnect = { scanModel.disconnect() },
 | 
			
		||||
                                        bluetoothRssi = bluetoothRssi,
 | 
			
		||||
                                    )
 | 
			
		||||
                                }
 | 
			
		||||
                            }
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -60,6 +60,9 @@ constructor(
 | 
			
		|||
 | 
			
		||||
    val bluetoothState = bluetoothRepository.state
 | 
			
		||||
 | 
			
		||||
    // Newly added: bluetooth RSSI stream (dBm, null if unavailable)
 | 
			
		||||
    val bluetoothRssi = serviceRepository.bluetoothRssi
 | 
			
		||||
 | 
			
		||||
    private val _hasShownNotPairedWarning = MutableStateFlow(uiPrefs.hasShownNotPairedWarning)
 | 
			
		||||
    val hasShownNotPairedWarning: StateFlow<Boolean> = _hasShownNotPairedWarning.asStateFlow()
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -23,13 +23,10 @@ import androidx.compose.foundation.layout.Row
 | 
			
		|||
import androidx.compose.foundation.layout.fillMaxWidth
 | 
			
		||||
import androidx.compose.foundation.layout.height
 | 
			
		||||
import androidx.compose.foundation.layout.padding
 | 
			
		||||
import androidx.compose.material.icons.Icons
 | 
			
		||||
import androidx.compose.material.icons.filled.Settings
 | 
			
		||||
import androidx.compose.material3.Button
 | 
			
		||||
import androidx.compose.material3.ButtonDefaults
 | 
			
		||||
import androidx.compose.material3.Icon
 | 
			
		||||
import androidx.compose.material3.IconButton
 | 
			
		||||
import androidx.compose.material3.MaterialTheme
 | 
			
		||||
import androidx.compose.material3.Surface
 | 
			
		||||
import androidx.compose.material3.Text
 | 
			
		||||
import androidx.compose.runtime.Composable
 | 
			
		||||
import androidx.compose.ui.Alignment
 | 
			
		||||
| 
						 | 
				
			
			@ -48,19 +45,31 @@ import com.geeksville.mesh.ui.node.components.NodeMenuAction
 | 
			
		|||
import org.meshtastic.core.database.model.Node
 | 
			
		||||
import org.meshtastic.core.strings.R
 | 
			
		||||
import org.meshtastic.core.ui.component.MaterialBatteryInfo
 | 
			
		||||
import org.meshtastic.core.ui.component.MaterialBluetoothSignalInfo
 | 
			
		||||
import org.meshtastic.core.ui.theme.AppTheme
 | 
			
		||||
import org.meshtastic.core.ui.theme.StatusColors.StatusRed
 | 
			
		||||
 | 
			
		||||
/** Converts Bluetooth RSSI to a 0-4 bar signal strength level. */
 | 
			
		||||
@Composable
 | 
			
		||||
fun CurrentlyConnectedInfo(
 | 
			
		||||
    node: Node,
 | 
			
		||||
    onNavigateToNodeDetails: (Int) -> Unit,
 | 
			
		||||
    onSetShowSharedContact: (Node) -> Unit,
 | 
			
		||||
    onNavigateToSettings: () -> Unit,
 | 
			
		||||
    onClickDisconnect: () -> Unit,
 | 
			
		||||
    modifier: Modifier = Modifier,
 | 
			
		||||
    bluetoothRssi: Int? = null,
 | 
			
		||||
) {
 | 
			
		||||
    Column(modifier = modifier) {
 | 
			
		||||
        Row(
 | 
			
		||||
            modifier = Modifier.fillMaxWidth().padding(start = 16.dp, end = 16.dp, top = 8.dp),
 | 
			
		||||
            horizontalArrangement = Arrangement.SpaceBetween,
 | 
			
		||||
            verticalAlignment = Alignment.CenterVertically,
 | 
			
		||||
        ) {
 | 
			
		||||
            MaterialBatteryInfo(level = node.batteryLevel)
 | 
			
		||||
            if (bluetoothRssi != null) {
 | 
			
		||||
                MaterialBluetoothSignalInfo(rssi = bluetoothRssi)
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
        Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
 | 
			
		||||
            Column(
 | 
			
		||||
                modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
 | 
			
		||||
| 
						 | 
				
			
			@ -79,8 +88,6 @@ fun CurrentlyConnectedInfo(
 | 
			
		|||
                        }
 | 
			
		||||
                    },
 | 
			
		||||
                )
 | 
			
		||||
 | 
			
		||||
                MaterialBatteryInfo(level = node.batteryLevel)
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            Column(modifier = Modifier.weight(1f, fill = true)) {
 | 
			
		||||
| 
						 | 
				
			
			@ -95,13 +102,6 @@ fun CurrentlyConnectedInfo(
 | 
			
		|||
                    )
 | 
			
		||||
                }
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            IconButton(enabled = true, onClick = onNavigateToSettings) {
 | 
			
		||||
                Icon(
 | 
			
		||||
                    imageVector = Icons.Default.Settings,
 | 
			
		||||
                    contentDescription = stringResource(id = R.string.radio_configuration),
 | 
			
		||||
                )
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        Button(
 | 
			
		||||
| 
						 | 
				
			
			@ -124,23 +124,26 @@ fun CurrentlyConnectedInfo(
 | 
			
		|||
@Composable
 | 
			
		||||
private fun CurrentlyConnectedInfoPreview() {
 | 
			
		||||
    AppTheme {
 | 
			
		||||
        CurrentlyConnectedInfo(
 | 
			
		||||
            node =
 | 
			
		||||
            Node(
 | 
			
		||||
                num = 13444,
 | 
			
		||||
                user = MeshProtos.User.newBuilder().setShortName("\uD83E\uDEE0").setLongName("John Doe").build(),
 | 
			
		||||
                isIgnored = false,
 | 
			
		||||
                paxcounter = PaxcountProtos.Paxcount.newBuilder().setBle(10).setWifi(5).build(),
 | 
			
		||||
                environmentMetrics =
 | 
			
		||||
                TelemetryProtos.EnvironmentMetrics.newBuilder()
 | 
			
		||||
                    .setTemperature(25f)
 | 
			
		||||
                    .setRelativeHumidity(60f)
 | 
			
		||||
                    .build(),
 | 
			
		||||
            ),
 | 
			
		||||
            onNavigateToNodeDetails = {},
 | 
			
		||||
            onSetShowSharedContact = {},
 | 
			
		||||
            onNavigateToSettings = {},
 | 
			
		||||
            onClickDisconnect = {},
 | 
			
		||||
        )
 | 
			
		||||
        Surface {
 | 
			
		||||
            CurrentlyConnectedInfo(
 | 
			
		||||
                node =
 | 
			
		||||
                Node(
 | 
			
		||||
                    num = 13444,
 | 
			
		||||
                    user =
 | 
			
		||||
                    MeshProtos.User.newBuilder().setShortName("\uD83E\uDEE0").setLongName("John Doe").build(),
 | 
			
		||||
                    isIgnored = false,
 | 
			
		||||
                    paxcounter = PaxcountProtos.Paxcount.newBuilder().setBle(10).setWifi(5).build(),
 | 
			
		||||
                    environmentMetrics =
 | 
			
		||||
                    TelemetryProtos.EnvironmentMetrics.newBuilder()
 | 
			
		||||
                        .setTemperature(25f)
 | 
			
		||||
                        .setRelativeHumidity(60f)
 | 
			
		||||
                        .build(),
 | 
			
		||||
                ),
 | 
			
		||||
                bluetoothRssi = -75, // Example RSSI for signal preview
 | 
			
		||||
                onNavigateToNodeDetails = {},
 | 
			
		||||
                onSetShowSharedContact = {},
 | 
			
		||||
                onClickDisconnect = {},
 | 
			
		||||
            )
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -918,4 +918,5 @@
 | 
			
		|||
    <string name="one_day">24 Hours</string>
 | 
			
		||||
    <string name="two_days">48 Hours</string>
 | 
			
		||||
    <string name="last_heard_filter_label">Filter by Last Heard time: %s</string>
 | 
			
		||||
    <string name="dbm_value">%1$d dBm</string>
 | 
			
		||||
</resources>
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -0,0 +1,141 @@
 | 
			
		|||
/*
 | 
			
		||||
 * Copyright (c) 2025 Meshtastic LLC
 | 
			
		||||
 *
 | 
			
		||||
 * This program is free software: you can redistribute it and/or modify
 | 
			
		||||
 * it under the terms of the GNU General Public License as published by
 | 
			
		||||
 * the Free Software Foundation, either version 3 of the License, or
 | 
			
		||||
 * (at your option) any later version.
 | 
			
		||||
 *
 | 
			
		||||
 * This program is distributed in the hope that it will be useful,
 | 
			
		||||
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 | 
			
		||||
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 | 
			
		||||
 * GNU General Public License for more details.
 | 
			
		||||
 *
 | 
			
		||||
 * You should have received a copy of the GNU General Public License
 | 
			
		||||
 * along with this program.  If not, see <https://www.gnu.org/licenses/>.
 | 
			
		||||
 */
 | 
			
		||||
 | 
			
		||||
package org.meshtastic.core.ui.component
 | 
			
		||||
 | 
			
		||||
import androidx.compose.foundation.layout.Arrangement
 | 
			
		||||
import androidx.compose.foundation.layout.Row
 | 
			
		||||
import androidx.compose.foundation.layout.size
 | 
			
		||||
import androidx.compose.material.icons.Icons
 | 
			
		||||
import androidx.compose.material.icons.rounded.Bluetooth
 | 
			
		||||
import androidx.compose.material.icons.rounded.SignalCellularOff
 | 
			
		||||
import androidx.compose.material3.Icon
 | 
			
		||||
import androidx.compose.material3.MaterialTheme
 | 
			
		||||
import androidx.compose.material3.Surface
 | 
			
		||||
import androidx.compose.material3.Text
 | 
			
		||||
import androidx.compose.runtime.Composable
 | 
			
		||||
import androidx.compose.ui.Alignment
 | 
			
		||||
import androidx.compose.ui.Modifier
 | 
			
		||||
import androidx.compose.ui.draw.drawWithContent
 | 
			
		||||
import androidx.compose.ui.geometry.Size
 | 
			
		||||
import androidx.compose.ui.graphics.ColorFilter
 | 
			
		||||
import androidx.compose.ui.graphics.vector.ImageVector
 | 
			
		||||
import androidx.compose.ui.graphics.vector.rememberVectorPainter
 | 
			
		||||
import androidx.compose.ui.res.stringResource
 | 
			
		||||
import androidx.compose.ui.tooling.preview.PreviewLightDark
 | 
			
		||||
import androidx.compose.ui.tooling.preview.PreviewParameter
 | 
			
		||||
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
 | 
			
		||||
import androidx.compose.ui.unit.dp
 | 
			
		||||
import org.meshtastic.core.strings.R
 | 
			
		||||
import org.meshtastic.core.ui.icon.MeshtasticIcons
 | 
			
		||||
import org.meshtastic.core.ui.icon.SignalCellular0Bar
 | 
			
		||||
import org.meshtastic.core.ui.icon.SignalCellular1Bar
 | 
			
		||||
import org.meshtastic.core.ui.icon.SignalCellular2Bar
 | 
			
		||||
import org.meshtastic.core.ui.icon.SignalCellular3Bar
 | 
			
		||||
import org.meshtastic.core.ui.icon.SignalCellular4Bar
 | 
			
		||||
import org.meshtastic.core.ui.theme.AppTheme
 | 
			
		||||
import org.meshtastic.core.ui.theme.StatusColors.StatusGreen
 | 
			
		||||
import org.meshtastic.core.ui.theme.StatusColors.StatusOrange
 | 
			
		||||
import org.meshtastic.core.ui.theme.StatusColors.StatusRed
 | 
			
		||||
import org.meshtastic.core.ui.theme.StatusColors.StatusYellow
 | 
			
		||||
 | 
			
		||||
private const val SIZE_ICON = 20
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * A composable that displays a signal strength indicator with an icon and optional text value. The icon and its color
 | 
			
		||||
 * change based on the number of signal bars.
 | 
			
		||||
 *
 | 
			
		||||
 * @param modifier Modifier for this composable.
 | 
			
		||||
 * @param signalBars The number of signal bars, typically from 0 to 4. Values outside this range (e.g., < 0) will
 | 
			
		||||
 *   display a "signal off" or unknown state icon.
 | 
			
		||||
 * @param signalStrengthValue Optional text to display next to the icon, such as dBm or SNR value.
 | 
			
		||||
 */
 | 
			
		||||
@Suppress("MagicNumber")
 | 
			
		||||
@Composable
 | 
			
		||||
fun MaterialSignalInfo(
 | 
			
		||||
    signalBars: Int,
 | 
			
		||||
    modifier: Modifier = Modifier,
 | 
			
		||||
    signalStrengthValue: String? = null,
 | 
			
		||||
    typeIcon: ImageVector? = null,
 | 
			
		||||
) {
 | 
			
		||||
    Row(
 | 
			
		||||
        modifier = modifier,
 | 
			
		||||
        verticalAlignment = Alignment.CenterVertically,
 | 
			
		||||
        horizontalArrangement = Arrangement.spacedBy(2.dp),
 | 
			
		||||
    ) {
 | 
			
		||||
        val (iconVector, iconTint) =
 | 
			
		||||
            when (signalBars) {
 | 
			
		||||
                0 -> MeshtasticIcons.SignalCellular0Bar to MaterialTheme.colorScheme.StatusRed
 | 
			
		||||
                1 -> MeshtasticIcons.SignalCellular1Bar to MaterialTheme.colorScheme.StatusRed
 | 
			
		||||
                2 -> MeshtasticIcons.SignalCellular2Bar to MaterialTheme.colorScheme.StatusOrange
 | 
			
		||||
                3 -> MeshtasticIcons.SignalCellular3Bar to MaterialTheme.colorScheme.StatusYellow
 | 
			
		||||
                4 -> MeshtasticIcons.SignalCellular4Bar to MaterialTheme.colorScheme.StatusGreen
 | 
			
		||||
                else -> Icons.Rounded.SignalCellularOff to MaterialTheme.colorScheme.onSurfaceVariant
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
        val foregroundPainter = typeIcon?.let { rememberVectorPainter(typeIcon) }
 | 
			
		||||
        Icon(
 | 
			
		||||
            imageVector = iconVector,
 | 
			
		||||
            contentDescription = null,
 | 
			
		||||
            tint = iconTint,
 | 
			
		||||
            modifier =
 | 
			
		||||
            Modifier.size(SIZE_ICON.dp).drawWithContent {
 | 
			
		||||
                drawContent()
 | 
			
		||||
                @Suppress("MagicNumber")
 | 
			
		||||
                if (foregroundPainter != null) {
 | 
			
		||||
                    val badgeSize = size.width * .45f
 | 
			
		||||
                    with(foregroundPainter) {
 | 
			
		||||
                        draw(Size(badgeSize, badgeSize), colorFilter = ColorFilter.tint(iconTint))
 | 
			
		||||
                    }
 | 
			
		||||
                }
 | 
			
		||||
            },
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
        signalStrengthValue?.let {
 | 
			
		||||
            Text(text = it, color = MaterialTheme.colorScheme.onSurface, style = MaterialTheme.typography.labelLarge)
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@Composable
 | 
			
		||||
fun MaterialBluetoothSignalInfo(rssi: Int, modifier: Modifier = Modifier) {
 | 
			
		||||
    MaterialSignalInfo(
 | 
			
		||||
        modifier = modifier,
 | 
			
		||||
        signalBars = getBluetoothSignalBars(rssi = rssi),
 | 
			
		||||
        signalStrengthValue = stringResource(R.string.dbm_value, rssi),
 | 
			
		||||
        typeIcon = Icons.Rounded.Bluetooth,
 | 
			
		||||
    )
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@Suppress("MagicNumber")
 | 
			
		||||
private fun getBluetoothSignalBars(rssi: Int): Int = when {
 | 
			
		||||
    rssi > -60 -> 4 // Excellent
 | 
			
		||||
    rssi > -70 -> 3 // Good
 | 
			
		||||
    rssi > -80 -> 2 // Fair
 | 
			
		||||
    rssi > -90 -> 1 // Weak
 | 
			
		||||
    else -> 0 // Poor/No Signal
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
class SignalStrengthProvider : PreviewParameterProvider<Int> {
 | 
			
		||||
    override val values: Sequence<Int> = sequenceOf(-95, -85, -75, -65, -55)
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@PreviewLightDark
 | 
			
		||||
@Composable
 | 
			
		||||
private fun MaterialBluetoothSignalInfoPreview(@PreviewParameter(SignalStrengthProvider::class) rssi: Int) {
 | 
			
		||||
    AppTheme { Surface { MaterialBluetoothSignalInfo(rssi = rssi) } }
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -0,0 +1,238 @@
 | 
			
		|||
/*
 | 
			
		||||
 * Copyright (c) 2025 Meshtastic LLC
 | 
			
		||||
 *
 | 
			
		||||
 * This program is free software: you can redistribute it and/or modify
 | 
			
		||||
 * it under the terms of the GNU General Public License as published by
 | 
			
		||||
 * the Free Software Foundation, either version 3 of the License, or
 | 
			
		||||
 * (at your option) any later version.
 | 
			
		||||
 *
 | 
			
		||||
 * This program is distributed in the hope that it will be useful,
 | 
			
		||||
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 | 
			
		||||
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 | 
			
		||||
 * GNU General Public License for more details.
 | 
			
		||||
 *
 | 
			
		||||
 * You should have received a copy of the GNU General Public License
 | 
			
		||||
 * along with this program.  If not, see <https://www.gnu.org/licenses/>.
 | 
			
		||||
 */
 | 
			
		||||
 | 
			
		||||
package org.meshtastic.core.ui.icon
 | 
			
		||||
 | 
			
		||||
import androidx.compose.ui.graphics.Color
 | 
			
		||||
import androidx.compose.ui.graphics.SolidColor
 | 
			
		||||
import androidx.compose.ui.graphics.vector.ImageVector
 | 
			
		||||
import androidx.compose.ui.graphics.vector.path
 | 
			
		||||
import androidx.compose.ui.unit.dp
 | 
			
		||||
 | 
			
		||||
val MeshtasticIcons.SignalCellular0Bar: ImageVector
 | 
			
		||||
    get() {
 | 
			
		||||
        if (signalCellular0Bar != null) {
 | 
			
		||||
            return signalCellular0Bar!!
 | 
			
		||||
        }
 | 
			
		||||
        signalCellular0Bar =
 | 
			
		||||
            ImageVector.Builder(
 | 
			
		||||
                name = "SignalCellular0Bar",
 | 
			
		||||
                defaultWidth = 24.dp,
 | 
			
		||||
                defaultHeight = 24.dp,
 | 
			
		||||
                viewportWidth = 960f,
 | 
			
		||||
                viewportHeight = 960f,
 | 
			
		||||
            )
 | 
			
		||||
                .apply {
 | 
			
		||||
                    path(fill = SolidColor(Color(0xFFE3E3E3))) {
 | 
			
		||||
                        moveTo(177f, 880f)
 | 
			
		||||
                        quadToRelative(-27f, 0f, -37.5f, -24.5f)
 | 
			
		||||
                        reflectiveQuadTo(148f, 812f)
 | 
			
		||||
                        lineToRelative(664f, -664f)
 | 
			
		||||
                        quadToRelative(19f, -19f, 43.5f, -8.5f)
 | 
			
		||||
                        reflectiveQuadTo(880f, 177f)
 | 
			
		||||
                        verticalLineToRelative(643f)
 | 
			
		||||
                        quadToRelative(0f, 25f, -17.5f, 42.5f)
 | 
			
		||||
                        reflectiveQuadTo(820f, 880f)
 | 
			
		||||
                        lineTo(177f, 880f)
 | 
			
		||||
                        close()
 | 
			
		||||
                        moveTo(273f, 800f)
 | 
			
		||||
                        horizontalLineToRelative(527f)
 | 
			
		||||
                        verticalLineToRelative(-526f)
 | 
			
		||||
                        lineTo(273f, 800f)
 | 
			
		||||
                        close()
 | 
			
		||||
                    }
 | 
			
		||||
                }
 | 
			
		||||
                .build()
 | 
			
		||||
 | 
			
		||||
        return signalCellular0Bar!!
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
private var signalCellular0Bar: ImageVector? = null
 | 
			
		||||
 | 
			
		||||
val MeshtasticIcons.SignalCellular1Bar: ImageVector
 | 
			
		||||
    get() {
 | 
			
		||||
        if (signalCellular1Bar != null) {
 | 
			
		||||
            return signalCellular1Bar!!
 | 
			
		||||
        }
 | 
			
		||||
        signalCellular1Bar =
 | 
			
		||||
            ImageVector.Builder(
 | 
			
		||||
                name = "SignalCellular1Bar",
 | 
			
		||||
                defaultWidth = 24.dp,
 | 
			
		||||
                defaultHeight = 24.dp,
 | 
			
		||||
                viewportWidth = 960f,
 | 
			
		||||
                viewportHeight = 960f,
 | 
			
		||||
            )
 | 
			
		||||
                .apply {
 | 
			
		||||
                    path(fill = SolidColor(Color(0xFFE3E3E3))) {
 | 
			
		||||
                        moveTo(177f, 880f)
 | 
			
		||||
                        quadToRelative(-18f, 0f, -29.5f, -12f)
 | 
			
		||||
                        reflectiveQuadTo(136f, 840f)
 | 
			
		||||
                        quadToRelative(0f, -8f, 3f, -15f)
 | 
			
		||||
                        reflectiveQuadToRelative(9f, -13f)
 | 
			
		||||
                        lineToRelative(664f, -664f)
 | 
			
		||||
                        quadToRelative(6f, -6f, 13f, -9f)
 | 
			
		||||
                        reflectiveQuadToRelative(15f, -3f)
 | 
			
		||||
                        quadToRelative(16f, 0f, 28f, 11.5f)
 | 
			
		||||
                        reflectiveQuadToRelative(12f, 29.5f)
 | 
			
		||||
                        verticalLineToRelative(643f)
 | 
			
		||||
                        quadToRelative(0f, 25f, -17.5f, 42.5f)
 | 
			
		||||
                        reflectiveQuadTo(820f, 880f)
 | 
			
		||||
                        lineTo(177f, 880f)
 | 
			
		||||
                        close()
 | 
			
		||||
                        moveTo(400f, 800f)
 | 
			
		||||
                        horizontalLineToRelative(400f)
 | 
			
		||||
                        verticalLineToRelative(-526f)
 | 
			
		||||
                        lineTo(400f, 674f)
 | 
			
		||||
                        verticalLineToRelative(126f)
 | 
			
		||||
                        close()
 | 
			
		||||
                    }
 | 
			
		||||
                }
 | 
			
		||||
                .build()
 | 
			
		||||
 | 
			
		||||
        return signalCellular1Bar!!
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
private var signalCellular1Bar: ImageVector? = null
 | 
			
		||||
 | 
			
		||||
val MeshtasticIcons.SignalCellular2Bar: ImageVector
 | 
			
		||||
    get() {
 | 
			
		||||
        if (signalCellular2Bar != null) {
 | 
			
		||||
            return signalCellular2Bar!!
 | 
			
		||||
        }
 | 
			
		||||
        signalCellular2Bar =
 | 
			
		||||
            ImageVector.Builder(
 | 
			
		||||
                name = "SignalCellular2Bar",
 | 
			
		||||
                defaultWidth = 24.dp,
 | 
			
		||||
                defaultHeight = 24.dp,
 | 
			
		||||
                viewportWidth = 960f,
 | 
			
		||||
                viewportHeight = 960f,
 | 
			
		||||
            )
 | 
			
		||||
                .apply {
 | 
			
		||||
                    path(fill = SolidColor(Color(0xFFE3E3E3))) {
 | 
			
		||||
                        moveTo(177f, 880f)
 | 
			
		||||
                        quadToRelative(-18f, 0f, -29.5f, -12f)
 | 
			
		||||
                        reflectiveQuadTo(136f, 840f)
 | 
			
		||||
                        quadToRelative(0f, -8f, 3f, -15f)
 | 
			
		||||
                        reflectiveQuadToRelative(9f, -13f)
 | 
			
		||||
                        lineToRelative(664f, -664f)
 | 
			
		||||
                        quadToRelative(6f, -6f, 13f, -9f)
 | 
			
		||||
                        reflectiveQuadToRelative(15f, -3f)
 | 
			
		||||
                        quadToRelative(16f, 0f, 28f, 11.5f)
 | 
			
		||||
                        reflectiveQuadToRelative(12f, 29.5f)
 | 
			
		||||
                        verticalLineToRelative(643f)
 | 
			
		||||
                        quadToRelative(0f, 25f, -17.5f, 42.5f)
 | 
			
		||||
                        reflectiveQuadTo(820f, 880f)
 | 
			
		||||
                        lineTo(177f, 880f)
 | 
			
		||||
                        close()
 | 
			
		||||
                        moveTo(520f, 800f)
 | 
			
		||||
                        horizontalLineToRelative(280f)
 | 
			
		||||
                        verticalLineToRelative(-526f)
 | 
			
		||||
                        lineTo(520f, 554f)
 | 
			
		||||
                        verticalLineToRelative(246f)
 | 
			
		||||
                        close()
 | 
			
		||||
                    }
 | 
			
		||||
                }
 | 
			
		||||
                .build()
 | 
			
		||||
 | 
			
		||||
        return signalCellular2Bar!!
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
private var signalCellular2Bar: ImageVector? = null
 | 
			
		||||
 | 
			
		||||
val MeshtasticIcons.SignalCellular3Bar: ImageVector
 | 
			
		||||
    get() {
 | 
			
		||||
        if (signalCellular3Bar != null) {
 | 
			
		||||
            return signalCellular3Bar!!
 | 
			
		||||
        }
 | 
			
		||||
        signalCellular3Bar =
 | 
			
		||||
            ImageVector.Builder(
 | 
			
		||||
                name = "SignalCellular3Bar",
 | 
			
		||||
                defaultWidth = 24.dp,
 | 
			
		||||
                defaultHeight = 24.dp,
 | 
			
		||||
                viewportWidth = 960f,
 | 
			
		||||
                viewportHeight = 960f,
 | 
			
		||||
            )
 | 
			
		||||
                .apply {
 | 
			
		||||
                    path(fill = SolidColor(Color(0xFFE3E3E3))) {
 | 
			
		||||
                        moveTo(177f, 880f)
 | 
			
		||||
                        quadToRelative(-18f, 0f, -29.5f, -12f)
 | 
			
		||||
                        reflectiveQuadTo(136f, 840f)
 | 
			
		||||
                        quadToRelative(0f, -8f, 3f, -15f)
 | 
			
		||||
                        reflectiveQuadToRelative(9f, -13f)
 | 
			
		||||
                        lineToRelative(664f, -664f)
 | 
			
		||||
                        quadToRelative(6f, -6f, 13f, -9f)
 | 
			
		||||
                        reflectiveQuadToRelative(15f, -3f)
 | 
			
		||||
                        quadToRelative(16f, 0f, 28f, 11.5f)
 | 
			
		||||
                        reflectiveQuadToRelative(12f, 29.5f)
 | 
			
		||||
                        verticalLineToRelative(643f)
 | 
			
		||||
                        quadToRelative(0f, 25f, -17.5f, 42.5f)
 | 
			
		||||
                        reflectiveQuadTo(820f, 880f)
 | 
			
		||||
                        lineTo(177f, 880f)
 | 
			
		||||
                        close()
 | 
			
		||||
                        moveTo(600f, 800f)
 | 
			
		||||
                        horizontalLineToRelative(200f)
 | 
			
		||||
                        verticalLineToRelative(-526f)
 | 
			
		||||
                        lineTo(600f, 474f)
 | 
			
		||||
                        verticalLineToRelative(326f)
 | 
			
		||||
                        close()
 | 
			
		||||
                    }
 | 
			
		||||
                }
 | 
			
		||||
                .build()
 | 
			
		||||
 | 
			
		||||
        return signalCellular3Bar!!
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
private var signalCellular3Bar: ImageVector? = null
 | 
			
		||||
 | 
			
		||||
val MeshtasticIcons.SignalCellular4Bar: ImageVector
 | 
			
		||||
    get() {
 | 
			
		||||
        if (signalCellular4Bar != null) {
 | 
			
		||||
            return signalCellular4Bar!!
 | 
			
		||||
        }
 | 
			
		||||
        signalCellular4Bar =
 | 
			
		||||
            ImageVector.Builder(
 | 
			
		||||
                name = "SignalCellular4Bar",
 | 
			
		||||
                defaultWidth = 24.dp,
 | 
			
		||||
                defaultHeight = 24.dp,
 | 
			
		||||
                viewportWidth = 960f,
 | 
			
		||||
                viewportHeight = 960f,
 | 
			
		||||
            )
 | 
			
		||||
                .apply {
 | 
			
		||||
                    path(fill = SolidColor(Color(0xFFE3E3E3))) {
 | 
			
		||||
                        moveTo(177f, 880f)
 | 
			
		||||
                        quadToRelative(-18f, 0f, -29.5f, -12f)
 | 
			
		||||
                        reflectiveQuadTo(136f, 840f)
 | 
			
		||||
                        quadToRelative(0f, -8f, 3f, -15f)
 | 
			
		||||
                        reflectiveQuadToRelative(9f, -13f)
 | 
			
		||||
                        lineToRelative(664f, -664f)
 | 
			
		||||
                        quadToRelative(6f, -6f, 13f, -9f)
 | 
			
		||||
                        reflectiveQuadToRelative(15f, -3f)
 | 
			
		||||
                        quadToRelative(16f, 0f, 28f, 11.5f)
 | 
			
		||||
                        reflectiveQuadToRelative(12f, 29.5f)
 | 
			
		||||
                        verticalLineToRelative(643f)
 | 
			
		||||
                        quadToRelative(0f, 25f, -17.5f, 42.5f)
 | 
			
		||||
                        reflectiveQuadTo(820f, 880f)
 | 
			
		||||
                        lineTo(177f, 880f)
 | 
			
		||||
                        close()
 | 
			
		||||
                    }
 | 
			
		||||
                }
 | 
			
		||||
                .build()
 | 
			
		||||
 | 
			
		||||
        return signalCellular4Bar!!
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
private var signalCellular4Bar: ImageVector? = null
 | 
			
		||||
		Ładowanie…
	
		Reference in New Issue