feat(service): Introduce fallback worker to ensure service stays alive (#4295)

Signed-off-by: James Rich <2199651+jamesarich@users.noreply.github.com>
This commit is contained in:
James Rich 2026-01-24 20:25:09 -06:00 committed by GitHub
parent bd4cb80877
commit 576ae6757f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 144 additions and 26 deletions

View file

@ -65,6 +65,7 @@
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_CONNECTED_DEVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_LOCATION" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
<uses-permission android:name="android.permission.CHANGE_WIFI_MULTICAST_STATE" />
<!-- Needed to open our bluetooth connection to our paired device (after reboot) -->
@ -124,7 +125,7 @@
<service
android:name="com.geeksville.mesh.service.MeshService"
android:enabled="true"
android:foregroundServiceType="connectedDevice|location"
android:foregroundServiceType="connectedDevice|location|dataSync"
android:exported="true" tools:ignore="ExportedActivity">
<intent-filter>
<action android:name="com.geeksville.mesh.Service" />

View file

@ -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) {

View file

@ -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 {

View file

@ -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 <https://www.gnu.org/licenses/>.
*/
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<ServiceKeepAliveWorker>()
.setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST)
.build()
WorkManager.getInstance(context).enqueue(request)
}

View file

@ -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 <https://www.gnu.org/licenses/>.
*/
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()
}
}

View file

@ -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))
}
}
}

View file

@ -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 <https://www.gnu.org/licenses/>.
*/
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()