diff --git a/.skills/compose-ui/strings-index.txt b/.skills/compose-ui/strings-index.txt index fdd16ebf5..341ba4288 100644 --- a/.skills/compose-ui/strings-index.txt +++ b/.skills/compose-ui/strings-index.txt @@ -123,6 +123,7 @@ bluetooth_feature_config_description bluetooth_feature_discovery bluetooth_feature_discovery_description bluetooth_permission +bluetooth_scan_missing_permission bluetooth_scan_start_failed bold_heading bonding_failed_permissions diff --git a/core/ble/src/commonMain/kotlin/org/meshtastic/core/ble/BleScanStartException.kt b/core/ble/src/commonMain/kotlin/org/meshtastic/core/ble/BleScanStartException.kt index 5c26b679b..737b51803 100644 --- a/core/ble/src/commonMain/kotlin/org/meshtastic/core/ble/BleScanStartException.kt +++ b/core/ble/src/commonMain/kotlin/org/meshtastic/core/ble/BleScanStartException.kt @@ -22,6 +22,10 @@ enum class BleScanStartFailureReason(val androidCode: String, val description: S androidCode = "SCAN_FAILED_APPLICATION_REGISTRATION_FAILED(2)", description = "Android could not register the app for BLE scanning", ), + MissingScanPermission( + androidCode = "MISSING_SCAN_PERMISSION", + description = "A runtime permission required for BLE scanning is not granted", + ), } /** A discovery scan-start failure. No advertisements can be delivered until a future scan starts successfully. */ diff --git a/core/ble/src/commonMain/kotlin/org/meshtastic/core/ble/KableBleScanner.kt b/core/ble/src/commonMain/kotlin/org/meshtastic/core/ble/KableBleScanner.kt index c4e53bbbf..12dfb4e70 100644 --- a/core/ble/src/commonMain/kotlin/org/meshtastic/core/ble/KableBleScanner.kt +++ b/core/ble/src/commonMain/kotlin/org/meshtastic/core/ble/KableBleScanner.kt @@ -98,15 +98,20 @@ private fun Throwable.asBleScanStartExceptionOrNull(): BleScanStartException? { var current: Throwable? = this var depth = 0 while (current != null && depth < MAX_SCAN_START_FAILURE_CAUSE_DEPTH) { - if (current.isApplicationRegistrationFailure()) { - return BleScanStartException(BleScanStartFailureReason.ApplicationRegistrationFailed, this) - } + val reason = current.scanStartFailureReasonOrNull() + if (reason != null) return BleScanStartException(reason, this) current = current.cause depth++ } return null } +private fun Throwable.scanStartFailureReasonOrNull(): BleScanStartFailureReason? = when { + isApplicationRegistrationFailure() -> BleScanStartFailureReason.ApplicationRegistrationFailed + isMissingScanPermission() -> BleScanStartFailureReason.MissingScanPermission + else -> null +} + // Kable exposes Android scan-start registration failure as an IllegalStateException message, // so keep this matcher narrow and local to the scanner adapter. private fun Throwable.isApplicationRegistrationFailure(): Boolean = this is IllegalStateException && @@ -114,3 +119,11 @@ private fun Throwable.isApplicationRegistrationFailure(): Boolean = this is Ille failureMessage.contains("app cannot be registered", ignoreCase = true) || failureMessage.contains("SCAN_FAILED_APPLICATION_REGISTRATION_FAILED", ignoreCase = true) } == true + +// Kable's scan-permission check throws a plain IllegalStateException "Missing required for scanning" +// (ACCESS_COARSE/FINE_LOCATION on Android <12, BLUETOOTH_SCAN on 12+). Match the stable message anchors. +private fun Throwable.isMissingScanPermission(): Boolean = this is IllegalStateException && + message?.let { failureMessage -> + failureMessage.contains("Missing required", ignoreCase = true) && + failureMessage.contains("for scanning", ignoreCase = true) + } == true diff --git a/core/ble/src/commonMain/kotlin/org/meshtastic/core/ble/MeshtasticBleDevice.kt b/core/ble/src/commonMain/kotlin/org/meshtastic/core/ble/MeshtasticBleDevice.kt index 0e2cb7140..b4e1f03f6 100644 --- a/core/ble/src/commonMain/kotlin/org/meshtastic/core/ble/MeshtasticBleDevice.kt +++ b/core/ble/src/commonMain/kotlin/org/meshtastic/core/ble/MeshtasticBleDevice.kt @@ -17,7 +17,7 @@ package org.meshtastic.core.ble import com.juul.kable.Advertisement -import com.juul.kable.ExperimentalApi +import com.juul.kable.ExperimentalKableApi import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow @@ -54,7 +54,7 @@ class MeshtasticBleDevice( override val rssi: Int? = advertisement?.rssi - @OptIn(ExperimentalApi::class) + @OptIn(ExperimentalKableApi::class) override suspend fun readRssi(): Int { val active = ActiveBleConnection.active return if (active != null && active.address == address) { diff --git a/core/ble/src/commonTest/kotlin/org/meshtastic/core/ble/KableBleConnectionTest.kt b/core/ble/src/commonTest/kotlin/org/meshtastic/core/ble/KableBleConnectionTest.kt index 7af428d76..491b9d44a 100644 --- a/core/ble/src/commonTest/kotlin/org/meshtastic/core/ble/KableBleConnectionTest.kt +++ b/core/ble/src/commonTest/kotlin/org/meshtastic/core/ble/KableBleConnectionTest.kt @@ -145,6 +145,16 @@ class KableBleConnectionTest { assertEquals(BleScanStartFailureReason.ApplicationRegistrationFailed, failure.reason) } + @Test + fun `scan wraps missing scan permission failure`() = runTest { + val message = "Missing required android.permission.ACCESS_COARSE_LOCATION for scanning" + val scanner = TestKableBleScanner(scanResults = flow { throw IllegalStateException(message) }) + + val failure = assertFailsWith { scanner.scan(timeout = 1.seconds).toList() } + + assertEquals(BleScanStartFailureReason.MissingScanPermission, failure.reason) + } + @Test fun `scan preserves unrelated illegal state failure`() = runTest { val scanner = diff --git a/core/resources/src/commonMain/composeResources/values/strings.xml b/core/resources/src/commonMain/composeResources/values/strings.xml index 5918d4c36..2bfb65559 100644 --- a/core/resources/src/commonMain/composeResources/values/strings.xml +++ b/core/resources/src/commonMain/composeResources/values/strings.xml @@ -141,6 +141,7 @@ Discovery Find and identify Meshtastic devices near you. Bluetooth + Bluetooth scan needs permission. Grant the Nearby devices (or Location) permission to find devices. Bluetooth scan couldn't start. Try again, or toggle Bluetooth if the problem continues. Bold Heading Pairing failed. Grant nearby device permissions and try again. diff --git a/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/ScannerViewModel.kt b/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/ScannerViewModel.kt index 80e4c38a5..24ca4fc1d 100644 --- a/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/ScannerViewModel.kt +++ b/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/ScannerViewModel.kt @@ -40,6 +40,7 @@ import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import org.meshtastic.core.ble.BleDevice import org.meshtastic.core.ble.BleScanStartException +import org.meshtastic.core.ble.BleScanStartFailureReason import org.meshtastic.core.ble.BleScanner import org.meshtastic.core.ble.MeshtasticBleConstants import org.meshtastic.core.common.util.safeCatchingAll @@ -58,6 +59,7 @@ import org.meshtastic.core.repository.RadioPrefs import org.meshtastic.core.repository.ServiceRepository import org.meshtastic.core.repository.UiPrefs import org.meshtastic.core.resources.Res +import org.meshtastic.core.resources.bluetooth_scan_missing_permission import org.meshtastic.core.resources.bluetooth_scan_start_failed import org.meshtastic.core.resources.getStringSuspend import org.meshtastic.core.ui.viewmodel.safeLaunch @@ -396,9 +398,13 @@ open class ScannerViewModel( Logger.w(exception) { "BLE scan could not start: ${exception.reason.androidCode} (${exception.reason.description})" } + val messageRes = + when (exception.reason) { + BleScanStartFailureReason.MissingScanPermission -> Res.string.bluetooth_scan_missing_permission + BleScanStartFailureReason.ApplicationRegistrationFailed -> Res.string.bluetooth_scan_start_failed + } val errorMessage = - safeCatchingAll { getStringSuspend(Res.string.bluetooth_scan_start_failed) } - .getOrDefault(BLE_SCAN_START_FAILURE_MESSAGE_FALLBACK) + safeCatchingAll { getStringSuspend(messageRes) }.getOrDefault(BLE_SCAN_START_FAILURE_MESSAGE_FALLBACK) serviceRepository.setErrorMessage(text = errorMessage, severity = Severity.Warn) } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 681ebea47..fbd06ae67 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -91,7 +91,7 @@ uri-kmp = "0.0.21" osmdroid-android = "6.1.20" spotless = "8.8.0" vico = "3.3.0-next.1" -kable = "0.43.1" +kable = "0.44.0" mqttastic = "0.4.0" jmdns = "3.6.3" qrcode-kotlin = "4.5.0"