mirror of
https://github.com/d4rken-org/capod.git
synced 2026-09-15 02:36:12 -04:00
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:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user