mirror of
https://github.com/d4rken-org/capod.git
synced 2026-09-14 18:26:11 -04:00
fix(monitor): Recover from revoked Bluetooth scan permission
This commit is contained in:
@@ -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,
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user