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()