diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index e5d62f95e..ff2107c5a 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -65,6 +65,7 @@
+
@@ -124,7 +125,7 @@
diff --git a/app/src/main/java/com/geeksville/mesh/repository/radio/RadioInterfaceService.kt b/app/src/main/java/com/geeksville/mesh/repository/radio/RadioInterfaceService.kt
index d26e1177b..25a7a7bfd 100644
--- a/app/src/main/java/com/geeksville/mesh/repository/radio/RadioInterfaceService.kt
+++ b/app/src/main/java/com/geeksville/mesh/repository/radio/RadioInterfaceService.kt
@@ -257,7 +257,8 @@ constructor(
/** Start our configured interface (if it isn't already running) */
private fun startInterface() {
if (radioIf !is NopInterface) {
- Logger.w { "Can't start interface - $radioIf is already running" }
+ // Already running
+ return
} else {
val address = getBondedDeviceAddress()
if (address == null) {
diff --git a/app/src/main/java/com/geeksville/mesh/service/MeshService.kt b/app/src/main/java/com/geeksville/mesh/service/MeshService.kt
index 65a195bfd..c7761b229 100644
--- a/app/src/main/java/com/geeksville/mesh/service/MeshService.kt
+++ b/app/src/main/java/com/geeksville/mesh/service/MeshService.kt
@@ -98,12 +98,7 @@ class MeshService : Service() {
fun changeDeviceAddress(context: Context, service: IMeshService, address: String?) {
service.setDeviceAddress(address)
- val intent = Intent(context, MeshService::class.java)
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
- context.startForegroundService(intent)
- } else {
- context.startService(intent)
- }
+ startService(context)
}
val minDeviceVersion = DeviceVersion(BuildConfig.MIN_FW_VERSION)
@@ -145,11 +140,13 @@ class MeshService : Service() {
SERVICE_NOTIFY_ID,
notification,
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
+ var types =
+ ServiceInfo.FOREGROUND_SERVICE_TYPE_CONNECTED_DEVICE or
+ ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC
if (hasLocationPermission()) {
- ServiceInfo.FOREGROUND_SERVICE_TYPE_MANIFEST
- } else {
- ServiceInfo.FOREGROUND_SERVICE_TYPE_CONNECTED_DEVICE
+ types = types or ServiceInfo.FOREGROUND_SERVICE_TYPE_LOCATION
}
+ types
} else {
0
},
@@ -300,7 +297,12 @@ class MeshService : Service() {
}
override fun removeByNodenum(requestId: Int, nodeNum: Int) = toRemoteExceptions {
- router.actionHandler.handleRemoveByNodenum(nodeNum, requestId, myNodeNum)
+ val myNodeNum = nodeManager.myNodeNum
+ if (myNodeNum != null) {
+ router.actionHandler.handleRemoveByNodenum(nodeNum, requestId, myNodeNum)
+ } else {
+ nodeManager.removeByNodenum(nodeNum)
+ }
}
override fun requestUserInfo(destNum: Int) = toRemoteExceptions {
diff --git a/app/src/main/java/com/geeksville/mesh/service/MeshServiceStarter.kt b/app/src/main/java/com/geeksville/mesh/service/MeshServiceStarter.kt
index ed59ff5b2..da1e006d7 100644
--- a/app/src/main/java/com/geeksville/mesh/service/MeshServiceStarter.kt
+++ b/app/src/main/java/com/geeksville/mesh/service/MeshServiceStarter.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,14 +14,17 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see .
*/
-
package com.geeksville.mesh.service
import android.app.ForegroundServiceStartNotAllowedException
import android.content.Context
import android.os.Build
+import androidx.work.OneTimeWorkRequestBuilder
+import androidx.work.OutOfQuotaPolicy
+import androidx.work.WorkManager
import co.touchlab.kermit.Logger
import com.geeksville.mesh.BuildConfig
+import com.geeksville.mesh.worker.ServiceKeepAliveWorker
// / Helper function to start running our service
fun MeshService.Companion.startService(context: Context) {
@@ -40,9 +43,19 @@ fun MeshService.Companion.startService(context: Context) {
try {
context.startForegroundService(intent)
} catch (ex: ForegroundServiceStartNotAllowedException) {
- Logger.e { "Unable to start service: ${ex.message}" }
+ Logger.w { "Unable to start service foreground: ${ex.message}. Scheduling fallback worker." }
+ scheduleKeepAliveWorker(context)
}
} else {
context.startForegroundService(intent)
}
}
+
+private fun scheduleKeepAliveWorker(context: Context) {
+ val request =
+ OneTimeWorkRequestBuilder()
+ .setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST)
+ .build()
+
+ WorkManager.getInstance(context).enqueue(request)
+}
diff --git a/app/src/main/java/com/geeksville/mesh/worker/ServiceKeepAliveWorker.kt b/app/src/main/java/com/geeksville/mesh/worker/ServiceKeepAliveWorker.kt
new file mode 100644
index 000000000..d980d265e
--- /dev/null
+++ b/app/src/main/java/com/geeksville/mesh/worker/ServiceKeepAliveWorker.kt
@@ -0,0 +1,94 @@
+/*
+ * 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
+ * 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 .
+ */
+package com.geeksville.mesh.worker
+
+import android.app.Notification
+import android.content.Context
+import android.content.pm.ServiceInfo
+import android.os.Build
+import androidx.core.app.NotificationCompat
+import androidx.hilt.work.HiltWorker
+import androidx.work.CoroutineWorker
+import androidx.work.ForegroundInfo
+import androidx.work.WorkerParameters
+import co.touchlab.kermit.Logger
+import com.geeksville.mesh.R
+import com.geeksville.mesh.service.MeshService
+import com.geeksville.mesh.service.startService
+import dagger.assisted.Assisted
+import dagger.assisted.AssistedInject
+import org.meshtastic.core.service.MeshServiceNotifications
+import org.meshtastic.core.service.SERVICE_NOTIFY_ID
+
+/**
+ * A worker whose sole purpose is to start the MeshService from the background. This is used as a fallback when
+ * `startForegroundService` is blocked by Android 14+ restrictions. It runs as an Expedited worker to gain temporary
+ * foreground start privileges.
+ */
+@HiltWorker
+class ServiceKeepAliveWorker
+@AssistedInject
+constructor(
+ @Assisted appContext: Context,
+ @Assisted workerParams: WorkerParameters,
+ private val serviceNotifications: MeshServiceNotifications,
+) : CoroutineWorker(appContext, workerParams) {
+
+ override suspend fun getForegroundInfo(): ForegroundInfo {
+ // We use the same notification channel as the main service notification
+ // to minimize user disruption.
+ // On Android 12+, we need to provide a foreground info for expedited work.
+ val notification = createNotification()
+
+ return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
+ ForegroundInfo(
+ SERVICE_NOTIFY_ID,
+ notification,
+ ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC or ServiceInfo.FOREGROUND_SERVICE_TYPE_CONNECTED_DEVICE,
+ )
+ } else {
+ ForegroundInfo(SERVICE_NOTIFY_ID, notification)
+ }
+ }
+
+ @Suppress("TooGenericExceptionCaught")
+ override suspend fun doWork(): Result {
+ Logger.i { "ServiceKeepAliveWorker: Attempting to start MeshService" }
+ return try {
+ MeshService.startService(applicationContext)
+ Result.success()
+ } catch (e: Exception) {
+ Logger.e(e) { "ServiceKeepAliveWorker failed to start service" }
+ Result.failure()
+ }
+ }
+
+ private fun createNotification(): Notification {
+ // We ensure channels are created
+ serviceNotifications.initChannels()
+
+ // We create a generic "Resuming" notification.
+ // We use "my_service" which matches NotificationType.ServiceState.channelId in MeshServiceNotificationsImpl
+
+ return NotificationCompat.Builder(applicationContext, "my_service")
+ .setSmallIcon(R.drawable.ic_launcher_foreground)
+ .setContentTitle("Resuming Mesh Service")
+ .setPriority(NotificationCompat.PRIORITY_LOW)
+ .setOngoing(true)
+ .build()
+ }
+}
diff --git a/core/analytics/src/google/kotlin/org/meshtastic/core/analytics/platform/GooglePlatformAnalytics.kt b/core/analytics/src/google/kotlin/org/meshtastic/core/analytics/platform/GooglePlatformAnalytics.kt
index b9682cb28..e76595977 100644
--- a/core/analytics/src/google/kotlin/org/meshtastic/core/analytics/platform/GooglePlatformAnalytics.kt
+++ b/core/analytics/src/google/kotlin/org/meshtastic/core/analytics/platform/GooglePlatformAnalytics.kt
@@ -57,6 +57,7 @@ import com.google.firebase.crashlytics.setCustomKeys
import com.google.firebase.initialize
import dagger.hilt.android.qualifiers.ApplicationContext
import io.opentelemetry.api.GlobalOpenTelemetry
+import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import org.meshtastic.core.analytics.BuildConfig
@@ -233,16 +234,22 @@ constructor(
override fun log(severity: Severity, message: String, tag: String, throwable: Throwable?) {
if (!Firebase.crashlytics.isCrashlyticsCollectionEnabled) return
- Firebase.crashlytics.setCustomKeys {
- key(KEY_PRIORITY, severity.ordinal)
- key(KEY_TAG, tag)
- key(KEY_MESSAGE, message)
- }
+ // Add the log to the Crashlytics log buffer so it appears in reports
+ Firebase.crashlytics.log("$severity/$tag: $message")
- if (throwable == null) {
- Firebase.crashlytics.recordException(Exception(message))
- } else {
+ // Filter out normal coroutine cancellations
+ if (throwable is CancellationException) return
+
+ // Only record non-fatal exceptions for actual Errors (or if a throwable is provided)
+ if (throwable != null) {
Firebase.crashlytics.recordException(throwable)
+ } else if (severity >= Severity.Error) {
+ Firebase.crashlytics.setCustomKeys {
+ key(KEY_PRIORITY, severity.ordinal)
+ key(KEY_TAG, tag)
+ key(KEY_MESSAGE, message)
+ }
+ Firebase.crashlytics.recordException(Exception(message))
}
}
}
diff --git a/core/datastore/src/main/kotlin/org/meshtastic/core/datastore/LocalConfigDataSource.kt b/core/datastore/src/main/kotlin/org/meshtastic/core/datastore/LocalConfigDataSource.kt
index 06423c6b8..7ab985eb2 100644
--- a/core/datastore/src/main/kotlin/org/meshtastic/core/datastore/LocalConfigDataSource.kt
+++ b/core/datastore/src/main/kotlin/org/meshtastic/core/datastore/LocalConfigDataSource.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 org.meshtastic.core.datastore
import androidx.datastore.core.DataStore
@@ -53,7 +52,8 @@ class LocalConfigDataSource @Inject constructor(private val localConfigStore: Da
if (localField != null) {
builder.setField(localField, value)
} else {
- Logger.e { "Error writing LocalConfig settings: ${config.payloadVariantCase}" }
+ // Some fields like SESSIONKEY are not intended to be persisted in LocalConfig
+ Logger.d { "Skipping non-persistent LocalConfig field: ${field.name}" }
}
}
builder.build()