Skip to content

Commit ecc888e

Browse files
committed
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).
1 parent 12e3563 commit ecc888e

File tree

10 files changed

+51
-20
lines changed

10 files changed

+51
-20
lines changed

app/src/main/java/eu/darken/capod/common/permissions/Permission.kt

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ enum class Permission(
1616
@StringRes val labelRes: Int,
1717
@StringRes val descriptionRes: Int,
1818
val permissionId: String,
19+
val isScanBlocking: Boolean = false,
1920
val isGranted: (Context) -> Boolean = {
2021
ContextCompat.checkSelfPermission(it, permissionId) == PackageManager.PERMISSION_GRANTED
2122
},
@@ -26,6 +27,7 @@ enum class Permission(
2627
labelRes = R.string.permission_bluetooth_label,
2728
descriptionRes = R.string.permission_bluetooth_description,
2829
permissionId = "android.permission.BLUETOOTH",
30+
isScanBlocking = true,
2931
),
3032
BLUETOOTH_CONNECT(
3133
minApiLevel = Build.VERSION_CODES.S,
@@ -38,13 +40,15 @@ enum class Permission(
3840
labelRes = R.string.permission_bluetooth_scan_label,
3941
descriptionRes = R.string.permission_bluetooth_scan_description,
4042
permissionId = "android.permission.BLUETOOTH_SCAN",
43+
isScanBlocking = true,
4144
),
4245
ACCESS_FINE_LOCATION(
4346
minApiLevel = Build.VERSION_CODES.BASE,
4447
maxApiLevel = Build.VERSION_CODES.R,
4548
labelRes = R.string.permission_access_fine_location_label,
4649
descriptionRes = R.string.permission_access_fine_location_description,
4750
permissionId = "android.permission.ACCESS_FINE_LOCATION",
51+
isScanBlocking = true,
4852
),
4953
ACCESS_BACKGROUND_LOCATION(
5054
minApiLevel = Build.VERSION_CODES.Q,
@@ -73,7 +77,7 @@ enum class Permission(
7377
},
7478
),
7579
POST_NOTIFICATIONS(
76-
minApiLevel = Build.VERSION_CODES.S,
80+
minApiLevel = Build.VERSION_CODES.TIRAMISU,
7781
labelRes = R.string.permission_post_notifications_label,
7882
descriptionRes = R.string.permission_post_notifications_description,
7983
permissionId = "android.permission.POST_NOTIFICATIONS",

app/src/main/java/eu/darken/capod/main/core/PermissionTool.kt

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import eu.darken.capod.reaction.core.ReactionSettings
1010
import kotlinx.coroutines.flow.Flow
1111
import kotlinx.coroutines.flow.MutableStateFlow
1212
import kotlinx.coroutines.flow.combine
13+
import kotlinx.coroutines.flow.map
1314
import kotlinx.coroutines.flow.onEach
1415
import java.util.UUID
1516
import javax.inject.Inject
@@ -42,6 +43,9 @@ class PermissionTool @Inject constructor(
4243
}
4344
.onEach { log(TAG) { "Missing permission: $it" } }
4445

46+
val missingScanPermissions: Flow<Set<Permission>> = missingPermissions
47+
.map { perms -> perms.filter { it.isScanBlocking }.toSet() }
48+
4549
companion object {
4650
private val TAG = logTag("PermissionTool")
4751
}

app/src/main/java/eu/darken/capod/main/ui/overview/OverviewScreen.kt

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -195,7 +195,7 @@ fun OverviewScreen(
195195
) {
196196
// 1. Permission cards
197197
items(
198-
items = state.permissions.toList(),
198+
items = state.permissions.sortedByDescending { it.isScanBlocking },
199199
key = { it.permissionId },
200200
) { permission ->
201201
PermissionCard(
@@ -205,21 +205,21 @@ fun OverviewScreen(
205205
}
206206

207207
// 2. Bluetooth disabled card
208-
if (!state.isBluetoothEnabled && state.permissions.isEmpty()) {
208+
if (!state.isBluetoothEnabled && !state.isScanBlocked) {
209209
item(key = "bluetooth_disabled") {
210210
BluetoothDisabledCard()
211211
}
212212
}
213213

214214
// 3. No profiles card
215-
if (state.profiles.isEmpty() && state.permissions.isEmpty() && state.isBluetoothEnabled) {
215+
if (state.profiles.isEmpty() && !state.isScanBlocked && state.isBluetoothEnabled) {
216216
item(key = "no_profiles") {
217217
NoProfilesCard(onManageDevices = onManageDevices)
218218
}
219219
}
220220

221221
// 4. Profiled device cards
222-
if (state.permissions.isEmpty() && state.isBluetoothEnabled) {
222+
if (!state.isScanBlocked && state.isBluetoothEnabled) {
223223
items(
224224
items = state.profiledDevices,
225225
key = { it.identifier.hashCode() },

app/src/main/java/eu/darken/capod/main/ui/overview/OverviewViewModel.kt

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -53,18 +53,23 @@ class OverviewViewModel @Inject constructor(
5353

5454
private val showUnmatchedDevices = MutableStateFlow(false)
5555

56-
val workerAutolaunch = permissionTool.missingPermissions
56+
val workerAutolaunch = permissionTool.missingScanPermissions
5757
.onEach { permissions ->
5858
if (permissions.isNotEmpty()) {
59-
log(TAG) { "Missing permissions: $permissions" }
59+
log(TAG) { "Missing scan permissions: $permissions" }
6060
return@onEach
6161
}
6262

6363
val shouldStart = when (generalSettings.monitorMode.valueBlocking) {
6464
MonitorMode.MANUAL -> false
6565
MonitorMode.AUTOMATIC -> {
66-
val devices = withTimeoutOrNull(5_000) { bluetoothManager.connectedDevices.first() }
67-
devices?.isNotEmpty() == true
66+
try {
67+
val devices = withTimeoutOrNull(5_000) { bluetoothManager.connectedDevices.first() }
68+
devices?.isNotEmpty() == true
69+
} catch (e: SecurityException) {
70+
log(TAG) { "Can't check connected devices without BLUETOOTH_CONNECT: ${e.message}" }
71+
false
72+
}
6873
}
6974
MonitorMode.ALWAYS -> true
7075
}
@@ -82,7 +87,7 @@ class OverviewViewModel @Inject constructor(
8287
}
8388
}
8489

85-
private val pods = permissionTool.missingPermissions
90+
private val pods = permissionTool.missingScanPermissions
8691
.flatMapLatest { permissions ->
8792
if (permissions.isNotEmpty()) {
8893
return@flatMapLatest flowOf(emptyList())
@@ -124,6 +129,7 @@ class OverviewViewModel @Inject constructor(
124129
val upgradeInfo: UpgradeRepo.Info,
125130
val showUnmatchedDevices: Boolean,
126131
) {
132+
val isScanBlocked: Boolean get() = permissions.any { it.isScanBlocking }
127133
val profiledDevices: List<PodDevice> get() = devices.filter { it.meta.profile != null }
128134
val unmatchedDevices: List<PodDevice> get() = devices.filter { it.meta.profile == null }
129135
}

app/src/main/java/eu/darken/capod/main/ui/overview/cards/PermissionCard.kt

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import androidx.compose.foundation.layout.height
77
import androidx.compose.foundation.layout.padding
88
import androidx.compose.material3.Button
99
import androidx.compose.material3.Card
10+
import androidx.compose.material3.CardDefaults
1011
import androidx.compose.material3.MaterialTheme
1112
import androidx.compose.material3.Text
1213
import androidx.compose.runtime.Composable
@@ -24,10 +25,17 @@ fun PermissionCard(
2425
permission: Permission,
2526
onRequest: (Permission) -> Unit,
2627
) {
28+
val colors = if (permission.isScanBlocking) {
29+
CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.errorContainer)
30+
} else {
31+
CardDefaults.cardColors()
32+
}
33+
2734
Card(
2835
modifier = Modifier
2936
.fillMaxWidth()
3037
.padding(8.dp),
38+
colors = colors,
3139
) {
3240
Column(
3341
modifier = Modifier.padding(16.dp),

app/src/main/java/eu/darken/capod/monitor/core/PodMonitor.kt

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -55,11 +55,11 @@ class PodMonitor @Inject constructor(
5555
private val cacheLock = Mutex()
5656

5757
val devices: Flow<List<PodDevice>> = combine(
58-
permissionTool.missingPermissions,
58+
permissionTool.missingScanPermissions,
5959
bluetoothManager.isBluetoothEnabled
60-
) { missingPermissions, isBluetoothEnabled ->
61-
log(TAG) { "devices: missingPermissions=$missingPermissions, isBluetoothEnabled=$isBluetoothEnabled" }
62-
missingPermissions.isEmpty() && isBluetoothEnabled
60+
) { missingScanPermissions, isBluetoothEnabled ->
61+
log(TAG) { "devices: missingScanPermissions=$missingScanPermissions, isBluetoothEnabled=$isBluetoothEnabled" }
62+
missingScanPermissions.isEmpty() && isBluetoothEnabled
6363
}
6464
.flatMapLatest { isReady ->
6565
if (!isReady) {

app/src/main/java/eu/darken/capod/monitor/core/receiver/BluetoothEventReceiver.kt

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,12 @@ class BluetoothEventReceiver : BroadcastReceiver() {
3434
} else {
3535
log { "Event related to $bluetoothDevice" }
3636
}
37-
val supportedFeatures = ContinuityProtocol.BLE_FEATURE_UUIDS.filter { bluetoothDevice.hasFeature(it) }
37+
val supportedFeatures = try {
38+
ContinuityProtocol.BLE_FEATURE_UUIDS.filter { bluetoothDevice.hasFeature(it) }
39+
} catch (e: SecurityException) {
40+
log(TAG, WARN) { "Missing BLUETOOTH_CONNECT, can't check device features: ${e.message}" }
41+
return
42+
}
3843

3944
if (supportedFeatures.isEmpty()) {
4045
log(TAG) { "Device has no features we support." }

app/src/main/java/eu/darken/capod/monitor/core/worker/MonitorControl.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ class MonitorControl @Inject constructor(
2222
log(TAG, VERBOSE) { "startMonitor(forceStart=$forceStart)" }
2323

2424
val hasBluetoothPermission =
25-
Permission.BLUETOOTH.isGranted(context) || Permission.BLUETOOTH_CONNECT.isGranted(context)
25+
Permission.BLUETOOTH.isGranted(context) || Permission.BLUETOOTH_SCAN.isGranted(context)
2626
if (!hasBluetoothPermission) {
2727
log(TAG, WARN) { "Missing Bluetooth permission, not starting monitor service." }
2828
return

app/src/main/java/eu/darken/capod/monitor/core/worker/MonitorService.kt

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -162,9 +162,9 @@ class MonitorService : Service() {
162162
}
163163

164164
private suspend fun doMonitor() {
165-
val permissionsMissingOnStart = permissionTool.missingPermissions.first()
165+
val permissionsMissingOnStart = permissionTool.missingScanPermissions.first()
166166
if (permissionsMissingOnStart.isNotEmpty()) {
167-
log(TAG, WARN) { "Aborting, missing permissions: $permissionsMissingOnStart" }
167+
log(TAG, WARN) { "Aborting, missing scan permissions: $permissionsMissingOnStart" }
168168
return
169169
}
170170

@@ -190,10 +190,10 @@ class MonitorService : Service() {
190190
}
191191
.launchIn(monitorScope)
192192

193-
permissionTool.missingPermissions
193+
permissionTool.missingScanPermissions
194194
.flatMapLatest { missingPermsFlow ->
195195
if (missingPermsFlow.isNotEmpty()) {
196-
log(TAG, WARN) { "Aborting, permissions are missing: $missingPermsFlow" }
196+
log(TAG, WARN) { "Aborting, scan permissions are missing: $missingPermsFlow" }
197197
monitorScope.coroutineContext.cancelChildren()
198198
emptyFlow()
199199
} else {

app/src/test/java/eu/darken/capod/main/ui/overview/OverviewViewModelTest.kt

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import kotlinx.coroutines.Dispatchers
2222
import kotlinx.coroutines.ExperimentalCoroutinesApi
2323
import kotlinx.coroutines.flow.MutableStateFlow
2424
import kotlinx.coroutines.flow.first
25+
import kotlinx.coroutines.flow.map
2526
import kotlinx.coroutines.test.UnconfinedTestDispatcher
2627
import kotlinx.coroutines.test.resetMain
2728
import kotlinx.coroutines.test.runTest
@@ -81,6 +82,9 @@ class OverviewViewModelTest : BaseTest() {
8182

8283
permissionTool = mockk<PermissionTool>(relaxed = true).also {
8384
every { it.missingPermissions } returns missingPermissionsFlow
85+
every { it.missingScanPermissions } returns missingPermissionsFlow.map { perms ->
86+
perms.filter { it.isScanBlocking }.toSet()
87+
}
8488
}
8589

8690
generalSettings = mockk<GeneralSettings>().also {

0 commit comments

Comments
 (0)