diff --git a/app/src/google/kotlin/org/meshtastic/app/map/component/NodeClusterMarkers.kt b/app/src/google/kotlin/org/meshtastic/app/map/component/NodeClusterMarkers.kt index 32e250475..16d399e49 100644 --- a/app/src/google/kotlin/org/meshtastic/app/map/component/NodeClusterMarkers.kt +++ b/app/src/google/kotlin/org/meshtastic/app/map/component/NodeClusterMarkers.kt @@ -17,7 +17,7 @@ package org.meshtastic.app.map.component import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.SideEffect import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalView import androidx.lifecycle.compose.LocalLifecycleOwner @@ -49,11 +49,13 @@ fun NodeClusterMarkers( val savedStateRegistryOwner = LocalSavedStateRegistryOwner.current // Workaround for https://github.com/googlemaps/android-maps-compose/issues/858 + // and https://github.com/googlemaps/android-maps-compose/issues/875 // 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) { + // We do this in a SideEffect to ensure it happens before or during composition of children. + SideEffect { val root = view.rootView if (root.findViewTreeLifecycleOwner() == null) { root.setViewTreeLifecycleOwner(lifecycleOwner) diff --git a/core/ble/src/commonMain/kotlin/org/meshtastic/core/ble/KableBleConnection.kt b/core/ble/src/commonMain/kotlin/org/meshtastic/core/ble/KableBleConnection.kt index d64a88dde..5bb26c8a5 100644 --- a/core/ble/src/commonMain/kotlin/org/meshtastic/core/ble/KableBleConnection.kt +++ b/core/ble/src/commonMain/kotlin/org/meshtastic/core/ble/KableBleConnection.kt @@ -17,6 +17,7 @@ package org.meshtastic.core.ble import co.touchlab.kermit.Logger +import com.juul.kable.NotConnectedException import com.juul.kable.Peripheral import com.juul.kable.PeripheralBuilder import com.juul.kable.State @@ -259,6 +260,9 @@ class KableBleConnection(private val scope: CoroutineScope, private val loggingC private suspend fun safeClosePeripheral(tag: String) { try { peripheral?.disconnect() + } catch (_: NotConnectedException) { + // Silence "Disconnect requested" which Kable throws if already disconnected. + // This is a common non-fatal reported in Crashlytics that is safe to ignore here. } catch (e: Exception) { Logger.w(e) { "[$tag] Failed to disconnect peripheral" } } diff --git a/core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/BleRadioTransport.kt b/core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/BleRadioTransport.kt index 7da67a54a..769476822 100644 --- a/core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/BleRadioTransport.kt +++ b/core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/BleRadioTransport.kt @@ -356,6 +356,9 @@ class BleRadioTransport( // materially speeds up the initial config drain and any bulk fromRadio reads. if (bleConnection.requestHighConnectionPriority()) { Logger.d { "[$address] Requested high BLE connection priority" } + // Wait for the connection parameter update to succeed before starting the heavy traffic + // in onConnect(). Otherwise, the Android BLE stack may disconnect with GATT 147. + delay(1.seconds) } this@BleRadioTransport.callback.onConnect() diff --git a/core/service/src/androidMain/kotlin/org/meshtastic/core/service/MeshService.kt b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/MeshService.kt index 5869ce94f..6c4087298 100644 --- a/core/service/src/androidMain/kotlin/org/meshtastic/core/service/MeshService.kt +++ b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/MeshService.kt @@ -159,9 +159,24 @@ class MeshService : Service() { 0 } + startForegroundSafely(notification, foregroundServiceType) + + return if (!wantForeground) { + Logger.i { "Stopping mesh service because no device is selected" } + ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_REMOVE) + stopSelf() + START_NOT_STICKY + } else { + START_STICKY + } + } + + private fun startForegroundSafely(notification: android.app.Notification, foregroundServiceType: Int) { @Suppress("TooGenericExceptionCaught") try { ServiceCompat.startForeground(this, SERVICE_NOTIFY_ID, notification, foregroundServiceType) + } catch (ex: android.app.ForegroundServiceStartNotAllowedException) { + Logger.e(ex) { "ForegroundServiceStartNotAllowedException: OS restricted background start." } } catch (ex: SecurityException) { // On Android 14+ starting a location FGS from the background can fail with SecurityException // if the app is not in an allowed state. Retry without the location type if that was requested. @@ -177,6 +192,8 @@ class MeshService : Service() { } try { ServiceCompat.startForeground(this, SERVICE_NOTIFY_ID, notification, connectedDeviceOnly) + } catch (retryEx: android.app.ForegroundServiceStartNotAllowedException) { + Logger.e(retryEx) { "ForegroundServiceStartNotAllowedException on retry." } } catch (retryEx: Exception) { Logger.e(retryEx) { "Failed to start foreground service even after retry" } } @@ -185,16 +202,6 @@ class MeshService : Service() { } } catch (ex: Exception) { Logger.e(ex) { "Error starting foreground service" } - return START_NOT_STICKY - } - - return if (!wantForeground) { - Logger.i { "Stopping mesh service because no device is selected" } - ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_REMOVE) - stopSelf() - START_NOT_STICKY - } else { - START_STICKY } }