fix(logging): stop reporting expected conditions as crashes (#6470)

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
James RichandClaude Opus 5 authored and GitHub committed 2026-07-27 18:53:54 +00:00
1 parent 2d63db8dd4
commit 5235b8fc51
16 files changed
+598 -37

No files matched your search

@@ -16,24 +16,55 @@
*/
package org.meshtastic.core.ble
import org.meshtastic.core.common.log.ExpectedCondition
import kotlin.time.Duration
/** Known reasons a BLE discovery scan failed before Android registered the scanner. */
enum class BleScanStartFailureReason(val androidCode: String, val description: String) {
/**
* Known reasons a BLE discovery scan failed before Android registered the scanner.
*
* Every reason here is an environment state — a permission not granted, a radio switched off, an OS quota — so
* [BleScanStartException] is an [ExpectedCondition] and is never reported as a crash.
*
* @property androidCode the platform-level code or constant this maps to, used in log lines.
* @property description a human-readable explanation for log lines.
* @property label the stable, low-cardinality [ExpectedCondition.expectedConditionLabel] for rate tracking.
*/
enum class BleScanStartFailureReason(val androidCode: String, val description: String, val label: String) {
ApplicationRegistrationFailed(
androidCode = "SCAN_FAILED_APPLICATION_REGISTRATION_FAILED(2)",
description = "Android could not register the app for BLE scanning",
label = "ble-scan-registration-failed",
),
MissingScanPermission(
androidCode = "MISSING_SCAN_PERMISSION",
description = "A runtime permission required for BLE scanning is not granted",
label = "ble-scan-missing-permission",
),
ScanningTooFrequently(
androidCode = "SCAN_FAILED_SCANNING_TOO_FREQUENTLY(6)",
description = "Android rejected a BLE scan because the app reached its scan-start quota",
label = "ble-scan-too-frequently",
),
BluetoothDisabled(
androidCode = "BLUETOOTH_DISABLED",
description = "Bluetooth is switched off",
label = "ble-scan-bluetooth-disabled",
),
LocationServicesDisabled(
androidCode = "LOCATION_SERVICES_DISABLED",
description = "Location services are off, and this Android version requires them for BLE scanning",
label = "ble-scan-location-services-disabled",
),
}
/** A discovery scan-start failure. No advertisements can be delivered until a future scan starts successfully. */
/**
* A discovery scan-start failure. No advertisements can be delivered until a future scan starts successfully.
*
* This is an [ExpectedCondition]: every [BleScanStartFailureReason] describes the environment refusing the scan, not a
* defect in the app, so callers should surface it to the user and log it at warn level rather than reporting it.
*/
class BleScanStartException(val reason: BleScanStartFailureReason, cause: Throwable, val retryAfter: Duration? = null) :
IllegalStateException("BLE scan could not start: ${reason.androidCode}", cause)
IllegalStateException("BLE scan could not start: ${reason.androidCode}", cause),
ExpectedCondition {
override val expectedConditionLabel: String = reason.label
}
@@ -18,6 +18,8 @@ package org.meshtastic.core.ble
import com.juul.kable.Advertisement
import com.juul.kable.Scanner
import com.juul.kable.UnmetRequirementException
import com.juul.kable.UnmetRequirementReason
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.channelFlow
@@ -71,6 +73,10 @@ open class KableBleScanner(private val loggingConfig: BleLoggingConfig) : BleSca
return scanner.advertisements.map(Advertisement::toScanResult)
}
// ThrowsCount: three deliberate rethrow paths, one per exception family Kable can surface here — cancellation
// (must propagate untouched), UnmetRequirementException (an IOException) and IllegalStateException. They have no
// common supertype below Exception, so merging them would mean catching Exception broadly instead.
@Suppress("ThrowsCount")
override fun scan(timeout: Duration, serviceUuid: Uuid?, address: String?): Flow<BleDevice> {
val filter = resolveKableScanFilter(serviceUuid = serviceUuid, address = address)
@@ -91,6 +97,12 @@ open class KableBleScanner(private val loggingConfig: BleLoggingConfig) : BleSca
}
} catch (ex: CancellationException) {
throw ex
} catch (ex: UnmetRequirementException) {
// Kable models "Bluetooth is off" and "location services are off" as an IOException, so these
// never matched the IllegalStateException branch below and escaped raw to ViewModel-level
// catch-alls that log at error — the top source of Crashlytics/RUM noise. Map them to the
// typed, non-reported BleScanStartException instead.
throw ex.asBleScanStartException()
} catch (ex: IllegalStateException) {
throw ex.asBleScanStartExceptionOrNull() ?: ex
}
@@ -99,6 +111,23 @@ open class KableBleScanner(private val loggingConfig: BleLoggingConfig) : BleSca
}
}
/**
* Maps Kable's typed [UnmetRequirementReason] onto the matching [BleScanStartFailureReason].
*
* Kable's reason enum is exhaustive over the preconditions it checks, so this needs no message matching — unlike the
* [IllegalStateException] paths below, which Kable only distinguishes by message text.
*
* Kept `internal` and separate from the exception so it stays unit-testable: [UnmetRequirementException] has an
* `internal` constructor in Kable and cannot be instantiated from our tests.
*/
internal fun UnmetRequirementReason.toBleScanStartFailureReason(): BleScanStartFailureReason = when (this) {
UnmetRequirementReason.BluetoothDisabled -> BleScanStartFailureReason.BluetoothDisabled
UnmetRequirementReason.LocationServicesDisabled -> BleScanStartFailureReason.LocationServicesDisabled
}
private fun UnmetRequirementException.asBleScanStartException(): BleScanStartException =
BleScanStartException(reason.toBleScanStartFailureReason(), this)
private fun Throwable.asBleScanStartExceptionOrNull(): BleScanStartException? {
var current: Throwable? = this
var depth = 0
@@ -0,0 +1,84 @@
/*
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.ble
import co.touchlab.kermit.Severity
import com.juul.kable.UnmetRequirementReason
import org.meshtastic.core.common.log.isExpectedCondition
import org.meshtastic.core.common.log.shouldReportAsException
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertTrue
/**
* A BLE scan refused by the environment must never reach a crash reporter.
*
* Bluetooth off, location services off and a missing scan permission were four of the loudest Crashlytics issues during
* 2.8.0 triage, all of them non-actionable.
*/
class BleScanStartExceptionTest {
@Test
fun `every scan-start failure is an expected condition`() {
BleScanStartFailureReason.entries.forEach { reason ->
val exception = BleScanStartException(reason, cause = IllegalStateException("cause"))
assertTrue(exception.isExpectedCondition(), "$reason must be an expected condition")
assertEquals(reason.label, exception.expectedConditionLabel)
}
}
@Test
fun `scan-start failures are never reported even when logged at error`() {
BleScanStartFailureReason.entries.forEach { reason ->
val exception = BleScanStartException(reason, cause = IllegalStateException("cause"))
assertFalse(
shouldReportAsException(Severity.Error, exception),
"$reason must not be recorded as a non-fatal",
)
}
}
@Test
fun `labels are unique and low cardinality`() {
val labels = BleScanStartFailureReason.entries.map { it.label }
assertEquals(labels.size, labels.toSet().size, "labels must be unique to be usable as rate keys")
labels.forEach { label ->
assertTrue(label.isNotBlank(), "label must not be blank")
assertEquals(label.lowercase(), label, "label '$label' must be lowercase for stable grouping")
}
}
// Kable's UnmetRequirementException has an internal constructor, so the reason mapping is verified directly.
@Test
fun `kable unmet-requirement reasons map onto scan-start reasons`() {
assertEquals(
BleScanStartFailureReason.BluetoothDisabled,
UnmetRequirementReason.BluetoothDisabled.toBleScanStartFailureReason(),
)
assertEquals(
BleScanStartFailureReason.LocationServicesDisabled,
UnmetRequirementReason.LocationServicesDisabled.toBleScanStartFailureReason(),
)
}
@Test
fun `every kable unmet-requirement reason is mapped`() {
// Guards against a Kable upgrade adding a reason that silently falls through to error-level logging.
UnmetRequirementReason.entries.forEach { it.toBleScanStartFailureReason() }
}
}