From 06b41ec8eb30e84a5286df762f2ae973b335862f Mon Sep 17 00:00:00 2001 From: darken Date: Sun, 3 May 2026 11:47:52 +0200 Subject: [PATCH] fix(monitor): Recover from revoked Bluetooth scan permission --- .../capod/common/bluetooth/BleScanner.kt | 75 +++++---- .../capod/common/permissions/Permission.kt | 7 +- .../capod/monitor/core/ble/BlePodMonitor.kt | 38 +++-- .../monitor/core/ble/BlePodMonitorTest.kt | 148 ++++++++++++++++++ 4 files changed, 224 insertions(+), 44 deletions(-) create mode 100644 app/src/test/java/eu/darken/capod/monitor/core/ble/BlePodMonitorTest.kt diff --git a/app/src/main/java/eu/darken/capod/common/bluetooth/BleScanner.kt b/app/src/main/java/eu/darken/capod/common/bluetooth/BleScanner.kt index e13d4f5e..19ad36b3 100644 --- a/app/src/main/java/eu/darken/capod/common/bluetooth/BleScanner.kt +++ b/app/src/main/java/eu/darken/capod/common/bluetooth/BleScanner.kt @@ -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" } } diff --git a/app/src/main/java/eu/darken/capod/common/permissions/Permission.kt b/app/src/main/java/eu/darken/capod/common/permissions/Permission.kt index 608a86e7..70b27ac0 100644 --- a/app/src/main/java/eu/darken/capod/common/permissions/Permission.kt +++ b/app/src/main/java/eu/darken/capod/common/permissions/Permission.kt @@ -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) -} \ No newline at end of file +} diff --git a/app/src/main/java/eu/darken/capod/monitor/core/ble/BlePodMonitor.kt b/app/src/main/java/eu/darken/capod/monitor/core/ble/BlePodMonitor.kt index 285a5e5a..3a8cf150 100644 --- a/app/src/main/java/eu/darken/capod/monitor/core/ble/BlePodMonitor.kt +++ b/app/src/main/java/eu/darken/capod/monitor/core/ble/BlePodMonitor.kt @@ -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() } diff --git a/app/src/test/java/eu/darken/capod/monitor/core/ble/BlePodMonitorTest.kt b/app/src/test/java/eu/darken/capod/monitor/core/ble/BlePodMonitorTest.kt new file mode 100644 index 00000000..7706d887 --- /dev/null +++ b/app/src/test/java/eu/darken/capod/monitor/core/ble/BlePodMonitorTest.kt @@ -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> { 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> { 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>, + ): Fixture { + mockkObject(ProximityPairing) + every { ProximityPairing.getBleScanFilter() } returns emptySet() + + val bleScanner = mockk().apply { + every { + scan( + filters = any(), + scannerMode = any(), + disableOffloadFiltering = any(), + disableOffloadBatching = any(), + disableDirectScanCallback = any(), + ) + } answers { scanFlowFactory() } + } + + val scanModeController = mockk().apply { + every { scannerMode } returns MutableStateFlow(ScannerMode.BALANCED) + } + val generalSettings = mockk().apply { + every { isOffloadedBatchingDisabled } returns FakeDataStoreValue(false).mock + every { isOffloadedFilteringDisabled } returns FakeDataStoreValue(false).mock + every { useIndirectScanResultCallback } returns FakeDataStoreValue(false).mock + } + val debugSettings = mockk().apply { + every { showUnfiltered } returns FakeDataStoreValue(false).mock + } + val permissionTool = mockk().apply { + every { missingScanPermissions } returns MutableStateFlow>(emptySet()) + every { recheck() } just Runs + } + val bluetoothManager = mockk().apply { + every { isBluetoothEnabled } returns MutableStateFlow(true) + } + val profilesRepo = mockk().apply { + every { profiles } returns MutableStateFlow(emptyList()) + } + val timeSource: TimeSource = TestTimeSource() + + return Fixture( + monitor = BlePodMonitor( + appScope = backgroundScope, + bleScanner = bleScanner, + bleScanModeController = scanModeController, + podFactory = mockk(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, + ) +}