refactor: remove Companion Device Pairing

https://developer.android.com/develop/connectivity/bluetooth/companion-device-pairing
This commit is contained in:
andrekir 2024-08-03 07:53:59 -03:00
parent ca537becd1
commit bc05280988
6 changed files with 42 additions and 153 deletions

View file

@ -53,10 +53,6 @@
<!-- Needed to open our bluetooth connection to our paired device (after reboot) -->
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<!-- For android >=26 we can use the new BLE scanning API, which allows auto launching our service when our device is seen -->
<uses-permission android:name="android.permission.REQUEST_COMPANION_RUN_IN_BACKGROUND" />
<uses-permission android:name="android.permission.REQUEST_COMPANION_USE_DATA_IN_BACKGROUND" />
<!-- zxing library for QR Code scanning using camera -->
<uses-permission android:name="android.permission.CAMERA" />
@ -68,11 +64,6 @@
android:name="android.hardware.bluetooth_le"
android:required="false" />
<!-- For the modern BLE scanning API -->
<uses-feature
android:name="android.software.companion_device_setup"
android:required="false" />
<!-- for USB serial access -->
<uses-feature
android:name="android.hardware.usb.host"

View file

@ -1,11 +1,9 @@
package com.geeksville.mesh.android
import android.Manifest
import android.annotation.SuppressLint
import android.app.Activity
import android.app.NotificationManager
import android.bluetooth.BluetoothManager
import android.companion.CompanionDeviceManager
import android.content.Context
import android.content.pm.PackageManager
import android.location.LocationManager
@ -21,22 +19,10 @@ import com.google.android.material.dialog.MaterialAlertDialogBuilder
val Context.bluetoothManager: BluetoothManager?
get() = getSystemService(Context.BLUETOOTH_SERVICE).takeIf { hasBluetoothPermission() } as? BluetoothManager?
val Context.companionDeviceManager: CompanionDeviceManager?
@SuppressLint("NewApi")
get() = getSystemService(Context.COMPANION_DEVICE_SERVICE).takeIf { hasCompanionDeviceApi() } as? CompanionDeviceManager?
val Context.notificationManager: NotificationManager get() = requireNotNull(getSystemService(Context.NOTIFICATION_SERVICE) as? NotificationManager?)
val Context.locationManager: LocationManager get() = requireNotNull(getSystemService(Context.LOCATION_SERVICE) as? LocationManager?)
/**
* @return true if CompanionDeviceManager API is present
*/
fun Context.hasCompanionDeviceApi(): Boolean =
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.O)
packageManager.hasSystemFeature(PackageManager.FEATURE_COMPANION_DEVICE_SETUP)
else false
/**
* @return true if the device has a GPS receiver
*/
@ -126,7 +112,7 @@ fun Context.getBluetoothPermissions(): Array<String> {
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.S) {
perms.add(Manifest.permission.BLUETOOTH_SCAN)
perms.add(Manifest.permission.BLUETOOTH_CONNECT)
} else if (!hasCompanionDeviceApi()) {
} else {
perms.add(Manifest.permission.ACCESS_FINE_LOCATION)
}
return getMissingPermissions(perms)

View file

@ -3,22 +3,16 @@ package com.geeksville.mesh.model
import android.annotation.SuppressLint
import android.app.Application
import android.bluetooth.BluetoothDevice
import android.companion.AssociationRequest
import android.companion.BluetoothDeviceFilter
import android.companion.BluetoothLeDeviceFilter
import android.companion.CompanionDeviceManager
import android.content.*
import android.content.Context
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.Logging
import com.geeksville.mesh.R
import com.geeksville.mesh.android.*
import com.geeksville.mesh.repository.bluetooth.BluetoothRepository
import com.geeksville.mesh.repository.network.NetworkRepository
import com.geeksville.mesh.repository.radio.InterfaceId
@ -34,7 +28,6 @@ import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import java.util.regex.Pattern
import javax.inject.Inject
@HiltViewModel
@ -151,10 +144,11 @@ class BTScanModel @Inject constructor(
/// Use the string for the NopInterface
val selectedNotNull: String get() = selectedAddress ?: "n"
private fun addDevice(entry: DeviceListEntry) {
val oldDevs = devices.value!!
oldDevs[entry.fullAddress] = entry // Add/replace entry
devices.value = oldDevs // trigger gui updates
val scanResult = MutableLiveData<MutableMap<String, DeviceListEntry>>(mutableMapOf())
fun clearScanResults() {
stopScan()
scanResult.value = mutableMapOf()
}
fun stopScan() {
@ -166,21 +160,16 @@ class BTScanModel @Inject constructor(
warn("Ignoring error stopping scan, probably BT adapter was disabled suddenly: ${ex.message}")
} finally {
scanJob = null
_spinner.value = false
}
} else _spinner.value = false
}
fun startScan(context: Context?) {
_spinner.value = true
if (context != null) startCompanionScan(context) else startClassicScan()
}
_spinner.value = false
}
@SuppressLint("MissingPermission")
private fun startClassicScan() {
fun startScan() {
debug("starting classic scan")
_spinner.value = true
scanJob = bluetoothRepository.scan()
.onEach { result ->
val fullAddress = radioInterfaceService.toInterfaceAddress(
@ -189,12 +178,13 @@ class BTScanModel @Inject constructor(
)
// 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 oldDevs = scanResult.value!!
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)
oldDevs[entry.fullAddress] = entry
scanResult.value = oldDevs
}
}.catch { ex ->
serviceRepository.setErrorMessage("Unexpected Bluetooth scan failure: ${ex.message}")
@ -271,72 +261,6 @@ class BTScanModel @Inject constructor(
}
}
fun onSelectedBle(address: String): Boolean {
val device = bluetoothRepository.getRemoteDevice(address) ?: return false
return onSelected(BLEDeviceListEntry(device))
}
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 = AssociationRequest.Builder()
.addDeviceFilter(
BluetoothDeviceFilter.Builder()
.setNamePattern(Pattern.compile(BLE_NAME_PATTERN))
.build()
)
.addDeviceFilter(
BluetoothLeDeviceFilter.Builder()
.setNamePattern(Pattern.compile(BLE_NAME_PATTERN))
// .setScanFilter(
// ScanFilter.Builder()
// .setServiceUuid(ParcelUuid(BluetoothInterface.BTM_SERVICE_UUID))
// .build()
// )
.build()
)
.setSingleDevice(false)
.build()
@SuppressLint("NewApi")
private fun startCompanionScan(context: Context) {
debug("starting companion scan")
context.companionDeviceManager?.associate(
associationRequest(),
object : CompanionDeviceManager.Callback() {
@Deprecated("Deprecated in Java", ReplaceWith("onAssociationPending(intentSender)"))
override fun onDeviceFound(intentSender: IntentSender) {
onAssociationPending(intentSender)
}
override fun onAssociationPending(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
)
}
companion object {
const val BLE_NAME_PATTERN = BluetoothRepository.BLE_NAME_PATTERN
}
}

View file

@ -15,6 +15,7 @@ import android.widget.AdapterView
import android.widget.ArrayAdapter
import android.widget.RadioButton
import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AlertDialog
import androidx.core.widget.doAfterTextChanged
import androidx.fragment.app.activityViewModels
import androidx.lifecycle.asLiveData
@ -29,7 +30,6 @@ import com.geeksville.mesh.model.getInitials
import com.geeksville.mesh.repository.location.LocationRepository
import com.geeksville.mesh.service.MeshService
import com.geeksville.mesh.util.exceptionToSnackbar
import com.geeksville.mesh.util.getAssociationResult
import com.geeksville.mesh.util.onEditorAction
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import dagger.hilt.android.AndroidEntryPoint
@ -50,7 +50,6 @@ class SettingsFragment : ScreenFragment("Settings"), Logging {
internal lateinit var locationRepository: LocationRepository
private val hasGps by lazy { requireContext().hasGps() }
private val hasCompanionDeviceApi by lazy { requireContext().hasCompanionDeviceApi() }
override fun onCreateView(
inflater: LayoutInflater, container: ViewGroup?,
@ -139,14 +138,6 @@ class SettingsFragment : ScreenFragment("Settings"), Logging {
private fun initCommonUI() {
val associationResultLauncher = registerForActivityResult(
ActivityResultContracts.StartIntentSenderForResult()
) {
it.data
?.getAssociationResult()
?.let { address -> scanModel.onSelectedBle(address) }
}
val requestBackgroundAndCheckLauncher =
registerForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { permissions ->
if (permissions.entries.any { !it.value }) {
@ -202,19 +193,36 @@ class SettingsFragment : ScreenFragment("Settings"), Logging {
}
}
var scanDialog: AlertDialog? = null
scanModel.scanResult.observe(viewLifecycleOwner) { results ->
val devices = results.values.ifEmpty { return@observe }
scanDialog?.dismiss()
scanDialog = MaterialAlertDialogBuilder(requireContext())
.setTitle("Select a Bluetooth device")
.setSingleChoiceItems(
devices.map { it.name }.toTypedArray(),
-1
) { dialog, position ->
val selectedDevice = devices.elementAt(position)
scanModel.onSelected(selectedDevice)
scanModel.clearScanResults()
dialog.dismiss()
scanDialog = null
}
.setPositiveButton(R.string.cancel) { dialog, _ ->
scanModel.clearScanResults()
dialog.dismiss()
scanDialog = null
}
.show()
}
// show the spinner when [spinner] is true
scanModel.spinner.observe(viewLifecycleOwner) { show ->
binding.changeRadioButton.isEnabled = !show
binding.scanProgressBar.visibility = if (show) View.VISIBLE else View.GONE
}
scanModel.associationRequest.observe(viewLifecycleOwner) { request ->
request?.let {
associationResultLauncher.launch(request)
scanModel.clearAssociationRequest()
}
}
binding.usernameEditText.onEditorAction(EditorInfo.IME_ACTION_DONE) {
debug("received IME_ACTION_DONE")
val n = binding.usernameEditText.text.toString().trim()
@ -372,7 +380,7 @@ class SettingsFragment : ScreenFragment("Settings"), Logging {
private var scanning = false
private fun scanLeDevice() {
if (!checkBTEnabled()) return
if (!hasCompanionDeviceApi) checkLocationEnabled()
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) checkLocationEnabled()
if (!scanning) { // Stops scanning after a pre-defined scan period.
Handler(Looper.getMainLooper()).postDelayed({
@ -380,7 +388,7 @@ class SettingsFragment : ScreenFragment("Settings"), Logging {
scanModel.stopScan()
}, SCAN_PERIOD)
scanning = true
scanModel.startScan(requireActivity().takeIf { hasCompanionDeviceApi })
scanModel.startScan()
} else {
scanning = false
scanModel.stopScan()

View file

@ -1,10 +1,6 @@
package com.geeksville.mesh.util
import android.app.PendingIntent
import android.bluetooth.BluetoothDevice
import android.bluetooth.le.ScanResult
import android.companion.AssociationInfo
import android.companion.CompanionDeviceManager
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
@ -45,19 +41,3 @@ fun Context.registerReceiverCompat(
) {
ContextCompat.registerReceiver(this, receiver, filter, flag)
}
fun Intent.getAssociationResult(): String? = when {
android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.TIRAMISU ->
getParcelableExtraCompat<AssociationInfo>(CompanionDeviceManager.EXTRA_ASSOCIATION)
?.deviceMacAddress?.toString()?.uppercase()
android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.O ->
@Suppress("DEPRECATION")
when (val it = getParcelableExtra<Parcelable>(CompanionDeviceManager.EXTRA_DEVICE)) {
is BluetoothDevice -> it.address
is ScanResult -> it.device.address
else -> null
}
else -> null
}

View file

@ -82,7 +82,7 @@
<string name="modem_config_very_long">Very Long Range / Slow</string>
<string name="modem_config_unrecognized">UNRECOGNIZED</string>
<string name="meshtastic_service_notifications">Service notifications</string>
<string name="location_disabled_warning">Location must be turned on (high accuracy) to find new devices via bluetooth. You can turn it off again afterwards.</string>
<string name="location_disabled_warning">Location must be turned on to find new devices via Bluetooth. You can turn it off again afterwards.</string>
<string name="about">About</string>
<string name="a_list_of_nodes_in_the_mesh">A list of nodes in the mesh</string>
<string name="text_messages">Text messages</string>