kopia lustrzana https://github.com/meshtastic/Meshtastic-Android
move BTScanModel out of SettingsFragment
rodzic
e5030d2100
commit
6dbfda0e8f
|
@ -36,6 +36,7 @@ import com.geeksville.android.ServiceClient
|
||||||
import com.geeksville.concurrent.handledLaunch
|
import com.geeksville.concurrent.handledLaunch
|
||||||
import com.geeksville.mesh.android.*
|
import com.geeksville.mesh.android.*
|
||||||
import com.geeksville.mesh.databinding.ActivityMainBinding
|
import com.geeksville.mesh.databinding.ActivityMainBinding
|
||||||
|
import com.geeksville.mesh.model.BTScanModel
|
||||||
import com.geeksville.mesh.model.BluetoothViewModel
|
import com.geeksville.mesh.model.BluetoothViewModel
|
||||||
import com.geeksville.mesh.model.ChannelSet
|
import com.geeksville.mesh.model.ChannelSet
|
||||||
import com.geeksville.mesh.model.DeviceVersion
|
import com.geeksville.mesh.model.DeviceVersion
|
||||||
|
|
|
@ -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<String?>(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<Boolean> get() = _spinner
|
||||||
|
|
||||||
|
private val _associationRequest = MutableLiveData<IntentSenderRequest?>(null)
|
||||||
|
val associationRequest: LiveData<IntentSenderRequest?> 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<MutableMap<String, DeviceListEntry>>(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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,30 +1,18 @@
|
||||||
package com.geeksville.mesh.ui
|
package com.geeksville.mesh.ui
|
||||||
|
|
||||||
import android.annotation.SuppressLint
|
import android.annotation.SuppressLint
|
||||||
import android.app.Application
|
|
||||||
import android.app.PendingIntent
|
|
||||||
import android.bluetooth.BluetoothDevice
|
import android.bluetooth.BluetoothDevice
|
||||||
import android.bluetooth.le.*
|
|
||||||
import android.companion.AssociationRequest
|
|
||||||
import android.companion.BluetoothDeviceFilter
|
|
||||||
import android.companion.CompanionDeviceManager
|
import android.companion.CompanionDeviceManager
|
||||||
import android.content.*
|
import android.content.*
|
||||||
import android.hardware.usb.UsbDevice
|
|
||||||
import android.hardware.usb.UsbManager
|
|
||||||
import android.location.LocationManager
|
import android.location.LocationManager
|
||||||
import android.net.nsd.NsdServiceInfo
|
|
||||||
import android.os.*
|
import android.os.*
|
||||||
import android.view.LayoutInflater
|
import android.view.LayoutInflater
|
||||||
import android.view.View
|
import android.view.View
|
||||||
import android.view.ViewGroup
|
import android.view.ViewGroup
|
||||||
import android.view.inputmethod.EditorInfo
|
import android.view.inputmethod.EditorInfo
|
||||||
import android.widget.*
|
import android.widget.*
|
||||||
import androidx.activity.result.IntentSenderRequest
|
|
||||||
import androidx.activity.result.contract.ActivityResultContracts
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
import androidx.fragment.app.activityViewModels
|
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.analytics.DataPair
|
||||||
import com.geeksville.android.GeeksvilleApplication
|
import com.geeksville.android.GeeksvilleApplication
|
||||||
import com.geeksville.android.Logging
|
import com.geeksville.android.Logging
|
||||||
|
@ -35,30 +23,17 @@ import com.geeksville.mesh.R
|
||||||
import com.geeksville.mesh.ConfigProtos
|
import com.geeksville.mesh.ConfigProtos
|
||||||
import com.geeksville.mesh.android.*
|
import com.geeksville.mesh.android.*
|
||||||
import com.geeksville.mesh.databinding.SettingsFragmentBinding
|
import com.geeksville.mesh.databinding.SettingsFragmentBinding
|
||||||
|
import com.geeksville.mesh.model.BTScanModel
|
||||||
import com.geeksville.mesh.model.BluetoothViewModel
|
import com.geeksville.mesh.model.BluetoothViewModel
|
||||||
import com.geeksville.mesh.model.UIViewModel
|
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.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.repository.usb.UsbRepository
|
||||||
import com.geeksville.mesh.service.MeshService
|
import com.geeksville.mesh.service.MeshService
|
||||||
import com.geeksville.mesh.service.SoftwareUpdateService
|
import com.geeksville.mesh.service.SoftwareUpdateService
|
||||||
import com.geeksville.util.anonymize
|
|
||||||
import com.geeksville.util.exceptionReporter
|
|
||||||
import com.geeksville.util.exceptionToSnackbar
|
import com.geeksville.util.exceptionToSnackbar
|
||||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||||
import com.google.android.material.snackbar.Snackbar
|
import com.google.android.material.snackbar.Snackbar
|
||||||
import com.hoho.android.usbserial.driver.UsbSerialDriver
|
|
||||||
import dagger.hilt.android.AndroidEntryPoint
|
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
|
import javax.inject.Inject
|
||||||
|
|
||||||
object SLogging : Logging
|
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<String?>(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<Boolean> get() = _spinner
|
|
||||||
|
|
||||||
private val _associationRequest = MutableLiveData<IntentSenderRequest?>(null)
|
|
||||||
val associationRequest: LiveData<IntentSenderRequest?> 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<MutableMap<String, DeviceListEntry>>(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
|
@AndroidEntryPoint
|
||||||
class SettingsFragment : ScreenFragment("Settings"), Logging {
|
class SettingsFragment : ScreenFragment("Settings"), Logging {
|
||||||
private var _binding: SettingsFragmentBinding? = null
|
private var _binding: SettingsFragmentBinding? = null
|
||||||
|
|
Ładowanie…
Reference in New Issue