fix(monitor): Recover from revoked Bluetooth scan permission

This commit is contained in:
darken
2026-05-03 11:54:11 +02:00
committed by Matthias Urhahn
parent acc6e92b2d
commit 06b41ec8eb
4 changed files with 224 additions and 44 deletions
@@ -16,6 +16,7 @@ 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 eu.darken.capod.common.notifications.PendingIntentCompat
import kotlinx.coroutines.Job
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
@@ -113,22 +114,7 @@ class BleScanner @Inject constructor(
null
}
val flushJob = if (!disableDirectScanCallback) {
launch {
log(TAG) { "Flush job launched" }
while (isActive) {
// 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
}
var flushJob: Job? = null
val filterList = when {
useOffloadedFiltering -> filters.toList()
@@ -167,26 +153,55 @@ class BleScanner @Inject constructor(
setReportDelay(delay)
}.build()
if (disableDirectScanCallback) {
val callbackIntent = createStartIntent()
log(TAG) {
"startScan(mode=$scannerMode, filterCount=${filterList.size}, batching=$useOffloadedBatching, filtering=$useOffloadedFiltering, callback=intent)"
try {
if (disableDirectScanCallback) {
val callbackIntent = createStartIntent()
log(TAG) {
"startScan(mode=$scannerMode, filterCount=${filterList.size}, batching=$useOffloadedBatching, filtering=$useOffloadedFiltering, callback=intent)"
}
scanner.startScan(filterList, scanSettings, callbackIntent)
} else {
log(TAG) {
"startScan(mode=$scannerMode, filterCount=${filterList.size}, batching=$useOffloadedBatching, filtering=$useOffloadedFiltering, callback=direct)"
}
scanner.startScan(filterList, scanSettings, callback)
flushJob = launch {
log(TAG) { "Flush job launched" }
while (isActive) {
try {
// Can undercut the minimum setReportDelay(), e.g. 5000ms on a Pixel5@12
scanner.flushPendingScanResults(callback)
} catch (e: SecurityException) {
log(TAG, WARN) { "flushPendingScanResults() denied: ${e.message}" }
close(e)
break
}
when (scannerMode) {
ScannerMode.LOW_POWER -> break
ScannerMode.BALANCED -> delay(2000)
ScannerMode.LOW_LATENCY -> delay(500)
}
}
}
}
scanner.startScan(filterList, scanSettings, callbackIntent)
} else {
log(TAG) {
"startScan(mode=$scannerMode, filterCount=${filterList.size}, batching=$useOffloadedBatching, filtering=$useOffloadedFiltering, callback=direct)"
}
scanner.startScan(filterList, scanSettings, callback)
} catch (e: SecurityException) {
log(TAG, WARN) { "startScan() denied: ${e.message}" }
forwarderConsumer?.cancel()
close(e)
return@callbackFlow
}
awaitClose {
forwarderConsumer?.cancel()
flushJob?.cancel()
if (disableDirectScanCallback) {
scanner.stopScan(createStopIntent())
} else {
scanner.stopScan(callback)
try {
if (disableDirectScanCallback) {
scanner.stopScan(createStopIntent())
} else {
scanner.stopScan(callback)
}
} catch (e: SecurityException) {
log(TAG, WARN) { "stopScan() denied: ${e.message}" }
}
log(TAG) { "BleScanner stopped" }
}
@@ -1,11 +1,10 @@
package eu.darken.capod.common.permissions
import android.content.Context
import android.content.pm.PackageManager
import android.os.Build
import android.os.PowerManager
import androidx.annotation.StringRes
import androidx.core.content.ContextCompat
import androidx.core.content.PermissionChecker
import eu.darken.capod.common.BuildConfigWrap
import eu.darken.capod.R
import eu.darken.capod.common.withinApiLevel
@@ -18,7 +17,7 @@ enum class Permission(
val permissionId: String,
val isScanBlocking: Boolean = false,
val isGranted: (Context) -> Boolean = {
ContextCompat.checkSelfPermission(it, permissionId) == PackageManager.PERMISSION_GRANTED
PermissionChecker.checkSelfPermission(it, permissionId) == PermissionChecker.PERMISSION_GRANTED
},
) {
BLUETOOTH(
@@ -87,4 +86,4 @@ enum class Permission(
fun Permission.isRequired(context: Context): Boolean = when {
!withinApiLevel(minApiLevel, maxApiLevel) -> false
else -> !isGranted(context)
}
}
@@ -27,6 +27,8 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.emitAll
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flowOf
@@ -51,7 +53,7 @@ class BlePodMonitor @Inject constructor(
private val generalSettings: GeneralSettings,
bluetoothManager: BluetoothManager2,
private val debugSettings: DebugSettings,
permissionTool: PermissionTool,
private val permissionTool: PermissionTool,
private val profilesRepo: DeviceProfilesRepo,
) {
@@ -89,8 +91,10 @@ class BlePodMonitor @Inject constructor(
log(
TAG,
Logging.Priority.WARN
) { "PodMonitor failed due to missing permission, not retrying: ${cause.asLog()}" }
false
) { "PodMonitor failed due to missing permission, rechecking and retrying: ${cause.asLog()}" }
permissionTool.recheck()
delay(3000)
true
} else {
log(TAG, Logging.Priority.WARN) { "PodMonitor failed (attempt=$attempt), will retry: ${cause.asLog()}" }
delay(3000)
@@ -160,13 +164,27 @@ class BlePodMonitor @Inject constructor(
else -> ProximityPairing.getBleScanFilter()
}
bleScanner.scan(
filters = filters,
scannerMode = options.scannerMode,
disableOffloadFiltering = options.offloadedFilteringDisabled,
disableOffloadBatching = options.offloadedBatchingDisabled,
disableDirectScanCallback = options.disableDirectCallback,
)
flow {
emitAll(
bleScanner.scan(
filters = filters,
scannerMode = options.scannerMode,
disableOffloadFiltering = options.offloadedFilteringDisabled,
disableOffloadBatching = options.offloadedBatchingDisabled,
disableDirectScanCallback = options.disableDirectCallback,
)
)
}.catch { cause ->
if (cause is SecurityException) {
log(TAG, Logging.Priority.WARN) {
"BLE scanner failed due to missing permission, rechecking permissions: ${cause.asLog()}"
}
permissionTool.recheck()
emit(emptyList())
} else {
throw cause
}
}
}
.map { it.onlyNewAndUnique() }
@@ -0,0 +1,148 @@
package eu.darken.capod.monitor.core.ble
import eu.darken.capod.common.TimeSource
import eu.darken.capod.common.bluetooth.BleScanResult
import eu.darken.capod.common.bluetooth.BleScanner
import eu.darken.capod.common.bluetooth.BluetoothManager2
import eu.darken.capod.common.bluetooth.ScannerMode
import eu.darken.capod.common.debug.DebugSettings
import eu.darken.capod.common.permissions.Permission
import eu.darken.capod.main.core.GeneralSettings
import eu.darken.capod.main.core.PermissionTool
import eu.darken.capod.pods.core.apple.ble.PodFactory
import eu.darken.capod.pods.core.apple.ble.protocol.ProximityPairing
import eu.darken.capod.profiles.core.DeviceProfilesRepo
import io.kotest.matchers.shouldBe
import io.mockk.Runs
import io.mockk.every
import io.mockk.just
import io.mockk.mockk
import io.mockk.mockkObject
import io.mockk.verify
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.drop
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Test
import testhelpers.BaseTest
import testhelpers.TestTimeSource
import testhelpers.datastore.FakeDataStoreValue
class BlePodMonitorTest : BaseTest() {
@Test
fun `scan security exception emits empty devices and rechecks permissions`() = runTest {
val fixture = createFixture {
flow<Collection<BleScanResult>> { throw SecurityException("scan denied") }
}
fixture.monitor.devices.drop(1).first() shouldBe emptyList()
verify(exactly = 1) { fixture.permissionTool.recheck() }
verify(exactly = 1) {
fixture.bleScanner.scan(
filters = any(),
scannerMode = any(),
disableOffloadFiltering = any(),
disableOffloadBatching = any(),
disableDirectScanCallback = any(),
)
}
}
@Test
fun `non security scan failure retries`() = runTest {
var attempts = 0
val fixture = createFixture {
attempts += 1
if (attempts == 1) {
flow<Collection<BleScanResult>> { throw IllegalStateException("temporary scanner failure") }
} else {
flowOf(emptyList())
}
}
fixture.monitor.devices.drop(1).first() shouldBe emptyList()
attempts shouldBe 2
verify(exactly = 0) { fixture.permissionTool.recheck() }
verify(exactly = 2) {
fixture.bleScanner.scan(
filters = any(),
scannerMode = any(),
disableOffloadFiltering = any(),
disableOffloadBatching = any(),
disableDirectScanCallback = any(),
)
}
}
private fun TestScope.createFixture(
scanFlowFactory: () -> Flow<Collection<BleScanResult>>,
): Fixture {
mockkObject(ProximityPairing)
every { ProximityPairing.getBleScanFilter() } returns emptySet()
val bleScanner = mockk<BleScanner>().apply {
every {
scan(
filters = any(),
scannerMode = any(),
disableOffloadFiltering = any(),
disableOffloadBatching = any(),
disableDirectScanCallback = any(),
)
} answers { scanFlowFactory() }
}
val scanModeController = mockk<BleScanModeController>().apply {
every { scannerMode } returns MutableStateFlow(ScannerMode.BALANCED)
}
val generalSettings = mockk<GeneralSettings>().apply {
every { isOffloadedBatchingDisabled } returns FakeDataStoreValue(false).mock
every { isOffloadedFilteringDisabled } returns FakeDataStoreValue(false).mock
every { useIndirectScanResultCallback } returns FakeDataStoreValue(false).mock
}
val debugSettings = mockk<DebugSettings>().apply {
every { showUnfiltered } returns FakeDataStoreValue(false).mock
}
val permissionTool = mockk<PermissionTool>().apply {
every { missingScanPermissions } returns MutableStateFlow<Set<Permission>>(emptySet())
every { recheck() } just Runs
}
val bluetoothManager = mockk<BluetoothManager2>().apply {
every { isBluetoothEnabled } returns MutableStateFlow(true)
}
val profilesRepo = mockk<DeviceProfilesRepo>().apply {
every { profiles } returns MutableStateFlow(emptyList())
}
val timeSource: TimeSource = TestTimeSource()
return Fixture(
monitor = BlePodMonitor(
appScope = backgroundScope,
bleScanner = bleScanner,
bleScanModeController = scanModeController,
podFactory = mockk<PodFactory>(relaxed = true),
timeSource = timeSource,
generalSettings = generalSettings,
bluetoothManager = bluetoothManager,
debugSettings = debugSettings,
permissionTool = permissionTool,
profilesRepo = profilesRepo,
),
bleScanner = bleScanner,
permissionTool = permissionTool,
)
}
private data class Fixture(
val monitor: BlePodMonitor,
val bleScanner: BleScanner,
val permissionTool: PermissionTool,
)
}