diff --git a/app/src/main/java/com/geeksville/mesh/model/BTScanModel.kt b/app/src/main/java/com/geeksville/mesh/model/BTScanModel.kt index 708de3bc9..3b90dfbb8 100644 --- a/app/src/main/java/com/geeksville/mesh/model/BTScanModel.kt +++ b/app/src/main/java/com/geeksville/mesh/model/BTScanModel.kt @@ -24,6 +24,7 @@ import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import co.touchlab.kermit.Logger +import co.touchlab.kermit.Severity import com.geeksville.mesh.repository.bluetooth.BluetoothRepository import com.geeksville.mesh.repository.network.NetworkRepository import com.geeksville.mesh.repository.network.NetworkRepository.Companion.toAddressString @@ -51,8 +52,6 @@ import org.meshtastic.core.strings.meshtastic import org.meshtastic.core.ui.viewmodel.stateInWhileSubscribed import javax.inject.Inject -// ... (DeviceListEntry sealed class remains the same) ... - @HiltViewModel @Suppress("LongParameterList", "TooManyFunctions") class BTScanModel @@ -209,11 +208,20 @@ constructor( changeDeviceAddress(entry.fullAddress) } catch (ex: SecurityException) { Logger.w(ex) { "Bonding failed for ${entry.peripheral.address.anonymize} Permissions not granted" } - serviceRepository.setErrorMessage("Bonding failed: ${ex.message} Permissions not granted") + serviceRepository.setErrorMessage( + text = "Bonding failed: ${ex.message} Permissions not granted", + severity = Severity.Warn, + ) } catch (ex: Exception) { // Bonding is often flaky and can fail for many reasons (timeout, user cancel, etc) - Logger.w(ex) { "Bonding failed for ${entry.peripheral.address.anonymize}" } - serviceRepository.setErrorMessage("Bonding failed: ${ex.message}") + val message = ex.message ?: "" + if (message.contains("Received bond state changed 11")) { + // This is a known issue where bonding is still in progress, ignore as error + Logger.d { "Bonding still in progress for ${entry.peripheral.address.anonymize}" } + } else { + Logger.w(ex) { "Bonding failed for ${entry.peripheral.address.anonymize}" } + serviceRepository.setErrorMessage(text = "Bonding failed: ${ex.message}", severity = Severity.Warn) + } } } } diff --git a/app/src/main/java/com/geeksville/mesh/repository/network/MQTTRepository.kt b/app/src/main/java/com/geeksville/mesh/repository/network/MQTTRepository.kt index d0186da8b..5c90f0aeb 100644 --- a/app/src/main/java/com/geeksville/mesh/repository/network/MQTTRepository.kt +++ b/app/src/main/java/com/geeksville/mesh/repository/network/MQTTRepository.kt @@ -70,10 +70,12 @@ constructor( fun disconnect() { Logger.i { "MQTT Disconnected" } mqttClient?.apply { - ignoreException { disconnect() } - close(true) - mqttClient = null + if (isConnected) { + ignoreException { disconnect() } + } + ignoreException { close(true) } } + mqttClient = null } val proxyMessageFlow: Flow = callbackFlow { @@ -166,7 +168,11 @@ constructor( val token = mqttClient?.publish(topic, data, DEFAULT_QOS, retained) Logger.i { "MQTT Publish messageId: ${token?.messageId}" } } catch (ex: Exception) { - Logger.e { "MQTT Publish error: ${ex.message}" } + if (ex.message?.contains("Client is disconnected") == true) { + Logger.w { "MQTT Publish skipped: Client is disconnected" } + } else { + Logger.e(ex) { "MQTT Publish error: ${ex.message}" } + } } } } diff --git a/app/src/main/java/com/geeksville/mesh/repository/radio/SerialInterface.kt b/app/src/main/java/com/geeksville/mesh/repository/radio/SerialInterface.kt index 9d91d0c73..15c7929ac 100644 --- a/app/src/main/java/com/geeksville/mesh/repository/radio/SerialInterface.kt +++ b/app/src/main/java/com/geeksville/mesh/repository/radio/SerialInterface.kt @@ -96,7 +96,8 @@ constructor( 0 } thrown?.let { e -> - Logger.e(e) { "[$address] Serial error after ${uptime}ms: ${e.message}" } + // USB errors are common when unplugging; log as warning to avoid Crashlytics noise + Logger.w(e) { "[$address] Serial error after ${uptime}ms: ${e.message}" } } Logger.w { "[$address] Serial device disconnected - " + diff --git a/app/src/main/java/com/geeksville/mesh/repository/radio/TCPInterface.kt b/app/src/main/java/com/geeksville/mesh/repository/radio/TCPInterface.kt index 3e3e19ef1..6ea9e95de 100644 --- a/app/src/main/java/com/geeksville/mesh/repository/radio/TCPInterface.kt +++ b/app/src/main/java/com/geeksville/mesh/repository/radio/TCPInterface.kt @@ -84,7 +84,8 @@ constructor( try { stream.write(p) } catch (ex: IOException) { - Logger.e(ex) { "[$address] TCP write error: ${ex.message}" } + // TCP write errors are common when the connection is lost; log as warning to avoid Crashlytics noise + Logger.w(ex) { "[$address] TCP write error: ${ex.message}" } onDeviceDisconnect(false) } } @@ -95,7 +96,8 @@ constructor( try { stream.flush() } catch (ex: IOException) { - Logger.e(ex) { "[$address] TCP flush error: ${ex.message}" } + // TCP flush errors are common when the connection is lost; log as warning to avoid Crashlytics noise + Logger.w(ex) { "[$address] TCP flush error: ${ex.message}" } onDeviceDisconnect(false) } } diff --git a/app/src/main/java/com/geeksville/mesh/repository/usb/SerialConnectionImpl.kt b/app/src/main/java/com/geeksville/mesh/repository/usb/SerialConnectionImpl.kt index 260e47c40..42c805b6c 100644 --- a/app/src/main/java/com/geeksville/mesh/repository/usb/SerialConnectionImpl.kt +++ b/app/src/main/java/com/geeksville/mesh/repository/usb/SerialConnectionImpl.kt @@ -42,29 +42,30 @@ internal class SerialConnectionImpl( @Suppress("TooGenericExceptionCaught") override fun sendBytes(bytes: ByteArray) { ioRef.get()?.let { - Logger.d { "writing ${bytes.size} byte(s }" } + Logger.d { "writing ${bytes.size} byte(s)" } try { it.writeAsync(bytes) } catch (e: BufferOverflowException) { - Logger.e(e) { "Buffer overflow while writing to serial port" } + Logger.w(e) { "Buffer overflow while writing to serial port" } } catch (e: Exception) { - Logger.e(e) { "Failed to write to serial port" } + // USB disconnections often cause IOExceptions here; log as warning to avoid Crashlytics noise + Logger.w(e) { "Failed to write to serial port (likely disconnected)" } } } } override fun close(waitForStopped: Boolean) { - ignoreException { - if (closed.compareAndSet(false, true)) { - ioRef.get()?.stop() + if (closed.compareAndSet(false, true)) { + ignoreException(silent = true) { ioRef.get()?.stop() } + ignoreException(silent = true) { port.close() // This will cause the reader thread to exit } + } - // Allow a short amount of time for the manager to quit (so the port can be cleanly closed) - if (waitForStopped) { - Logger.d { "Waiting for USB manager to stop..." } - closedLatch.await(1.seconds) - } + // Allow a short amount of time for the manager to quit (so the port can be cleanly closed) + if (waitForStopped) { + Logger.d { "Waiting for USB manager to stop..." } + ignoreException(silent = true) { closedLatch.await(1.seconds) } } } @@ -99,11 +100,9 @@ internal class SerialConnectionImpl( override fun onRunError(e: Exception?) { closed.set(true) - ignoreException { - port.dtr = false - port.rts = false - port.close() - } + // Connection is already failing, don't try to set DTR/RTS as it will just throw more + // IOExceptions + ignoreException(silent = true) { port.close() } closedLatch.countDown() listener.onDisconnected(e) } diff --git a/app/src/main/java/com/geeksville/mesh/service/MeshDataHandler.kt b/app/src/main/java/com/geeksville/mesh/service/MeshDataHandler.kt index 30a8a805f..8c0d1c03e 100644 --- a/app/src/main/java/com/geeksville/mesh/service/MeshDataHandler.kt +++ b/app/src/main/java/com/geeksville/mesh/service/MeshDataHandler.kt @@ -18,6 +18,7 @@ package com.geeksville.mesh.service import android.util.Log import co.touchlab.kermit.Logger +import co.touchlab.kermit.Severity import com.geeksville.mesh.BuildConfig import com.geeksville.mesh.concurrent.handledLaunch import com.geeksville.mesh.repository.radio.InterfaceId @@ -459,7 +460,7 @@ constructor( val payload = packet.decoded?.payload ?: return val r = Routing.ADAPTER.decodeOrNull(payload, Logger) ?: return if (r.error_reason == Routing.Error.DUTY_CYCLE_LIMIT) { - serviceRepository.setErrorMessage(getString(Res.string.error_duty_cycle)) + serviceRepository.setErrorMessage(getString(Res.string.error_duty_cycle), Severity.Warn) } handleAckNak( packet.decoded?.request_id ?: 0, diff --git a/app/src/main/java/com/geeksville/mesh/service/MeshMqttManager.kt b/app/src/main/java/com/geeksville/mesh/service/MeshMqttManager.kt index ed741dcc6..314b7c99c 100644 --- a/app/src/main/java/com/geeksville/mesh/service/MeshMqttManager.kt +++ b/app/src/main/java/com/geeksville/mesh/service/MeshMqttManager.kt @@ -17,6 +17,7 @@ package com.geeksville.mesh.service import co.touchlab.kermit.Logger +import co.touchlab.kermit.Severity import com.geeksville.mesh.repository.network.MQTTRepository import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -49,7 +50,12 @@ constructor( mqttMessageFlow = mqttRepository.proxyMessageFlow .onEach { message -> packetHandler.sendToRadio(ToRadio(mqttClientProxyMessage = message)) } - .catch { throwable -> serviceRepository.setErrorMessage("MqttClientProxy failed: $throwable") } + .catch { throwable -> + serviceRepository.setErrorMessage( + text = "MqttClientProxy failed: $throwable", + severity = Severity.Warn, + ) + } .launchIn(scope) } } diff --git a/app/src/main/java/com/geeksville/mesh/service/PacketHandler.kt b/app/src/main/java/com/geeksville/mesh/service/PacketHandler.kt index 4104e5a8c..d0ab953b6 100644 --- a/app/src/main/java/com/geeksville/mesh/service/PacketHandler.kt +++ b/app/src/main/java/com/geeksville/mesh/service/PacketHandler.kt @@ -196,6 +196,10 @@ constructor( throw RadioNotConnectedException() } sendToRadio(ToRadio(packet = packet)) + } catch (ex: RadioNotConnectedException) { + // Expected when radio is not connected, log as warning to avoid Crashlytics noise + Logger.w(ex) { "sendToRadio skipped: Not connected to radio" } + deferred.complete(false) } catch (ex: Exception) { Logger.e(ex) { "sendToRadio error: ${ex.message}" } deferred.complete(false) diff --git a/app/src/main/java/com/geeksville/mesh/ui/connections/components/CurrentlyConnectedInfo.kt b/app/src/main/java/com/geeksville/mesh/ui/connections/components/CurrentlyConnectedInfo.kt index 55733443a..014c70e42 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/connections/components/CurrentlyConnectedInfo.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/connections/components/CurrentlyConnectedInfo.kt @@ -63,7 +63,7 @@ import kotlin.time.Duration.Companion.seconds private const val RSSI_DELAY = 10 private const val RSSI_TIMEOUT = 5 -@Suppress("LongMethod", "LoopWithTooManyJumpStatements") +@Suppress("LongMethod", "LoopWithTooManyJumpStatements", "TooGenericExceptionCaught") @Composable fun CurrentlyConnectedInfo( node: Node, @@ -80,13 +80,17 @@ fun CurrentlyConnectedInfo( rssi = withTimeout(RSSI_TIMEOUT.seconds) { bleDevice.peripheral.readRssi() } delay(RSSI_DELAY.seconds) } catch (e: PeripheralNotConnectedException) { - Logger.e(e) { "Failed to read RSSI ${e.message}" } + Logger.w(e) { "Failed to read RSSI ${e.message}" } break } catch (e: OperationFailedException) { - Logger.e(e) { "Failed to read RSSI ${e.message}" } + // RSSI reading failures are common when disconnecting; log as warning to avoid Crashlytics noise + Logger.w(e) { "Failed to read RSSI ${e.message}" } break } catch (e: SecurityException) { - Logger.e(e) { "Failed to read RSSI ${e.message}" } + Logger.w(e) { "Failed to read RSSI ${e.message}" } + break + } catch (e: Exception) { + Logger.w(e) { "Unexpected error reading RSSI: ${e.message}" } break } } diff --git a/app/src/main/java/com/geeksville/mesh/util/Exceptions.kt b/app/src/main/java/com/geeksville/mesh/util/Exceptions.kt index 4471efd4f..b76d85da7 100644 --- a/app/src/main/java/com/geeksville/mesh/util/Exceptions.kt +++ b/app/src/main/java/com/geeksville/mesh/util/Exceptions.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2025 Meshtastic LLC + * Copyright (c) 2025-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 @@ -14,7 +14,6 @@ * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ - package com.geeksville.mesh.util import android.os.RemoteException @@ -57,7 +56,7 @@ fun ignoreException(silent: Boolean = false, inner: () -> Unit) { inner() } catch (ex: Throwable) { // DO NOT THROW users expect we have fully handled/discarded the exception - if (!silent) Logger.e(ex) { "ignoring exception" } + if (!silent) Logger.w(ex) { "ignoring exception" } } } diff --git a/core/service/src/main/kotlin/org/meshtastic/core/service/ServiceRepository.kt b/core/service/src/main/kotlin/org/meshtastic/core/service/ServiceRepository.kt index 7b8c4b170..2137061f3 100644 --- a/core/service/src/main/kotlin/org/meshtastic/core/service/ServiceRepository.kt +++ b/core/service/src/main/kotlin/org/meshtastic/core/service/ServiceRepository.kt @@ -17,6 +17,7 @@ package org.meshtastic.core.service import co.touchlab.kermit.Logger +import co.touchlab.kermit.Severity import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow @@ -65,7 +66,7 @@ class ServiceRepository @Inject constructor() { get() = _clientNotification fun setClientNotification(notification: ClientNotification?) { - Logger.e { notification?.message.orEmpty() } + notification?.message?.let { Logger.w { it } } _clientNotification.value = notification } @@ -78,8 +79,8 @@ class ServiceRepository @Inject constructor() { val errorMessage: StateFlow get() = _errorMessage - fun setErrorMessage(text: String) { - Logger.e { text } + fun setErrorMessage(text: String, severity: Severity = Severity.Error) { + Logger.log(severity, "ServiceRepository", null, text) _errorMessage.value = text } diff --git a/feature/map/build.gradle.kts b/feature/map/build.gradle.kts index 03be3de20..49d40cc51 100644 --- a/feature/map/build.gradle.kts +++ b/feature/map/build.gradle.kts @@ -50,6 +50,7 @@ dependencies { implementation(libs.androidx.lifecycle.viewmodel.compose) implementation(libs.androidx.lifecycle.viewmodel.ktx) implementation(libs.androidx.navigation.common) + implementation(libs.androidx.savedstate.compose) implementation(libs.androidx.savedstate.ktx) implementation(libs.material) implementation(libs.kermit) diff --git a/feature/map/src/google/kotlin/org/meshtastic/feature/map/component/NodeClusterMarkers.kt b/feature/map/src/google/kotlin/org/meshtastic/feature/map/component/NodeClusterMarkers.kt index 7f85d74e3..08789a2ba 100644 --- a/feature/map/src/google/kotlin/org/meshtastic/feature/map/component/NodeClusterMarkers.kt +++ b/feature/map/src/google/kotlin/org/meshtastic/feature/map/component/NodeClusterMarkers.kt @@ -20,11 +20,11 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.key import androidx.compose.ui.graphics.Color -import androidx.compose.ui.platform.LocalContext -import androidx.lifecycle.LifecycleOwner +import androidx.compose.ui.platform.LocalView +import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.lifecycle.findViewTreeLifecycleOwner import androidx.lifecycle.setViewTreeLifecycleOwner -import androidx.savedstate.SavedStateRegistryOwner +import androidx.savedstate.compose.LocalSavedStateRegistryOwner import androidx.savedstate.findViewTreeSavedStateRegistryOwner import androidx.savedstate.setViewTreeSavedStateRegistryOwner import com.google.maps.android.clustering.Cluster @@ -44,20 +44,22 @@ fun NodeClusterMarkers( navigateToNodeDetails: (Int) -> Unit, onClusterClick: (Cluster) -> Boolean, ) { - val context = LocalContext.current + val view = LocalView.current + val lifecycleOwner = LocalLifecycleOwner.current + val savedStateRegistryOwner = LocalSavedStateRegistryOwner.current + // Workaround for https://github.com/googlemaps/android-maps-compose/issues/858 - // Ensure owners are set on the Activity decor view so the internal ComposeView created by - // the clustering renderer can find them when walking up the view tree. - LaunchedEffect(Unit) { - val activity = context as? android.app.Activity - if (activity != null) { - val decorView = activity.window.decorView - if (decorView.findViewTreeLifecycleOwner() == null && activity is LifecycleOwner) { - decorView.setViewTreeLifecycleOwner(activity) - } - if (decorView.findViewTreeSavedStateRegistryOwner() == null && activity is SavedStateRegistryOwner) { - decorView.setViewTreeSavedStateRegistryOwner(activity) - } + // The maps clustering library creates an internal ComposeView to snapshot markers. + // If that view is not attached to the hierarchy (which it often isn't during rendering), + // it fails to find the Lifecycle and SavedState owners. We propagate them to the root view + // so the internal snapshot view can find them when walking up the tree. + LaunchedEffect(view, lifecycleOwner, savedStateRegistryOwner) { + val root = view.rootView + if (root.findViewTreeLifecycleOwner() == null) { + root.setViewTreeLifecycleOwner(lifecycleOwner) + } + if (root.findViewTreeSavedStateRegistryOwner() == null) { + root.setViewTreeSavedStateRegistryOwner(savedStateRegistryOwner) } } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 66755c60e..510889364 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -98,6 +98,7 @@ androidx-room-compiler = { module = "androidx.room:room-compiler", version.ref = androidx-room-paging = { module = "androidx.room:room-paging", version.ref = "room" } androidx-room-runtime = { module = "androidx.room:room-runtime", version.ref = "room" } androidx-room-testing = { module = "androidx.room:room-testing", version.ref = "room" } +androidx-savedstate-compose = { module = "androidx.savedstate:savedstate-compose", version.ref = "savedstate" } androidx-savedstate-ktx = { module = "androidx.savedstate:savedstate-ktx", version.ref = "savedstate" } androidx-work-runtime-ktx = { module = "androidx.work:work-runtime-ktx", version = "2.11.1" }