diff --git a/app/src/main/java/com/geeksville/mesh/MainActivity.kt b/app/src/main/java/com/geeksville/mesh/MainActivity.kt index 264543d09..14420a6bb 100644 --- a/app/src/main/java/com/geeksville/mesh/MainActivity.kt +++ b/app/src/main/java/com/geeksville/mesh/MainActivity.kt @@ -36,6 +36,7 @@ import com.geeksville.android.ServiceClient import com.geeksville.concurrent.handledLaunch import com.geeksville.mesh.android.* import com.geeksville.mesh.databinding.ActivityMainBinding +import com.geeksville.mesh.model.BTScanModel import com.geeksville.mesh.model.BluetoothViewModel import com.geeksville.mesh.model.ChannelSet import com.geeksville.mesh.model.DeviceVersion diff --git a/app/src/main/java/com/geeksville/mesh/model/BTScanModel.kt b/app/src/main/java/com/geeksville/mesh/model/BTScanModel.kt new file mode 100644 index 000000000..7ab8423f0 --- /dev/null +++ b/app/src/main/java/com/geeksville/mesh/model/BTScanModel.kt @@ -0,0 +1,504 @@ +package com.geeksville.mesh.model + +import android.annotation.SuppressLint +import android.app.Application +import android.app.PendingIntent +import android.bluetooth.BluetoothDevice +import android.bluetooth.le.* +import android.companion.AssociationRequest +import android.companion.BluetoothDeviceFilter +import android.companion.CompanionDeviceManager +import android.content.* +import android.hardware.usb.UsbDevice +import android.hardware.usb.UsbManager +import android.net.nsd.NsdServiceInfo +import android.os.RemoteException +import androidx.activity.result.IntentSenderRequest +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.ViewModel +import com.geeksville.android.GeeksvilleApplication +import com.geeksville.android.Logging +import com.geeksville.mesh.MainActivity +import com.geeksville.mesh.R +import com.geeksville.mesh.android.* +import com.geeksville.mesh.repository.bluetooth.BluetoothRepository +import com.geeksville.mesh.repository.nsd.NsdRepository +import com.geeksville.mesh.repository.radio.MockInterface +import com.geeksville.mesh.repository.radio.RadioInterfaceService +import com.geeksville.mesh.repository.radio.SerialInterface +import com.geeksville.mesh.repository.usb.UsbRepository +import com.geeksville.mesh.ui.SLogging +import com.geeksville.mesh.ui.changeDeviceSelection +import com.geeksville.util.anonymize +import com.geeksville.util.exceptionReporter +import com.hoho.android.usbserial.driver.UsbSerialDriver +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import java.util.regex.Pattern +import javax.inject.Inject + +/// Show the UI asking the user to bond with a device, call changeSelection() if/when bonding completes +@SuppressLint("MissingPermission") +private fun requestBonding( + activity: MainActivity, + device: BluetoothDevice, + onComplete: (Int) -> Unit +) { + SLogging.info("Starting bonding for ${device.anonymize}") + + // We need this receiver to get informed when the bond attempt finished + val bondChangedReceiver = object : BroadcastReceiver() { + + override fun onReceive( + context: Context, + intent: Intent + ) = exceptionReporter { + val state = + intent.getIntExtra(BluetoothDevice.EXTRA_BOND_STATE, -1) + SLogging.debug("Received bond state changed $state") + + if (state != BluetoothDevice.BOND_BONDING) { + context.unregisterReceiver(this) // we stay registered until bonding completes (either with BONDED or NONE) + SLogging.debug("Bonding completed, state=$state") + onComplete(state) + } + } + } + + val filter = IntentFilter() + filter.addAction(BluetoothDevice.ACTION_BOND_STATE_CHANGED) + activity.registerReceiver(bondChangedReceiver, filter) + + // We ignore missing BT adapters, because it lets us run on the emulator + try { + device.createBond() + } catch (ex: Throwable) { + SLogging.warn("Failed creating Bluetooth bond: ${ex.message}") + } +} + +@HiltViewModel +class BTScanModel @Inject constructor( + private val application: Application, + private val bluetoothRepository: BluetoothRepository, + private val usbRepository: UsbRepository, + private val nsdRepository: NsdRepository, + private val radioInterfaceService: RadioInterfaceService, +) : ViewModel(), Logging { + + private val context: Context get() = application.applicationContext + + init { + debug("BTScanModel created") + } + + /** *fullAddress* = interface prefix + address (example: "x7C:9E:BD:F0:BE:BE") */ + open class DeviceListEntry(val name: String, val fullAddress: String, val bonded: Boolean) { + val prefix get() = fullAddress[0] + val address get() = fullAddress.substring(1) + + override fun toString(): String { + return "DeviceListEntry(name=${name.anonymize}, addr=${address.anonymize}, bonded=$bonded)" + } + + val isBLE: Boolean get() = prefix == 'x' + val isUSB: Boolean get() = prefix == 's' + val isTCP: Boolean get() = prefix == 't' + } + + class USBDeviceListEntry(usbManager: UsbManager, val usb: UsbSerialDriver) : DeviceListEntry( + usb.device.deviceName, + SerialInterface.toInterfaceName(usb.device.deviceName), + SerialInterface.assumePermission || usbManager.hasPermission(usb.device) + ) + + class TCPDeviceListEntry(val service: NsdServiceInfo) : DeviceListEntry( + service.host.toString().substring(1), + service.host.toString().replace("/", "t"), + true + ) + + override fun onCleared() { + super.onCleared() + debug("BTScanModel cleared") + } + + private val bluetoothAdapter = context.bluetoothManager?.adapter + private val deviceManager get() = context.deviceManager + val hasCompanionDeviceApi get() = context.hasCompanionDeviceApi() + private val hasConnectPermission get() = application.hasConnectPermission() + private val usbManager get() = context.usbManager + + var selectedAddress: String? = null + val errorText = object : MutableLiveData(null) {} + + private var scanner: BluetoothLeScanner? = null + + val selectedBluetooth: Boolean get() = selectedAddress?.get(0) == 'x' + + /// Use the string for the NopInterface + val selectedNotNull: String get() = selectedAddress ?: "n" + + private val scanCallback = object : ScanCallback() { + override fun onScanFailed(errorCode: Int) { + val msg = "Unexpected bluetooth scan failure: $errorCode" + errormsg(msg) + // error code2 seems to be indicate hung bluetooth stack + errorText.value = msg + } + + // For each device that appears in our scan, ask for its GATT, when the gatt arrives, + // check if it is an eligible device and store it in our list of candidates + // if that device later disconnects remove it as a candidate + @SuppressLint("MissingPermission") + override fun onScanResult(callbackType: Int, result: ScanResult) { + + if ((result.device.name?.startsWith("Mesh") == true)) { + val addr = result.device.address + val fullAddr = "x$addr" // full address with the bluetooth prefix added + // prevent log spam because we'll get lots of redundant scan results + val isBonded = result.device.bondState == BluetoothDevice.BOND_BONDED + val oldDevs = devices.value!! + val oldEntry = oldDevs[fullAddr] + if (oldEntry == null || oldEntry.bonded != isBonded) { // Don't spam the GUI with endless updates for non changing nodes + val entry = DeviceListEntry( + result.device.name + ?: "unnamed-$addr", // autobug: some devices might not have a name, if someone is running really old device code? + fullAddr, + isBonded + ) + // If nothing was selected, by default select the first valid thing we see + val activity: MainActivity? = try { + GeeksvilleApplication.currentActivity as MainActivity? // Can be null if app is shutting down + } catch (_: ClassCastException) { + // Buggy "Z812" phones apparently have the wrong class type for this + errormsg("Unexpected class for main activity") + null + } + + if (selectedAddress == null && entry.bonded && activity != null) + changeScanSelection(activity, fullAddr) + addDevice(entry) // Add/replace entry + } + } + } + } + + private fun addDevice(entry: DeviceListEntry) { + val oldDevs = devices.value!! + oldDevs[entry.fullAddress] = entry // Add/replace entry + devices.value = oldDevs // trigger gui updates + } + + @SuppressLint("MissingPermission") + fun stopScan() { + // Stop Network Service Discovery (for TCP) + networkDiscovery?.cancel() + + if (scanner != null) { + debug("stopping scan") + try { + scanner?.stopScan(scanCallback) + } catch (ex: Throwable) { + warn("Ignoring error stopping scan, probably BT adapter was disabled suddenly: ${ex.message}") + } finally { + scanner = null + _spinner.value = false + } + } else _spinner.value = false + } + + /** + * returns true if we could start scanning, false otherwise + */ + fun setupScan(): Boolean { + selectedAddress = radioInterfaceService.getDeviceAddress() + + return if (bluetoothAdapter == null || MockInterface.addressValid(context, usbRepository, "")) { + warn("No bluetooth adapter. Running under emulation?") + + val testnodes = listOf( + DeviceListEntry("Included simulator", "m", true), + DeviceListEntry("Complete simulator", "t10.0.2.2", true), + DeviceListEntry(context.getString(R.string.none), "n", true) + /* Don't populate fake bluetooth devices, because we don't want testlab inside of google + to try and use them. + + DeviceListEntry("Meshtastic_ab12", "xaa", false), + DeviceListEntry("Meshtastic_32ac", "xbb", true) */ + ) + + devices.value = (testnodes.map { it.fullAddress to it }).toMap().toMutableMap() + + // If nothing was selected, by default select the first thing we see + if (selectedAddress == null) + changeScanSelection( + GeeksvilleApplication.currentActivity as MainActivity, + testnodes.first().fullAddress + ) + + true + } else { + if (scanner == null) { + // Clear the old device list + devices.value?.clear() + + // Include a placeholder for "None" + addDevice(DeviceListEntry(context.getString(R.string.none), "n", true)) + + // Include CompanionDeviceManager valid associations + addDeviceAssociations() + + // Include Network Service Discovery + nsdRepository.resolvedList?.forEach { service -> + addDevice(TCPDeviceListEntry(service)) + } + + val serialDevices by lazy { usbRepository.serialDevicesWithDrivers.value } + serialDevices.forEach { (_, d) -> + addDevice(USBDeviceListEntry(usbManager, d)) + } + } else { + debug("scan already running") + } + true + } + } + + private var networkDiscovery: Job? = null + fun startScan() { + // Start Network Service Discovery (find TCP devices) + networkDiscovery = nsdRepository.networkDiscoveryFlow() + .onEach { addDevice(TCPDeviceListEntry(it)) } + .launchIn(CoroutineScope(Dispatchers.Main)) + + if (hasCompanionDeviceApi) { + startCompanionScan() + } else startClassicScan() + } + + @SuppressLint("MissingPermission") + private fun startClassicScan() { + /// The following call might return null if the user doesn't have bluetooth access permissions + val bluetoothLeScanner = bluetoothRepository.getBluetoothLeScanner() + + if (bluetoothLeScanner != null) { // could be null if bluetooth is disabled + debug("starting classic scan") + _spinner.value = true + + // filter and only accept devices that have our service + val filter = + ScanFilter.Builder() + // Samsung doesn't seem to filter properly by service so this can't work + // see https://stackoverflow.com/questions/57981986/altbeacon-android-beacon-library-not-working-after-device-has-screen-off-for-a-s/57995960#57995960 + // and https://stackoverflow.com/a/45590493 + // .setServiceUuid(ParcelUuid(BluetoothInterface.BTM_SERVICE_UUID)) + .build() + + val settings = + ScanSettings.Builder().setScanMode(ScanSettings.SCAN_MODE_LOW_LATENCY) + .build() + bluetoothLeScanner.startScan(listOf(filter), settings, scanCallback) + scanner = bluetoothLeScanner + } + } + + /** + * @return DeviceListEntry from full Address (prefix + address). + * If Bluetooth is enabled and BLE Address is valid, get remote device information. + */ + @SuppressLint("MissingPermission") + fun getDeviceListEntry(fullAddress: String, bonded: Boolean = false): DeviceListEntry { + val address = fullAddress.substring(1) + val device = bluetoothRepository.getRemoteDevice(address) + return if (device != null && device.name != null) { + DeviceListEntry(device.name, fullAddress, device.bondState != BluetoothDevice.BOND_NONE) + } else { + DeviceListEntry(address, fullAddress, bonded) + } + } + + @SuppressLint("NewApi") + fun addDeviceAssociations() { + if (hasCompanionDeviceApi) deviceManager?.associations?.forEach { bleAddress -> + val bleDevice = getDeviceListEntry("x$bleAddress", true) + // Disassociate after pairing is removed (if BLE is disabled, assume bonded) + if (bleDevice.name.startsWith("Mesh") && !bleDevice.bonded) { + debug("Forgetting old BLE association ${bleAddress.anonymize}") + deviceManager?.disassociate(bleAddress) + } + addDevice(bleDevice) + } + } + + private val _spinner = MutableLiveData(false) + val spinner: LiveData get() = _spinner + + private val _associationRequest = MutableLiveData(null) + val associationRequest: LiveData get() = _associationRequest + + /** + * Called immediately after fragment observes CompanionDeviceManager activity result + */ + fun clearAssociationRequest() { + _associationRequest.value = null + } + + @SuppressLint("NewApi") + private fun associationRequest(): AssociationRequest { + // To skip filtering based on name and supported feature flags (UUIDs), + // don't include calls to setNamePattern() and addServiceUuid(), + // respectively. This example uses Bluetooth. + // We only look for Mesh (rather than the full name) because NRF52 uses a very short name + val deviceFilter: BluetoothDeviceFilter = BluetoothDeviceFilter.Builder() + .setNamePattern(Pattern.compile("Mesh.*")) + // .addServiceUuid(ParcelUuid(RadioInterfaceService.BTM_SERVICE_UUID), null) + .build() + + // The argument provided in setSingleDevice() determines whether a single + // device name or a list of device names is presented to the user as + // pairing options. + return AssociationRequest.Builder() + .addDeviceFilter(deviceFilter) + .setSingleDevice(false) + .build() + } + + @SuppressLint("NewApi") + private fun startCompanionScan() { + debug("starting companion scan") + _spinner.value = true + deviceManager?.associate( + associationRequest(), + @SuppressLint("NewApi") + object : CompanionDeviceManager.Callback() { + override fun onDeviceFound(chooserLauncher: IntentSender) { + debug("CompanionDeviceManager - device found") + _spinner.value = false + chooserLauncher.let { + val request: IntentSenderRequest = IntentSenderRequest.Builder(it).build() + _associationRequest.value = request + } + } + + override fun onFailure(error: CharSequence?) { + warn("BLE selection service failed $error") + } + }, null + ) + } + + val devices = object : MutableLiveData>(mutableMapOf()) { + + /** + * Called when the number of active observers change from 1 to 0. + * + * + * This does not mean that there are no observers left, there may still be observers but their + * lifecycle states aren't [Lifecycle.State.STARTED] or [Lifecycle.State.RESUMED] + * (like an Activity in the back stack). + * + * + * You can check if there are observers via [.hasObservers]. + */ + override fun onInactive() { + super.onInactive() + stopScan() + } + } + + /// Called by the GUI when a new device has been selected by the user + /// Returns true if we were able to change to that item + fun onSelected(activity: MainActivity, it: DeviceListEntry): Boolean { + // If the device is paired, let user select it, otherwise start the pairing flow + if (it.bonded) { + changeScanSelection(activity, it.fullAddress) + return true + } else { + // Handle requesting USB or bluetooth permissions for the device + debug("Requesting permissions for the device") + + exceptionReporter { + if (it.isBLE) { + // Request bonding for bluetooth + // We ignore missing BT adapters, because it lets us run on the emulator + bluetoothRepository + .getRemoteDevice(it.address)?.let { device -> + requestBonding(activity, device) { state -> + if (state == BluetoothDevice.BOND_BONDED) { + errorText.value = activity.getString(R.string.pairing_completed) + changeScanSelection(activity, it.fullAddress) + } else { + errorText.value = + activity.getString(R.string.pairing_failed_try_again) + } + + // Force the GUI to redraw + devices.value = devices.value + } + } + } + } + + if (it.isUSB) { + it as USBDeviceListEntry + + val ACTION_USB_PERMISSION = "com.geeksville.mesh.USB_PERMISSION" + + val usbReceiver = object : BroadcastReceiver() { + + override fun onReceive(context: Context, intent: Intent) { + if (ACTION_USB_PERMISSION == intent.action) { + + val device: UsbDevice = + intent.getParcelableExtra(UsbManager.EXTRA_DEVICE)!! + + if (intent.getBooleanExtra( + UsbManager.EXTRA_PERMISSION_GRANTED, + false + ) + ) { + info("User approved USB access") + changeScanSelection(activity, it.fullAddress) + + // Force the GUI to redraw + devices.value = devices.value + } else { + errormsg("USB permission denied for device $device") + } + } + // We don't need to stay registered + activity.unregisterReceiver(this) + } + } + + val permissionIntent = + PendingIntent.getBroadcast(activity, 0, Intent(ACTION_USB_PERMISSION), 0) + val filter = IntentFilter(ACTION_USB_PERMISSION) + activity.registerReceiver(usbReceiver, filter) + usbManager.requestPermission(it.usb.device, permissionIntent) + } + + return false + } + } + + /// Change to a new macaddr selection, updating GUI and radio + fun changeScanSelection(context: MainActivity, newAddr: String) { + try { + info("Changing device to ${newAddr.anonymize}") + changeDeviceSelection(context, newAddr) + selectedAddress = + newAddr // do this after changeDeviceSelection, so if it throws the change will be discarded + devices.value = devices.value // Force a GUI update + } catch (ex: RemoteException) { + errormsg("Failed talking to service, probably it is shutting down $ex.message") + // ignore the failure and the GUI won't be updating anyways + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/geeksville/mesh/ui/SettingsFragment.kt b/app/src/main/java/com/geeksville/mesh/ui/SettingsFragment.kt index a6dc42bbf..b9a6c8c04 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/SettingsFragment.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/SettingsFragment.kt @@ -1,30 +1,18 @@ package com.geeksville.mesh.ui import android.annotation.SuppressLint -import android.app.Application -import android.app.PendingIntent import android.bluetooth.BluetoothDevice -import android.bluetooth.le.* -import android.companion.AssociationRequest -import android.companion.BluetoothDeviceFilter import android.companion.CompanionDeviceManager import android.content.* -import android.hardware.usb.UsbDevice -import android.hardware.usb.UsbManager import android.location.LocationManager -import android.net.nsd.NsdServiceInfo import android.os.* import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import android.view.inputmethod.EditorInfo import android.widget.* -import androidx.activity.result.IntentSenderRequest import androidx.activity.result.contract.ActivityResultContracts import androidx.fragment.app.activityViewModels -import androidx.lifecycle.LiveData -import androidx.lifecycle.MutableLiveData -import androidx.lifecycle.ViewModel import com.geeksville.analytics.DataPair import com.geeksville.android.GeeksvilleApplication import com.geeksville.android.Logging @@ -35,30 +23,17 @@ import com.geeksville.mesh.R import com.geeksville.mesh.ConfigProtos import com.geeksville.mesh.android.* import com.geeksville.mesh.databinding.SettingsFragmentBinding +import com.geeksville.mesh.model.BTScanModel import com.geeksville.mesh.model.BluetoothViewModel import com.geeksville.mesh.model.UIViewModel -import com.geeksville.mesh.repository.bluetooth.BluetoothRepository -import com.geeksville.mesh.repository.nsd.NsdRepository import com.geeksville.mesh.repository.radio.MockInterface -import com.geeksville.mesh.repository.radio.RadioInterfaceService -import com.geeksville.mesh.repository.radio.SerialInterface import com.geeksville.mesh.repository.usb.UsbRepository import com.geeksville.mesh.service.MeshService import com.geeksville.mesh.service.SoftwareUpdateService -import com.geeksville.util.anonymize -import com.geeksville.util.exceptionReporter import com.geeksville.util.exceptionToSnackbar import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.google.android.material.snackbar.Snackbar -import com.hoho.android.usbserial.driver.UsbSerialDriver import dagger.hilt.android.AndroidEntryPoint -import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.Job -import kotlinx.coroutines.flow.launchIn -import kotlinx.coroutines.flow.onEach -import java.util.regex.Pattern import javax.inject.Inject object SLogging : Logging @@ -71,474 +46,6 @@ fun changeDeviceSelection(context: MainActivity, newAddr: String?) { } } -/// Show the UI asking the user to bond with a device, call changeSelection() if/when bonding completes -@SuppressLint("MissingPermission") -private fun requestBonding( - activity: MainActivity, - device: BluetoothDevice, - onComplete: (Int) -> Unit -) { - SLogging.info("Starting bonding for ${device.anonymize}") - - // We need this receiver to get informed when the bond attempt finished - val bondChangedReceiver = object : BroadcastReceiver() { - - override fun onReceive( - context: Context, - intent: Intent - ) = exceptionReporter { - val state = - intent.getIntExtra( - BluetoothDevice.EXTRA_BOND_STATE, - -1 - ) - SLogging.debug("Received bond state changed $state") - - if (state != BluetoothDevice.BOND_BONDING) { - context.unregisterReceiver(this) // we stay registered until bonding completes (either with BONDED or NONE) - SLogging.debug("Bonding completed, state=$state") - onComplete(state) - } - } - } - - val filter = IntentFilter() - filter.addAction(BluetoothDevice.ACTION_BOND_STATE_CHANGED) - activity.registerReceiver(bondChangedReceiver, filter) - - // We ignore missing BT adapters, because it lets us run on the emulator - try { - device.createBond() - } catch (ex: Throwable) { - SLogging.warn("Failed creating Bluetooth bond: ${ex.message}") - } -} - -@HiltViewModel -class BTScanModel @Inject constructor( - private val application: Application, - private val bluetoothRepository: BluetoothRepository, - private val usbRepository: UsbRepository, - private val nsdRepository: NsdRepository, - private val radioInterfaceService: RadioInterfaceService, -) : ViewModel(), Logging { - - private val context: Context get() = application.applicationContext - - init { - debug("BTScanModel created") - } - - /** *fullAddress* = interface prefix + address (example: "x7C:9E:BD:F0:BE:BE") */ - open class DeviceListEntry(val name: String, val fullAddress: String, val bonded: Boolean) { - val prefix get() = fullAddress[0] - val address get() = fullAddress.substring(1) - - override fun toString(): String { - return "DeviceListEntry(name=${name.anonymize}, addr=${address.anonymize}, bonded=$bonded)" - } - - val isBLE: Boolean get() = prefix == 'x' - val isUSB: Boolean get() = prefix == 's' - val isTCP: Boolean get() = prefix == 't' - } - - class USBDeviceListEntry(usbManager: UsbManager, val usb: UsbSerialDriver) : DeviceListEntry( - usb.device.deviceName, - SerialInterface.toInterfaceName(usb.device.deviceName), - SerialInterface.assumePermission || usbManager.hasPermission(usb.device) - ) - - class TCPDeviceListEntry(val service: NsdServiceInfo) : DeviceListEntry( - service.host.toString().substring(1), - service.host.toString().replace("/", "t"), - true - ) - - override fun onCleared() { - super.onCleared() - debug("BTScanModel cleared") - } - - private val bluetoothAdapter = context.bluetoothManager?.adapter - private val deviceManager get() = context.deviceManager - val hasCompanionDeviceApi get() = context.hasCompanionDeviceApi() - private val hasConnectPermission get() = application.hasConnectPermission() - private val usbManager get() = context.usbManager - - var selectedAddress: String? = null - val errorText = object : MutableLiveData(null) {} - - private var scanner: BluetoothLeScanner? = null - - val selectedBluetooth: Boolean get() = selectedAddress?.get(0) == 'x' - - /// Use the string for the NopInterface - val selectedNotNull: String get() = selectedAddress ?: "n" - - private val scanCallback = object : ScanCallback() { - override fun onScanFailed(errorCode: Int) { - val msg = "Unexpected bluetooth scan failure: $errorCode" - errormsg(msg) - // error code2 seems to be indicate hung bluetooth stack - errorText.value = msg - } - - // For each device that appears in our scan, ask for its GATT, when the gatt arrives, - // check if it is an eligible device and store it in our list of candidates - // if that device later disconnects remove it as a candidate - @SuppressLint("MissingPermission") - override fun onScanResult(callbackType: Int, result: ScanResult) { - - if ((result.device.name?.startsWith("Mesh") == true)) { - val addr = result.device.address - val fullAddr = "x$addr" // full address with the bluetooth prefix added - // prevent log spam because we'll get lots of redundant scan results - val isBonded = result.device.bondState == BluetoothDevice.BOND_BONDED - val oldDevs = devices.value!! - val oldEntry = oldDevs[fullAddr] - if (oldEntry == null || oldEntry.bonded != isBonded) { // Don't spam the GUI with endless updates for non changing nodes - val entry = DeviceListEntry( - result.device.name - ?: "unnamed-$addr", // autobug: some devices might not have a name, if someone is running really old device code? - fullAddr, - isBonded - ) - // If nothing was selected, by default select the first valid thing we see - val activity: MainActivity? = try { - GeeksvilleApplication.currentActivity as MainActivity? // Can be null if app is shutting down - } catch (_: ClassCastException) { - // Buggy "Z812" phones apparently have the wrong class type for this - errormsg("Unexpected class for main activity") - null - } - - if (selectedAddress == null && entry.bonded && activity != null) - changeScanSelection( - activity, - fullAddr - ) - addDevice(entry) // Add/replace entry - } - } - } - } - - private fun addDevice(entry: DeviceListEntry) { - val oldDevs = devices.value!! - oldDevs[entry.fullAddress] = entry // Add/replace entry - devices.value = oldDevs // trigger gui updates - } - - @SuppressLint("MissingPermission") - fun stopScan() { - // Stop Network Service Discovery (for TCP) - networkDiscovery?.cancel() - - if (scanner != null) { - debug("stopping scan") - try { - scanner?.stopScan(scanCallback) - } catch (ex: Throwable) { - warn("Ignoring error stopping scan, probably BT adapter was disabled suddenly: ${ex.message}") - } finally { - scanner = null - _spinner.value = false - } - } else _spinner.value = false - } - - /** - * returns true if we could start scanning, false otherwise - */ - fun setupScan(): Boolean { - selectedAddress = radioInterfaceService.getDeviceAddress() - - return if (bluetoothAdapter == null || MockInterface.addressValid(context, usbRepository, "")) { - warn("No bluetooth adapter. Running under emulation?") - - val testnodes = listOf( - DeviceListEntry("Included simulator", "m", true), - DeviceListEntry("Complete simulator", "t10.0.2.2", true), - DeviceListEntry(context.getString(R.string.none), "n", true) - /* Don't populate fake bluetooth devices, because we don't want testlab inside of google - to try and use them. - - DeviceListEntry("Meshtastic_ab12", "xaa", false), - DeviceListEntry("Meshtastic_32ac", "xbb", true) */ - ) - - devices.value = (testnodes.map { it.fullAddress to it }).toMap().toMutableMap() - - // If nothing was selected, by default select the first thing we see - if (selectedAddress == null) - changeScanSelection( - GeeksvilleApplication.currentActivity as MainActivity, - testnodes.first().fullAddress - ) - - true - } else { - if (scanner == null) { - // Clear the old device list - devices.value?.clear() - - // Include a placeholder for "None" - addDevice(DeviceListEntry(context.getString(R.string.none), "n", true)) - - // Include CompanionDeviceManager valid associations - addDeviceAssociations() - - // Include Network Service Discovery - nsdRepository.resolvedList?.forEach { service -> - addDevice(TCPDeviceListEntry(service)) - } - - val serialDevices by lazy { usbRepository.serialDevicesWithDrivers.value } - serialDevices.forEach { (_, d) -> - addDevice(USBDeviceListEntry(usbManager, d)) - } - } else { - debug("scan already running") - } - true - } - } - - private var networkDiscovery: Job? = null - fun startScan() { - // Start Network Service Discovery (find TCP devices) - networkDiscovery = nsdRepository.networkDiscoveryFlow() - .onEach { addDevice(TCPDeviceListEntry(it)) } - .launchIn(CoroutineScope(Dispatchers.Main)) - - if (hasCompanionDeviceApi) { - startCompanionScan() - } else startClassicScan() - } - - @SuppressLint("MissingPermission") - private fun startClassicScan() { - /// The following call might return null if the user doesn't have bluetooth access permissions - val bluetoothLeScanner = bluetoothRepository.getBluetoothLeScanner() - - if (bluetoothLeScanner != null) { // could be null if bluetooth is disabled - debug("starting classic scan") - _spinner.value = true - - // filter and only accept devices that have our service - val filter = - ScanFilter.Builder() - // Samsung doesn't seem to filter properly by service so this can't work - // see https://stackoverflow.com/questions/57981986/altbeacon-android-beacon-library-not-working-after-device-has-screen-off-for-a-s/57995960#57995960 - // and https://stackoverflow.com/a/45590493 - // .setServiceUuid(ParcelUuid(BluetoothInterface.BTM_SERVICE_UUID)) - .build() - - val settings = - ScanSettings.Builder().setScanMode(ScanSettings.SCAN_MODE_LOW_LATENCY) - .build() - bluetoothLeScanner.startScan(listOf(filter), settings, scanCallback) - scanner = bluetoothLeScanner - } - } - - /** - * @return DeviceListEntry from full Address (prefix + address). - * If Bluetooth is enabled and BLE Address is valid, get remote device information. - */ - @SuppressLint("MissingPermission") - fun getDeviceListEntry(fullAddress: String, bonded: Boolean = false): DeviceListEntry { - val address = fullAddress.substring(1) - val device = bluetoothRepository.getRemoteDevice(address) - return if (device != null && device.name != null) { - DeviceListEntry(device.name, fullAddress, device.bondState != BluetoothDevice.BOND_NONE) - } else { - DeviceListEntry(address, fullAddress, bonded) - } - } - - @SuppressLint("NewApi") - fun addDeviceAssociations() { - if (hasCompanionDeviceApi) deviceManager?.associations?.forEach { bleAddress -> - val bleDevice = getDeviceListEntry("x$bleAddress", true) - // Disassociate after pairing is removed (if BLE is disabled, assume bonded) - if (bleDevice.name.startsWith("Mesh") && !bleDevice.bonded) { - debug("Forgetting old BLE association ${bleAddress.anonymize}") - deviceManager?.disassociate(bleAddress) - } - addDevice(bleDevice) - } - } - - private val _spinner = MutableLiveData(false) - val spinner: LiveData get() = _spinner - - private val _associationRequest = MutableLiveData(null) - val associationRequest: LiveData get() = _associationRequest - - /** - * Called immediately after fragment observes CompanionDeviceManager activity result - */ - fun clearAssociationRequest() { - _associationRequest.value = null - } - - @SuppressLint("NewApi") - private fun associationRequest(): AssociationRequest { - // To skip filtering based on name and supported feature flags (UUIDs), - // don't include calls to setNamePattern() and addServiceUuid(), - // respectively. This example uses Bluetooth. - // We only look for Mesh (rather than the full name) because NRF52 uses a very short name - val deviceFilter: BluetoothDeviceFilter = BluetoothDeviceFilter.Builder() - .setNamePattern(Pattern.compile("Mesh.*")) - // .addServiceUuid(ParcelUuid(RadioInterfaceService.BTM_SERVICE_UUID), null) - .build() - - // The argument provided in setSingleDevice() determines whether a single - // device name or a list of device names is presented to the user as - // pairing options. - return AssociationRequest.Builder() - .addDeviceFilter(deviceFilter) - .setSingleDevice(false) - .build() - } - - @SuppressLint("NewApi") - private fun startCompanionScan() { - debug("starting companion scan") - _spinner.value = true - deviceManager?.associate( - associationRequest(), - @SuppressLint("NewApi") - object : CompanionDeviceManager.Callback() { - override fun onDeviceFound(chooserLauncher: IntentSender) { - debug("CompanionDeviceManager - device found") - _spinner.value = false - chooserLauncher.let { - val request: IntentSenderRequest = IntentSenderRequest.Builder(it).build() - _associationRequest.value = request - } - } - - override fun onFailure(error: CharSequence?) { - warn("BLE selection service failed $error") - } - }, null - ) - } - - val devices = object : MutableLiveData>(mutableMapOf()) { - - /** - * Called when the number of active observers change from 1 to 0. - * - * - * This does not mean that there are no observers left, there may still be observers but their - * lifecycle states aren't [Lifecycle.State.STARTED] or [Lifecycle.State.RESUMED] - * (like an Activity in the back stack). - * - * - * You can check if there are observers via [.hasObservers]. - */ - override fun onInactive() { - super.onInactive() - stopScan() - } - } - - /// Called by the GUI when a new device has been selected by the user - /// Returns true if we were able to change to that item - fun onSelected(activity: MainActivity, it: DeviceListEntry): Boolean { - // If the device is paired, let user select it, otherwise start the pairing flow - if (it.bonded) { - changeScanSelection(activity, it.fullAddress) - return true - } else { - // Handle requesting USB or bluetooth permissions for the device - debug("Requesting permissions for the device") - - exceptionReporter { - if (it.isBLE) { - // Request bonding for bluetooth - // We ignore missing BT adapters, because it lets us run on the emulator - bluetoothRepository - .getRemoteDevice(it.address)?.let { device -> - requestBonding(activity, device) { state -> - if (state == BluetoothDevice.BOND_BONDED) { - errorText.value = activity.getString(R.string.pairing_completed) - changeScanSelection(activity, it.fullAddress) - } else { - errorText.value = - activity.getString(R.string.pairing_failed_try_again) - } - - // Force the GUI to redraw - devices.value = devices.value - } - } - } - } - - if (it.isUSB) { - it as USBDeviceListEntry - - val ACTION_USB_PERMISSION = "com.geeksville.mesh.USB_PERMISSION" - - val usbReceiver = object : BroadcastReceiver() { - - override fun onReceive(context: Context, intent: Intent) { - if (ACTION_USB_PERMISSION == intent.action) { - - val device: UsbDevice = - intent.getParcelableExtra(UsbManager.EXTRA_DEVICE)!! - - if (intent.getBooleanExtra( - UsbManager.EXTRA_PERMISSION_GRANTED, - false - ) - ) { - info("User approved USB access") - changeScanSelection(activity, it.fullAddress) - - // Force the GUI to redraw - devices.value = devices.value - } else { - errormsg("USB permission denied for device $device") - } - } - // We don't need to stay registered - activity.unregisterReceiver(this) - } - } - - val permissionIntent = - PendingIntent.getBroadcast(activity, 0, Intent(ACTION_USB_PERMISSION), 0) - val filter = IntentFilter(ACTION_USB_PERMISSION) - activity.registerReceiver(usbReceiver, filter) - usbManager.requestPermission(it.usb.device, permissionIntent) - } - - return false - } - } - - /// Change to a new macaddr selection, updating GUI and radio - fun changeScanSelection(context: MainActivity, newAddr: String) { - try { - info("Changing device to ${newAddr.anonymize}") - changeDeviceSelection(context, newAddr) - selectedAddress = - newAddr // do this after changeDeviceSelection, so if it throws the change will be discarded - devices.value = devices.value // Force a GUI update - } catch (ex: RemoteException) { - errormsg("Failed talking to service, probably it is shutting down $ex.message") - // ignore the failure and the GUI won't be updating anyways - } - } -} - -@SuppressLint("NewApi") @AndroidEntryPoint class SettingsFragment : ScreenFragment("Settings"), Logging { private var _binding: SettingsFragmentBinding? = null