diff --git a/.pr5167.diff b/.pr5167.diff
deleted file mode 100644
index d0a809449..000000000
--- a/.pr5167.diff
+++ /dev/null
@@ -1,295 +0,0 @@
-diff --git a/core/common/src/commonMain/kotlin/org/meshtastic/core/common/di/ApplicationCoroutineScope.kt b/core/common/src/commonMain/kotlin/org/meshtastic/core/common/di/ApplicationCoroutineScope.kt
-new file mode 100644
-index 0000000000..2a27b96906
---- /dev/null
-+++ b/core/common/src/commonMain/kotlin/org/meshtastic/core/common/di/ApplicationCoroutineScope.kt
-@@ -0,0 +1,39 @@
-+/*
-+ * Copyright (c) 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 org.meshtastic.core.common.di
-+
-+import kotlinx.coroutines.CoroutineScope
-+import kotlinx.coroutines.SupervisorJob
-+import org.koin.core.annotation.Single
-+import org.meshtastic.core.common.util.ioDispatcher
-+
-+/**
-+ * A process-wide [CoroutineScope] that outlives individual ViewModels and UI components.
-+ *
-+ * Use this scope for fire-and-forget cleanup work that must continue after a ViewModel's own scope has been cancelled
-+ * (for example, deleting temporary files in `onCleared()`). Backed by a [SupervisorJob] so failures in one child do not
-+ * cancel siblings, and by [ioDispatcher] so work runs off the main thread.
-+ *
-+ * Prefer scoping work to a more specific scope (like `viewModelScope`) whenever possible; this scope is an escape hatch
-+ * and should be used sparingly.
-+ */
-+interface ApplicationCoroutineScope : CoroutineScope
-+
-+@Single(binds = [ApplicationCoroutineScope::class])
-+internal class ApplicationCoroutineScopeImpl : ApplicationCoroutineScope {
-+ override val coroutineContext = SupervisorJob() + ioDispatcher
-+}
-diff --git a/core/ui/src/androidMain/kotlin/org/meshtastic/core/ui/util/PlatformUtils.kt b/core/ui/src/androidMain/kotlin/org/meshtastic/core/ui/util/PlatformUtils.kt
-index 231c84d401..5365ab95e2 100644
---- a/core/ui/src/androidMain/kotlin/org/meshtastic/core/ui/util/PlatformUtils.kt
-+++ b/core/ui/src/androidMain/kotlin/org/meshtastic/core/ui/util/PlatformUtils.kt
-@@ -37,12 +37,12 @@ import androidx.lifecycle.compose.LifecycleEventEffect
- import co.touchlab.kermit.Logger
- import com.eygraber.uri.toAndroidUri
- import com.eygraber.uri.toKmpUri
--import kotlinx.coroutines.Dispatchers
- import kotlinx.coroutines.withContext
- import org.jetbrains.compose.resources.StringResource
- import org.jetbrains.compose.resources.getString
- import org.meshtastic.core.common.gpsDisabled
- import org.meshtastic.core.common.util.CommonUri
-+import org.meshtastic.core.common.util.ioDispatcher
- import java.net.URLEncoder
-
- @Composable
-@@ -146,7 +146,7 @@ actual fun rememberReadTextFromUri(): suspend (uri: CommonUri, maxChars: Int) ->
- val context = LocalContext.current
- return remember(context) {
- { uri, maxChars ->
-- withContext(Dispatchers.IO) {
-+ withContext(ioDispatcher) {
- @Suppress("TooGenericExceptionCaught")
- try {
- val androidUri = uri.toAndroidUri()
-diff --git a/core/ui/src/jvmMain/kotlin/org/meshtastic/core/ui/util/PlatformUtils.kt b/core/ui/src/jvmMain/kotlin/org/meshtastic/core/ui/util/PlatformUtils.kt
-index 031e1fe35d..a938f92ea6 100644
---- a/core/ui/src/jvmMain/kotlin/org/meshtastic/core/ui/util/PlatformUtils.kt
-+++ b/core/ui/src/jvmMain/kotlin/org/meshtastic/core/ui/util/PlatformUtils.kt
-@@ -20,10 +20,10 @@ package org.meshtastic.core.ui.util
-
- import androidx.compose.runtime.Composable
- import co.touchlab.kermit.Logger
--import kotlinx.coroutines.Dispatchers
- import kotlinx.coroutines.withContext
- import org.jetbrains.compose.resources.StringResource
- import org.meshtastic.core.common.util.CommonUri
-+import org.meshtastic.core.common.util.ioDispatcher
- import java.awt.Desktop
- import java.awt.FileDialog
- import java.awt.Frame
-@@ -89,7 +89,7 @@ actual fun rememberOpenFileLauncher(onUriReceived: (CommonUri?) -> Unit): (mimeT
- /** JVM — Reads text from a file URI. */
- @Composable
- actual fun rememberReadTextFromUri(): suspend (uri: CommonUri, maxChars: Int) -> String? = { uri, maxChars ->
-- withContext(Dispatchers.IO) {
-+ withContext(ioDispatcher) {
- @Suppress("TooGenericExceptionCaught")
- try {
- val file = File(URI(uri.toString()))
-diff --git a/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateViewModel.kt b/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateViewModel.kt
-index dc1c459716..f8ff9fcac8 100644
---- a/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateViewModel.kt
-+++ b/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateViewModel.kt
-@@ -35,6 +35,7 @@ import kotlinx.coroutines.launch
- import kotlinx.coroutines.withTimeoutOrNull
- import org.jetbrains.compose.resources.StringResource
- import org.koin.core.annotation.KoinViewModel
-+import org.meshtastic.core.common.di.ApplicationCoroutineScope
- import org.meshtastic.core.common.util.CommonUri
- import org.meshtastic.core.common.util.safeCatching
- import org.meshtastic.core.database.entity.FirmwareRelease
-@@ -91,6 +92,7 @@ class FirmwareUpdateViewModel(
- private val firmwareUpdateManager: FirmwareUpdateManager,
- private val usbManager: FirmwareUsbManager,
- private val fileHandler: FirmwareFileHandler,
-+ private val applicationScope: ApplicationCoroutineScope,
- ) : ViewModel() {
-
- private val _state = MutableStateFlow(FirmwareUpdateState.Idle)
-@@ -124,12 +126,10 @@ class FirmwareUpdateViewModel(
-
- override fun onCleared() {
- super.onCleared()
-- // viewModelScope is already cancelled when onCleared() runs, so launch cleanup in a
-- // standalone scope. SupervisorJob prevents the coroutine from propagating failures to a
-- // shared parent, and NonCancellable on the launch keeps cleanup running even if the scope
-- // is cancelled concurrently.
-- @OptIn(kotlinx.coroutines.DelicateCoroutinesApi::class)
-- kotlinx.coroutines.GlobalScope.launch(NonCancellable) {
-+ // viewModelScope is already cancelled when onCleared() runs, so launch cleanup on the
-+ // application-wide scope (SupervisorJob + ioDispatcher). NonCancellable keeps cleanup
-+ // running even if something tries to cancel it mid-flight.
-+ applicationScope.launch(NonCancellable) {
- tempFirmwareFile = cleanupTemporaryFiles(fileHandler, tempFirmwareFile)
- }
- }
-diff --git a/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateIntegrationTest.kt b/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateIntegrationTest.kt
-index 4c48a1ced5..030d84effd 100644
---- a/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateIntegrationTest.kt
-+++ b/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateIntegrationTest.kt
-@@ -108,6 +108,7 @@ class FirmwareUpdateIntegrationTest {
- firmwareUpdateManager,
- usbManager,
- fileHandler,
-+ TestApplicationCoroutineScope(testDispatcher),
- )
-
- @Test
-diff --git a/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateViewModelTest.kt b/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateViewModelTest.kt
-index 7032ed4088..a8eddff838 100644
---- a/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateViewModelTest.kt
-+++ b/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateViewModelTest.kt
-@@ -124,6 +124,7 @@ class FirmwareUpdateViewModelTest {
- firmwareUpdateManager,
- usbManager,
- fileHandler,
-+ TestApplicationCoroutineScope(testDispatcher),
- )
-
- @Test
-diff --git a/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/TestApplicationCoroutineScope.kt b/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/TestApplicationCoroutineScope.kt
-new file mode 100644
-index 0000000000..3ef5c44ef4
---- /dev/null
-+++ b/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/TestApplicationCoroutineScope.kt
-@@ -0,0 +1,26 @@
-+/*
-+ * Copyright (c) 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 org.meshtastic.feature.firmware
-+
-+import kotlinx.coroutines.CoroutineDispatcher
-+import kotlinx.coroutines.CoroutineScope
-+import kotlinx.coroutines.SupervisorJob
-+import org.meshtastic.core.common.di.ApplicationCoroutineScope
-+
-+internal class TestApplicationCoroutineScope(dispatcher: CoroutineDispatcher) :
-+ ApplicationCoroutineScope,
-+ CoroutineScope by CoroutineScope(SupervisorJob() + dispatcher)
-diff --git a/feature/firmware/src/jvmTest/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateViewModelFileTest.kt b/feature/firmware/src/jvmTest/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateViewModelFileTest.kt
-index acb1545bdd..23a0d03ab2 100644
---- a/feature/firmware/src/jvmTest/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateViewModelFileTest.kt
-+++ b/feature/firmware/src/jvmTest/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateViewModelFileTest.kt
-@@ -116,6 +116,7 @@ class FirmwareUpdateViewModelFileTest {
- firmwareUpdateManager,
- usbManager,
- fileHandler,
-+ TestApplicationCoroutineScope(testDispatcher),
- )
-
- // -----------------------------------------------------------------------
-diff --git a/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/debugging/LogExporter.kt b/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/debugging/LogExporter.kt
-index c251b4d5ef..315ad1da85 100644
---- a/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/debugging/LogExporter.kt
-+++ b/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/debugging/LogExporter.kt
-@@ -27,6 +27,7 @@ import co.touchlab.kermit.Logger
- import kotlinx.coroutines.Dispatchers
- import kotlinx.coroutines.launch
- import kotlinx.coroutines.withContext
-+import org.meshtastic.core.common.util.ioDispatcher
- import org.meshtastic.core.resources.Res
- import org.meshtastic.core.resources.debug_export_failed
- import org.meshtastic.core.resources.debug_export_success
-@@ -48,7 +49,7 @@ actual fun rememberLogExporter(logsProvider: suspend () -> List) =
-- withContext(Dispatchers.IO) {
-+ withContext(ioDispatcher) {
- try {
- if (logs.isEmpty()) {
- withContext(Dispatchers.Main) { context.showToast(Res.string.debug_export_failed, "No logs to export") }
-diff --git a/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/tak/PrefExporter.kt b/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/tak/PrefExporter.kt
-index 9afde85e5f..a28a576788 100644
---- a/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/tak/PrefExporter.kt
-+++ b/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/tak/PrefExporter.kt
-@@ -24,9 +24,9 @@ import androidx.compose.runtime.Composable
- import androidx.compose.runtime.rememberCoroutineScope
- import androidx.compose.ui.platform.LocalContext
- import co.touchlab.kermit.Logger
--import kotlinx.coroutines.Dispatchers
- import kotlinx.coroutines.launch
- import kotlinx.coroutines.withContext
-+import org.meshtastic.core.common.util.ioDispatcher
-
- @Composable
- actual fun rememberDataPackageExporter(dataPackageProvider: suspend () -> ByteArray): (fileName: String) -> Unit {
-@@ -41,7 +41,7 @@ actual fun rememberDataPackageExporter(dataPackageProvider: suspend () -> ByteAr
- return { fileName -> exportLauncher.launch(fileName) }
- }
-
--private suspend fun exportZipToUri(context: Context, targetUri: Uri, data: ByteArray) = withContext(Dispatchers.IO) {
-+private suspend fun exportZipToUri(context: Context, targetUri: Uri, data: ByteArray) = withContext(ioDispatcher) {
- try {
- context.contentResolver.openOutputStream(targetUri)?.use { os -> os.write(data) }
- Logger.i { "TAK data package exported successfully to $targetUri" }
-diff --git a/feature/settings/src/jvmMain/kotlin/org/meshtastic/feature/settings/debugging/LogExporter.kt b/feature/settings/src/jvmMain/kotlin/org/meshtastic/feature/settings/debugging/LogExporter.kt
-index 5b63cc90a3..a9a7285593 100644
---- a/feature/settings/src/jvmMain/kotlin/org/meshtastic/feature/settings/debugging/LogExporter.kt
-+++ b/feature/settings/src/jvmMain/kotlin/org/meshtastic/feature/settings/debugging/LogExporter.kt
-@@ -19,9 +19,9 @@ package org.meshtastic.feature.settings.debugging
- import androidx.compose.runtime.Composable
- import androidx.compose.runtime.rememberCoroutineScope
- import co.touchlab.kermit.Logger
--import kotlinx.coroutines.Dispatchers
- import kotlinx.coroutines.launch
- import kotlinx.coroutines.withContext
-+import org.meshtastic.core.common.util.ioDispatcher
- import java.awt.FileDialog
- import java.awt.Frame
- import java.io.File
-@@ -41,7 +41,7 @@ actual fun rememberLogExporter(logsProvider: suspend () -> List ByteAr
- if (directory != null && file != null) {
- val targetFile = File(directory, file)
- val data = dataPackageProvider()
-- withContext(Dispatchers.IO) { targetFile.writeBytes(data) }
-+ withContext(ioDispatcher) { targetFile.writeBytes(data) }
- Logger.i { "TAK data package exported successfully to ${targetFile.absolutePath}" }
- }
- }
diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro
index de2b3144c..b1fbcdace 100644
--- a/app/proguard-rules.pro
+++ b/app/proguard-rules.pro
@@ -34,6 +34,14 @@
# for auditing. Inspect this file after a release build to see what libraries inject.
-printconfiguration build/outputs/mapping/r8-merged-config.txt
+# ---- Android Auto / Car App Library -----------------------------------------
+
+# MeshtasticCarAppService and MeshtasticCarSession are instantiated by class name
+# by the Android Auto host. Keep both classes (and their no-arg constructors) so
+# release builds aren't broken by R8 tree-shaking.
+-keep class org.meshtastic.feature.auto.MeshtasticCarAppService { (); }
+-keep class org.meshtastic.feature.auto.MeshtasticCarSession { (); }
+
# ---- Networking (transitive references from Ktor on Android) ----------------
-dontwarn org.conscrypt.**
diff --git a/core/service/src/androidMain/kotlin/org/meshtastic/core/service/ConversationShortcutManager.kt b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/ConversationShortcutManager.kt
index db6fd37d4..d6393a4e8 100644
--- a/core/service/src/androidMain/kotlin/org/meshtastic/core/service/ConversationShortcutManager.kt
+++ b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/ConversationShortcutManager.kt
@@ -19,13 +19,10 @@ package org.meshtastic.core.service
import android.content.Context
import android.content.Intent
import android.content.pm.ShortcutInfo
-import android.graphics.Canvas
-import android.graphics.Paint
import androidx.core.app.Person
import androidx.core.content.LocusIdCompat
import androidx.core.content.pm.ShortcutInfoCompat
import androidx.core.content.pm.ShortcutManagerCompat
-import androidx.core.graphics.createBitmap
import androidx.core.graphics.drawable.IconCompat
import androidx.core.net.toUri
import co.touchlab.kermit.Logger
@@ -186,34 +183,6 @@ class ConversationShortcutManager(
}
}
- private fun createPersonIcon(name: String, backgroundColor: Int, foregroundColor: Int): IconCompat {
- val size = ICON_SIZE
- val bitmap = createBitmap(size, size)
- val canvas = Canvas(bitmap)
- val paint = Paint(Paint.ANTI_ALIAS_FLAG)
-
- paint.color = backgroundColor
- canvas.drawCircle(size / 2f, size / 2f, size / 2f, paint)
-
- paint.color = foregroundColor
- paint.textSize = size * TEXT_SIZE_RATIO
- paint.textAlign = Paint.Align.CENTER
- val initial =
- if (name.isNotEmpty()) {
- val codePoint = name.codePointAt(0)
- String(Character.toChars(codePoint)).uppercase()
- } else {
- "?"
- }
- val xPos = canvas.width / 2f
- val yPos = (canvas.height / 2f - (paint.descent() + paint.ascent()) / 2f)
- canvas.drawText(initial, xPos, yPos, paint)
-
- return IconCompat.createWithBitmap(bitmap)
- }
-
- companion object {
- private const val ICON_SIZE = 128
- private const val TEXT_SIZE_RATIO = 0.5f
- }
+ private fun createPersonIcon(name: String, backgroundColor: Int, foregroundColor: Int): IconCompat =
+ PersonIconFactory.create(name, backgroundColor, foregroundColor)
}
diff --git a/core/service/src/androidMain/kotlin/org/meshtastic/core/service/MeshServiceNotificationsImpl.kt b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/MeshServiceNotificationsImpl.kt
index 37764047f..0e390ed0a 100644
--- a/core/service/src/androidMain/kotlin/org/meshtastic/core/service/MeshServiceNotificationsImpl.kt
+++ b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/MeshServiceNotificationsImpl.kt
@@ -24,9 +24,7 @@ import android.app.TaskStackBuilder
import android.content.ContentResolver.SCHEME_ANDROID_RESOURCE
import android.content.Context
import android.content.Intent
-import android.graphics.Canvas
import android.graphics.Color
-import android.graphics.Paint
import android.media.AudioAttributes
import android.media.RingtoneManager
import androidx.core.app.NotificationCompat
@@ -34,7 +32,6 @@ import androidx.core.app.Person
import androidx.core.app.RemoteInput
import androidx.core.content.LocusIdCompat
import androidx.core.content.getSystemService
-import androidx.core.graphics.createBitmap
import androidx.core.graphics.drawable.IconCompat
import androidx.core.net.toUri
import kotlinx.coroutines.flow.first
@@ -122,8 +119,6 @@ class MeshServiceNotificationsImpl(
private const val SNIPPET_LENGTH = 30
private const val GROUP_KEY_MESSAGES = "com.geeksville.mesh.GROUP_MESSAGES"
private const val SUMMARY_ID = 1
- private const val PERSON_ICON_SIZE = 128
- private const val PERSON_ICON_TEXT_SIZE_RATIO = 0.5f
private const val STATS_UPDATE_MINUTES = 15
private val STATS_UPDATE_INTERVAL = STATS_UPDATE_MINUTES.minutes
private const val BULLET = "• "
@@ -537,9 +532,11 @@ class MeshServiceNotificationsImpl(
}
.first()
- val unread = history.filter { !it.read }
- if (unread.isEmpty()) return
- val displayHistory = unread.take(MAX_HISTORY_MESSAGES).reversed()
+ // For the brief outgoing-reply confirmation we don't gate on unread state — the
+ // user just sent something and Android Auto needs to reflect that in the
+ // MessagingStyle notification regardless of whether other unread messages remain.
+ // We still cap the displayed history so the notification stays compact.
+ val displayHistory = history.take(MAX_HISTORY_MESSAGES).reversed()
val dest = if (contactKey.isNotEmpty()) contactKey.substring(1) else contactKey
val isBroadcast = dest == DataPacket.ID_BROADCAST
@@ -939,32 +936,8 @@ class MeshServiceNotificationsImpl(
.setContentIntent(contentIntent ?: openAppIntent)
}
- private fun createPersonIcon(name: String, backgroundColor: Int, foregroundColor: Int): IconCompat {
- val bitmap = createBitmap(PERSON_ICON_SIZE, PERSON_ICON_SIZE)
- val canvas = Canvas(bitmap)
- val paint = Paint(Paint.ANTI_ALIAS_FLAG)
-
- // Draw background circle
- paint.color = backgroundColor
- canvas.drawCircle(PERSON_ICON_SIZE / 2f, PERSON_ICON_SIZE / 2f, PERSON_ICON_SIZE / 2f, paint)
-
- // Draw initials
- paint.color = foregroundColor
- paint.textSize = PERSON_ICON_SIZE * PERSON_ICON_TEXT_SIZE_RATIO
- paint.textAlign = Paint.Align.CENTER
- val initial =
- if (name.isNotEmpty()) {
- val codePoint = name.codePointAt(0)
- String(Character.toChars(codePoint)).uppercase()
- } else {
- "?"
- }
- val xPos = canvas.width / 2f
- val yPos = (canvas.height / 2f - (paint.descent() + paint.ascent()) / 2f)
- canvas.drawText(initial, xPos, yPos, paint)
-
- return IconCompat.createWithBitmap(bitmap)
- }
+ private fun createPersonIcon(name: String, backgroundColor: Int, foregroundColor: Int): IconCompat =
+ PersonIconFactory.create(name, backgroundColor, foregroundColor)
// endregion
diff --git a/core/service/src/androidMain/kotlin/org/meshtastic/core/service/PersonIconFactory.kt b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/PersonIconFactory.kt
new file mode 100644
index 000000000..84a015d20
--- /dev/null
+++ b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/PersonIconFactory.kt
@@ -0,0 +1,62 @@
+/*
+ * Copyright (c) 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 org.meshtastic.core.service
+
+import android.graphics.Canvas
+import android.graphics.Paint
+import androidx.core.graphics.createBitmap
+import androidx.core.graphics.drawable.IconCompat
+
+/**
+ * Renders a circular avatar with a single uppercase initial — used for [androidx.core.app.Person]
+ * icons in MessagingStyle notifications and for conversation shortcut avatars.
+ *
+ * Shared by [MeshServiceNotificationsImpl] and [ConversationShortcutManager] to keep the avatar
+ * appearance consistent across the notification shade and the launcher / Android Auto.
+ */
+internal object PersonIconFactory {
+
+ private const val ICON_SIZE = 128
+ private const val TEXT_SIZE_RATIO = 0.5f
+
+ fun create(name: String, backgroundColor: Int, foregroundColor: Int): IconCompat {
+ val bitmap = createBitmap(ICON_SIZE, ICON_SIZE)
+ val canvas = Canvas(bitmap)
+ val paint = Paint(Paint.ANTI_ALIAS_FLAG)
+
+ // Background circle.
+ paint.color = backgroundColor
+ canvas.drawCircle(ICON_SIZE / 2f, ICON_SIZE / 2f, ICON_SIZE / 2f, paint)
+
+ // Single uppercase initial centered on the circle.
+ paint.color = foregroundColor
+ paint.textSize = ICON_SIZE * TEXT_SIZE_RATIO
+ paint.textAlign = Paint.Align.CENTER
+ val initial =
+ if (name.isNotEmpty()) {
+ val codePoint = name.codePointAt(0)
+ String(Character.toChars(codePoint)).uppercase()
+ } else {
+ "?"
+ }
+ val xPos = canvas.width / 2f
+ val yPos = (canvas.height / 2f - (paint.descent() + paint.ascent()) / 2f)
+ canvas.drawText(initial, xPos, yPos, paint)
+
+ return IconCompat.createWithBitmap(bitmap)
+ }
+}
diff --git a/feature/auto/build.gradle.kts b/feature/auto/build.gradle.kts
index 692285118..c9da00795 100644
--- a/feature/auto/build.gradle.kts
+++ b/feature/auto/build.gradle.kts
@@ -15,10 +15,7 @@
* along with this program. If not, see .
*/
-plugins {
- alias(libs.plugins.meshtastic.android.library)
- alias(libs.plugins.meshtastic.koin)
-}
+plugins { alias(libs.plugins.meshtastic.android.library) }
android {
namespace = "org.meshtastic.feature.auto"
@@ -33,7 +30,10 @@ dependencies {
implementation(libs.androidx.car.app)
implementation(libs.kermit)
- implementation(libs.koin.annotations)
+ // KoinComponent for service-locator-style injection inside Car App Library callbacks.
+ // No @Single/@Factory bindings live in this module, so the meshtastic.koin convention
+ // plugin (which adds koin-annotations + the KSP compiler) is not needed here.
+ implementation(libs.koin.core)
testImplementation(kotlin("test"))
testImplementation(libs.kotest.assertions)
diff --git a/feature/auto/src/main/kotlin/org/meshtastic/feature/auto/MeshtasticCarScreen.kt b/feature/auto/src/main/kotlin/org/meshtastic/feature/auto/MeshtasticCarScreen.kt
index d84ab2ad2..f8e80c667 100644
--- a/feature/auto/src/main/kotlin/org/meshtastic/feature/auto/MeshtasticCarScreen.kt
+++ b/feature/auto/src/main/kotlin/org/meshtastic/feature/auto/MeshtasticCarScreen.kt
@@ -74,7 +74,7 @@ import org.meshtastic.core.repository.ServiceRepository
*
* When the user taps a [MessagingStyle][androidx.core.app.NotificationCompat.MessagingStyle]
* notification in the Android Auto notification shade the host calls
- * [MeshtasticCarSession.onNewIntent] which delegates to [selectContactKey] to switch to the
+ * [MeshtasticCarSession.onNewIntent] which delegates to [selectMessagesTab] to switch to the
* Messages tab.
*/
class MeshtasticCarScreen(carContext: CarContext) :
@@ -252,14 +252,15 @@ class MeshtasticCarScreen(carContext: CarContext) :
}
/**
- * Called by [MeshtasticCarSession.onNewIntent] when the user taps a conversation notification
- * in the Android Auto notification shade. Switches to [TAB_MESSAGES] regardless of whether
- * the originating contact is a channel broadcast or a DM, because both appear in the same tab.
+ * Called by [MeshtasticCarSession.onNewIntent] when the user taps a conversation
+ * notification in the Android Auto notification shade. Switches to [TAB_MESSAGES] —
+ * channels and DMs both live in the same tab, so no per-key handling is required.
*
- * The [contactKey] parameter is accepted for API symmetry with the session and may be used in
- * the future to scroll the Messages list to the tapped conversation.
+ * `androidx.car.app.model.ListTemplate` does not currently expose a programmatic scroll
+ * API, so we cannot focus a specific conversation row. If/when a scroll API is added,
+ * the contactKey can be threaded through `MeshtasticCarSession.onNewIntent`.
*/
- fun selectContactKey(@Suppress("UNUSED_PARAMETER") contactKey: String) {
+ fun selectMessagesTab() {
activeTabId = TAB_MESSAGES
invalidate()
}
diff --git a/feature/auto/src/main/kotlin/org/meshtastic/feature/auto/MeshtasticCarSession.kt b/feature/auto/src/main/kotlin/org/meshtastic/feature/auto/MeshtasticCarSession.kt
index 13b3c5d3b..d9d42afbe 100644
--- a/feature/auto/src/main/kotlin/org/meshtastic/feature/auto/MeshtasticCarSession.kt
+++ b/feature/auto/src/main/kotlin/org/meshtastic/feature/auto/MeshtasticCarSession.kt
@@ -25,31 +25,25 @@ class MeshtasticCarSession : Session() {
override fun onCreateScreen(intent: Intent): Screen {
val screen = MeshtasticCarScreen(carContext)
- handleIntent(intent, screen)
+ applyIntent(intent, screen)
return screen
}
/**
- * Called by the Android Auto host when the session is re-activated from an
- * existing [MessagingStyle][androidx.core.app.NotificationCompat.MessagingStyle]
- * notification tap or a launcher shortcut.
+ * Called by the Android Auto host when the session is re-activated from an existing
+ * [MessagingStyle][androidx.core.app.NotificationCompat.MessagingStyle] notification tap or a
+ * launcher shortcut. Switches the root screen to the Messages tab.
*
- * Parses the conversation [contactKey] from the deep-link URI
- * (`meshtastic://messages/`) and delegates to
- * [MeshtasticCarScreen.selectContactKey] so the correct tab is pre-selected.
+ * The deep-link URI (`meshtastic://meshtastic/messages/`) carries the originating
+ * contact key, but `androidx.car.app.model.ListTemplate` does not currently expose a
+ * programmatic scroll API, so we cannot focus a specific conversation row.
*/
override fun onNewIntent(intent: Intent) {
val screen = screenManager.top as? MeshtasticCarScreen ?: return
- handleIntent(intent, screen)
+ applyIntent(intent, screen)
}
- private fun handleIntent(intent: Intent, screen: MeshtasticCarScreen) {
- // Deep-link URIs from MessagingStyle notifications look like:
- // meshtastic://messages/0!abcd1234 (DM: channel=0, nodeId=!abcd1234)
- // meshtastic://messages/2^all (channel broadcast, e.g. contactKey "2^all")
- // Both channels and DMs now live in the same Messages tab, so we simply
- // switch to that tab regardless of the contact type.
- val contactKey = intent.data?.lastPathSegment ?: return
- screen.selectContactKey(contactKey)
+ private fun applyIntent(intent: Intent, screen: MeshtasticCarScreen) {
+ if (intent.data?.lastPathSegment != null) screen.selectMessagesTab()
}
}
diff --git a/feature/auto/src/main/res/xml/auto_app_desc.xml b/feature/auto/src/main/res/xml/auto_app_desc.xml
deleted file mode 100644
index b84cd9041..000000000
--- a/feature/auto/src/main/res/xml/auto_app_desc.xml
+++ /dev/null
@@ -1,22 +0,0 @@
-
-
-
-
-
-
-