fix(service): resolve MeshService crash from eager notification channel init (#5034)

This commit is contained in:
James Rich 2026-04-09 22:24:03 -05:00 committed by GitHub
parent dba037466e
commit ebf3b8272c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 51 additions and 21 deletions

View file

@ -40,6 +40,10 @@
-dontobfuscate
-optimizations !code/simplification/arithmetic,!field/*,!class/merging/*,!code/allocation/variable
# Koin DI: prevent R8 from merging exception classes (observed as io.ktor.http.URLDecodeException
# replacing Koin's InstanceCreationException in stack traces, making crashes undiagnosable).
-keep class org.koin.core.error.** { *; }
# R8 optimization for Kotlin null checks (AGP 9.0+)
-processkotlinnullchecks remove

View file

@ -65,10 +65,11 @@ class AndroidNotificationManagerTest {
}
@Test
fun `init removes legacy node channel and creates canonical node channel`() {
fun `dispatch removes legacy node channel and creates canonical node channel`() {
createChannel("NodeEvent")
AndroidNotificationManager(context)
val manager = AndroidNotificationManager(context)
manager.dispatch(Notification(title = "Node", message = "Seen", category = Notification.Category.NodeEvent))
assertNull(systemNotificationManager.getNotificationChannel("NodeEvent"))
assertNotNull(systemNotificationManager.getNotificationChannel(NotificationChannels.NEW_NODES))

View file

@ -40,11 +40,21 @@ class AndroidNotificationManager(private val context: Context) : NotificationMan
private data class ChannelConfig(val id: String, val importance: Int)
init {
initChannels()
}
/**
* Tracks whether notification channels have been created.
*
* Channels are **not** created in the constructor because this singleton is instantiated by Koin during
* [org.meshtastic.core.service.MeshService.onCreate] on the main thread. The CMP [getString] helper uses
* [kotlinx.coroutines.runBlocking] which can fail in that context, crashing the entire service startup chain.
* Instead, channels are lazily ensured before the first [dispatch] call. Note that
* [MeshServiceNotificationsImpl.initChannels] already creates a superset of these channels when the orchestrator
* starts, so this lazy path is only a safety net for notifications dispatched before orchestrator initialization.
*/
private var channelsInitialized = false
private fun initChannels() {
private fun ensureChannelsInitialized() {
if (channelsInitialized) return
channelsInitialized = true
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val channels =
listOf(
@ -91,6 +101,7 @@ class AndroidNotificationManager(private val context: Context) : NotificationMan
}
override fun dispatch(notification: Notification) {
ensureChannelsInitialized()
val builder =
NotificationCompat.Builder(context, notification.category.channelConfig().id)
.setContentTitle(notification.title)

View file

@ -73,6 +73,8 @@ class MeshService : Service() {
private val serviceJob = Job()
private val serviceScope = CoroutineScope(Dispatchers.IO + serviceJob)
private var isServiceInitialized = false
private val myNodeNum: Int
get() = nodeManager.myNodeNum.value ?: throw RadioNotConnectedException()
@ -96,25 +98,35 @@ class MeshService : Service() {
}
override fun onCreate() {
try {
super.onCreate()
} catch (e: IllegalStateException) {
// Koin can throw IllegalStateException in tests if the component is not created.
// This can happen if the service is started by the system (e.g. after a crash or on boot)
// before the test rule has a chance to create the component.
if (e.message?.contains("HiltAndroidRule") == true || e.message?.contains("Koin") == true) {
Logger.w(e) { "MeshService created before DI component was ready in test, stopping service" }
stopSelf()
return
}
throw e
}
super.onCreate()
Logger.i { "Creating mesh service" }
orchestrator.start()
try {
orchestrator.start()
isServiceInitialized = true
} catch (e: IllegalStateException) {
// Koin throws IllegalStateException when the DI graph is not yet initialized.
// This can happen if the system restarts the service (e.g. after a crash or on boot)
// before Application.onCreate() has finished setting up Koin.
// In release builds, R8 may merge Koin's InstanceCreationException with unrelated
// exception classes (observed as io.ktor.http.URLDecodeException), so we cannot rely
// on the exception type alone. We catch IllegalStateException narrowly around the
// orchestrator/DI access — not around super.onCreate() — so framework exceptions
// still propagate normally.
Logger.e(e) { "MeshService: DI not ready, stopping service" }
stopSelf()
return
}
}
@Suppress("ReturnCount")
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
if (!isServiceInitialized) {
Logger.w { "onStartCommand called but service is not initialized (likely DI failure). Stopping." }
stopSelf()
return START_NOT_STICKY
}
val a = radioInterfaceService.getDeviceAddress()
val wantForeground = a != null && a != "n"
@ -180,7 +192,9 @@ class MeshService : Service() {
override fun onDestroy() {
Logger.i { "Destroying mesh service" }
ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_REMOVE)
orchestrator.stop()
if (isServiceInitialized) {
orchestrator.stop()
}
serviceJob.cancel()
super.onDestroy()
}