Skip to content

Commit

Permalink
New compatibility option for receiving BLE data (#82)
Browse files Browse the repository at this point in the history
* Improve compat options: Add alternative method for receiving BLE scan results via PendingIntents

* Add missing logtags

* Reduce log spam

* Make the linter happy
  • Loading branch information
d4rken authored Jan 25, 2023
1 parent 5d44737 commit d917c8f
Show file tree
Hide file tree
Showing 11 changed files with 236 additions and 45 deletions.
9 changes: 9 additions & 0 deletions app-common/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -23,4 +23,13 @@
android:name="android.permission.BLUETOOTH_SCAN"
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>
Original file line number Diff line number Diff line change
@@ -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")
}
}
Original file line number Diff line number Diff line change
@@ -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"
}
}
Original file line number Diff line number Diff line change
@@ -1,20 +1,21 @@
package eu.darken.capod.common.bluetooth

import android.annotation.SuppressLint
import android.app.PendingIntent
import android.bluetooth.le.ScanCallback
import android.bluetooth.le.ScanFilter
import android.bluetooth.le.ScanResult
import android.bluetooth.le.ScanSettings
import android.content.Context
import android.content.Intent
import dagger.hilt.android.qualifiers.ApplicationContext
import eu.darken.capod.common.debug.logging.Logging.Priority.*
import eu.darken.capod.common.debug.logging.log
import eu.darken.capod.common.debug.logging.logTag
import eu.darken.capod.common.notifications.PendingIntentCompat
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import javax.inject.Inject
Expand All @@ -25,31 +26,35 @@ class BleScanner @Inject constructor(
@ApplicationContext private val context: Context,
private val bluetoothManager: BluetoothManager2,
private val fakeBleData: FakeBleData,
private val scanResultForwarder: BleScanResultForwarder,
) {

@SuppressLint("MissingPermission") fun scan(
filters: Set<ScanFilter>,
scannerMode: ScannerMode = ScannerMode.BALANCED,
offloadFiltering: Boolean = true,
offloadBatching: Boolean = true,
disableOffloadFiltering: Boolean = true,
disableOffloadBatching: Boolean = true,
disableDirectScanCallback: Boolean = true,
): Flow<Collection<BleScanResult>> = callbackFlow {
log(TAG) { "scan(filters=$filters, scannerMode=$scannerMode)" }

val adapter = bluetoothManager.adapter ?: throw IllegalStateException("Bluetooth adapter unavailable")

val useOffloadedFiltering = adapter.isOffloadedFilteringSupported.also {
log(TAG, if (it) DEBUG else WARN) { "isOffloadedFilteringSupported=$it" }
} && offloadFiltering
if (!offloadFiltering) log(TAG, WARN) { "Offloaded filtering is disabled!" }
} && !disableOffloadFiltering
if (disableOffloadFiltering) log(TAG, WARN) { "Offloaded filtering is disabled!" }

val useOffloadedBatching = adapter.isOffloadedScanBatchingSupported.also {
log(TAG, if (it) DEBUG else WARN) { "isOffloadedScanBatchingSupported=$it" }
} && offloadBatching
if (!offloadBatching) log(TAG, WARN) { "Offloaded scan-batching is disabled!" }
} && !disableOffloadBatching
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 resultFilter: (Collection<ScanResult>) -> Collection<BleScanResult> = { results ->
val filterResults: (Collection<ScanResult>) -> Collection<BleScanResult> = { results ->
results
.filter { result ->
val passed = when {
Expand All @@ -72,7 +77,7 @@ class BleScanner @Inject constructor(
"onScanResult(delay=${delay}ms, callbackType=$callbackType, result=$result)"
}

trySend(resultFilter(setOf(result)))
trySend(filterResults(setOf(result)))
}

override fun onBatchScanResults(results: MutableList<ScanResult>) {
Expand All @@ -82,14 +87,45 @@ class BleScanner @Inject constructor(
"onBatchScanResults(delay=${delay}ms, results=$results)"
}

trySend(resultFilter(results))
trySend(filterResults(results))
}

override fun onScanFailed(errorCode: Int) {
log(TAG, WARN) { "onScanFailed(errorCode=$errorCode)" }
}
}

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 {
setCallbackType(ScanSettings.CALLBACK_TYPE_ALL_MATCHES)
when (scannerMode) {
Expand Down Expand Up @@ -122,38 +158,50 @@ class BleScanner @Inject constructor(
setReportDelay(delay)
}.build()


val flushJob = 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)
}
}
}

log(TAG) { "startScan(filters=$filters, settings=$scanSettings, callback=$callback)" }
val filterList = when {
useOffloadedFiltering -> filters.toList()
else -> emptyList()
if (disableDirectScanCallback) {
val callbackIntent = createStartIntent()
log(TAG) { "Intent callback: startScan(filters=$filters, settings=$scanSettings, callbackIntent=$callbackIntent)" }
scanner.startScan(filterList, scanSettings, callbackIntent)
} else {
log(TAG) { "Direct callback: startScan(filters=$filters, settings=$scanSettings, callback=$callback)" }
scanner.startScan(filterList, scanSettings, callback)
}

scanner.startScan(filterList, scanSettings, callback)

awaitClose {
flushJob.cancel()
scanner.stopScan(callback)
forwarderConsumer?.cancel()
flushJob?.cancel()
if (disableDirectScanCallback) {
scanner.stopScan(createStopIntent())
} else {
scanner.stopScan(callback)
}
log(TAG) { "BleScanner stopped" }
}
}
.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 {
private const val CALLBACK_INTENT_REQUESTCODE = 270
private val TAG = logTag("Bluetooth", "BleScanner")
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -9,4 +9,9 @@ object PendingIntentCompat {
} else {
0
}
val FLAG_MUTABLE: Int = if (hasApiLevel(31)) {
PendingIntent.FLAG_MUTABLE
} else {
0
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -33,9 +33,12 @@ class GeneralSettings @Inject constructor(
val mainDeviceAddress = preferences.createFlowPreference<String?>("core.maindevice.address", null)
val mainDeviceModel = preferences.createFlowPreference("core.maindevice.model", PodDevice.Model.UNKNOWN, moshi)

val isOffloadedFilteringDisabled =
preferences.createFlowPreference("core.compat.offloaded.filtering.disabled", false)
val isOffloadedFilteringDisabled = preferences.createFlowPreference(
"core.compat.offloaded.filtering.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(
monitorMode,
Expand All @@ -45,6 +48,7 @@ class GeneralSettings @Inject constructor(
mainDeviceAddress,
isOffloadedFilteringDisabled,
isOffloadedBatchingDisabled,
useIndirectScanResultCallback,
debugSettings.isAutoReportingEnabled,
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -88,20 +88,29 @@ class PodMonitor @Inject constructor(
val scannerMode: ScannerMode,
val showUnfiltered: Boolean,
val offloadedFilteringDisabled: Boolean,
val offloadedBatchingDisabled: Boolean
val offloadedBatchingDisabled: Boolean,
val disableDirectCallback: Boolean,
)

private fun createBleScanner() = combine(
generalSettings.scannerMode.flow,
debugSettings.showUnfiltered.flow,
generalSettings.isOffloadedBatchingDisabled.flow,
generalSettings.isOffloadedFilteringDisabled.flow,
) { scannermode, showUnfiltered, isOffloadedBatchingDisabled, isOffloadedFilteringDisabled ->
generalSettings.useIndirectScanResultCallback.flow,
) {
scannermode,
showUnfiltered,
isOffloadedBatchingDisabled,
isOffloadedFilteringDisabled,
useIndirectScanResultCallback,
->
ScannerOptions(
scannerMode = scannermode,
showUnfiltered = showUnfiltered,
offloadedFilteringDisabled = isOffloadedFilteringDisabled,
offloadedBatchingDisabled = isOffloadedBatchingDisabled,
disableDirectCallback = useIndirectScanResultCallback,
)
}
.flatMapLatest { options ->
Expand All @@ -116,8 +125,9 @@ class PodMonitor @Inject constructor(
bleScanner.scan(
filters = filters,
scannerMode = options.scannerMode,
offloadFiltering = !options.offloadedFilteringDisabled,
offloadBatching = !options.offloadedBatchingDisabled
disableOffloadFiltering = options.offloadedFilteringDisabled,
disableOffloadBatching = options.offloadedBatchingDisabled,
disableDirectScanCallback = options.disableDirectCallback,
).map { preFilterAndMap(it) }
}

Expand Down
Loading

0 comments on commit d917c8f

Please sign in to comment.