package com.geeksville.mesh.ui import android.bluetooth.BluetoothDevice import android.bluetooth.BluetoothManager import android.bluetooth.le.* import android.content.Context import android.os.ParcelUuid import androidx.compose.Composable import androidx.compose.Model import androidx.compose.frames.modelMapOf import androidx.compose.onCommit import androidx.ui.core.ContextAmbient import androidx.ui.core.Text import androidx.ui.layout.Column import androidx.ui.layout.LayoutGravity import androidx.ui.material.CircularProgressIndicator import androidx.ui.material.EmphasisLevels import androidx.ui.material.ProvideEmphasis import androidx.ui.material.RadioGroup import androidx.ui.tooling.preview.Preview import com.geeksville.android.Logging import com.geeksville.mesh.service.RadioInterfaceService @Model object ScanUIState { var selectedMacAddr: String? = null var errorText: String? = null val devices = modelMapOf() /// Change to a new macaddr selection, updating GUI and radio fun changeSelection(context: Context, newAddr: String) { ScanState.info("Changing BT device to $newAddr") selectedMacAddr = newAddr RadioInterfaceService.setBondedDeviceAddress(context, newAddr) } } /// FIXME, remove once compose has better lifecycle management object ScanState : Logging { var scanner: BluetoothLeScanner? = null var callback: ScanCallback? = null // SUPER NASTY FIXME fun stopScan() { if (callback != null) { debug("stopping scan") try { scanner!!.stopScan(callback) } catch (ex: Throwable) { warn("Ignoring error stopping scan, probably BT adapter was disabled suddenly: ${ex.message}") } callback = null } } } @Model data class BTScanEntry(val name: String, val macAddress: String, val bonded: Boolean) { val isSelected get() = macAddress == ScanUIState.selectedMacAddr } @Composable fun BTScanScreen() { val context = ContextAmbient.current /// Note: may be null on platforms without a bluetooth driver (ie. the emulator) val bluetoothAdapter = (context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager?)?.adapter analyticsScreen(name = "settings") onCommit(AppStatus.currentScreen) { ScanState.debug("BTScan component active") ScanUIState.selectedMacAddr = RadioInterfaceService.getBondedDeviceAddress(context) val scanCallback = object : ScanCallback() { override fun onScanFailed(errorCode: Int) { val msg = "Unexpected bluetooth scan failure: $errorCode" // error code2 seeems to be indicate hung bluetooth stack ScanUIState.errorText = msg ScanState.reportError(msg) } // For each device that appears in our scan, ask for its GATT, when the gatt arrives, // check if it is an eligable device and store it in our list of candidates // if that device later disconnects remove it as a candidate override fun onScanResult(callbackType: Int, result: ScanResult) { val addr = result.device.address // prevent logspam because weill get get lots of redundant scan results if (!ScanUIState.devices.contains(addr)) { val entry = BTScanEntry( result.device.name, addr, result.device.bondState == BluetoothDevice.BOND_BONDED ) ScanState.debug("onScanResult ${entry}") ScanUIState.devices[addr] = entry // If nothing was selected, by default select the first thing we see if (ScanUIState.selectedMacAddr == null && entry.bonded) ScanUIState.changeSelection(context, addr) } } } if (bluetoothAdapter == null) { ScanState.warn("No bluetooth adapter. Running under emulation?") val testnodes = listOf( BTScanEntry("Meshtastic_ab12", "xx", false), BTScanEntry("Meshtastic_32ac", "xb", true) ) ScanUIState.devices.putAll(testnodes.map { it.macAddress to it }) // If nothing was selected, by default select the first thing we see if (ScanUIState.selectedMacAddr == null) ScanUIState.changeSelection(context, testnodes.first().macAddress) } else { /// The following call might return null if the user doesn't have bluetooth access permissions val s: BluetoothLeScanner? = bluetoothAdapter.bluetoothLeScanner if (s == null) { ScanUIState.errorText = "This application requires bluetooth access. Please grant access in android settings." } else { ScanState.debug("starting scan") // filter and only accept devices that have a sw update service val filter = ScanFilter.Builder() .setServiceUuid(ParcelUuid(RadioInterfaceService.BTM_SERVICE_UUID)) .build() val settings = ScanSettings.Builder().setScanMode(ScanSettings.SCAN_MODE_LOW_LATENCY).build() s.startScan(listOf(filter), settings, scanCallback) ScanState.scanner = s ScanState.callback = scanCallback } } onDispose { ScanState.debug("BTScan component deactivated") ScanState.stopScan() } } Column { if (ScanUIState.errorText != null) { Text("An unexpected error was encountered. Please file a bug on our github: ${ScanUIState.errorText}") } else { if (ScanUIState.devices.isEmpty()) { Text( text = "Looking for Meshtastic devices... (zero found)", modifier = LayoutGravity.Center ) CircularProgressIndicator() // Show that we are searching still } else { // val allPaired = bluetoothAdapter?.bondedDevices.orEmpty().map { it.address }.toSet() /* Only let user select paired devices val paired = devices.values.filter { allPaired.contains(it.macAddress) } if (paired.size < devices.size) { Text( "Warning: there are nearby Meshtastic devices that are not paired with this phone. Before you can select a device, you will need to pair it in Bluetooth Settings." ) } */ RadioGroup { Column { ScanUIState.devices.values.forEach { // disabled pending https://issuetracker.google.com/issues/149528535 ProvideEmphasis(emphasis = if (it.bonded) EmphasisLevels().high else EmphasisLevels().disabled) { RadioGroupTextItem( selected = (it.isSelected), onSelect = { // If the device is paired, let user select it, otherwise start the pairing flow if (it.bonded) { ScanUIState.changeSelection(context, it.macAddress) } else { ScanState.info("Starting bonding for $it") // We ignore missing BT adapters, because it lets us run on the emulator bluetoothAdapter ?.getRemoteDevice(it.macAddress) ?.createBond() } }, text = it.name ) } } } } } } } } @Preview @Composable fun btScanScreenPreview() { BTScanScreen() }