refactor: move activity out of BTScanModel

This commit is contained in:
andrekir
2023-04-03 18:29:41 -03:00
parent b7d91224e4
commit 7fedc2b0e1
3 changed files with 159 additions and 190 deletions

View File

@@ -644,6 +644,20 @@ class MainActivity : AppCompatActivity(), Logging {
override fun onStart() {
super.onStart()
scanModel.changeDeviceAddress.observe(this) { newAddr ->
newAddr?.let {
try {
model.meshService?.let { service ->
MeshService.changeDeviceAddress(this, service, newAddr)
}
scanModel.changeSelectedAddress(newAddr) // 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
}
}
}
bluetoothViewModel.enabled.observe(this) { enabled ->
if (!enabled && !requestedEnable && scanModel.selectedBluetooth) {
requestedEnable = true

View File

@@ -2,25 +2,20 @@ 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 androidx.lifecycle.viewModelScope
import com.geeksville.mesh.android.GeeksvilleApplication
import com.geeksville.mesh.android.Logging
import com.geeksville.mesh.MainActivity
import com.geeksville.mesh.R
import com.geeksville.mesh.android.*
import com.geeksville.mesh.repository.bluetooth.BluetoothRepository
@@ -29,10 +24,7 @@ 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.mesh.util.anonymize
import com.geeksville.mesh.util.exceptionReporter
import com.hoho.android.usbserial.driver.UsbSerialDriver
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Job
@@ -41,46 +33,6 @@ 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,
@@ -181,16 +133,8 @@ class BTScanModel @Inject constructor(
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)
if (selectedAddress == null && entry.bonded)
changeDeviceAddress(fullAddr)
addDevice(entry) // Add/replace entry
}
}
@@ -244,12 +188,8 @@ class BTScanModel @Inject constructor(
devices.value = (testnodes.map { it.fullAddress to it }).toMap().toMutableMap()
// If nothing was selected, by default select the first thing we see
val activity = GeeksvilleApplication.currentActivity
if (selectedAddress == null && activity is MainActivity)
changeScanSelection(
activity,
testnodes.first().fullAddress
)
if (selectedAddress == null)
changeDeviceAddress(testnodes.first().fullAddress)
true
} else {
@@ -319,6 +259,8 @@ class BTScanModel @Inject constructor(
}
}
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.
@@ -326,9 +268,9 @@ class BTScanModel @Inject constructor(
@SuppressLint("MissingPermission")
fun getDeviceListEntry(fullAddress: String, bonded: Boolean = false): DeviceListEntry {
val address = fullAddress.substring(1)
val device = bluetoothRepository.getRemoteDevice(address)
val device = getRemoteDevice(address)
return if (device != null && device.name != null) {
DeviceListEntry(device.name, fullAddress, device.bondState != BluetoothDevice.BOND_NONE)
BLEDeviceListEntry(device)
} else {
DeviceListEntry(address, fullAddress, bonded)
}
@@ -399,118 +341,28 @@ class BTScanModel @Inject constructor(
)
}
val devices = object : MutableLiveData<MutableMap<String, DeviceListEntry>>(mutableMapOf()) {
val devices = 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 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 =
if (android.os.Build.VERSION.SDK_INT < android.os.Build.VERSION_CODES.S) {
PendingIntent.getBroadcast(activity, 0, Intent(ACTION_USB_PERMISSION), 0)
} else {
PendingIntent.getBroadcast(activity, 0, Intent(ACTION_USB_PERMISSION), PendingIntent.FLAG_IMMUTABLE)
}
val filter = IntentFilter(ACTION_USB_PERMISSION)
activity.registerReceiver(usbReceiver, filter)
usbManager.requestPermission(it.usb.device, permissionIntent)
}
return false
}
}
private val _changeDeviceAddress = MutableLiveData<String?>(null)
val changeDeviceAddress: LiveData<String?> get() = _changeDeviceAddress
/// 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
}
fun changeDeviceAddress(newAddr: String) {
info("Changing device to ${newAddr.anonymize}")
_changeDeviceAddress.value = newAddr
}
/**
* Called immediately after activity observes changeDeviceAddress
*/
fun changeSelectedAddress(newAddress: String) {
_changeDeviceAddress.value = null
selectedAddress = newAddress
devices.value = devices.value // Force a GUI update
}
companion object {
const val BLE_NAME_PATTERN = "^.*_([0-9a-fA-F]{4})$"
const val ACTION_USB_PERMISSION = "com.geeksville.mesh.USB_PERMISSION"
}
}
}

View File

@@ -1,11 +1,15 @@
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.UsbDevice
import android.hardware.usb.UsbManager
import android.os.Bundle
import android.os.Handler
import android.os.Looper
@@ -43,6 +47,8 @@ import com.geeksville.mesh.repository.radio.MockInterface
import com.geeksville.mesh.repository.usb.UsbRepository
import com.geeksville.mesh.service.MeshService
import com.geeksville.mesh.service.SoftwareUpdateService
import com.geeksville.mesh.util.anonymize
import com.geeksville.mesh.util.exceptionReporter
import com.geeksville.mesh.util.exceptionToSnackbar
import com.geeksville.mesh.util.onEditorAction
import com.google.android.material.dialog.MaterialAlertDialogBuilder
@@ -53,16 +59,6 @@ import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import javax.inject.Inject
object SLogging : Logging
/// Change to a new macaddr selection, updating GUI and radio
fun changeDeviceSelection(context: MainActivity, newAddr: String?) {
// FIXME, this is a kinda yucky way to find the service
context.model.meshService?.let { service ->
MeshService.changeDeviceAddress(context, service, newAddr)
}
}
@AndroidEntryPoint
class SettingsFragment : ScreenFragment("Settings"), Logging {
private var _binding: SettingsFragmentBinding? = null
@@ -250,10 +246,7 @@ class SettingsFragment : ScreenFragment("Settings"), Logging {
it.data
?.getParcelableExtra<BluetoothDevice>(CompanionDeviceManager.EXTRA_DEVICE)
?.let { device ->
scanModel.onSelected(
myActivity,
BTScanModel.BLEDeviceListEntry(device)
)
onSelected(BTScanModel.BLEDeviceListEntry(device))
}
}
@@ -455,8 +448,7 @@ class SettingsFragment : ScreenFragment("Settings"), Logging {
if (!device.bonded) // If user just clicked on us, try to bond
binding.scanStatusText.setText(R.string.starting_pairing)
b.isChecked =
scanModel.onSelected(myActivity, device)
b.isChecked = onSelected(device)
}
}
@@ -493,7 +485,7 @@ class SettingsFragment : ScreenFragment("Settings"), Logging {
if (curRadio != null && !MockInterface.addressValid(requireContext(), usbRepository, "")) {
binding.warningNotPaired.visibility = View.GONE
// binding.scanStatusText.text = getString(R.string.current_pair).format(curRadio)
} else if (bluetoothViewModel.enabled.value == true){
} else if (bluetoothViewModel.enabled.value == true) {
binding.warningNotPaired.visibility = View.VISIBLE
binding.scanStatusText.text = getString(R.string.not_paired_yet)
}
@@ -516,6 +508,117 @@ class SettingsFragment : ScreenFragment("Settings"), Logging {
}
}
/// 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) {
scanModel.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) {
binding.scanStatusText.text =
getString(R.string.pairing_completed)
scanModel.changeDeviceAddress(it.fullAddress)
} else {
binding.scanStatusText.text =
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) {
val device: UsbDevice =
intent.getParcelableExtra(UsbManager.EXTRA_DEVICE)!!
if (intent.getBooleanExtra(
UsbManager.EXTRA_PERMISSION_GRANTED,
false
)
) {
info("User approved USB access")
scanModel.changeDeviceAddress(it.fullAddress)
} else {
errormsg("USB permission denied for device $device")
}
}
// We don't need to stay registered
requireActivity().unregisterReceiver(this)
}
}
val permissionIntent =
if (android.os.Build.VERSION.SDK_INT < android.os.Build.VERSION_CODES.S) {
PendingIntent.getBroadcast(activity, 0, Intent(BTScanModel.ACTION_USB_PERMISSION), 0)
} else {
PendingIntent.getBroadcast(activity, 0, Intent(BTScanModel.ACTION_USB_PERMISSION), PendingIntent.FLAG_IMMUTABLE)
}
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)