From f29d4e2309eea4d9e15f969a31f4630d0937ec13 Mon Sep 17 00:00:00 2001 From: andrekir Date: Fri, 17 Nov 2023 08:46:54 -0300 Subject: [PATCH] refactor: extract Bluetooth and USB API methods to repositories --- .../com/geeksville/mesh/model/BTScanModel.kt | 265 ++++++++---------- .../repository/bluetooth/BluetoothDevice.kt | 31 ++ .../bluetooth/BluetoothLeScanner.kt | 31 ++ .../bluetooth/BluetoothRepository.kt | 33 ++- .../NetworkRepository.kt} | 31 +- .../NetworkRepositoryModule.kt} | 4 +- .../repository/radio/RadioInterfaceService.kt | 6 +- .../repository/radio/SerialInterfaceSpec.kt | 9 +- .../mesh/repository/usb/UsbManager.kt | 39 +++ .../mesh/repository/usb/UsbRepository.kt | 3 + .../geeksville/mesh/ui/SettingsFragment.kt | 158 +---------- .../geeksville/mesh/util/CompatExtensions.kt | 2 - 12 files changed, 288 insertions(+), 324 deletions(-) create mode 100644 app/src/main/java/com/geeksville/mesh/repository/bluetooth/BluetoothDevice.kt create mode 100644 app/src/main/java/com/geeksville/mesh/repository/bluetooth/BluetoothLeScanner.kt rename app/src/main/java/com/geeksville/mesh/repository/{nsd/NsdRepository.kt => network/NetworkRepository.kt} (81%) rename app/src/main/java/com/geeksville/mesh/repository/{nsd/NsdRepositoryModule.kt => network/NetworkRepositoryModule.kt} (90%) create mode 100644 app/src/main/java/com/geeksville/mesh/repository/usb/UsbManager.kt diff --git a/app/src/main/java/com/geeksville/mesh/model/BTScanModel.kt b/app/src/main/java/com/geeksville/mesh/model/BTScanModel.kt index 9026c6ec6..569c0c6e0 100644 --- a/app/src/main/java/com/geeksville/mesh/model/BTScanModel.kt +++ b/app/src/main/java/com/geeksville/mesh/model/BTScanModel.kt @@ -3,13 +3,13 @@ package com.geeksville.mesh.model import android.annotation.SuppressLint import android.app.Application 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.UsbManager import android.net.nsd.NsdServiceInfo +import android.os.RemoteException import androidx.activity.result.IntentSenderRequest import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData @@ -19,14 +19,17 @@ import com.geeksville.mesh.android.Logging 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.network.NetworkRepository import com.geeksville.mesh.repository.radio.InterfaceId import com.geeksville.mesh.repository.radio.RadioInterfaceService import com.geeksville.mesh.repository.usb.UsbRepository +import com.geeksville.mesh.service.MeshService +import com.geeksville.mesh.service.ServiceRepository import com.geeksville.mesh.util.anonymize import com.hoho.android.usbserial.driver.UsbSerialDriver import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach @@ -36,16 +39,16 @@ import javax.inject.Inject @HiltViewModel class BTScanModel @Inject constructor( private val application: Application, + private val serviceRepository: ServiceRepository, private val bluetoothRepository: BluetoothRepository, private val usbRepository: UsbRepository, - private val nsdRepository: NsdRepository, + private val usbManagerLazy: dagger.Lazy, + private val networkRepository: NetworkRepository, private val radioInterfaceService: RadioInterfaceService, ) : ViewModel(), Logging { private val context: Context get() = application.applicationContext val devices = MutableLiveData>(mutableMapOf()) - private val bleDevices = MutableLiveData>(listOf()) - private val usbDevices = MutableLiveData>(mapOf()) val isMockInterfaceAddressValid: Boolean by lazy { radioInterfaceService.isAddressValid(radioInterfaceService.mockInterfaceAddress) @@ -54,11 +57,34 @@ class BTScanModel @Inject constructor( init { combine( bluetoothRepository.state, + networkRepository.resolvedList, usbRepository.serialDevicesWithDrivers - ) { ble, usb -> - bleDevices.value = ble.bondedDevices - usbDevices.value = usb - }.onEach { setupScan() }.launchIn(viewModelScope) + ) { ble, tcp, usb -> + devices.value = mutableMapOf().apply { + fun addDevice(entry: DeviceListEntry) { this[entry.fullAddress] = entry } + + // Include a placeholder for "None" + addDevice(DeviceListEntry(context.getString(R.string.none), "n", true)) + + if (isMockInterfaceAddressValid) { + addDevice(DeviceListEntry("Included simulator", "m", true)) + } + + // Include paired Bluetooth devices + ble.bondedDevices.forEach { + addDevice(BLEDeviceListEntry(it)) + } + + // Include Network Service Discovery + tcp.forEach { service -> + addDevice(TCPDeviceListEntry(service)) + } + + usb.forEach { (_, d) -> + addDevice(USBDeviceListEntry(radioInterfaceService, usbManagerLazy.get(), d)) + } + } + }.launchIn(viewModelScope) debug("BTScanModel created") } @@ -107,136 +133,49 @@ class BTScanModel @Inject constructor( debug("BTScanModel cleared") } - var selectedAddress: String? = null val errorText = MutableLiveData(null) fun setErrorText(text: String) { errorText.value = text } - private var scanner: BluetoothLeScanner? = null + private var scanJob: Job? = null + val selectedAddress get() = radioInterfaceService.getDeviceAddress() val selectedBluetooth: Boolean get() = selectedAddress?.getOrNull(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.let { it != null && it.matches(Regex(BLE_NAME_PATTERN)) }) { - 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, - fullAddr, - isBonded - ) - 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) { + if (scanJob != null) { debug("stopping scan") try { - scanner?.stopScan(scanCallback) + scanJob?.cancel() } catch (ex: Throwable) { warn("Ignoring error stopping scan, probably BT adapter was disabled suddenly: ${ex.message}") } finally { - scanner = null + scanJob = null _spinner.value = false } } else _spinner.value = false } - /** - * returns true if we could start scanning, false otherwise - */ - private fun setupScan(): Boolean { - selectedAddress = radioInterfaceService.getDeviceAddress() - - return if (isMockInterfaceAddressValid) { - warn("Running under emulator/test lab") - - listOf( - DeviceListEntry(context.getString(R.string.none), "n", true), - DeviceListEntry("Included simulator", "m", 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) */ - ).forEach(::addDevice) - true - } else { - if (scanner == null) { - val newDevs = mutableMapOf() - - fun addDevice(entry: DeviceListEntry) { - newDevs[entry.fullAddress] = entry - } - - // Include a placeholder for "None" - addDevice(DeviceListEntry(context.getString(R.string.none), "n", true)) - - // Include paired Bluetooth devices - bleDevices.value?.forEach { - addDevice(BLEDeviceListEntry(it)) - } - - // Include Network Service Discovery - nsdRepository.resolvedList.value.forEach { service -> - addDevice(TCPDeviceListEntry(service)) - } - - usbDevices.value?.forEach { (_, d) -> - addDevice(USBDeviceListEntry(radioInterfaceService, context.usbManager, d)) - } - - devices.value = newDevs - } else { - debug("scan already running") - } - true - } - } - private var networkDiscovery: Job? = null fun startScan(context: Context?) { _spinner.value = true // Start Network Service Discovery (find TCP devices) - networkDiscovery = nsdRepository.networkDiscoveryFlow() - .onEach { addDevice(TCPDeviceListEntry(it)) } + networkDiscovery = networkRepository.networkDiscoveryFlow() .launchIn(viewModelScope) if (context != null) startCompanionScan(context) else startClassicScan() @@ -244,43 +183,95 @@ class BTScanModel @Inject constructor( @SuppressLint("MissingPermission") private fun startClassicScan() { - /// The following call might return null if the user doesn't have bluetooth access permissions - val bluetoothLeScanner = bluetoothRepository.getBluetoothLeScanner() + debug("starting classic scan") - if (bluetoothLeScanner != null) { // could be null if bluetooth is disabled - debug("starting classic scan") + scanJob = bluetoothRepository.scan() + .onEach { result -> + val fullAddress = radioInterfaceService.toInterfaceAddress( + InterfaceId.BLUETOOTH, + result.device.address + ) + // prevent log spam because we'll get lots of redundant scan results + val isBonded = result.device.bondState == BluetoothDevice.BOND_BONDED + val oldDevs = devices.value ?: emptyMap() + val oldEntry = oldDevs[fullAddress] + // Don't spam the GUI with endless updates for non changing nodes + if (oldEntry == null || oldEntry.bonded != isBonded) { + val entry = DeviceListEntry(result.device.name, fullAddress, isBonded) + addDevice(entry) + } + }.catch { ex -> + radioInterfaceService.setErrorMessage("Unexpected Bluetooth scan failure: ${ex.message}") + }.launchIn(viewModelScope) + } - // 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 + private fun changeDeviceAddress(address: String) { + try { + serviceRepository.meshService?.let { service -> + MeshService.changeDeviceAddress(context, service, address) + } + devices.value = devices.value // Force a GUI update + } catch (ex: RemoteException) { + errormsg("changeDeviceSelection failed, probably it is shutting down", ex) + // ignore the failure and the GUI won't be updating anyways } } - fun getRemoteDevice(address: String) = bluetoothRepository.getRemoteDevice(address) - - /** - * @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 = getRemoteDevice(address) - return if (device != null && device.name != null) { - BLEDeviceListEntry(device) + private fun requestBonding(it: DeviceListEntry) { + val device = bluetoothRepository.getRemoteDevice(it.address) ?: return + info("Starting bonding for ${device.anonymize}") + + bluetoothRepository.createBond(device) + .onEach { state -> + debug("Received bond state changed $state") + if (state != BluetoothDevice.BOND_BONDING) { + debug("Bonding completed, state=$state") + if (state == BluetoothDevice.BOND_BONDED) { + setErrorText(context.getString(R.string.pairing_completed)) + changeDeviceAddress(it.fullAddress) + } else { + setErrorText(context.getString(R.string.pairing_failed_try_again)) + } + } + }.catch { ex -> + // We ignore missing BT adapters, because it lets us run on the emulator + warn("Failed creating Bluetooth bond: ${ex.message}") + }.launchIn(viewModelScope) + } + + private fun requestPermission(it: USBDeviceListEntry) { + usbRepository.requestPermission(it.usb.device) + .onEach { granted -> + if (granted) { + info("User approved USB access") + changeDeviceAddress(it.fullAddress) + } else { + errormsg("USB permission denied for device ${it.address}") + } + }.launchIn(viewModelScope) + } + + // 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(it: DeviceListEntry): Boolean { + // If the device is paired, let user select it, otherwise start the pairing flow + if (it.bonded) { + changeDeviceAddress(it.fullAddress) + return true } else { - DeviceListEntry(address, fullAddress, bonded) + // Handle requesting USB or bluetooth permissions for the device + debug("Requesting permissions for the device") + + if (it.isBLE) { + requestBonding(it) + } + + if (it.isUSB) { + requestPermission(it as USBDeviceListEntry) + } + + return false } } @@ -322,7 +313,6 @@ class BTScanModel @Inject constructor( debug("starting companion scan") context.companionDeviceManager?.associate( associationRequest(), - @SuppressLint("NewApi") object : CompanionDeviceManager.Callback() { @Deprecated("Deprecated in Java", ReplaceWith("onAssociationPending(intentSender)")) override fun onDeviceFound(intentSender: IntentSender) { @@ -345,16 +335,7 @@ class BTScanModel @Inject constructor( ) } - /** - * Called immediately after activity calls MeshService.changeDeviceAddress - */ - fun changeSelectedAddress(newAddress: String) { - selectedAddress = newAddress - devices.value = devices.value // Force a GUI update - } - companion object { const val BLE_NAME_PATTERN = BluetoothRepository.BLE_NAME_PATTERN - const val ACTION_USB_PERMISSION = "com.geeksville.mesh.USB_PERMISSION" } } diff --git a/app/src/main/java/com/geeksville/mesh/repository/bluetooth/BluetoothDevice.kt b/app/src/main/java/com/geeksville/mesh/repository/bluetooth/BluetoothDevice.kt new file mode 100644 index 000000000..85c09c1f8 --- /dev/null +++ b/app/src/main/java/com/geeksville/mesh/repository/bluetooth/BluetoothDevice.kt @@ -0,0 +1,31 @@ +package com.geeksville.mesh.repository.bluetooth + +import android.bluetooth.BluetoothDevice +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import androidx.annotation.RequiresPermission +import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.callbackFlow + +@RequiresPermission("android.permission.BLUETOOTH_CONNECT") +internal fun BluetoothDevice.createBond(context: Context): Flow = callbackFlow { + val receiver = object : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) { + val state = intent.getIntExtra(BluetoothDevice.EXTRA_BOND_STATE, -1) + trySend(state) + + // we stay registered until bonding completes (either with BONDED or NONE) + if (state != BluetoothDevice.BOND_BONDING) { + close() + } + } + } + val filter = IntentFilter(BluetoothDevice.ACTION_BOND_STATE_CHANGED) + context.registerReceiver(receiver, filter) + createBond() + + awaitClose { context.unregisterReceiver(receiver) } +} diff --git a/app/src/main/java/com/geeksville/mesh/repository/bluetooth/BluetoothLeScanner.kt b/app/src/main/java/com/geeksville/mesh/repository/bluetooth/BluetoothLeScanner.kt new file mode 100644 index 000000000..920f153c9 --- /dev/null +++ b/app/src/main/java/com/geeksville/mesh/repository/bluetooth/BluetoothLeScanner.kt @@ -0,0 +1,31 @@ +package com.geeksville.mesh.repository.bluetooth + +import android.bluetooth.le.BluetoothLeScanner +import android.bluetooth.le.ScanCallback +import android.bluetooth.le.ScanFilter +import android.bluetooth.le.ScanResult +import android.bluetooth.le.ScanSettings +import androidx.annotation.RequiresPermission +import kotlinx.coroutines.cancel +import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.callbackFlow + +@RequiresPermission("android.permission.BLUETOOTH_SCAN") +internal fun BluetoothLeScanner.scan( + filters: List = emptyList(), + scanSettings: ScanSettings = ScanSettings.Builder().build(), +): Flow = callbackFlow { + val callback = object : ScanCallback() { + override fun onScanResult(callbackType: Int, result: ScanResult) { + trySend(result) + } + + override fun onScanFailed(errorCode: Int) { + cancel("onScanFailed() called with errorCode: $errorCode") + } + } + startScan(filters, scanSettings, callback) + + awaitClose { stopScan(callback) } +} diff --git a/app/src/main/java/com/geeksville/mesh/repository/bluetooth/BluetoothRepository.kt b/app/src/main/java/com/geeksville/mesh/repository/bluetooth/BluetoothRepository.kt index 4c7f2a805..fd8cb140f 100644 --- a/app/src/main/java/com/geeksville/mesh/repository/bluetooth/BluetoothRepository.kt +++ b/app/src/main/java/com/geeksville/mesh/repository/bluetooth/BluetoothRepository.kt @@ -5,12 +5,21 @@ import android.app.Application import android.bluetooth.BluetoothAdapter import android.bluetooth.BluetoothDevice import android.bluetooth.le.BluetoothLeScanner +import android.bluetooth.le.ScanFilter +import android.bluetooth.le.ScanResult +import android.bluetooth.le.ScanSettings +import androidx.annotation.RequiresPermission import androidx.lifecycle.Lifecycle import androidx.lifecycle.coroutineScope import com.geeksville.mesh.android.Logging import com.geeksville.mesh.CoroutineDispatchers import com.geeksville.mesh.android.hasBluetoothPermission -import kotlinx.coroutines.flow.* +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.emptyFlow +import kotlinx.coroutines.flow.filter import kotlinx.coroutines.launch import javax.inject.Inject import javax.inject.Singleton @@ -59,12 +68,32 @@ class BluetoothRepository @Inject constructor( ?.getRemoteDevice(address) } - fun getBluetoothLeScanner(): BluetoothLeScanner? { + private fun getBluetoothLeScanner(): BluetoothLeScanner? { return bluetoothAdapterLazy.get() ?.takeIf { application.hasBluetoothPermission() } ?.bluetoothLeScanner } + @SuppressLint("MissingPermission") + fun scan(): Flow { + 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() + + return getBluetoothLeScanner()?.scan(listOf(filter), settings) + ?.filter { it.device.name?.matches(Regex(BLE_NAME_PATTERN)) == true } ?: emptyFlow() + } + + @RequiresPermission("android.permission.BLUETOOTH_CONNECT") + fun createBond(device: BluetoothDevice): Flow = device.createBond(application) + @SuppressLint("MissingPermission") internal suspend fun updateBluetoothState() { val newState: BluetoothState = bluetoothAdapterLazy.get()?.takeIf { diff --git a/app/src/main/java/com/geeksville/mesh/repository/nsd/NsdRepository.kt b/app/src/main/java/com/geeksville/mesh/repository/network/NetworkRepository.kt similarity index 81% rename from app/src/main/java/com/geeksville/mesh/repository/nsd/NsdRepository.kt rename to app/src/main/java/com/geeksville/mesh/repository/network/NetworkRepository.kt index 794a1bd01..7a4b000a2 100644 --- a/app/src/main/java/com/geeksville/mesh/repository/nsd/NsdRepository.kt +++ b/app/src/main/java/com/geeksville/mesh/repository/network/NetworkRepository.kt @@ -1,4 +1,4 @@ -package com.geeksville.mesh.repository.nsd +package com.geeksville.mesh.repository.network import android.net.ConnectivityManager import android.net.Network @@ -6,21 +6,18 @@ import android.net.NetworkRequest import android.net.nsd.NsdManager import android.net.nsd.NsdServiceInfo import com.geeksville.mesh.android.Logging -import com.geeksville.mesh.CoroutineDispatchers import kotlinx.coroutines.channels.awaitClose import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.callbackFlow -import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Semaphore import javax.inject.Inject import javax.inject.Singleton @Singleton -class NsdRepository @Inject constructor( - private val dispatchers: CoroutineDispatchers, +class NetworkRepository @Inject constructor( private val nsdManagerLazy: dagger.Lazy, private val connectivityManager: dagger.Lazy, ) : Logging { @@ -43,24 +40,20 @@ class NsdRepository @Inject constructor( awaitClose { connectivityManager.get().unregisterNetworkCallback(callback) } } - private val resolveQueue = Semaphore(1) - private val hostsList = mutableListOf() - private val _resolvedList = MutableStateFlow>(emptyList()) val resolvedList: StateFlow> get() = _resolvedList private val _networkDiscovery: Flow = callbackFlow { + val resolveQueue = Semaphore(1) + val hostsList = mutableListOf() val discoveryListener = object : NsdManager.DiscoveryListener { - override fun onDiscoveryStarted(regType: String) { - debug("Service discovery started: $regType") - hostsList.clear() + override fun onDiscoveryStarted(serviceType: String) { + debug("Service discovery started: $serviceType") } override fun onServiceFound(service: NsdServiceInfo) { debug("Service discovery success: $service") - if (service.serviceType == SERVICE_TYPE && - service.serviceName.contains(serviceName) - ) { + if (service.serviceName.contains(SERVICE_NAME)) { val resolveListener = object : NsdManager.ResolveListener { override fun onServiceResolved(service: NsdServiceInfo) { debug("Resolve Succeeded: $service") @@ -97,26 +90,24 @@ class NsdRepository @Inject constructor( override fun onStartDiscoveryFailed(serviceType: String, errorCode: Int) { debug("Start Discovery failed: Error code: $errorCode") - nsdManagerLazy.get()?.stopServiceDiscovery(this) } override fun onStopDiscoveryFailed(serviceType: String, errorCode: Int) { debug("Stop Discovery failed: Error code: $errorCode") - nsdManagerLazy.get()?.stopServiceDiscovery(this) } } nsdManagerLazy.get() ?.discoverServices(SERVICE_TYPE, NsdManager.PROTOCOL_DNS_SD, discoveryListener) awaitClose { nsdManagerLazy.get()?.stopServiceDiscovery(discoveryListener) } - }.flowOn(dispatchers.default) + } fun networkDiscoveryFlow(): Flow { return _networkDiscovery } companion object { - //To find all the available networks SERVICE_TYPE = "_services._dns-sd._udp" - const val SERVICE_TYPE = "_https._tcp." - const val serviceName = "Meshtastic" + // To find all available services use SERVICE_TYPE = "_services._dns-sd._udp" + internal const val SERVICE_NAME = "Meshtastic" + internal const val SERVICE_TYPE = "_https._tcp." } } diff --git a/app/src/main/java/com/geeksville/mesh/repository/nsd/NsdRepositoryModule.kt b/app/src/main/java/com/geeksville/mesh/repository/network/NetworkRepositoryModule.kt similarity index 90% rename from app/src/main/java/com/geeksville/mesh/repository/nsd/NsdRepositoryModule.kt rename to app/src/main/java/com/geeksville/mesh/repository/network/NetworkRepositoryModule.kt index 6c06d1be5..bb54a0134 100644 --- a/app/src/main/java/com/geeksville/mesh/repository/nsd/NsdRepositoryModule.kt +++ b/app/src/main/java/com/geeksville/mesh/repository/network/NetworkRepositoryModule.kt @@ -1,4 +1,4 @@ -package com.geeksville.mesh.repository.nsd +package com.geeksville.mesh.repository.network import android.app.Application import android.content.Context @@ -11,7 +11,7 @@ import dagger.hilt.components.SingletonComponent @Module @InstallIn(SingletonComponent::class) -class NsdRepositoryModule { +class NetworkRepositoryModule { companion object { @Provides fun provideConnectivityManager(application: Application): ConnectivityManager { diff --git a/app/src/main/java/com/geeksville/mesh/repository/radio/RadioInterfaceService.kt b/app/src/main/java/com/geeksville/mesh/repository/radio/RadioInterfaceService.kt index 540673d2d..2ba121229 100644 --- a/app/src/main/java/com/geeksville/mesh/repository/radio/RadioInterfaceService.kt +++ b/app/src/main/java/com/geeksville/mesh/repository/radio/RadioInterfaceService.kt @@ -11,7 +11,7 @@ import com.geeksville.mesh.android.GeeksvilleApplication import com.geeksville.mesh.android.Logging import com.geeksville.mesh.concurrent.handledLaunch import com.geeksville.mesh.repository.bluetooth.BluetoothRepository -import com.geeksville.mesh.repository.nsd.NsdRepository +import com.geeksville.mesh.repository.network.NetworkRepository import com.geeksville.mesh.util.anonymize import com.geeksville.mesh.util.ignoreException import com.geeksville.mesh.util.toRemoteExceptions @@ -44,7 +44,7 @@ class RadioInterfaceService @Inject constructor( private val context: Application, private val dispatchers: CoroutineDispatchers, bluetoothRepository: BluetoothRepository, - nsdRepository: NsdRepository, + networkRepository: NetworkRepository, private val processLifecycle: Lifecycle, @RadioRepositoryQualifier private val prefs: SharedPreferences, private val interfaceFactory: InterfaceFactory, @@ -99,7 +99,7 @@ class RadioInterfaceService @Inject constructor( else if (radioIf is BluetoothInterface) stopInterface() }.launchIn(processLifecycle.coroutineScope) - nsdRepository.networkAvailable.onEach { state -> + networkRepository.networkAvailable.onEach { state -> if (state) startInterface() else if (radioIf is TCPInterface) stopInterface() }.launchIn(processLifecycle.coroutineScope) diff --git a/app/src/main/java/com/geeksville/mesh/repository/radio/SerialInterfaceSpec.kt b/app/src/main/java/com/geeksville/mesh/repository/radio/SerialInterfaceSpec.kt index 0eb28a3ba..170694efb 100644 --- a/app/src/main/java/com/geeksville/mesh/repository/radio/SerialInterfaceSpec.kt +++ b/app/src/main/java/com/geeksville/mesh/repository/radio/SerialInterfaceSpec.kt @@ -1,7 +1,6 @@ package com.geeksville.mesh.repository.radio -import android.app.Application -import com.geeksville.mesh.android.usbManager +import android.hardware.usb.UsbManager import com.geeksville.mesh.repository.usb.UsbRepository import com.hoho.android.usbserial.driver.UsbSerialDriver import javax.inject.Inject @@ -11,7 +10,7 @@ import javax.inject.Inject */ class SerialInterfaceSpec @Inject constructor( private val factory: SerialInterfaceFactory, - private val context: Application, + private val usbManager: dagger.Lazy, private val usbRepository: UsbRepository, ): InterfaceSpec { override fun createInterface(rest: String): SerialInterface { @@ -22,10 +21,10 @@ class SerialInterfaceSpec @Inject constructor( rest: String ): Boolean { usbRepository.serialDevicesWithDrivers.value.filterValues { - context.usbManager.hasPermission(it.device) + usbManager.get().hasPermission(it.device) } findSerial(rest)?.let { d -> - return context.usbManager.hasPermission(d.device) + return usbManager.get().hasPermission(d.device) } return false } diff --git a/app/src/main/java/com/geeksville/mesh/repository/usb/UsbManager.kt b/app/src/main/java/com/geeksville/mesh/repository/usb/UsbManager.kt new file mode 100644 index 000000000..8bdf50b44 --- /dev/null +++ b/app/src/main/java/com/geeksville/mesh/repository/usb/UsbManager.kt @@ -0,0 +1,39 @@ +package com.geeksville.mesh.repository.usb + +import android.app.PendingIntent +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import android.hardware.usb.UsbDevice +import android.hardware.usb.UsbManager +import com.geeksville.mesh.util.PendingIntentCompat +import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.callbackFlow + +private const val ACTION_USB_PERMISSION = "com.geeksville.mesh.USB_PERMISSION" + +internal fun UsbManager.requestPermission( + context: Context, + device: UsbDevice, +): Flow = callbackFlow { + val receiver = object : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) { + val granted = intent.getBooleanExtra(UsbManager.EXTRA_PERMISSION_GRANTED, false) + trySend(granted) + close() + } + } + val permissionIntent = PendingIntent.getBroadcast( + context, + 0, + Intent(ACTION_USB_PERMISSION), + PendingIntentCompat.FLAG_MUTABLE + ) + val filter = IntentFilter(ACTION_USB_PERMISSION) + context.registerReceiver(receiver, filter) + requestPermission(device, permissionIntent) + + awaitClose { context.unregisterReceiver(receiver) } +} diff --git a/app/src/main/java/com/geeksville/mesh/repository/usb/UsbRepository.kt b/app/src/main/java/com/geeksville/mesh/repository/usb/UsbRepository.kt index 3a0bec1c0..d9301cd4e 100644 --- a/app/src/main/java/com/geeksville/mesh/repository/usb/UsbRepository.kt +++ b/app/src/main/java/com/geeksville/mesh/repository/usb/UsbRepository.kt @@ -75,6 +75,9 @@ class UsbRepository @Inject constructor( return SerialConnectionImpl(usbManagerLazy, device, listener) } + fun requestPermission(device: UsbDevice): Flow = + usbManagerLazy.get()?.requestPermission(application, device) ?: emptyFlow() + fun refreshState() { processLifecycle.coroutineScope.launch(dispatchers.default) { refreshStateInternal() 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 ce784aae3..944436ff1 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/SettingsFragment.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/SettingsFragment.kt @@ -1,18 +1,14 @@ package com.geeksville.mesh.ui -import android.annotation.SuppressLint -import android.app.PendingIntent import android.bluetooth.BluetoothDevice import android.companion.CompanionDeviceManager import android.content.BroadcastReceiver import android.content.Context import android.content.Intent import android.content.IntentFilter -import android.hardware.usb.UsbManager import android.os.Bundle import android.os.Handler import android.os.Looper -import android.os.RemoteException import android.util.Patterns import android.view.LayoutInflater import android.view.View @@ -24,12 +20,8 @@ import android.widget.RadioButton import androidx.activity.result.contract.ActivityResultContracts import androidx.core.widget.doAfterTextChanged import androidx.fragment.app.activityViewModels -import androidx.lifecycle.Lifecycle import androidx.lifecycle.asLiveData -import androidx.lifecycle.lifecycleScope -import androidx.lifecycle.repeatOnLifecycle import com.geeksville.mesh.ConfigProtos -import com.geeksville.mesh.MainActivity import com.geeksville.mesh.R import com.geeksville.mesh.analytics.DataPair import com.geeksville.mesh.ModuleConfigProtos @@ -42,15 +34,11 @@ import com.geeksville.mesh.model.getInitials import com.geeksville.mesh.repository.location.LocationRepository import com.geeksville.mesh.service.MeshService import com.geeksville.mesh.service.SoftwareUpdateService -import com.geeksville.mesh.util.PendingIntentCompat -import com.geeksville.mesh.util.anonymize -import com.geeksville.mesh.util.exceptionReporter import com.geeksville.mesh.util.exceptionToSnackbar import com.geeksville.mesh.util.getParcelableExtraCompat import com.geeksville.mesh.util.onEditorAction import com.google.android.material.dialog.MaterialAlertDialogBuilder import dagger.hilt.android.AndroidEntryPoint -import kotlinx.coroutines.launch import javax.inject.Inject @AndroidEntryPoint @@ -67,8 +55,6 @@ class SettingsFragment : ScreenFragment("Settings"), Logging { @Inject internal lateinit var locationRepository: LocationRepository - private val myActivity get() = requireActivity() as MainActivity - private val hasGps by lazy { requireContext().hasGps() } private val hasCompanionDeviceApi by lazy { requireContext().hasCompanionDeviceApi() } private val useCompanionDeviceApi by lazy { @@ -239,14 +225,12 @@ class SettingsFragment : ScreenFragment("Settings"), Logging { ) { it.data ?.getParcelableExtraCompat(CompanionDeviceManager.EXTRA_DEVICE) - ?.let { device -> onSelected(BTScanModel.BLEDeviceListEntry(device)) } + ?.let { device -> scanModel.onSelected(BTScanModel.BLEDeviceListEntry(device)) } } val requestBackgroundAndCheckLauncher = registerForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { permissions -> - if (permissions.entries.all { it.value }) { - binding.provideLocationCheckbox.isChecked = true - } else { + if (permissions.entries.any { !it.value }) { debug("User denied background permission") model.showSnackbar(getString(R.string.why_background_required)) } @@ -256,9 +240,8 @@ class SettingsFragment : ScreenFragment("Settings"), Logging { registerForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { permissions -> if (permissions.entries.all { it.value }) { // Older versions of android only need Location permission - if (requireContext().hasBackgroundPermission()) { - binding.provideLocationCheckbox.isChecked = true - } else requestBackgroundAndCheckLauncher.launch(requireContext().getBackgroundPermissions()) + if (!requireContext().hasBackgroundPermission()) + requestBackgroundAndCheckLauncher.launch(requireContext().getBackgroundPermissions()) } else { debug("User denied location permission") model.showSnackbar(getString(R.string.why_background_required)) @@ -357,12 +340,8 @@ class SettingsFragment : ScreenFragment("Settings"), Logging { } // Observe receivingLocationUpdates state and update provideLocationCheckbox - viewLifecycleOwner.lifecycleScope.launch { - viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { - locationRepository.receivingLocationUpdates.collect { - binding.provideLocationCheckbox.isChecked = it - } - } + locationRepository.receivingLocationUpdates.asLiveData().observe(viewLifecycleOwner) { + binding.provideLocationCheckbox.isChecked = it } binding.provideLocationCheckbox.setOnCheckedChangeListener { view, isChecked -> @@ -439,8 +418,7 @@ class SettingsFragment : ScreenFragment("Settings"), Logging { b.setOnClickListener { if (!device.bonded) // If user just clicked on us, try to bond binding.scanStatusText.setText(R.string.starting_pairing) - - b.isChecked = onSelected(device) + b.isChecked = scanModel.onSelected(device) } } @@ -452,12 +430,8 @@ class SettingsFragment : ScreenFragment("Settings"), Logging { binding.deviceRadioGroup.addView(b) - b.setOnClickListener { - - - b.isChecked = onSelected(BTScanModel.DeviceListEntry("", "t" + e.text, true)) - + b.isChecked = scanModel.onSelected(BTScanModel.DeviceListEntry("", "t" + e.text, true)) } binding.deviceRadioGroup.addView(e) e.doAfterTextChanged { @@ -486,7 +460,7 @@ class SettingsFragment : ScreenFragment("Settings"), Logging { // and before use val curAddr = scanModel.selectedAddress if (curAddr != null) { - val curDevice = scanModel.getDeviceListEntry(curAddr) + val curDevice = BTScanModel.DeviceListEntry(curAddr.substring(1), curAddr, false) addDeviceButton(curDevice, model.isConnected()) } } @@ -522,115 +496,6 @@ class SettingsFragment : ScreenFragment("Settings"), Logging { } } - private fun changeDeviceAddress(address: String) { - try { - model.meshService?.let { service -> - MeshService.changeDeviceAddress(requireActivity(), service, address) - } - scanModel.changeSelectedAddress(address) // if it throws the change will be discarded - } catch (ex: RemoteException) { - errormsg("changeDeviceSelection failed, probably it is shutting down $ex.message") - // ignore the failure and the GUI won't be updating anyways - } - } - - /// 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 - private fun onSelected(it: BTScanModel.DeviceListEntry): Boolean { - // If the device is paired, let user select it, otherwise start the pairing flow - if (it.bonded) { - changeDeviceAddress(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 - scanModel.getRemoteDevice(it.address)?.let { device -> - requestBonding(device) { state -> - if (state == BluetoothDevice.BOND_BONDED) { - scanModel.setErrorText(getString(R.string.pairing_completed)) - changeDeviceAddress(it.fullAddress) - } else { - scanModel.setErrorText(getString(R.string.pairing_failed_try_again)) - } - } - } - } - } - - if (it.isUSB) { - it as BTScanModel.USBDeviceListEntry - - val usbReceiver = object : BroadcastReceiver() { - - override fun onReceive(context: Context, intent: Intent) { - if (BTScanModel.ACTION_USB_PERMISSION != intent.action) return - - if (intent.getBooleanExtra(UsbManager.EXTRA_PERMISSION_GRANTED, false)) { - info("User approved USB access") - changeDeviceAddress(it.fullAddress) - } else { - errormsg("USB permission denied for device ${it.address}") - } - // We don't need to stay registered - requireActivity().unregisterReceiver(this) - } - } - - val permissionIntent = PendingIntent.getBroadcast( - activity, - 0, - Intent(BTScanModel.ACTION_USB_PERMISSION), - PendingIntentCompat.FLAG_MUTABLE - ) - val filter = IntentFilter(BTScanModel.ACTION_USB_PERMISSION) - requireActivity().registerReceiver(usbReceiver, filter) - requireContext().usbManager.requestPermission(it.usb.device, permissionIntent) - } - - return false - } - } - - /// Show the UI asking the user to bond with a device, call changeSelection() if/when bonding completes - @SuppressLint("MissingPermission") - private fun requestBonding( - device: BluetoothDevice, - onComplete: (Int) -> Unit - ) { - 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) - 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) - debug("Bonding completed, state=$state") - onComplete(state) - } - } - } - - val filter = IntentFilter() - filter.addAction(BluetoothDevice.ACTION_BOND_STATE_CHANGED) - requireActivity().registerReceiver(bondChangedReceiver, filter) - - // We ignore missing BT adapters, because it lets us run on the emulator - try { - device.createBond() - } catch (ex: Throwable) { - warn("Failed creating Bluetooth bond: ${ex.message}") - } - } - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) @@ -702,10 +567,7 @@ class SettingsFragment : ScreenFragment("Settings"), Logging { override fun onResume() { super.onResume() - // system permissions might have changed while we were away - binding.provideLocationCheckbox.isChecked = requireContext().hasBackgroundPermission() && (model.provideLocation.value ?: false) - - myActivity.registerReceiver(updateProgressReceiver, updateProgressFilter) + requireActivity().registerReceiver(updateProgressReceiver, updateProgressFilter) // Warn user if BLE device is selected but BLE disabled if (scanModel.selectedBluetooth) checkBTEnabled() diff --git a/app/src/main/java/com/geeksville/mesh/util/CompatExtensions.kt b/app/src/main/java/com/geeksville/mesh/util/CompatExtensions.kt index 53046dfc2..cfad127aa 100644 --- a/app/src/main/java/com/geeksville/mesh/util/CompatExtensions.kt +++ b/app/src/main/java/com/geeksville/mesh/util/CompatExtensions.kt @@ -1,8 +1,6 @@ package com.geeksville.mesh.util -import android.annotation.SuppressLint import android.app.PendingIntent -import android.companion.CompanionDeviceManager import android.content.Intent import android.content.pm.PackageInfo import android.content.pm.PackageManager