mirror of
https://github.com/meshtastic/Meshtastic-Android.git
synced 2026-04-20 22:23:37 +00:00
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:
parent
bd4cb80877
commit
576ae6757f
7 changed files with 144 additions and 26 deletions
|
|
@ -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" />
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue