refactor: extract Bluetooth and USB API methods to repositories

This commit is contained in:
andrekir
2023-11-17 08:46:54 -03:00
parent c6ad5dcfdf
commit f29d4e2309
12 changed files with 288 additions and 324 deletions

View File

@@ -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<UsbManager>,
private val networkRepository: NetworkRepository,
private val radioInterfaceService: RadioInterfaceService,
) : ViewModel(), Logging {
private val context: Context get() = application.applicationContext
val devices = MutableLiveData<MutableMap<String, DeviceListEntry>>(mutableMapOf())
private val bleDevices = MutableLiveData<List<BluetoothDevice>>(listOf())
private val usbDevices = MutableLiveData<Map<String, UsbSerialDriver>>(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<String, DeviceListEntry>().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<String?>(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<String, DeviceListEntry>()
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"
}
}

View File

@@ -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<Int> = 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) }
}

View File

@@ -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<ScanFilter> = emptyList(),
scanSettings: ScanSettings = ScanSettings.Builder().build(),
): Flow<ScanResult> = 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) }
}

View File

@@ -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<ScanResult> {
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<Int> = device.createBond(application)
@SuppressLint("MissingPermission")
internal suspend fun updateBluetoothState() {
val newState: BluetoothState = bluetoothAdapterLazy.get()?.takeIf {

View File

@@ -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<NsdManager?>,
private val connectivityManager: dagger.Lazy<ConnectivityManager>,
) : Logging {
@@ -43,24 +40,20 @@ class NsdRepository @Inject constructor(
awaitClose { connectivityManager.get().unregisterNetworkCallback(callback) }
}
private val resolveQueue = Semaphore(1)
private val hostsList = mutableListOf<NsdServiceInfo>()
private val _resolvedList = MutableStateFlow<List<NsdServiceInfo>>(emptyList())
val resolvedList: StateFlow<List<NsdServiceInfo>> get() = _resolvedList
private val _networkDiscovery: Flow<NsdServiceInfo> = callbackFlow {
val resolveQueue = Semaphore(1)
val hostsList = mutableListOf<NsdServiceInfo>()
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<NsdServiceInfo> {
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."
}
}

View File

@@ -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 {

View File

@@ -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)

View File

@@ -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<UsbManager>,
private val usbRepository: UsbRepository,
): InterfaceSpec<SerialInterface> {
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
}

View File

@@ -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<Boolean> = 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) }
}

View File

@@ -75,6 +75,9 @@ class UsbRepository @Inject constructor(
return SerialConnectionImpl(usbManagerLazy, device, listener)
}
fun requestPermission(device: UsbDevice): Flow<Boolean> =
usbManagerLazy.get()?.requestPermission(application, device) ?: emptyFlow()
fun refreshState() {
processLifecycle.coroutineScope.launch(dispatchers.default) {
refreshStateInternal()

View File

@@ -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<BluetoothDevice>(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()

View File

@@ -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