mirror of
https://github.com/meshtastic/Meshtastic-Android.git
synced 2026-10-03 17:14:33 -04:00
fix(logging): stop reporting expected conditions as crashes (#6470)
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
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() }
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user