New compatibility option for receiving BLE data (#82)

* Improve compat options: Add alternative method for receiving BLE scan results via PendingIntents

* Add missing logtags

* Reduce log spam

* Make the linter happy
This commit is contained in:
Matthias Urhahn
2023-01-25 16:27:29 +01:00
committed by GitHub
parent 5d44737199
commit d917c8ffe6
11 changed files with 236 additions and 45 deletions
+9
View File
@@ -23,4 +23,13 @@
android:name="android.permission.BLUETOOTH_SCAN" android:name="android.permission.BLUETOOTH_SCAN"
android:usesPermissionFlags="neverForLocation" /> android:usesPermissionFlags="neverForLocation" />
<application>
<receiver
android:name=".bluetooth.BleScanResultReceiver"
android:exported="false">
<intent-filter>
<action android:name="eu.darken.capod.bluetooth.DELIVER_SCAN_RESULTS" />
</intent-filter>
</receiver>
</application>
</manifest> </manifest>
@@ -0,0 +1,33 @@
package eu.darken.capod.common.bluetooth
import android.bluetooth.le.ScanResult
import eu.darken.capod.common.debug.logging.Logging.Priority.VERBOSE
import eu.darken.capod.common.debug.logging.Logging.Priority.WARN
import eu.darken.capod.common.debug.logging.log
import eu.darken.capod.common.debug.logging.logTag
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class BleScanResultForwarder @Inject constructor() {
private val forwarder = MutableSharedFlow<Collection<ScanResult>>(
replay = 0,
extraBufferCapacity = 128,
onBufferOverflow = BufferOverflow.DROP_OLDEST
)
val results: Flow<Collection<ScanResult>> = forwarder
fun forward(scanResults: Collection<ScanResult>) {
log(TAG, VERBOSE) { "forward($scanResults)" }
val success = forwarder.tryEmit(scanResults)
if (!success) log(TAG, WARN) { "Failed to forward (overflow?) $scanResults" }
}
companion object {
private val TAG = logTag("Bluetooth", "BleScanner", "Forwarder")
}
}
@@ -0,0 +1,64 @@
package eu.darken.capod.common.bluetooth
import android.bluetooth.le.BluetoothLeScanner
import android.bluetooth.le.ScanResult
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import dagger.hilt.android.AndroidEntryPoint
import eu.darken.capod.common.coroutine.AppScope
import eu.darken.capod.common.debug.logging.Logging.Priority.VERBOSE
import eu.darken.capod.common.debug.logging.Logging.Priority.WARN
import eu.darken.capod.common.debug.logging.log
import eu.darken.capod.common.debug.logging.logTag
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import javax.inject.Inject
@AndroidEntryPoint
class BleScanResultReceiver : BroadcastReceiver() {
@Inject @AppScope lateinit var appScope: CoroutineScope
@Inject lateinit var scanResultForwarder: BleScanResultForwarder
override fun onReceive(context: Context, intent: Intent) {
log(TAG, VERBOSE) { "onReceive($context, $intent)" }
if (intent.action != ACTION) {
log(TAG, WARN) { "Unknown action: ${intent.action}" }
return
}
if (intent.extras == null) {
log(TAG) { "Extras are null!" }
return
}
val errorCode = intent.getIntExtra(BluetoothLeScanner.EXTRA_ERROR_CODE, 0)
log(TAG, VERBOSE) { "errorCode=$errorCode" }
if (errorCode != 0) {
log(TAG, WARN) { "ScanCallback error code: $errorCode" }
return
}
val callbackType = intent.getIntExtra(BluetoothLeScanner.EXTRA_CALLBACK_TYPE, -1)
log(TAG, VERBOSE) { "callbackType=$callbackType" }
val scanResults = intent.getParcelableArrayListExtra<ScanResult>(BluetoothLeScanner.EXTRA_LIST_SCAN_RESULT)
log(TAG, VERBOSE) { "scanResults=$scanResults" }
if (scanResults == null) {
log(TAG) { "Scan results were empty!" }
return
}
val pending = goAsync()
appScope.launch {
scanResultForwarder.forward(scanResults)
pending.finish()
}
}
companion object {
private val TAG = logTag("Bluetooth", "BleScanner", "Forwarder", "Receiver")
const val ACTION = "eu.darken.capod.bluetooth.DELIVER_SCAN_RESULTS"
}
}
@@ -1,20 +1,21 @@
package eu.darken.capod.common.bluetooth package eu.darken.capod.common.bluetooth
import android.annotation.SuppressLint import android.annotation.SuppressLint
import android.app.PendingIntent
import android.bluetooth.le.ScanCallback import android.bluetooth.le.ScanCallback
import android.bluetooth.le.ScanFilter import android.bluetooth.le.ScanFilter
import android.bluetooth.le.ScanResult import android.bluetooth.le.ScanResult
import android.bluetooth.le.ScanSettings import android.bluetooth.le.ScanSettings
import android.content.Context import android.content.Context
import android.content.Intent
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import eu.darken.capod.common.debug.logging.Logging.Priority.* import eu.darken.capod.common.debug.logging.Logging.Priority.*
import eu.darken.capod.common.debug.logging.log import eu.darken.capod.common.debug.logging.log
import eu.darken.capod.common.debug.logging.logTag import eu.darken.capod.common.debug.logging.logTag
import eu.darken.capod.common.notifications.PendingIntentCompat
import kotlinx.coroutines.channels.awaitClose import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.*
import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.isActive import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import javax.inject.Inject import javax.inject.Inject
@@ -25,13 +26,15 @@ class BleScanner @Inject constructor(
@ApplicationContext private val context: Context, @ApplicationContext private val context: Context,
private val bluetoothManager: BluetoothManager2, private val bluetoothManager: BluetoothManager2,
private val fakeBleData: FakeBleData, private val fakeBleData: FakeBleData,
private val scanResultForwarder: BleScanResultForwarder,
) { ) {
@SuppressLint("MissingPermission") fun scan( @SuppressLint("MissingPermission") fun scan(
filters: Set<ScanFilter>, filters: Set<ScanFilter>,
scannerMode: ScannerMode = ScannerMode.BALANCED, scannerMode: ScannerMode = ScannerMode.BALANCED,
offloadFiltering: Boolean = true, disableOffloadFiltering: Boolean = true,
offloadBatching: Boolean = true, disableOffloadBatching: Boolean = true,
disableDirectScanCallback: Boolean = true,
): Flow<Collection<BleScanResult>> = callbackFlow { ): Flow<Collection<BleScanResult>> = callbackFlow {
log(TAG) { "scan(filters=$filters, scannerMode=$scannerMode)" } log(TAG) { "scan(filters=$filters, scannerMode=$scannerMode)" }
@@ -39,17 +42,19 @@ class BleScanner @Inject constructor(
val useOffloadedFiltering = adapter.isOffloadedFilteringSupported.also { val useOffloadedFiltering = adapter.isOffloadedFilteringSupported.also {
log(TAG, if (it) DEBUG else WARN) { "isOffloadedFilteringSupported=$it" } log(TAG, if (it) DEBUG else WARN) { "isOffloadedFilteringSupported=$it" }
} && offloadFiltering } && !disableOffloadFiltering
if (!offloadFiltering) log(TAG, WARN) { "Offloaded filtering is disabled!" } if (disableOffloadFiltering) log(TAG, WARN) { "Offloaded filtering is disabled!" }
val useOffloadedBatching = adapter.isOffloadedScanBatchingSupported.also { val useOffloadedBatching = adapter.isOffloadedScanBatchingSupported.also {
log(TAG, if (it) DEBUG else WARN) { "isOffloadedScanBatchingSupported=$it" } log(TAG, if (it) DEBUG else WARN) { "isOffloadedScanBatchingSupported=$it" }
} && offloadBatching } && !disableOffloadBatching
if (!offloadBatching) log(TAG, WARN) { "Offloaded scan-batching is disabled!" } if (disableOffloadBatching) log(TAG, WARN) { "Offloaded scan-batching is disabled!" }
if (disableDirectScanCallback) log(TAG, WARN) { "Direct scan callback is disabled!" }
val scanner = bluetoothManager.scanner ?: throw IllegalStateException("BLE scanner unavailable") val scanner = bluetoothManager.scanner ?: throw IllegalStateException("BLE scanner unavailable")
val resultFilter: (Collection<ScanResult>) -> Collection<BleScanResult> = { results -> val filterResults: (Collection<ScanResult>) -> Collection<BleScanResult> = { results ->
results results
.filter { result -> .filter { result ->
val passed = when { val passed = when {
@@ -72,7 +77,7 @@ class BleScanner @Inject constructor(
"onScanResult(delay=${delay}ms, callbackType=$callbackType, result=$result)" "onScanResult(delay=${delay}ms, callbackType=$callbackType, result=$result)"
} }
trySend(resultFilter(setOf(result))) trySend(filterResults(setOf(result)))
} }
override fun onBatchScanResults(results: MutableList<ScanResult>) { override fun onBatchScanResults(results: MutableList<ScanResult>) {
@@ -82,7 +87,7 @@ class BleScanner @Inject constructor(
"onBatchScanResults(delay=${delay}ms, results=$results)" "onBatchScanResults(delay=${delay}ms, results=$results)"
} }
trySend(resultFilter(results)) trySend(filterResults(results))
} }
override fun onScanFailed(errorCode: Int) { override fun onScanFailed(errorCode: Int) {
@@ -90,6 +95,37 @@ class BleScanner @Inject constructor(
} }
} }
val forwarderConsumer = if (disableDirectScanCallback) {
scanResultForwarder.results
.onEach { results -> trySend(filterResults(results)) }
.launchIn(this)
} else {
null
}
val flushJob = if (!disableDirectScanCallback) {
launch {
log(TAG) { "Flush job launched" }
while (isActive) {
log(TAG, VERBOSE) { "Flushing scan results." }
// Can undercut the minimum setReportDelay(), e.g. 5000ms on a Pixel5@12
adapter.bluetoothLeScanner.flushPendingScanResults(callback)
when (scannerMode) {
ScannerMode.LOW_POWER -> break
ScannerMode.BALANCED -> delay(2000)
ScannerMode.LOW_LATENCY -> delay(500)
}
}
}
} else {
null
}
val filterList = when {
useOffloadedFiltering -> filters.toList()
else -> emptyList()
}
val scanSettings = ScanSettings.Builder().apply { val scanSettings = ScanSettings.Builder().apply {
setCallbackType(ScanSettings.CALLBACK_TYPE_ALL_MATCHES) setCallbackType(ScanSettings.CALLBACK_TYPE_ALL_MATCHES)
when (scannerMode) { when (scannerMode) {
@@ -122,38 +158,50 @@ class BleScanner @Inject constructor(
setReportDelay(delay) setReportDelay(delay)
}.build() }.build()
if (disableDirectScanCallback) {
val flushJob = launch { val callbackIntent = createStartIntent()
log(TAG) { "Flush job launched" } log(TAG) { "Intent callback: startScan(filters=$filters, settings=$scanSettings, callbackIntent=$callbackIntent)" }
while (isActive) { scanner.startScan(filterList, scanSettings, callbackIntent)
log(TAG, VERBOSE) { "Flushing scan results." } } else {
// Can undercut the minimum setReportDelay(), e.g. 5000ms on a Pixel5@12 log(TAG) { "Direct callback: startScan(filters=$filters, settings=$scanSettings, callback=$callback)" }
adapter.bluetoothLeScanner.flushPendingScanResults(callback) scanner.startScan(filterList, scanSettings, callback)
when (scannerMode) {
ScannerMode.LOW_POWER -> break
ScannerMode.BALANCED -> delay(2000)
ScannerMode.LOW_LATENCY -> delay(500)
}
}
} }
log(TAG) { "startScan(filters=$filters, settings=$scanSettings, callback=$callback)" }
val filterList = when {
useOffloadedFiltering -> filters.toList()
else -> emptyList()
}
scanner.startScan(filterList, scanSettings, callback)
awaitClose { awaitClose {
flushJob.cancel() forwarderConsumer?.cancel()
scanner.stopScan(callback) flushJob?.cancel()
if (disableDirectScanCallback) {
scanner.stopScan(createStopIntent())
} else {
scanner.stopScan(callback)
}
log(TAG) { "BleScanner stopped" } log(TAG) { "BleScanner stopped" }
} }
} }
.map { fakeBleData.maybeAddfakeData(it) } .map { fakeBleData.maybeAddfakeData(it) }
private val receiverIntent by lazy {
Intent(context, BleScanResultReceiver::class.java).apply {
action = BleScanResultReceiver.ACTION
}
}
private fun createStartIntent(): PendingIntent = PendingIntent.getBroadcast(
context,
CALLBACK_INTENT_REQUESTCODE,
receiverIntent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntentCompat.FLAG_MUTABLE
)
private fun createStopIntent(): PendingIntent = PendingIntent.getBroadcast(
context,
270,
receiverIntent,
PendingIntentCompat.FLAG_IMMUTABLE
)
companion object { companion object {
private const val CALLBACK_INTENT_REQUESTCODE = 270
private val TAG = logTag("Bluetooth", "BleScanner") private val TAG = logTag("Bluetooth", "BleScanner")
} }
} }
@@ -9,4 +9,9 @@ object PendingIntentCompat {
} else { } else {
0 0
} }
val FLAG_MUTABLE: Int = if (hasApiLevel(31)) {
PendingIntent.FLAG_MUTABLE
} else {
0
}
} }
@@ -33,9 +33,12 @@ class GeneralSettings @Inject constructor(
val mainDeviceAddress = preferences.createFlowPreference<String?>("core.maindevice.address", null) val mainDeviceAddress = preferences.createFlowPreference<String?>("core.maindevice.address", null)
val mainDeviceModel = preferences.createFlowPreference("core.maindevice.model", PodDevice.Model.UNKNOWN, moshi) val mainDeviceModel = preferences.createFlowPreference("core.maindevice.model", PodDevice.Model.UNKNOWN, moshi)
val isOffloadedFilteringDisabled = val isOffloadedFilteringDisabled = preferences.createFlowPreference(
preferences.createFlowPreference("core.compat.offloaded.filtering.disabled", false) "core.compat.offloaded.filtering.disabled",
false
)
val isOffloadedBatchingDisabled = preferences.createFlowPreference("core.compat.offloaded.batching.disabled", false) val isOffloadedBatchingDisabled = preferences.createFlowPreference("core.compat.offloaded.batching.disabled", false)
val useIndirectScanResultCallback = preferences.createFlowPreference("core.compat.indirectcallback.enabled", false)
override val preferenceDataStore: PreferenceDataStore = PreferenceStoreMapper( override val preferenceDataStore: PreferenceDataStore = PreferenceStoreMapper(
monitorMode, monitorMode,
@@ -45,6 +48,7 @@ class GeneralSettings @Inject constructor(
mainDeviceAddress, mainDeviceAddress,
isOffloadedFilteringDisabled, isOffloadedFilteringDisabled,
isOffloadedBatchingDisabled, isOffloadedBatchingDisabled,
useIndirectScanResultCallback,
debugSettings.isAutoReportingEnabled, debugSettings.isAutoReportingEnabled,
) )
} }
@@ -88,7 +88,8 @@ class PodMonitor @Inject constructor(
val scannerMode: ScannerMode, val scannerMode: ScannerMode,
val showUnfiltered: Boolean, val showUnfiltered: Boolean,
val offloadedFilteringDisabled: Boolean, val offloadedFilteringDisabled: Boolean,
val offloadedBatchingDisabled: Boolean val offloadedBatchingDisabled: Boolean,
val disableDirectCallback: Boolean,
) )
private fun createBleScanner() = combine( private fun createBleScanner() = combine(
@@ -96,12 +97,20 @@ class PodMonitor @Inject constructor(
debugSettings.showUnfiltered.flow, debugSettings.showUnfiltered.flow,
generalSettings.isOffloadedBatchingDisabled.flow, generalSettings.isOffloadedBatchingDisabled.flow,
generalSettings.isOffloadedFilteringDisabled.flow, generalSettings.isOffloadedFilteringDisabled.flow,
) { scannermode, showUnfiltered, isOffloadedBatchingDisabled, isOffloadedFilteringDisabled -> generalSettings.useIndirectScanResultCallback.flow,
) {
scannermode,
showUnfiltered,
isOffloadedBatchingDisabled,
isOffloadedFilteringDisabled,
useIndirectScanResultCallback,
->
ScannerOptions( ScannerOptions(
scannerMode = scannermode, scannerMode = scannermode,
showUnfiltered = showUnfiltered, showUnfiltered = showUnfiltered,
offloadedFilteringDisabled = isOffloadedFilteringDisabled, offloadedFilteringDisabled = isOffloadedFilteringDisabled,
offloadedBatchingDisabled = isOffloadedBatchingDisabled, offloadedBatchingDisabled = isOffloadedBatchingDisabled,
disableDirectCallback = useIndirectScanResultCallback,
) )
} }
.flatMapLatest { options -> .flatMapLatest { options ->
@@ -116,8 +125,9 @@ class PodMonitor @Inject constructor(
bleScanner.scan( bleScanner.scan(
filters = filters, filters = filters,
scannerMode = options.scannerMode, scannerMode = options.scannerMode,
offloadFiltering = !options.offloadedFilteringDisabled, disableOffloadFiltering = options.offloadedFilteringDisabled,
offloadBatching = !options.offloadedBatchingDisabled disableOffloadBatching = options.offloadedBatchingDisabled,
disableDirectScanCallback = options.disableDirectCallback,
).map { preFilterAndMap(it) } ).map { preFilterAndMap(it) }
} }
@@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:tint="?attr/colorControlNormal"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@android:color/white"
android:pathData="M6.91 5.5L9.21 7.79L7.79 9.21L5.5 6.91L3.21 9.21L1.79 7.79L4.09 5.5L1.79 3.21L3.21 1.79L5.5 4.09L7.79 1.79L9.21 3.21M22.21 16.21L20.79 14.79L18.5 17.09L16.21 14.79L14.79 16.21L17.09 18.5L14.79 20.79L16.21 22.21L18.5 19.91L20.79 22.21L22.21 20.79L19.91 18.5M20.4 6.83L17.18 11L15.6 9.73L16.77 8.23A9.08 9.08 0 0 0 10.11 13.85A4.5 4.5 0 1 1 7.5 13A4 4 0 0 1 8.28 13.08A11.27 11.27 0 0 1 16.43 6.26L15 5.18L16.27 3.6M10 17.5A2.5 2.5 0 1 0 7.5 20A2.5 2.5 0 0 0 10 17.5Z" />
</vector>
@@ -25,9 +25,9 @@ class BluetoothEventReceiver : BroadcastReceiver() {
@Inject @AppScope lateinit var appScope: CoroutineScope @Inject @AppScope lateinit var appScope: CoroutineScope
override fun onReceive(context: Context, intent: Intent) { override fun onReceive(context: Context, intent: Intent) {
log { "onReceive($context, $intent)" } log(TAG) { "onReceive($context, $intent)" }
if (!EXPECTED_ACTIONS.contains(intent.action)) { if (!EXPECTED_ACTIONS.contains(intent.action)) {
log(WARN) { "Unknown action: $intent.action" } log(TAG, WARN) { "Unknown action: ${intent.action}" }
return return
} }
@@ -41,7 +41,7 @@ class BluetoothEventReceiver : BroadcastReceiver() {
val supportedFeatures = ContinuityProtocol.BLE_FEATURE_UUIDS.filter { bluetoothDevice.hasFeature(it) } val supportedFeatures = ContinuityProtocol.BLE_FEATURE_UUIDS.filter { bluetoothDevice.hasFeature(it) }
if (supportedFeatures.isEmpty()) { if (supportedFeatures.isEmpty()) {
log { "Device has no features we support." } log(TAG) { "Device has no features we support." }
return return
} else { } else {
log { "Device has the following we features we support $supportedFeatures" } log { "Device has the following we features we support $supportedFeatures" }
@@ -49,7 +49,7 @@ class BluetoothEventReceiver : BroadcastReceiver() {
val pending = goAsync() val pending = goAsync()
appScope.launch { appScope.launch {
log { "Starting monitor" } log(TAG) { "Starting monitor" }
monitorControl.startMonitor(bluetoothDevice, forceStart = false) monitorControl.startMonitor(bluetoothDevice, forceStart = false)
pending.finish() pending.finish()
} }
+2
View File
@@ -100,4 +100,6 @@
<string name="translators_thanks_description">darken</string> <string name="translators_thanks_description">darken</string>
<string name="widget_description">A widget showing the last known device status.</string> <string name="widget_description">A widget showing the last known device status.</string>
<string name="settings_compat_indirectcallback_title">Indirect data delivery</string>
<string name="settings_compat_indirectcallback_summary">Use an alternative method to receive BLE data from the system (broadcast instead of callback).</string>
</resources> </resources>
@@ -61,6 +61,12 @@
android:summary="@string/settings_compat_offloaded_batching_disabled_summary" android:summary="@string/settings_compat_offloaded_batching_disabled_summary"
android:title="@string/settings_compat_offloaded_batching_disabled_title" /> android:title="@string/settings_compat_offloaded_batching_disabled_title" />
<CheckBoxPreference
android:icon="@drawable/ic_strategy_24"
android:key="core.compat.indirectcallback.enabled"
android:summary="@string/settings_compat_indirectcallback_summary"
android:title="@string/settings_compat_indirectcallback_title" />
</PreferenceCategory> </PreferenceCategory>
<PreferenceCategory android:title="@string/settings_category_other_label"> <PreferenceCategory android:title="@string/settings_category_other_label">