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).
This commit is contained in:
darken
2026-03-10 16:37:43 +00:00
committed by Matthias Urhahn
parent 12e35631d6
commit 193a1030fb
10 changed files with 51 additions and 20 deletions
@@ -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",
@@ -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<Set<Permission>> = missingPermissions
.map { perms -> perms.filter { it.isScanBlocking }.toSet() }
companion object {
private val TAG = logTag("PermissionTool")
}
@@ -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() },
@@ -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<PodDevice> get() = devices.filter { it.meta.profile != null }
val unmatchedDevices: List<PodDevice> get() = devices.filter { it.meta.profile == null }
}
@@ -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),
@@ -55,11 +55,11 @@ class PodMonitor @Inject constructor(
private val cacheLock = Mutex()
val devices: Flow<List<PodDevice>> = 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) {
@@ -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." }
@@ -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
@@ -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 {
@@ -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<PermissionTool>(relaxed = true).also {
every { it.missingPermissions } returns missingPermissionsFlow
every { it.missingScanPermissions } returns missingPermissionsFlow.map { perms ->
perms.filter { it.isScanBlocking }.toSet()
}
}
generalSettings = mockk<GeneralSettings>().also {