From 193a1030fb1ab1595744c75bd74e4faf0661937a Mon Sep 17 00:00:00 2001 From: darken Date: Tue, 10 Mar 2026 16:51:58 +0100 Subject: [PATCH] feat: Start BLE scanning with only core permissions granted Allow the app to scan for AirPods when only BLE scan permissions are granted, without waiting for all optional permissions (notifications, overlay, etc.). Add isScanBlocking flag to Permission enum. Gate monitor service and pod scanning on scan permissions only. Show scan-blocking permission cards with error color and sorted first. Wrap BLUETOOTH_CONNECT-dependent calls in try-catch for graceful degradation. Fix POST_NOTIFICATIONS minApiLevel from S (31) to TIRAMISU (33). --- .../capod/common/permissions/Permission.kt | 6 +++++- .../eu/darken/capod/main/core/PermissionTool.kt | 4 ++++ .../capod/main/ui/overview/OverviewScreen.kt | 8 ++++---- .../capod/main/ui/overview/OverviewViewModel.kt | 16 +++++++++++----- .../main/ui/overview/cards/PermissionCard.kt | 8 ++++++++ .../eu/darken/capod/monitor/core/PodMonitor.kt | 8 ++++---- .../core/receiver/BluetoothEventReceiver.kt | 7 ++++++- .../capod/monitor/core/worker/MonitorControl.kt | 2 +- .../capod/monitor/core/worker/MonitorService.kt | 8 ++++---- .../main/ui/overview/OverviewViewModelTest.kt | 4 ++++ 10 files changed, 51 insertions(+), 20 deletions(-) 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 4b544d19..608a86e7 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 @@ -16,6 +16,7 @@ enum class Permission( @StringRes val labelRes: Int, @StringRes val descriptionRes: Int, val permissionId: String, + val isScanBlocking: Boolean = false, val isGranted: (Context) -> Boolean = { ContextCompat.checkSelfPermission(it, permissionId) == PackageManager.PERMISSION_GRANTED }, @@ -26,6 +27,7 @@ enum class Permission( labelRes = R.string.permission_bluetooth_label, descriptionRes = R.string.permission_bluetooth_description, permissionId = "android.permission.BLUETOOTH", + isScanBlocking = true, ), BLUETOOTH_CONNECT( minApiLevel = Build.VERSION_CODES.S, @@ -38,6 +40,7 @@ enum class Permission( labelRes = R.string.permission_bluetooth_scan_label, descriptionRes = R.string.permission_bluetooth_scan_description, permissionId = "android.permission.BLUETOOTH_SCAN", + isScanBlocking = true, ), ACCESS_FINE_LOCATION( minApiLevel = Build.VERSION_CODES.BASE, @@ -45,6 +48,7 @@ enum class Permission( labelRes = R.string.permission_access_fine_location_label, descriptionRes = R.string.permission_access_fine_location_description, permissionId = "android.permission.ACCESS_FINE_LOCATION", + isScanBlocking = true, ), ACCESS_BACKGROUND_LOCATION( minApiLevel = Build.VERSION_CODES.Q, @@ -73,7 +77,7 @@ enum class Permission( }, ), POST_NOTIFICATIONS( - minApiLevel = Build.VERSION_CODES.S, + minApiLevel = Build.VERSION_CODES.TIRAMISU, labelRes = R.string.permission_post_notifications_label, descriptionRes = R.string.permission_post_notifications_description, permissionId = "android.permission.POST_NOTIFICATIONS", diff --git a/app/src/main/java/eu/darken/capod/main/core/PermissionTool.kt b/app/src/main/java/eu/darken/capod/main/core/PermissionTool.kt index 2575f529..c60256c0 100644 --- a/app/src/main/java/eu/darken/capod/main/core/PermissionTool.kt +++ b/app/src/main/java/eu/darken/capod/main/core/PermissionTool.kt @@ -10,6 +10,7 @@ import eu.darken.capod.reaction.core.ReactionSettings import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onEach import java.util.UUID import javax.inject.Inject @@ -42,6 +43,9 @@ class PermissionTool @Inject constructor( } .onEach { log(TAG) { "Missing permission: $it" } } + val missingScanPermissions: Flow> = missingPermissions + .map { perms -> perms.filter { it.isScanBlocking }.toSet() } + companion object { private val TAG = logTag("PermissionTool") } diff --git a/app/src/main/java/eu/darken/capod/main/ui/overview/OverviewScreen.kt b/app/src/main/java/eu/darken/capod/main/ui/overview/OverviewScreen.kt index 9d4d28d5..ee73eb57 100644 --- a/app/src/main/java/eu/darken/capod/main/ui/overview/OverviewScreen.kt +++ b/app/src/main/java/eu/darken/capod/main/ui/overview/OverviewScreen.kt @@ -195,7 +195,7 @@ fun OverviewScreen( ) { // 1. Permission cards items( - items = state.permissions.toList(), + items = state.permissions.sortedByDescending { it.isScanBlocking }, key = { it.permissionId }, ) { permission -> PermissionCard( @@ -205,21 +205,21 @@ fun OverviewScreen( } // 2. Bluetooth disabled card - if (!state.isBluetoothEnabled && state.permissions.isEmpty()) { + if (!state.isBluetoothEnabled && !state.isScanBlocked) { item(key = "bluetooth_disabled") { BluetoothDisabledCard() } } // 3. No profiles card - if (state.profiles.isEmpty() && state.permissions.isEmpty() && state.isBluetoothEnabled) { + if (state.profiles.isEmpty() && !state.isScanBlocked && state.isBluetoothEnabled) { item(key = "no_profiles") { NoProfilesCard(onManageDevices = onManageDevices) } } // 4. Profiled device cards - if (state.permissions.isEmpty() && state.isBluetoothEnabled) { + if (!state.isScanBlocked && state.isBluetoothEnabled) { items( items = state.profiledDevices, key = { it.identifier.hashCode() }, diff --git a/app/src/main/java/eu/darken/capod/main/ui/overview/OverviewViewModel.kt b/app/src/main/java/eu/darken/capod/main/ui/overview/OverviewViewModel.kt index e23d024f..1c4f91c8 100644 --- a/app/src/main/java/eu/darken/capod/main/ui/overview/OverviewViewModel.kt +++ b/app/src/main/java/eu/darken/capod/main/ui/overview/OverviewViewModel.kt @@ -53,18 +53,23 @@ class OverviewViewModel @Inject constructor( private val showUnmatchedDevices = MutableStateFlow(false) - val workerAutolaunch = permissionTool.missingPermissions + val workerAutolaunch = permissionTool.missingScanPermissions .onEach { permissions -> if (permissions.isNotEmpty()) { - log(TAG) { "Missing permissions: $permissions" } + log(TAG) { "Missing scan permissions: $permissions" } return@onEach } val shouldStart = when (generalSettings.monitorMode.valueBlocking) { MonitorMode.MANUAL -> false MonitorMode.AUTOMATIC -> { - val devices = withTimeoutOrNull(5_000) { bluetoothManager.connectedDevices.first() } - devices?.isNotEmpty() == true + try { + val devices = withTimeoutOrNull(5_000) { bluetoothManager.connectedDevices.first() } + devices?.isNotEmpty() == true + } catch (e: SecurityException) { + log(TAG) { "Can't check connected devices without BLUETOOTH_CONNECT: ${e.message}" } + false + } } MonitorMode.ALWAYS -> true } @@ -82,7 +87,7 @@ class OverviewViewModel @Inject constructor( } } - private val pods = permissionTool.missingPermissions + private val pods = permissionTool.missingScanPermissions .flatMapLatest { permissions -> if (permissions.isNotEmpty()) { return@flatMapLatest flowOf(emptyList()) @@ -124,6 +129,7 @@ class OverviewViewModel @Inject constructor( val upgradeInfo: UpgradeRepo.Info, val showUnmatchedDevices: Boolean, ) { + val isScanBlocked: Boolean get() = permissions.any { it.isScanBlocking } val profiledDevices: List get() = devices.filter { it.meta.profile != null } val unmatchedDevices: List get() = devices.filter { it.meta.profile == null } } diff --git a/app/src/main/java/eu/darken/capod/main/ui/overview/cards/PermissionCard.kt b/app/src/main/java/eu/darken/capod/main/ui/overview/cards/PermissionCard.kt index f80e7c58..f579807e 100644 --- a/app/src/main/java/eu/darken/capod/main/ui/overview/cards/PermissionCard.kt +++ b/app/src/main/java/eu/darken/capod/main/ui/overview/cards/PermissionCard.kt @@ -7,6 +7,7 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.material3.Button import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -24,10 +25,17 @@ fun PermissionCard( permission: Permission, onRequest: (Permission) -> Unit, ) { + val colors = if (permission.isScanBlocking) { + CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.errorContainer) + } else { + CardDefaults.cardColors() + } + Card( modifier = Modifier .fillMaxWidth() .padding(8.dp), + colors = colors, ) { Column( modifier = Modifier.padding(16.dp), diff --git a/app/src/main/java/eu/darken/capod/monitor/core/PodMonitor.kt b/app/src/main/java/eu/darken/capod/monitor/core/PodMonitor.kt index 2e2d1378..ed86d89d 100644 --- a/app/src/main/java/eu/darken/capod/monitor/core/PodMonitor.kt +++ b/app/src/main/java/eu/darken/capod/monitor/core/PodMonitor.kt @@ -55,11 +55,11 @@ class PodMonitor @Inject constructor( private val cacheLock = Mutex() val devices: Flow> = combine( - permissionTool.missingPermissions, + permissionTool.missingScanPermissions, bluetoothManager.isBluetoothEnabled - ) { missingPermissions, isBluetoothEnabled -> - log(TAG) { "devices: missingPermissions=$missingPermissions, isBluetoothEnabled=$isBluetoothEnabled" } - missingPermissions.isEmpty() && isBluetoothEnabled + ) { missingScanPermissions, isBluetoothEnabled -> + log(TAG) { "devices: missingScanPermissions=$missingScanPermissions, isBluetoothEnabled=$isBluetoothEnabled" } + missingScanPermissions.isEmpty() && isBluetoothEnabled } .flatMapLatest { isReady -> if (!isReady) { diff --git a/app/src/main/java/eu/darken/capod/monitor/core/receiver/BluetoothEventReceiver.kt b/app/src/main/java/eu/darken/capod/monitor/core/receiver/BluetoothEventReceiver.kt index f4ac545c..d1580c0c 100644 --- a/app/src/main/java/eu/darken/capod/monitor/core/receiver/BluetoothEventReceiver.kt +++ b/app/src/main/java/eu/darken/capod/monitor/core/receiver/BluetoothEventReceiver.kt @@ -34,7 +34,12 @@ class BluetoothEventReceiver : BroadcastReceiver() { } else { log { "Event related to $bluetoothDevice" } } - val supportedFeatures = ContinuityProtocol.BLE_FEATURE_UUIDS.filter { bluetoothDevice.hasFeature(it) } + val supportedFeatures = try { + ContinuityProtocol.BLE_FEATURE_UUIDS.filter { bluetoothDevice.hasFeature(it) } + } catch (e: SecurityException) { + log(TAG, WARN) { "Missing BLUETOOTH_CONNECT, can't check device features: ${e.message}" } + return + } if (supportedFeatures.isEmpty()) { log(TAG) { "Device has no features we support." } diff --git a/app/src/main/java/eu/darken/capod/monitor/core/worker/MonitorControl.kt b/app/src/main/java/eu/darken/capod/monitor/core/worker/MonitorControl.kt index 6af803f5..70eac0e5 100644 --- a/app/src/main/java/eu/darken/capod/monitor/core/worker/MonitorControl.kt +++ b/app/src/main/java/eu/darken/capod/monitor/core/worker/MonitorControl.kt @@ -22,7 +22,7 @@ class MonitorControl @Inject constructor( log(TAG, VERBOSE) { "startMonitor(forceStart=$forceStart)" } val hasBluetoothPermission = - Permission.BLUETOOTH.isGranted(context) || Permission.BLUETOOTH_CONNECT.isGranted(context) + Permission.BLUETOOTH.isGranted(context) || Permission.BLUETOOTH_SCAN.isGranted(context) if (!hasBluetoothPermission) { log(TAG, WARN) { "Missing Bluetooth permission, not starting monitor service." } return diff --git a/app/src/main/java/eu/darken/capod/monitor/core/worker/MonitorService.kt b/app/src/main/java/eu/darken/capod/monitor/core/worker/MonitorService.kt index 0364e0a3..3f3934e2 100644 --- a/app/src/main/java/eu/darken/capod/monitor/core/worker/MonitorService.kt +++ b/app/src/main/java/eu/darken/capod/monitor/core/worker/MonitorService.kt @@ -162,9 +162,9 @@ class MonitorService : Service() { } private suspend fun doMonitor() { - val permissionsMissingOnStart = permissionTool.missingPermissions.first() + val permissionsMissingOnStart = permissionTool.missingScanPermissions.first() if (permissionsMissingOnStart.isNotEmpty()) { - log(TAG, WARN) { "Aborting, missing permissions: $permissionsMissingOnStart" } + log(TAG, WARN) { "Aborting, missing scan permissions: $permissionsMissingOnStart" } return } @@ -190,10 +190,10 @@ class MonitorService : Service() { } .launchIn(monitorScope) - permissionTool.missingPermissions + permissionTool.missingScanPermissions .flatMapLatest { missingPermsFlow -> if (missingPermsFlow.isNotEmpty()) { - log(TAG, WARN) { "Aborting, permissions are missing: $missingPermsFlow" } + log(TAG, WARN) { "Aborting, scan permissions are missing: $missingPermsFlow" } monitorScope.coroutineContext.cancelChildren() emptyFlow() } else { diff --git a/app/src/test/java/eu/darken/capod/main/ui/overview/OverviewViewModelTest.kt b/app/src/test/java/eu/darken/capod/main/ui/overview/OverviewViewModelTest.kt index 696163b6..723567f3 100644 --- a/app/src/test/java/eu/darken/capod/main/ui/overview/OverviewViewModelTest.kt +++ b/app/src/test/java/eu/darken/capod/main/ui/overview/OverviewViewModelTest.kt @@ -22,6 +22,7 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map import kotlinx.coroutines.test.UnconfinedTestDispatcher import kotlinx.coroutines.test.resetMain import kotlinx.coroutines.test.runTest @@ -81,6 +82,9 @@ class OverviewViewModelTest : BaseTest() { permissionTool = mockk(relaxed = true).also { every { it.missingPermissions } returns missingPermissionsFlow + every { it.missingScanPermissions } returns missingPermissionsFlow.map { perms -> + perms.filter { it.isScanBlocking }.toSet() + } } generalSettings = mockk().also {