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 {