diff --git a/.agent_memory/session_context.md b/.agent_memory/session_context.md
index d13385679..02cca165a 100644
--- a/.agent_memory/session_context.md
+++ b/.agent_memory/session_context.md
@@ -23,12 +23,45 @@
- Fix (NodeClusterMarkers.kt ONLY): icons baked in-scope via `rememberComposeBitmapDescriptor(node){ PulsingNodeChip }` into a snapshot stateMap; custom `private class NodeClusterRenderer : DefaultClusterRenderer` assigns them in onBeforeClusterItemRendered/onClusterItemUpdated (bg thread, READ-only — never composes, so the crash class is gone). Native info windows (super sets title/snippet) + onClusterItemInfoWindowClick→navigateToNodeDetails; precision circles drawn from the renderer's own `unclusteredItems` MutableState (clusterItemDecoration can't fire — `ClusterRendererItemState` is lib-internal). Strictly better than the elegant-euler Canvas branch — keeps the REAL Compose chip.
- `compileGoogleDebugKotlin` + `spotlessCheck` + `detekt` PASS. NOT committed, NOT device-verified. Next: device-test (clusters show chips + info-window popups + no FATAL), then commit/push.
-## 2026-05-28 — Stabilized DatabaseManager withDb retry host test
-- Hardened `DatabaseManagerWithDbRetryTest` to remove CI race conditions by running the manager on a `StandardTestDispatcher(testScheduler)` instead of real `Dispatchers.IO`.
-- Added a `withTimeout(10_000)` guard around the test body to fail fast on coordination stalls instead of hanging/flapping.
-- Kept the deterministic retry trigger (`error("Connection pool is closed")`) and retained assertions that first attempt uses old DB and retry uses current DB.
-- Made teardown resilient with `if (::manager.isInitialized) manager.close()` so setup/early failures do not cascade into teardown crashes.
-- Verified with `:core:database:jvmTest --tests "org.meshtastic.core.database.DatabaseManagerWithDbRetryTest*"` and repeated it 5 consecutive runs without failures; `:core:database:detekt` also passed.
+## 2026-05-28 — Added comprehensive CarScreenDataBuilder unit coverage
+- Created `feature/car/src/test/kotlin/org/meshtastic/feature/car/util/CarScreenDataBuilderTest.kt` with 533 lines covering signal quality thresholds/boundaries, node UI mapping, node and conversation sorting, local stats fallbacks, uptime formatting, recent message limiting, contact key generation, and constants.
+- Restored the `MessageSnapshot` data class in `CarStateCoordinator.kt` and re-added `recentMessages()` plus `MAX_CONVERSATION_MESSAGES` in `CarScreenDataBuilder.kt` so the current source matched the requested pure-helper API surface for testing.
+- Verified with `./gradlew :feature:car:spotlessCheck :feature:car:detekt :feature:car:testFdroidDebugUnitTest --quiet` and the requested quiet test command (`./gradlew :feature:car:testFdroidDebugUnitTest --quiet 2>&1 | tail -20`), both successful.
+
+## 2026-05-28 — Lowered car min API to 7 and removed dead conversation code
+- Changed `feature/car` manifest `androidx.car.app.minCarApiLevel` metadata from 8 to 7.
+- Guarded `HomeScreen.showEmergencyAlert()` behind `carContext.carAppApiLevel >= 8` and logged unsupported API 7 hosts with Kermit.
+- Removed unused `ConversationScreen`, `CarTtsEngine`, message snapshot/cache/read-aloud plumbing, and now-unused car reply/read-aloud strings.
+- Simplified `CarStateCoordinator` and `CarScreenDataBuilder` to match the inline `ConversationItem` flow.
+- Verified with `./gradlew :feature:car:spotlessApply :feature:car:spotlessCheck :feature:car:detekt :feature:car:compileFdroidDebugKotlin --quiet 2>&1 | tail -30`.
+
+## 2026-05-28 — Migrated car home messages tab to ConversationItem
+- Reworked `feature/car` `HomeScreen` messaging tab to build CAL `ConversationItem` entries instead of browsable `Row`s, including `Person`/`CarMessage` helpers and native reply/mark-read callbacks.
+- Removed `HomeScreen` conversation navigation so the car host owns messaging affordances; `ConversationScreen` remains on disk for later cleanup phases.
+- Added `CarStateCoordinator.markAsRead()` using `packetRepository.clearUnreadCount(...)` with Kermit error logging via `runCatching`.
+- Verified with `./gradlew :feature:car:spotlessApply :feature:car:spotlessCheck :feature:car:detekt :feature:car:compileFdroidDebugKotlin` and the requested quiet compile command (`:feature:car:compileFdroidDebugKotlin --quiet 2>&1 | tail -20`), both successful.
+
+## 2026-05-28 — Implemented car conversation shortcuts and avatars
+- Added `feature/car/.../util/PersonIconFactory.kt` to render circular initial avatars using node-derived foreground/background colors for `Person` and shortcut icons.
+- Added `feature/car/.../service/ConversationShortcutManager.kt` to publish long-lived dynamic conversation shortcuts for favorite nodes and active channels, plus on-demand shortcut creation for notifications.
+- Wired `MeshtasticCarSession` to start/stop shortcut observation on a dedicated session coroutine scope.
+- Updated `CarNotificationManager` to ensure conversation shortcuts exist before posting and to attach both `shortcutId` and `LocusIdCompat` to messaging notifications.
+- Verified green with `./gradlew :feature:car:spotlessCheck :feature:car:detekt --quiet` and `./gradlew :feature:car:compileFdroidDebugKotlin --quiet 2>&1 | tail -20` after workspace bootstrap.
+
+## 2026-05-28 — Implemented car local stats tab and extracted screen data builder
+- Added `CarLocalStats` to `feature/car` UI models and exposed `localStatsState` from `CarStateCoordinator`.
+- Wired a new HomeScreen `Status` tab with battery, channel utilization, air utilization, node counts, uptime, and packet TX/RX rows.
+- Created `feature/car/.../util/CarScreenDataBuilder.kt` to centralize pure UI-model mapping helpers for nodes, conversations, local stats, uptime formatting, contact key building, and recent message selection.
+- Added the new `ic_car_status.xml` drawable plus status strings in `feature/car/src/main/res/values/strings.xml`.
+- Cleaned up `CarReplyReceiver` detekt violations that blocked module validation.
+- Ran `python3 scripts/sort-strings.py` and verified green with `./gradlew :feature:car:spotlessApply :feature:car:spotlessCheck :feature:car:detekt :feature:car:compileFdroidDebugKotlin :feature:car:testFdroidDebugUnitTest`.
+
+## 2026-05-28 — Implemented car module Phase 1 messaging wiring fixes
+- Replaced `CommandSender` usage in `feature/car` `CarStateCoordinator` with injected `SendMessageUseCase`, keeping the public `sendMessage()` API synchronous for UI callbacks while launching the use case on the coordinator scope after message-length validation.
+- Updated `CarNotificationManager` reply and mark-read notification actions with semantic action metadata and `setShowsUserInterface(false)` for automotive-friendly inline handling.
+- Reworked `CarReplyReceiver` into a `KoinComponent` that injects `SendMessageUseCase` and `PacketRepository`, then sends replies / clears unread counts asynchronously with Kermit error logging.
+- Added `android:permission="androidx.car.app.CarAppService"` to the `MeshtasticCarAppService` manifest declaration.
+- Verified with `./gradlew :feature:car:compileFdroidDebugKotlin --quiet` after required workspace bootstrap.
## 2026-05-21 — Upgraded Chirpy to a fully-personalized Live Diagnostic Node & Mesh Assistant
- Integrated `NodeRepository` into `GeminiNanoDocAssistant.kt` and the Google AI Koin dependency injection module (`GoogleAiModule.kt`).
diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md
index e6ccb2ebc..b72a68c96 100644
--- a/.github/copilot-instructions.md
+++ b/.github/copilot-instructions.md
@@ -41,5 +41,6 @@ These are specific to the Copilot CLI environment and are not covered in AGENTS.
For additional context about technologies to be used, project structure,
-shell commands, and other important information, read the current plan
+shell commands, and other important information, read the current plan at
+specs/20260521-153452-car-app-library-integration/plan.md
diff --git a/.specify/feature.json b/.specify/feature.json
index cd2b73f68..bc524dd91 100644
--- a/.specify/feature.json
+++ b/.specify/feature.json
@@ -1 +1,3 @@
-{"feature_directory":"specs/20260520-153412-nav-tab-labels"}
+{
+ "feature_directory": "specs/20260521-153452-car-app-library-integration"
+}
diff --git a/androidApp/build.gradle.kts b/androidApp/build.gradle.kts
index 25dc8e588..5f68d1848 100644
--- a/androidApp/build.gradle.kts
+++ b/androidApp/build.gradle.kts
@@ -267,6 +267,7 @@ dependencies {
debugImplementation(libs.androidx.compose.ui.test.manifest)
debugImplementation(libs.androidx.glance.preview)
+ googleImplementation(projects.feature.car)
googleImplementation(libs.location.services)
googleImplementation(libs.play.services.maps)
googleImplementation(libs.maps.compose)
diff --git a/androidApp/src/google/kotlin/org/meshtastic/app/di/FlavorModule.kt b/androidApp/src/google/kotlin/org/meshtastic/app/di/FlavorModule.kt
index b0ecb874c..d59a5890e 100644
--- a/androidApp/src/google/kotlin/org/meshtastic/app/di/FlavorModule.kt
+++ b/androidApp/src/google/kotlin/org/meshtastic/app/di/FlavorModule.kt
@@ -14,13 +14,22 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see .
*/
+@file:Suppress("ktlint:standard:max-line-length")
+
package org.meshtastic.app.di
import org.koin.core.annotation.Module
import org.meshtastic.app.map.prefs.di.GoogleMapsKoinModule
+import org.meshtastic.feature.car.di.FeatureCarModule
@Module(
includes =
- [GoogleNetworkModule::class, GoogleMapsKoinModule::class, GoogleAiModule::class, AppFunctionsModule::class],
+ [
+ GoogleNetworkModule::class,
+ GoogleMapsKoinModule::class,
+ GoogleAiModule::class,
+ AppFunctionsModule::class,
+ FeatureCarModule::class,
+ ],
)
class FlavorModule
diff --git a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/Node.kt b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/Node.kt
index 754a2462d..a1fd1d208 100644
--- a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/Node.kt
+++ b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/Node.kt
@@ -75,15 +75,7 @@ data class Node(
internal fun isOnline(threshold: Int): Boolean = lastHeard > threshold
val colors: Pair
- get() { // returns foreground and background @ColorInt for each 'num'
- val r = (num and 0xFF0000) shr 16
- val g = (num and 0x00FF00) shr 8
- val b = num and 0x0000FF
- val brightness = ((r * 0.299) + (g * 0.587) + (b * 0.114)) / 255
- val foreground = if (brightness > 0.5) 0xFF000000.toInt() else 0xFFFFFFFF.toInt()
- val background = (0xFF shl 24) or (r shl 16) or (g shl 8) or b
- return foreground to background
- }
+ get() = nodeColorsFromNum(num)
val isUnknownUser
get() = user.hw_model == HardwareModel.UNSET
diff --git a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/NodeColors.kt b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/NodeColors.kt
new file mode 100644
index 000000000..3f63d4bfd
--- /dev/null
+++ b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/NodeColors.kt
@@ -0,0 +1,43 @@
+/*
+ * 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.model
+
+private const val RED_WEIGHT = 0.299
+private const val GREEN_WEIGHT = 0.587
+private const val BLUE_WEIGHT = 0.114
+private const val BRIGHTNESS_THRESHOLD = 0.5
+private const val MAX_CHANNEL = 255
+private const val RED_MASK = 0xFF0000
+private const val GREEN_MASK = 0x00FF00
+private const val BLUE_MASK = 0x0000FF
+private const val ALPHA_MASK = 0xFF
+private const val RED_SHIFT = 16
+private const val GREEN_SHIFT = 8
+private const val ALPHA_SHIFT = 24
+private const val BLACK = 0xFF000000.toInt()
+private const val WHITE = 0xFFFFFFFF.toInt()
+
+/** Derives a unique color pair from a node number. Returns (foreground, background) as @ColorInt. */
+fun nodeColorsFromNum(nodeNum: Int): Pair {
+ val r = (nodeNum and RED_MASK) shr RED_SHIFT
+ val g = (nodeNum and GREEN_MASK) shr GREEN_SHIFT
+ val b = nodeNum and BLUE_MASK
+ val brightness = ((r * RED_WEIGHT) + (g * GREEN_WEIGHT) + (b * BLUE_WEIGHT)) / MAX_CHANNEL
+ val foreground = if (brightness > BRIGHTNESS_THRESHOLD) BLACK else WHITE
+ val background = (ALPHA_MASK shl ALPHA_SHIFT) or (r shl RED_SHIFT) or (g shl GREEN_SHIFT) or b
+ return foreground to background
+}
diff --git a/feature/car/build.gradle.kts b/feature/car/build.gradle.kts
new file mode 100644
index 000000000..43b532bf7
--- /dev/null
+++ b/feature/car/build.gradle.kts
@@ -0,0 +1,57 @@
+/*
+ * 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 .
+ */
+
+plugins {
+ alias(libs.plugins.meshtastic.android.library)
+ alias(libs.plugins.meshtastic.android.library.flavors)
+ id("meshtastic.koin")
+}
+
+android {
+ namespace = "org.meshtastic.feature.car"
+
+ buildFeatures { buildConfig = true }
+
+ defaultConfig {
+ minSdk = 23
+ consumerProguardFiles("proguard-rules.pro")
+ }
+}
+
+dependencies {
+ implementation(projects.core.common)
+ implementation(projects.core.data)
+ implementation(projects.core.database)
+ implementation(projects.core.domain)
+ implementation(projects.core.model)
+ implementation(projects.core.repository)
+
+ implementation(libs.androidx.car.app)
+ implementation(libs.androidx.car.app.projected)
+
+ implementation(libs.koin.android)
+ implementation(libs.koin.annotations)
+
+ implementation(platform(libs.firebase.bom))
+ implementation(libs.firebase.crashlytics)
+ implementation(libs.kermit)
+
+ testImplementation(libs.androidx.car.app.testing)
+ testImplementation(libs.koin.test)
+ testImplementation(kotlin("test-junit"))
+ testRuntimeOnly(libs.junit.vintage.engine)
+}
diff --git a/feature/car/proguard-rules.pro b/feature/car/proguard-rules.pro
new file mode 100644
index 000000000..8cc0a99c2
--- /dev/null
+++ b/feature/car/proguard-rules.pro
@@ -0,0 +1,9 @@
+# Car App Library ProGuard/R8 rules
+
+# CarAppService must not be obfuscated (resolved by android:exported="true" in manifest,
+# but keep rule ensures R8 doesn't remove it during aggressive shrinking)
+-keep class org.meshtastic.feature.car.service.MeshtasticCarAppService { *; }
+
+# Keep Koin-annotated classes for runtime DI resolution
+-keep @org.koin.core.annotation.Single class * { *; }
+-keep @org.koin.core.annotation.Factory class * { *; }
diff --git a/feature/car/src/main/AndroidManifest.xml b/feature/car/src/main/AndroidManifest.xml
new file mode 100644
index 000000000..c0706eac9
--- /dev/null
+++ b/feature/car/src/main/AndroidManifest.xml
@@ -0,0 +1,23 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/feature/car/src/main/kotlin/org/meshtastic/feature/car/alerts/EmergencyHandler.kt b/feature/car/src/main/kotlin/org/meshtastic/feature/car/alerts/EmergencyHandler.kt
new file mode 100644
index 000000000..22166e243
--- /dev/null
+++ b/feature/car/src/main/kotlin/org/meshtastic/feature/car/alerts/EmergencyHandler.kt
@@ -0,0 +1,113 @@
+/*
+ * 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.car.alerts
+
+import android.media.AudioManager
+import android.media.ToneGenerator
+import co.touchlab.kermit.Logger
+import kotlinx.coroutines.CoroutineExceptionHandler
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.SupervisorJob
+import kotlinx.coroutines.cancel
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.launch
+import org.koin.core.annotation.Single
+import org.meshtastic.feature.car.model.EmergencyAlert
+
+/**
+ * Manages emergency alert state for the car display. Observes incoming packets for emergency-priority messages,
+ * maintains active alert list, and triggers audio notifications.
+ */
+@Single
+class EmergencyHandler {
+
+ private var scope: CoroutineScope? = null
+ private val _activeAlerts = MutableStateFlow>(emptyList())
+ val activeAlerts: StateFlow> = _activeAlerts.asStateFlow()
+
+ private val _latestAlert = MutableStateFlow(null)
+ val latestAlert: StateFlow = _latestAlert.asStateFlow()
+
+ private var toneGenerator: ToneGenerator? = null
+
+ private val exceptionHandler = CoroutineExceptionHandler { _, throwable ->
+ Logger.e(tag = "EmergencyHandler", throwable = throwable) { "Emergency flow collection failed" }
+ }
+
+ fun startCollecting(emergencyFlow: Flow) {
+ scope?.cancel()
+ scope =
+ CoroutineScope(SupervisorJob() + Dispatchers.Main + exceptionHandler).also { newScope ->
+ newScope.launch {
+ emergencyFlow.collect { alert ->
+ addAlert(alert)
+ _latestAlert.value = alert
+ playEmergencyTone()
+ }
+ }
+ }
+ }
+
+ fun stopCollecting() {
+ scope?.cancel()
+ scope = null
+ toneGenerator?.release()
+ toneGenerator = null
+ }
+
+ fun dismissAlert(nodeNum: Int) {
+ _activeAlerts.value =
+ _activeAlerts.value.map { alert -> if (alert.nodeNum == nodeNum) alert.copy(isActive = false) else alert }
+ }
+
+ fun clearAll() {
+ _activeAlerts.value = emptyList()
+ }
+
+ private fun addAlert(alert: EmergencyAlert) {
+ val current = _activeAlerts.value.toMutableList()
+ // Replace existing alert from same node, or add new
+ val existingIndex = current.indexOfFirst { it.nodeNum == alert.nodeNum }
+ if (existingIndex >= 0) {
+ current[existingIndex] = alert
+ } else {
+ current.add(0, alert) // newest first
+ }
+ _activeAlerts.value = current
+ }
+
+ @Suppress("TooGenericExceptionCaught") // ToneGenerator may throw various runtime exceptions
+ private fun playEmergencyTone() {
+ try {
+ if (toneGenerator == null) {
+ toneGenerator = ToneGenerator(AudioManager.STREAM_NOTIFICATION, TONE_VOLUME)
+ }
+ toneGenerator?.startTone(ToneGenerator.TONE_PROP_BEEP, TONE_DURATION_MS)
+ } catch (e: RuntimeException) {
+ Logger.w(tag = "EmergencyHandler", throwable = e) { "Emergency tone playback failed" }
+ }
+ }
+
+ companion object {
+ private const val TONE_VOLUME = 80
+ private const val TONE_DURATION_MS = 1000
+ }
+}
diff --git a/feature/car/src/main/kotlin/org/meshtastic/feature/car/di/FeatureCarModule.kt b/feature/car/src/main/kotlin/org/meshtastic/feature/car/di/FeatureCarModule.kt
new file mode 100644
index 000000000..490f0c133
--- /dev/null
+++ b/feature/car/src/main/kotlin/org/meshtastic/feature/car/di/FeatureCarModule.kt
@@ -0,0 +1,24 @@
+/*
+ * 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.car.di
+
+import org.koin.core.annotation.ComponentScan
+import org.koin.core.annotation.Module
+
+@Module
+@ComponentScan("org.meshtastic.feature.car")
+class FeatureCarModule
diff --git a/feature/car/src/main/kotlin/org/meshtastic/feature/car/model/CarUiModels.kt b/feature/car/src/main/kotlin/org/meshtastic/feature/car/model/CarUiModels.kt
new file mode 100644
index 000000000..b72d2615e
--- /dev/null
+++ b/feature/car/src/main/kotlin/org/meshtastic/feature/car/model/CarUiModels.kt
@@ -0,0 +1,89 @@
+/*
+ * 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.car.model
+
+import org.meshtastic.core.model.ConnectionState
+
+data class CarSessionState(
+ val connectionStatus: ConnectionState,
+ val onlineNodeCount: Int,
+ val lastMessageTime: Long?,
+ val activeEmergencies: List,
+ val meshName: String?,
+)
+
+data class MessagingUiState(
+ val channels: List,
+ val selectedChannelIndex: Int,
+ val conversations: List,
+ val emergencySpotlight: List?,
+)
+
+data class ChannelUi(val index: Int, val name: String, val unreadCount: Int)
+
+data class ConversationUi(
+ val contactKey: String,
+ val displayName: String,
+ val lastMessage: String,
+ val lastMessageTime: Long,
+ val unreadCount: Int,
+ val isEmergency: Boolean,
+)
+
+data class NodeDashboardUiState(val nodes: List, val topologyHeader: TopologyHeader)
+
+data class NodeUi(
+ val nodeNum: Int,
+ val userId: String,
+ val longName: String,
+ val shortName: String,
+ val signalQuality: SignalQuality,
+ val batteryPercent: Int?,
+ val isOnline: Boolean,
+ val lastHeard: Long,
+ val hasPosition: Boolean,
+)
+
+enum class SignalQuality {
+ EXCELLENT,
+ GOOD,
+ FAIR,
+ BAD,
+ NONE,
+}
+
+data class TopologyHeader(val totalNodes: Int, val onlineNodes: Int, val meshName: String?)
+
+data class EmergencyAlert(
+ val nodeNum: Int,
+ val nodeName: String,
+ val message: String,
+ val timestamp: Long,
+ val isActive: Boolean,
+)
+
+data class CarLocalStats(
+ val batteryLevel: Int = 0,
+ val hasBattery: Boolean = false,
+ val channelUtilization: Float = 0f,
+ val airUtilization: Float = 0f,
+ val totalNodes: Int = 0,
+ val onlineNodes: Int = 0,
+ val uptimeSeconds: Int = 0,
+ val numPacketsTx: Int = 0,
+ val numPacketsRx: Int = 0,
+)
diff --git a/feature/car/src/main/kotlin/org/meshtastic/feature/car/screens/HomeScreen.kt b/feature/car/src/main/kotlin/org/meshtastic/feature/car/screens/HomeScreen.kt
new file mode 100644
index 000000000..c8f80a85b
--- /dev/null
+++ b/feature/car/src/main/kotlin/org/meshtastic/feature/car/screens/HomeScreen.kt
@@ -0,0 +1,409 @@
+/*
+ * 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.car.screens
+
+import androidx.car.app.AppManager
+import androidx.car.app.CarContext
+import androidx.car.app.CarToast
+import androidx.car.app.Screen
+import androidx.car.app.messaging.model.CarMessage
+import androidx.car.app.messaging.model.ConversationCallback
+import androidx.car.app.messaging.model.ConversationItem
+import androidx.car.app.model.Action
+import androidx.car.app.model.Alert
+import androidx.car.app.model.AlertCallback
+import androidx.car.app.model.CarColor
+import androidx.car.app.model.CarIcon
+import androidx.car.app.model.CarText
+import androidx.car.app.model.Header
+import androidx.car.app.model.ItemList
+import androidx.car.app.model.ListTemplate
+import androidx.car.app.model.Pane
+import androidx.car.app.model.PaneTemplate
+import androidx.car.app.model.Row
+import androidx.car.app.model.Tab
+import androidx.car.app.model.TabContents
+import androidx.car.app.model.TabTemplate
+import androidx.car.app.model.Template
+import androidx.core.app.Person
+import androidx.core.graphics.drawable.IconCompat
+import androidx.lifecycle.DefaultLifecycleObserver
+import androidx.lifecycle.LifecycleOwner
+import co.touchlab.kermit.Logger
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.SupervisorJob
+import kotlinx.coroutines.cancel
+import kotlinx.coroutines.launch
+import org.meshtastic.core.model.ConnectionState
+import org.meshtastic.core.model.DataPacket
+import org.meshtastic.core.model.nodeColorsFromNum
+import org.meshtastic.feature.car.R
+import org.meshtastic.feature.car.alerts.EmergencyHandler
+import org.meshtastic.feature.car.model.ConversationUi
+import org.meshtastic.feature.car.service.CarStateCoordinator
+import org.meshtastic.feature.car.util.CarScreenDataBuilder
+import org.meshtastic.feature.car.util.NodeSubtitleFormatter
+import java.util.Locale
+
+@Suppress("TooManyFunctions")
+class HomeScreen(
+ carContext: CarContext,
+ private val stateCoordinator: CarStateCoordinator,
+ private val emergencyHandler: EmergencyHandler,
+) : Screen(carContext) {
+
+ private var selectedTabId: String = TAB_ID_MESSAGES
+ private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
+ private var previousConnectionState: ConnectionState = ConnectionState.Disconnected
+
+ init {
+ lifecycle.addObserver(
+ object : DefaultLifecycleObserver {
+ override fun onStart(owner: LifecycleOwner) {
+ observeState()
+ }
+
+ override fun onDestroy(owner: LifecycleOwner) {
+ scope.cancel()
+ }
+ },
+ )
+ }
+
+ private fun observeState() {
+ scope.launch { stateCoordinator.messagingState.collect { invalidate() } }
+ scope.launch { stateCoordinator.nodeDashboardState.collect { invalidate() } }
+ scope.launch { stateCoordinator.localStatsState.collect { invalidate() } }
+ scope.launch {
+ stateCoordinator.sessionState.collect { state ->
+ val newState = state.connectionStatus
+ if (previousConnectionState == ConnectionState.Disconnected && newState == ConnectionState.Connected) {
+ CarToast.makeText(carContext, carContext.getString(R.string.car_reconnected), CarToast.LENGTH_SHORT)
+ .show()
+ }
+ previousConnectionState = newState
+ invalidate()
+ }
+ }
+ scope.launch {
+ emergencyHandler.latestAlert.collect { alert ->
+ if (alert != null && alert.isActive) {
+ showEmergencyAlert(alert.nodeNum, alert.nodeName, alert.message)
+ }
+ }
+ }
+ }
+
+ private fun showEmergencyAlert(nodeNum: Int, nodeName: String, message: String) {
+ if (carContext.carAppApiLevel < MIN_ALERT_CAR_API_LEVEL) {
+ Logger.w(tag = "HomeScreen") { "Alert API unavailable on car API ${carContext.carAppApiLevel}" }
+ return
+ }
+
+ val alert =
+ Alert.Builder(
+ nodeNum,
+ CarText.create(carContext.getString(R.string.car_emergency_from, nodeName)),
+ ALERT_DURATION_MS.toLong(),
+ )
+ .setSubtitle(CarText.create(message))
+ .addAction(
+ Action.Builder()
+ .setTitle(carContext.getString(R.string.car_dismiss))
+ .setOnClickListener { emergencyHandler.dismissAlert(nodeNum) }
+ .build(),
+ )
+ .setCallback(
+ object : AlertCallback {
+ override fun onCancel(reason: Int) {
+ emergencyHandler.dismissAlert(nodeNum)
+ }
+
+ override fun onDismiss() {
+ emergencyHandler.dismissAlert(nodeNum)
+ }
+ },
+ )
+ .build()
+
+ carContext.getCarService(AppManager::class.java).showAlert(alert)
+ }
+
+ @Suppress("ReturnCount")
+ override fun onGetTemplate(): Template {
+ val connectionStatus = stateCoordinator.sessionState.value.connectionStatus
+ if (connectionStatus == ConnectionState.Disconnected || connectionStatus == ConnectionState.DeviceSleep) {
+ return buildDisconnectedTemplate()
+ }
+ val messaging = stateCoordinator.messagingState.value
+ if (messaging.channels.isEmpty()) {
+ return buildOnboardingTemplate()
+ }
+ val messagingTab =
+ Tab.Builder()
+ .setContentId(TAB_ID_MESSAGES)
+ .setTitle(carContext.getString(R.string.car_tab_messages))
+ .setIcon(CarIcon.Builder(IconCompat.createWithResource(carContext, R.drawable.ic_car_message)).build())
+ .build()
+
+ val nodesTab =
+ Tab.Builder()
+ .setContentId(TAB_ID_NODES)
+ .setTitle(carContext.getString(R.string.car_tab_nodes))
+ .setIcon(CarIcon.Builder(IconCompat.createWithResource(carContext, R.drawable.ic_car_nodes)).build())
+ .build()
+
+ val statusTab =
+ Tab.Builder()
+ .setContentId(TAB_ID_STATUS)
+ .setTitle(carContext.getString(R.string.car_tab_status))
+ .setIcon(CarIcon.Builder(IconCompat.createWithResource(carContext, R.drawable.ic_car_status)).build())
+ .build()
+
+ return TabTemplate.Builder(
+ object : TabTemplate.TabCallback {
+ override fun onTabSelected(tabContentId: String) {
+ selectedTabId = tabContentId
+ invalidate()
+ }
+ },
+ )
+ .apply {
+ setHeaderAction(Action.APP_ICON)
+ addTab(messagingTab)
+ addTab(nodesTab)
+ addTab(statusTab)
+ setTabContents(getTabContents())
+ }
+ .build()
+ }
+
+ private fun getTabContents(): TabContents {
+ val template =
+ when (selectedTabId) {
+ TAB_ID_MESSAGES -> buildMessagingList()
+ TAB_ID_NODES -> buildNodeList()
+ TAB_ID_STATUS -> buildStatusList()
+ else -> buildMessagingList()
+ }
+ return TabContents.Builder(template).build()
+ }
+
+ private fun buildMessagingList(): Template {
+ val state = stateCoordinator.messagingState.value
+ val listBuilder = ItemList.Builder()
+
+ val validConversations = state.conversations.filter { it.lastMessage.isNotEmpty() }
+ if (validConversations.isEmpty()) {
+ listBuilder.setNoItemsMessage(carContext.getString(R.string.car_no_messages))
+ } else {
+ val selfPerson = buildSelfPerson()
+ validConversations.take(CarScreenDataBuilder.MAX_CONVERSATIONS).forEach { conversation ->
+ listBuilder.addItem(buildConversationItem(conversation, selfPerson))
+ }
+ }
+
+ return ListTemplate.Builder().setSingleList(listBuilder.build()).build()
+ }
+
+ private fun buildSelfPerson(): Person {
+ val myName = stateCoordinator.sessionState.value.meshName ?: "Me"
+ return Person.Builder().setName(myName).setKey("self").build()
+ }
+
+ private fun buildConversationItem(conversation: ConversationUi, selfPerson: Person): ConversationItem {
+ val senderPerson = Person.Builder().setName(conversation.displayName).setKey(conversation.contactKey).build()
+
+ val messages = buildCarMessages(conversation, senderPerson)
+
+ val callback =
+ object : ConversationCallback {
+ override fun onMarkAsRead() {
+ stateCoordinator.markAsRead(conversation.contactKey)
+ }
+
+ override fun onTextReply(replyText: String) {
+ stateCoordinator.sendMessage(conversation.contactKey, replyText)
+ }
+ }
+
+ return ConversationItem.Builder()
+ .setId(conversation.contactKey)
+ .setTitle(CarText.create(conversation.displayName))
+ .setMessages(messages)
+ .setSelf(selfPerson)
+ .setConversationCallback(callback)
+ .setGroupConversation(conversation.contactKey.contains(DataPacket.ID_BROADCAST))
+ .build()
+ }
+
+ private fun buildCarMessages(conversation: ConversationUi, senderPerson: Person): List = listOf(
+ CarMessage.Builder()
+ .setSender(senderPerson)
+ .setBody(CarText.create(conversation.lastMessage))
+ .setReceivedTimeEpochMillis(conversation.lastMessageTime)
+ .setRead(conversation.unreadCount == 0)
+ .build(),
+ )
+
+ private fun buildNodeList(): Template {
+ val state = stateCoordinator.nodeDashboardState.value
+ val listBuilder = ItemList.Builder()
+
+ if (state.nodes.isEmpty()) {
+ listBuilder.setNoItemsMessage(carContext.getString(R.string.car_no_nodes))
+ } else {
+ val baseIcon = IconCompat.createWithResource(carContext, R.drawable.ic_car_nodes)
+ state.nodes.forEach { node ->
+ val (_, nodeColor) = nodeColorsFromNum(node.nodeNum)
+ val tintedIcon = CarIcon.Builder(baseIcon).setTint(CarColor.createCustom(nodeColor, nodeColor)).build()
+ listBuilder.addItem(
+ Row.Builder()
+ .setTitle(node.longName)
+ .addText(NodeSubtitleFormatter.format(carContext, node))
+ .setImage(tintedIcon, Row.IMAGE_TYPE_ICON)
+ .setBrowsable(true)
+ .setOnClickListener {
+ screenManager.push(
+ NodeDetailScreen(
+ carContext = carContext,
+ nodeProvider = { node },
+ onMessageClick = { contactKey ->
+ screenManager.pop()
+ selectedTabId = TAB_ID_MESSAGES
+ stateCoordinator.ensureDmConversation(
+ contactKey,
+ node.longName,
+ carContext.getString(R.string.car_new_conversation),
+ )
+ invalidate()
+ },
+ ),
+ )
+ }
+ .build(),
+ )
+ }
+ }
+
+ return ListTemplate.Builder().setSingleList(listBuilder.build()).build()
+ }
+
+ private fun buildStatusList(): Template {
+ val stats = stateCoordinator.localStatsState.value
+ val listBuilder = ItemList.Builder()
+
+ if (stats.hasBattery) {
+ listBuilder.addItem(
+ Row.Builder()
+ .setTitle(carContext.getString(R.string.car_stat_battery))
+ .addText("${stats.batteryLevel}%")
+ .build(),
+ )
+ }
+
+ listBuilder.addItem(
+ Row.Builder()
+ .setTitle(carContext.getString(R.string.car_stat_channel_util))
+ .addText(String.format(Locale.getDefault(), "%.1f%%", stats.channelUtilization))
+ .build(),
+ )
+
+ listBuilder.addItem(
+ Row.Builder()
+ .setTitle(carContext.getString(R.string.car_stat_air_util))
+ .addText(String.format(Locale.getDefault(), "%.1f%%", stats.airUtilization))
+ .build(),
+ )
+
+ listBuilder.addItem(
+ Row.Builder()
+ .setTitle(carContext.getString(R.string.car_stat_nodes))
+ .addText("${stats.onlineNodes} / ${stats.totalNodes}")
+ .build(),
+ )
+
+ listBuilder.addItem(
+ Row.Builder()
+ .setTitle(carContext.getString(R.string.car_stat_uptime))
+ .addText(CarScreenDataBuilder.formatUptime(stats.uptimeSeconds))
+ .build(),
+ )
+
+ listBuilder.addItem(
+ Row.Builder()
+ .setTitle(carContext.getString(R.string.car_stat_packets))
+ .addText("TX: ${stats.numPacketsTx} / RX: ${stats.numPacketsRx}")
+ .build(),
+ )
+
+ return ListTemplate.Builder().setSingleList(listBuilder.build()).build()
+ }
+
+ private fun buildDisconnectedTemplate(): Template = PaneTemplate.Builder(
+ Pane.Builder()
+ .addRow(
+ Row.Builder()
+ .setTitle(carContext.getString(R.string.car_disconnected))
+ .addText(carContext.getString(R.string.car_reconnecting))
+ .setImage(
+ CarIcon.Builder(IconCompat.createWithResource(carContext, R.drawable.ic_car_warning))
+ .build(),
+ )
+ .build(),
+ )
+ .build(),
+ )
+ .setHeader(
+ Header.Builder()
+ .setTitle(carContext.getString(R.string.car_app_name))
+ .setStartHeaderAction(Action.APP_ICON)
+ .build(),
+ )
+ .build()
+
+ private fun buildOnboardingTemplate(): Template = PaneTemplate.Builder(
+ Pane.Builder()
+ .addRow(
+ Row.Builder()
+ .setTitle(carContext.getString(R.string.car_onboarding_title))
+ .addText(carContext.getString(R.string.car_onboarding_text))
+ .setImage(
+ CarIcon.Builder(IconCompat.createWithResource(carContext, R.drawable.ic_car_meshtastic))
+ .build(),
+ )
+ .build(),
+ )
+ .build(),
+ )
+ .setHeader(
+ Header.Builder()
+ .setTitle(carContext.getString(R.string.car_app_name))
+ .setStartHeaderAction(Action.APP_ICON)
+ .build(),
+ )
+ .build()
+
+ companion object {
+ private const val TAB_ID_MESSAGES = "messages"
+ private const val TAB_ID_NODES = "nodes"
+ private const val TAB_ID_STATUS = "status"
+ private const val MIN_ALERT_CAR_API_LEVEL = 8
+ private const val ALERT_DURATION_MS = 10_000
+ }
+}
diff --git a/feature/car/src/main/kotlin/org/meshtastic/feature/car/screens/NodeDetailScreen.kt b/feature/car/src/main/kotlin/org/meshtastic/feature/car/screens/NodeDetailScreen.kt
new file mode 100644
index 000000000..231f78597
--- /dev/null
+++ b/feature/car/src/main/kotlin/org/meshtastic/feature/car/screens/NodeDetailScreen.kt
@@ -0,0 +1,114 @@
+/*
+ * 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.car.screens
+
+import androidx.car.app.CarContext
+import androidx.car.app.Screen
+import androidx.car.app.model.Action
+import androidx.car.app.model.Header
+import androidx.car.app.model.Pane
+import androidx.car.app.model.PaneTemplate
+import androidx.car.app.model.Row
+import androidx.car.app.model.Template
+import org.meshtastic.core.common.util.DateFormatter
+import org.meshtastic.feature.car.R
+import org.meshtastic.feature.car.model.NodeUi
+import org.meshtastic.feature.car.model.SignalQuality
+
+class NodeDetailScreen(
+ carContext: CarContext,
+ private val nodeProvider: () -> NodeUi?,
+ private val onMessageClick: (String) -> Unit,
+) : Screen(carContext) {
+
+ override fun onGetTemplate(): Template {
+ val node = nodeProvider() ?: return buildErrorTemplate()
+
+ val paneBuilder = Pane.Builder()
+
+ paneBuilder.addRow(
+ Row.Builder()
+ .setTitle(carContext.getString(R.string.car_status_signal))
+ .addText(formatSignal(node.signalQuality))
+ .build(),
+ )
+
+ node.batteryPercent?.let { battery ->
+ paneBuilder.addRow(
+ Row.Builder().setTitle(carContext.getString(R.string.car_status_battery)).addText("$battery%").build(),
+ )
+ }
+
+ paneBuilder.addRow(
+ Row.Builder()
+ .setTitle(carContext.getString(R.string.car_status_last_heard))
+ .addText(formatLastHeard(node.lastHeard))
+ .build(),
+ )
+
+ paneBuilder.addRow(
+ Row.Builder()
+ .setTitle(carContext.getString(R.string.car_status_status))
+ .addText(
+ if (node.isOnline) {
+ carContext.getString(R.string.car_status_online)
+ } else {
+ carContext.getString(R.string.car_status_offline)
+ },
+ )
+ .build(),
+ )
+
+ // Direct message action — constructs contactKey "0" for DM
+ paneBuilder.addAction(
+ Action.Builder()
+ .setTitle(carContext.getString(R.string.car_message_node))
+ .setOnClickListener { onMessageClick("0${node.userId}") }
+ .build(),
+ )
+
+ return PaneTemplate.Builder(paneBuilder.build())
+ .setHeader(Header.Builder().setTitle(node.longName).setStartHeaderAction(Action.BACK).build())
+ .build()
+ }
+
+ private fun buildErrorTemplate(): Template = PaneTemplate.Builder(
+ Pane.Builder()
+ .addRow(Row.Builder().setTitle(carContext.getString(R.string.car_node_not_found)).build())
+ .build(),
+ )
+ .setHeader(
+ Header.Builder()
+ .setTitle(carContext.getString(R.string.car_error))
+ .setStartHeaderAction(Action.BACK)
+ .build(),
+ )
+ .build()
+
+ private fun formatSignal(quality: SignalQuality): String = when (quality) {
+ SignalQuality.EXCELLENT -> carContext.getString(R.string.car_signal_excellent)
+ SignalQuality.GOOD -> carContext.getString(R.string.car_signal_good)
+ SignalQuality.FAIR -> carContext.getString(R.string.car_signal_fair)
+ SignalQuality.BAD -> carContext.getString(R.string.car_signal_bad)
+ SignalQuality.NONE -> carContext.getString(R.string.car_signal_none)
+ }
+
+ private fun formatLastHeard(epochMillis: Long): String {
+ if (epochMillis == 0L) return carContext.getString(R.string.car_time_never)
+ return DateFormatter.formatRelativeTime(epochMillis)
+ }
+}
diff --git a/feature/car/src/main/kotlin/org/meshtastic/feature/car/service/CarNotificationManager.kt b/feature/car/src/main/kotlin/org/meshtastic/feature/car/service/CarNotificationManager.kt
new file mode 100644
index 000000000..216e95ceb
--- /dev/null
+++ b/feature/car/src/main/kotlin/org/meshtastic/feature/car/service/CarNotificationManager.kt
@@ -0,0 +1,121 @@
+/*
+ * 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.car.service
+
+import android.app.NotificationChannel
+import android.app.NotificationManager
+import android.app.PendingIntent
+import android.content.Context
+import android.content.Intent
+import android.os.Build
+import androidx.core.app.NotificationCompat
+import androidx.core.app.NotificationManagerCompat
+import androidx.core.app.Person
+import androidx.core.app.RemoteInput
+import androidx.core.content.LocusIdCompat
+import org.koin.core.annotation.Single
+import org.meshtastic.feature.car.R
+
+@Single
+class CarNotificationManager(private val context: Context, private val shortcutManager: ConversationShortcutManager) {
+
+ init {
+ createNotificationChannel()
+ }
+
+ private fun createNotificationChannel() {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+ val channel =
+ NotificationChannel(CHANNEL_ID, "Mesh Messages", NotificationManager.IMPORTANCE_HIGH).apply {
+ description = "Messages from Meshtastic mesh network"
+ }
+ val manager = context.getSystemService(NotificationManager::class.java)
+ manager.createNotificationChannel(channel)
+ }
+ }
+
+ fun postMessagingNotification(conversationId: String, senderName: String, messages: List>) {
+ shortcutManager.ensureConversationShortcut(conversationId, senderName)
+
+ val person = Person.Builder().setName(senderName).build()
+
+ val messagingStyle = NotificationCompat.MessagingStyle(Person.Builder().setName("Me").build())
+ messagingStyle.setConversationTitle(senderName)
+ messages.forEach { (text, timestamp) -> messagingStyle.addMessage(text, timestamp, person) }
+
+ val replyAction = buildReplyAction(conversationId)
+ val markReadAction = buildMarkReadAction(conversationId)
+
+ val notification =
+ NotificationCompat.Builder(context, CHANNEL_ID)
+ .setSmallIcon(R.drawable.ic_car_meshtastic)
+ .setStyle(messagingStyle)
+ .addAction(replyAction)
+ .addAction(markReadAction)
+ .setCategory(NotificationCompat.CATEGORY_MESSAGE)
+ .setShortcutId(conversationId)
+ .setLocusId(LocusIdCompat(conversationId))
+ .build()
+
+ NotificationManagerCompat.from(context).notify(conversationId.hashCode(), notification)
+ }
+
+ private fun buildReplyAction(conversationId: String): NotificationCompat.Action {
+ val remoteInput = RemoteInput.Builder(KEY_TEXT_REPLY).setLabel("Reply").build()
+
+ val replyIntent =
+ PendingIntent.getBroadcast(
+ context,
+ conversationId.hashCode(),
+ Intent(context, CarReplyReceiver::class.java)
+ .setAction(ACTION_REPLY)
+ .putExtra(EXTRA_CONVERSATION_ID, conversationId),
+ PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_MUTABLE,
+ )
+
+ return NotificationCompat.Action.Builder(android.R.drawable.ic_menu_send, "Reply", replyIntent)
+ .addRemoteInput(remoteInput)
+ .setSemanticAction(NotificationCompat.Action.SEMANTIC_ACTION_REPLY)
+ .setShowsUserInterface(false)
+ .build()
+ }
+
+ private fun buildMarkReadAction(conversationId: String): NotificationCompat.Action {
+ val markReadIntent =
+ PendingIntent.getBroadcast(
+ context,
+ conversationId.hashCode() + 1,
+ Intent(context, CarReplyReceiver::class.java)
+ .setAction(ACTION_MARK_READ)
+ .putExtra(EXTRA_CONVERSATION_ID, conversationId),
+ PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
+ )
+
+ return NotificationCompat.Action.Builder(android.R.drawable.ic_menu_view, "Mark as Read", markReadIntent)
+ .setSemanticAction(NotificationCompat.Action.SEMANTIC_ACTION_MARK_AS_READ)
+ .setShowsUserInterface(false)
+ .build()
+ }
+
+ companion object {
+ const val CHANNEL_ID = "meshtastic_car_messages"
+ const val KEY_TEXT_REPLY = "key_text_reply"
+ const val ACTION_REPLY = "org.meshtastic.feature.car.REPLY"
+ const val ACTION_MARK_READ = "org.meshtastic.feature.car.MARK_READ"
+ const val EXTRA_CONVERSATION_ID = "conversation_id"
+ }
+}
diff --git a/feature/car/src/main/kotlin/org/meshtastic/feature/car/service/CarReplyReceiver.kt b/feature/car/src/main/kotlin/org/meshtastic/feature/car/service/CarReplyReceiver.kt
new file mode 100644
index 000000000..8ddc890bc
--- /dev/null
+++ b/feature/car/src/main/kotlin/org/meshtastic/feature/car/service/CarReplyReceiver.kt
@@ -0,0 +1,79 @@
+/*
+ * 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.car.service
+
+import android.content.BroadcastReceiver
+import android.content.Context
+import android.content.Intent
+import androidx.core.app.RemoteInput
+import co.touchlab.kermit.Logger
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.SupervisorJob
+import kotlinx.coroutines.launch
+import org.koin.core.component.KoinComponent
+import org.koin.core.component.inject
+import org.meshtastic.core.repository.PacketRepository
+import org.meshtastic.core.repository.usecase.SendMessageUseCase
+
+/**
+ * Handles inline reply and mark-read actions from car messaging notifications. Uses [goAsync] to keep the receiver
+ * alive while the coroutine completes, preventing premature process kill.
+ */
+class CarReplyReceiver :
+ BroadcastReceiver(),
+ KoinComponent {
+
+ private val sendMessageUseCase: SendMessageUseCase by inject()
+ private val packetRepository: PacketRepository by inject()
+
+ override fun onReceive(context: Context, intent: Intent) {
+ val pendingResult = goAsync()
+ val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
+ scope.launch {
+ try {
+ when (intent.action) {
+ CarNotificationManager.ACTION_REPLY -> handleReply(intent)
+ CarNotificationManager.ACTION_MARK_READ -> handleMarkRead(intent)
+ }
+ } finally {
+ pendingResult.finish()
+ }
+ }
+ }
+
+ private suspend fun handleReply(intent: Intent) {
+ val conversationId = intent.getStringExtra(CarNotificationManager.EXTRA_CONVERSATION_ID) ?: return
+ val remoteInput = RemoteInput.getResultsFromIntent(intent)
+ val replyText = remoteInput?.getCharSequence(CarNotificationManager.KEY_TEXT_REPLY)?.toString() ?: return
+
+ Logger.d(tag = TAG) { "Reply to conversation: $conversationId (${replyText.length} chars)" }
+ runCatching { sendMessageUseCase(replyText, conversationId) }
+ .onFailure { error -> Logger.e(tag = TAG, throwable = error) { "Failed to send reply" } }
+ }
+
+ private suspend fun handleMarkRead(intent: Intent) {
+ val conversationId = intent.getStringExtra(CarNotificationManager.EXTRA_CONVERSATION_ID) ?: return
+ Logger.d(tag = TAG) { "Mark read: $conversationId" }
+ runCatching { packetRepository.clearUnreadCount(conversationId, System.currentTimeMillis()) }
+ .onFailure { error -> Logger.e(tag = TAG, throwable = error) { "Failed to mark as read" } }
+ }
+
+ companion object {
+ private const val TAG = "CarReplyReceiver"
+ }
+}
diff --git a/feature/car/src/main/kotlin/org/meshtastic/feature/car/service/CarStateCoordinator.kt b/feature/car/src/main/kotlin/org/meshtastic/feature/car/service/CarStateCoordinator.kt
new file mode 100644
index 000000000..b47f1a81c
--- /dev/null
+++ b/feature/car/src/main/kotlin/org/meshtastic/feature/car/service/CarStateCoordinator.kt
@@ -0,0 +1,263 @@
+/*
+ * 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.car.service
+
+import co.touchlab.kermit.Logger
+import kotlinx.coroutines.CoroutineExceptionHandler
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.SupervisorJob
+import kotlinx.coroutines.cancel
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.combine
+import kotlinx.coroutines.launch
+import org.koin.core.annotation.Factory
+import org.meshtastic.core.model.Channel
+import org.meshtastic.core.model.ConnectionState
+import org.meshtastic.core.repository.NodeRepository
+import org.meshtastic.core.repository.PacketRepository
+import org.meshtastic.core.repository.RadioConfigRepository
+import org.meshtastic.core.repository.ServiceRepository
+import org.meshtastic.core.repository.usecase.SendMessageUseCase
+import org.meshtastic.feature.car.model.CarLocalStats
+import org.meshtastic.feature.car.model.CarSessionState
+import org.meshtastic.feature.car.model.ChannelUi
+import org.meshtastic.feature.car.model.ConversationUi
+import org.meshtastic.feature.car.model.MessagingUiState
+import org.meshtastic.feature.car.model.NodeDashboardUiState
+import org.meshtastic.feature.car.model.TopologyHeader
+import org.meshtastic.feature.car.util.CarScreenDataBuilder
+import org.meshtastic.feature.car.util.MessageFilter
+
+/** Snapshot of a message for car display (avoids leaking domain models to UI). */
+data class MessageSnapshot(
+ val id: Int,
+ val senderName: String,
+ val text: String,
+ val timestamp: Long,
+ val isFromMe: Boolean,
+)
+
+/**
+ * Bridges repository data flows to car screen presentation state. Created per car session — destroyed when session
+ * ends.
+ */
+@Factory
+@Suppress("TooManyFunctions")
+class CarStateCoordinator(
+ private val nodeRepository: NodeRepository,
+ private val packetRepository: PacketRepository,
+ private val serviceRepository: ServiceRepository,
+ private val radioConfigRepository: RadioConfigRepository,
+ private val sendMessageUseCase: SendMessageUseCase,
+ private val messageFilter: MessageFilter,
+) {
+ private val exceptionHandler = CoroutineExceptionHandler { _, throwable ->
+ Logger.e(tag = "CarStateCoordinator", throwable = throwable) { "Unhandled error in car state flow" }
+ }
+ private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate + exceptionHandler)
+ private var nodeJob: Job? = null
+ private var messagingJob: Job? = null
+
+ private val _sessionState =
+ MutableStateFlow(
+ CarSessionState(
+ connectionStatus = ConnectionState.Disconnected,
+ onlineNodeCount = 0,
+ lastMessageTime = null,
+ activeEmergencies = emptyList(),
+ meshName = null,
+ ),
+ )
+ val sessionState: StateFlow = _sessionState.asStateFlow()
+
+ private val _messagingState =
+ MutableStateFlow(
+ MessagingUiState(
+ channels = emptyList(),
+ selectedChannelIndex = 0,
+ conversations = emptyList(),
+ emergencySpotlight = null,
+ ),
+ )
+ val messagingState: StateFlow = _messagingState.asStateFlow()
+
+ private val _nodeDashboardState =
+ MutableStateFlow(NodeDashboardUiState(nodes = emptyList(), topologyHeader = TopologyHeader(0, 0, null)))
+ val nodeDashboardState: StateFlow = _nodeDashboardState.asStateFlow()
+
+ private val _localStatsState = MutableStateFlow(CarLocalStats())
+ val localStatsState: StateFlow = _localStatsState.asStateFlow()
+
+ private val selectedChannel = MutableStateFlow(0)
+
+ fun start() {
+ collectConnectionState()
+ collectNodeData()
+ collectMessagingData()
+ collectLocalStats()
+ }
+
+ fun refresh() {
+ nodeJob?.cancel()
+ messagingJob?.cancel()
+ collectNodeData()
+ collectMessagingData()
+ }
+
+ fun selectChannel(index: Int) {
+ selectedChannel.value = index
+ _messagingState.value = _messagingState.value.copy(selectedChannelIndex = index)
+ }
+
+ fun sendMessage(contactKey: String, text: String): Boolean {
+ val validation = messageFilter.validateOutgoing(text)
+ if (validation is MessageFilter.ValidationResult.TooLong) {
+ return false
+ }
+ scope.launch { sendMessageUseCase(text, contactKey) }
+ return true
+ }
+
+ fun markAsRead(contactKey: String) {
+ scope.launch {
+ runCatching { packetRepository.clearUnreadCount(contactKey, System.currentTimeMillis()) }
+ .onFailure { throwable ->
+ Logger.e(tag = "CarStateCoordinator", throwable = throwable) { "Failed to mark as read" }
+ }
+ }
+ }
+
+ /**
+ * Ensures a DM conversation appears in the messaging list for the given [contactKey]. If the contact doesn't have
+ * an existing conversation, adds a placeholder entry so the ConversationItem is visible for voice reply.
+ */
+ fun ensureDmConversation(contactKey: String, displayName: String, placeholderMessage: String) {
+ val current = _messagingState.value
+ if (current.conversations.any { it.contactKey == contactKey }) return
+ val placeholder =
+ ConversationUi(
+ contactKey = contactKey,
+ displayName = displayName,
+ lastMessage = placeholderMessage,
+ lastMessageTime = System.currentTimeMillis(),
+ unreadCount = 0,
+ isEmergency = false,
+ )
+ _messagingState.value = current.copy(conversations = listOf(placeholder) + current.conversations)
+ }
+
+ fun destroy() {
+ scope.cancel()
+ }
+
+ private fun collectConnectionState() {
+ scope.launch {
+ serviceRepository.connectionState.collect { state ->
+ _sessionState.value = _sessionState.value.copy(connectionStatus = state)
+ }
+ }
+ }
+
+ private fun collectNodeData() {
+ nodeJob =
+ scope.launch {
+ combine(nodeRepository.nodeDBbyNum, nodeRepository.onlineNodeCount) { nodeMap, onlineCount ->
+ val nodes = CarScreenDataBuilder.sortNodes(nodeMap.values)
+ val totalCount = nodeMap.size
+ val meshName = nodeRepository.myNodeInfo.value?.firmwareVersion
+
+ _nodeDashboardState.value =
+ NodeDashboardUiState(
+ nodes = nodes,
+ topologyHeader =
+ TopologyHeader(
+ totalNodes = totalCount,
+ onlineNodes = onlineCount,
+ meshName = meshName,
+ ),
+ )
+ _sessionState.value = _sessionState.value.copy(onlineNodeCount = onlineCount)
+ }
+ .collect {}
+ }
+ }
+
+ private fun collectMessagingData() {
+ messagingJob =
+ scope.launch {
+ combine(packetRepository.getContacts(), radioConfigRepository.channelSetFlow) { contacts, channelSet ->
+ val channels =
+ channelSet.settings.mapIndexed { index, settings ->
+ val channel = Channel(settings = settings)
+ ChannelUi(
+ index = index,
+ name = channel.name,
+ unreadCount = 0, // will be updated per-channel
+ )
+ }
+
+ val conversations =
+ CarScreenDataBuilder.sortConversations(
+ contacts.entries.map { (contactKey, packet) ->
+ val senderNode =
+ nodeRepository.nodeDBbyNum.value.values.find { it.user.id == packet.from }
+ ConversationUi(
+ contactKey = contactKey,
+ displayName = senderNode?.user?.long_name ?: contactKey,
+ lastMessage = packet.bytes?.utf8() ?: "",
+ lastMessageTime = packet.time,
+ unreadCount = 0,
+ isEmergency = false,
+ )
+ },
+ )
+ .take(CarScreenDataBuilder.MAX_CONVERSATIONS)
+
+ _messagingState.value =
+ MessagingUiState(
+ channels = channels,
+ selectedChannelIndex = selectedChannel.value,
+ conversations = conversations,
+ emergencySpotlight = null,
+ )
+
+ // Update last message time in session state
+ val lastTime = conversations.maxOfOrNull { it.lastMessageTime }
+ if (lastTime != null) {
+ _sessionState.value = _sessionState.value.copy(lastMessageTime = lastTime)
+ }
+ }
+ .collect {}
+ }
+ }
+
+ private fun collectLocalStats() {
+ scope.launch {
+ combine(nodeRepository.localStats, nodeRepository.nodeDBbyNum) { stats, nodeMap ->
+ val ourNode =
+ nodeRepository.ourNodeInfo.value
+ ?: nodeRepository.myNodeInfo.value?.myNodeNum?.let(nodeMap::get)
+ CarScreenDataBuilder.buildLocalStats(ourNode = ourNode, stats = stats, allNodes = nodeMap.values)
+ }
+ .collect { _localStatsState.value = it }
+ }
+ }
+}
diff --git a/feature/car/src/main/kotlin/org/meshtastic/feature/car/service/ConversationShortcutManager.kt b/feature/car/src/main/kotlin/org/meshtastic/feature/car/service/ConversationShortcutManager.kt
new file mode 100644
index 000000000..ef9e54dac
--- /dev/null
+++ b/feature/car/src/main/kotlin/org/meshtastic/feature/car/service/ConversationShortcutManager.kt
@@ -0,0 +1,190 @@
+/*
+ * 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.car.service
+
+import android.content.Context
+import android.content.Intent
+import android.content.pm.ShortcutInfo
+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.net.toUri
+import co.touchlab.kermit.Logger
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.flow.combine
+import kotlinx.coroutines.flow.distinctUntilChanged
+import kotlinx.coroutines.flow.map
+import kotlinx.coroutines.launch
+import org.koin.core.annotation.Single
+import org.meshtastic.core.model.DataPacket
+import org.meshtastic.core.model.nodeColorsFromNum
+import org.meshtastic.core.repository.NodeRepository
+import org.meshtastic.core.repository.PacketRepository
+import org.meshtastic.core.repository.RadioConfigRepository
+import org.meshtastic.feature.car.util.PersonIconFactory
+
+/**
+ * Publishes dynamic shortcuts for active DM conversations and channels so that Android Auto can surface Meshtastic
+ * conversations as messaging destinations and link notifications to template conversations via [LocusIdCompat].
+ */
+@Single
+class ConversationShortcutManager(
+ private val context: Context,
+ private val nodeRepository: NodeRepository,
+ private val packetRepository: PacketRepository,
+ private val radioConfigRepository: RadioConfigRepository,
+) {
+
+ private var observeJob: Job? = null
+
+ fun startObserving(scope: CoroutineScope) {
+ observeJob?.cancel()
+ observeJob =
+ scope.launch {
+ val dmContactsFlow =
+ packetRepository
+ .getContacts()
+ .map { contacts ->
+ // DM contacts are those whose key does NOT contain the broadcast ID
+ contacts.entries
+ .filter { (key, _) -> !key.contains(DataPacket.ID_BROADCAST) }
+ .sortedByDescending { (_, packet) -> packet.time }
+ .map { (key, packet) -> DmContact(key, packet.from.orEmpty(), packet.time) }
+ }
+ .distinctUntilChanged()
+
+ val channelsFlow =
+ radioConfigRepository.channelSetFlow
+ .map { channelSet ->
+ channelSet.settings.mapIndexedNotNull { index, settings ->
+ if (index == 0 || settings.name.isNotEmpty()) {
+ index to settings.name
+ } else {
+ null
+ }
+ }
+ }
+ .distinctUntilChanged()
+
+ combine(dmContactsFlow, channelsFlow) { dms, channels -> dms to channels }
+ .collect { (dms, channels) -> publishShortcuts(dms, channels) }
+ }
+ }
+
+ fun stopObserving() {
+ observeJob?.cancel()
+ observeJob = null
+ }
+
+ private fun publishShortcuts(dmContacts: List, channels: List>) {
+ val shortcuts =
+ dmContacts.mapNotNull { buildDmShortcut(it) } +
+ channels.map { (index, name) -> buildChannelShortcut(index, name) }
+
+ try {
+ val limit = ShortcutManagerCompat.getMaxShortcutCountPerActivity(context)
+ val currentKeys = shortcuts.map { it.id }.toSet()
+ val stale = ShortcutManagerCompat.getDynamicShortcuts(context).map { it.id }.filter { it !in currentKeys }
+ if (stale.isNotEmpty()) {
+ ShortcutManagerCompat.removeDynamicShortcuts(context, stale)
+ }
+ for (shortcut in shortcuts.take(limit)) {
+ ShortcutManagerCompat.pushDynamicShortcut(context, shortcut)
+ }
+ Logger.d(tag = TAG) { "Published ${shortcuts.size.coerceAtMost(limit)} conversation shortcuts" }
+ } catch (e: IllegalArgumentException) {
+ Logger.e(tag = TAG, throwable = e) { "Failed to publish conversation shortcuts" }
+ } catch (e: IllegalStateException) {
+ Logger.e(tag = TAG, throwable = e) { "Failed to publish conversation shortcuts" }
+ }
+ }
+
+ private fun buildDmShortcut(dm: DmContact): ShortcutInfoCompat? {
+ val node = nodeRepository.nodeDBbyNum.value.values.find { it.user.id == dm.userId }
+ val label = node?.user?.long_name?.ifEmpty { node.user.short_name } ?: dm.contactKey
+ val personBuilder = Person.Builder().setName(label).setKey(dm.contactKey)
+ if (node != null) {
+ val (foregroundColor, backgroundColor) = nodeColorsFromNum(node.num)
+ personBuilder.setIcon(PersonIconFactory.create(node.user.short_name, backgroundColor, foregroundColor))
+ }
+ val person = personBuilder.build()
+ return ShortcutInfoCompat.Builder(context, dm.contactKey)
+ .setShortLabel(label)
+ .setLongLabel(label)
+ .setLocusId(LocusIdCompat(dm.contactKey))
+ .setPerson(person)
+ .setLongLived(true)
+ .setCategories(setOf(ShortcutInfo.SHORTCUT_CATEGORY_CONVERSATION))
+ .setIntent(conversationIntent(dm.contactKey))
+ .build()
+ }
+
+ private fun buildChannelShortcut(index: Int, name: String): ShortcutInfoCompat {
+ val contactKey = "${index}${DataPacket.ID_BROADCAST}"
+ val channelName = name.ifEmpty { "Primary Channel" }
+ val person = Person.Builder().setName(channelName).setKey("channel-$index").build()
+ return ShortcutInfoCompat.Builder(context, contactKey)
+ .setShortLabel(channelName)
+ .setLongLabel(channelName)
+ .setLocusId(LocusIdCompat(contactKey))
+ .setPerson(person)
+ .setLongLived(true)
+ .setCategories(setOf(ShortcutInfo.SHORTCUT_CATEGORY_CONVERSATION))
+ .setIntent(conversationIntent(contactKey))
+ .build()
+ }
+
+ private fun conversationIntent(contactKey: String): Intent =
+ Intent(Intent.ACTION_VIEW, "meshtastic://messages/$contactKey".toUri()).apply {
+ setPackage(context.packageName)
+ }
+
+ /**
+ * Ensures a long-lived conversation shortcut exists for [contactKey]. Called on demand when a notification is about
+ * to reference a shortcut ID that may not have been pre-published.
+ */
+ fun ensureConversationShortcut(contactKey: String, displayName: String) {
+ val alreadyPublished = ShortcutManagerCompat.getDynamicShortcuts(context).any { it.id == contactKey }
+ if (alreadyPublished) return
+ val person = Person.Builder().setName(displayName).setKey(contactKey).build()
+ val shortcut =
+ ShortcutInfoCompat.Builder(context, contactKey)
+ .setShortLabel(displayName)
+ .setLongLabel(displayName)
+ .setLocusId(LocusIdCompat(contactKey))
+ .setPerson(person)
+ .setLongLived(true)
+ .setCategories(setOf(ShortcutInfo.SHORTCUT_CATEGORY_CONVERSATION))
+ .setIntent(conversationIntent(contactKey))
+ .build()
+ try {
+ ShortcutManagerCompat.pushDynamicShortcut(context, shortcut)
+ } catch (e: IllegalArgumentException) {
+ Logger.e(tag = TAG, throwable = e) { "Failed to publish on-demand shortcut $contactKey" }
+ } catch (e: IllegalStateException) {
+ Logger.e(tag = TAG, throwable = e) { "Failed to publish on-demand shortcut $contactKey" }
+ }
+ }
+
+ private data class DmContact(val contactKey: String, val userId: String, val lastMessageTime: Long)
+
+ companion object {
+ private const val TAG = "ConversationShortcuts"
+ }
+}
diff --git a/feature/car/src/main/kotlin/org/meshtastic/feature/car/service/MeshtasticCarAppService.kt b/feature/car/src/main/kotlin/org/meshtastic/feature/car/service/MeshtasticCarAppService.kt
new file mode 100644
index 000000000..6ebf92157
--- /dev/null
+++ b/feature/car/src/main/kotlin/org/meshtastic/feature/car/service/MeshtasticCarAppService.kt
@@ -0,0 +1,37 @@
+/*
+ * 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.car.service
+
+import androidx.car.app.CarAppService
+import androidx.car.app.Session
+import androidx.car.app.SessionInfo
+import androidx.car.app.validation.HostValidator
+import co.touchlab.kermit.Logger
+import org.meshtastic.feature.car.BuildConfig
+import org.meshtastic.feature.car.R
+
+class MeshtasticCarAppService : CarAppService() {
+
+ override fun createHostValidator(): HostValidator = if (BuildConfig.DEBUG) {
+ Logger.w(tag = "CarAppService") { "Using ALLOW_ALL_HOSTS_VALIDATOR — debug build only" }
+ HostValidator.ALLOW_ALL_HOSTS_VALIDATOR
+ } else {
+ HostValidator.Builder(applicationContext).addAllowedHosts(R.array.car_hosts_allowlist).build()
+ }
+
+ override fun onCreateSession(sessionInfo: SessionInfo): Session = MeshtasticCarSession()
+}
diff --git a/feature/car/src/main/kotlin/org/meshtastic/feature/car/service/MeshtasticCarSession.kt b/feature/car/src/main/kotlin/org/meshtastic/feature/car/service/MeshtasticCarSession.kt
new file mode 100644
index 000000000..29798dedc
--- /dev/null
+++ b/feature/car/src/main/kotlin/org/meshtastic/feature/car/service/MeshtasticCarSession.kt
@@ -0,0 +1,79 @@
+/*
+ * 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.car.service
+
+import android.content.Intent
+import android.content.res.Configuration
+import androidx.car.app.Screen
+import androidx.car.app.Session
+import androidx.lifecycle.DefaultLifecycleObserver
+import androidx.lifecycle.LifecycleOwner
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.SupervisorJob
+import kotlinx.coroutines.cancel
+import kotlinx.coroutines.flow.emptyFlow
+import org.koin.core.component.KoinComponent
+import org.koin.core.component.inject
+import org.meshtastic.feature.car.alerts.EmergencyHandler
+import org.meshtastic.feature.car.screens.HomeScreen
+import org.meshtastic.feature.car.util.CrashlyticsCarTagger
+
+class MeshtasticCarSession :
+ Session(),
+ KoinComponent {
+
+ private val crashlyticsCarTagger: CrashlyticsCarTagger by inject()
+ private val stateCoordinator: CarStateCoordinator by inject()
+ private val emergencyHandler: EmergencyHandler by inject()
+ private val conversationShortcutManager: ConversationShortcutManager by inject()
+ private val sessionScope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
+
+ override fun onCreateScreen(intent: Intent): Screen {
+ crashlyticsCarTagger.setCarSession(true)
+ stateCoordinator.start()
+ conversationShortcutManager.startObserving(sessionScope)
+ // Emergency flow wired to emptyFlow() until emergency packet detection is implemented
+ emergencyHandler.startCollecting(emptyFlow())
+
+ lifecycle.addObserver(
+ object : DefaultLifecycleObserver {
+ override fun onDestroy(owner: LifecycleOwner) {
+ destroy()
+ }
+ },
+ )
+
+ return HomeScreen(carContext, stateCoordinator, emergencyHandler)
+ }
+
+ override fun onNewIntent(intent: Intent) {
+ // Deep link handling (e.g., open specific conversation from notification)
+ }
+
+ override fun onCarConfigurationChanged(newConfiguration: Configuration) {
+ // Handle theme/density changes — templates auto-update
+ }
+
+ private fun destroy() {
+ conversationShortcutManager.stopObserving()
+ sessionScope.cancel()
+ emergencyHandler.stopCollecting()
+ stateCoordinator.destroy()
+ crashlyticsCarTagger.setCarSession(false)
+ }
+}
diff --git a/feature/car/src/main/kotlin/org/meshtastic/feature/car/util/CarScreenDataBuilder.kt b/feature/car/src/main/kotlin/org/meshtastic/feature/car/util/CarScreenDataBuilder.kt
new file mode 100644
index 000000000..4a967e509
--- /dev/null
+++ b/feature/car/src/main/kotlin/org/meshtastic/feature/car/util/CarScreenDataBuilder.kt
@@ -0,0 +1,128 @@
+/*
+ * 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.car.util
+
+import org.meshtastic.core.model.DataPacket
+import org.meshtastic.core.model.Node
+import org.meshtastic.feature.car.model.CarLocalStats
+import org.meshtastic.feature.car.model.ConversationUi
+import org.meshtastic.feature.car.model.NodeUi
+import org.meshtastic.feature.car.model.SignalQuality
+import org.meshtastic.feature.car.service.MessageSnapshot
+import org.meshtastic.proto.LocalStats
+
+/**
+ * Pure-function helpers that convert domain models into car UI models.
+ *
+ * All methods are free of Car App Library dependencies, making them testable as plain JVM unit tests without
+ * Robolectric.
+ */
+internal object CarScreenDataBuilder {
+
+ private const val SECONDS_TO_MILLIS = 1000L
+ private const val MINUTE_SECONDS = 60
+ private const val HOUR_SECONDS = 3600
+ private const val DAY_SECONDS = 86400
+ private const val BATTERY_MAX_PERCENT = 100
+
+ // Thresholds aligned with core/ui LoraSignalIndicator.kt
+ private const val SNR_GOOD_THRESHOLD = -7f
+ private const val SNR_FAIR_THRESHOLD = -15f
+ private const val RSSI_GOOD_THRESHOLD = -115
+ private const val RSSI_FAIR_THRESHOLD = -126
+
+ /** Converts a [Node] to a [NodeUi] for car display. */
+ fun buildNodeUi(node: Node): NodeUi = NodeUi(
+ nodeNum = node.num,
+ userId = node.user.id,
+ longName = node.user.long_name.ifEmpty { "Unknown" },
+ shortName = node.user.short_name.ifEmpty { "?" },
+ signalQuality = determineSignalQuality(node.snr, node.rssi),
+ batteryPercent = node.batteryLevel?.takeIf { it in 1..BATTERY_MAX_PERCENT },
+ isOnline = node.isOnline,
+ lastHeard = node.lastHeard.toLong() * SECONDS_TO_MILLIS,
+ hasPosition = node.validPosition != null,
+ )
+
+ /** Sorts nodes for car display: online nodes first, then by lastHeard descending. */
+ fun sortNodes(nodes: Collection): List = nodes
+ .map(::buildNodeUi)
+ .sortedWith(compareByDescending { it.isOnline }.thenByDescending { it.lastHeard })
+
+ /** Builds ordered conversation list: sorted by most recent message time descending. */
+ fun sortConversations(conversations: List): List =
+ conversations.sortedByDescending { it.lastMessageTime }
+
+ /** Determines signal quality from SNR and RSSI values. */
+ fun determineSignalQuality(snr: Float, rssi: Int): SignalQuality = when {
+ snr == Float.MAX_VALUE || rssi == Int.MAX_VALUE -> SignalQuality.NONE
+ snr > SNR_GOOD_THRESHOLD && rssi > RSSI_GOOD_THRESHOLD -> SignalQuality.EXCELLENT
+ snr > SNR_GOOD_THRESHOLD && rssi > RSSI_FAIR_THRESHOLD -> SignalQuality.GOOD
+ snr > SNR_FAIR_THRESHOLD && rssi > RSSI_GOOD_THRESHOLD -> SignalQuality.GOOD
+ snr > SNR_FAIR_THRESHOLD -> SignalQuality.FAIR
+ else -> SignalQuality.BAD
+ }
+
+ /**
+ * Builds a [CarLocalStats] snapshot from the device's [Node], [LocalStats], and node DB. Falls back to
+ * Node.deviceMetrics when LocalStats hasn't been populated yet.
+ */
+ @Suppress("MagicNumber")
+ fun buildLocalStats(ourNode: Node?, stats: LocalStats, allNodes: Collection): CarLocalStats {
+ val metrics = ourNode?.deviceMetrics
+ val hasStats = stats.uptime_seconds != 0
+ return CarLocalStats(
+ batteryLevel = metrics?.battery_level ?: 0,
+ hasBattery = metrics?.battery_level != null,
+ channelUtilization = if (hasStats) stats.channel_utilization else metrics?.channel_utilization ?: 0f,
+ airUtilization = if (hasStats) stats.air_util_tx else metrics?.air_util_tx ?: 0f,
+ totalNodes = allNodes.size,
+ onlineNodes = allNodes.count { it.isOnline },
+ uptimeSeconds = if (hasStats) stats.uptime_seconds else metrics?.uptime_seconds ?: 0,
+ numPacketsTx = stats.num_packets_tx,
+ numPacketsRx = stats.num_packets_rx,
+ )
+ }
+
+ /** Formats uptime seconds as a human-readable string. */
+ fun formatUptime(seconds: Int): String {
+ val days = seconds / DAY_SECONDS
+ val hours = (seconds % DAY_SECONDS) / HOUR_SECONDS
+ val minutes = (seconds % HOUR_SECONDS) / MINUTE_SECONDS
+ return when {
+ days > 0 -> "${days}d ${hours}h"
+ hours > 0 -> "${hours}h ${minutes}m"
+ else -> "${minutes}m"
+ }
+ }
+
+ /**
+ * Returns the contact key in the format expected by the messaging system. Channels use `"^all"`
+ * format; DMs use `"0"`.
+ */
+ fun buildContactKey(channelIndex: Int): String = "${channelIndex}${DataPacket.ID_BROADCAST}"
+
+ /** Returns the most recent N messages from a list, ordered chronologically (oldest first). */
+ fun recentMessages(messages: List, limit: Int = MAX_CONVERSATION_MESSAGES): List =
+ messages.takeLast(limit)
+
+ /** Maximum messages to include in a ConversationItem. */
+ const val MAX_CONVERSATION_MESSAGES = 5
+
+ /** Maximum conversations to display in the messaging list. */
+ const val MAX_CONVERSATIONS = 10
+}
diff --git a/feature/car/src/main/kotlin/org/meshtastic/feature/car/util/CrashlyticsCarTagger.kt b/feature/car/src/main/kotlin/org/meshtastic/feature/car/util/CrashlyticsCarTagger.kt
new file mode 100644
index 000000000..810cdd521
--- /dev/null
+++ b/feature/car/src/main/kotlin/org/meshtastic/feature/car/util/CrashlyticsCarTagger.kt
@@ -0,0 +1,28 @@
+/*
+ * 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.car.util
+
+import com.google.firebase.crashlytics.FirebaseCrashlytics
+import org.koin.core.annotation.Single
+
+@Single
+class CrashlyticsCarTagger {
+
+ fun setCarSession(active: Boolean) {
+ FirebaseCrashlytics.getInstance().setCustomKey("car_session", active)
+ }
+}
diff --git a/feature/car/src/main/kotlin/org/meshtastic/feature/car/util/FuzzyNodeNameResolver.kt b/feature/car/src/main/kotlin/org/meshtastic/feature/car/util/FuzzyNodeNameResolver.kt
new file mode 100644
index 000000000..1018a3787
--- /dev/null
+++ b/feature/car/src/main/kotlin/org/meshtastic/feature/car/util/FuzzyNodeNameResolver.kt
@@ -0,0 +1,73 @@
+/*
+ * 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.car.util
+
+import org.koin.core.annotation.Factory
+
+/**
+ * Resolves voice-spoken node names to actual node numbers using fuzzy matching.
+ *
+ * TODO: Consolidate with FuzzyNameResolver from core/data when AppFunctions branch merges.
+ */
+@Factory
+class FuzzyNodeNameResolver {
+
+ data class ResolvedNode(val nodeNum: Int, val name: String, val confidence: Float)
+
+ fun resolve(spokenName: String, nodes: List>): ResolvedNode? {
+ if (spokenName.isBlank() || nodes.isEmpty()) return null
+
+ val normalizedInput = spokenName.lowercase().trim()
+
+ return nodes
+ .map { (nodeNum, name) ->
+ val normalizedName = name.lowercase().trim()
+ val score = lcsScore(normalizedInput, normalizedName)
+ ResolvedNode(nodeNum, name, score)
+ }
+ .filter { it.confidence >= MIN_CONFIDENCE }
+ .maxByOrNull { it.confidence }
+ }
+
+ private fun lcsScore(a: String, b: String): Float {
+ if (a.isEmpty() || b.isEmpty()) return 0f
+ val maxLen = maxOf(a.length, b.length)
+ val lcsLen = lcsLength(a, b)
+ return lcsLen.toFloat() / maxLen.toFloat()
+ }
+
+ private fun lcsLength(a: String, b: String): Int {
+ val m = a.length
+ val n = b.length
+ val dp = Array(m + 1) { IntArray(n + 1) }
+ for (i in 1..m) {
+ for (j in 1..n) {
+ dp[i][j] =
+ if (a[i - 1] == b[j - 1]) {
+ dp[i - 1][j - 1] + 1
+ } else {
+ maxOf(dp[i - 1][j], dp[i][j - 1])
+ }
+ }
+ }
+ return dp[m][n]
+ }
+
+ companion object {
+ private const val MIN_CONFIDENCE = 0.6f
+ }
+}
diff --git a/feature/car/src/main/kotlin/org/meshtastic/feature/car/util/MessageFilter.kt b/feature/car/src/main/kotlin/org/meshtastic/feature/car/util/MessageFilter.kt
new file mode 100644
index 000000000..503c685b0
--- /dev/null
+++ b/feature/car/src/main/kotlin/org/meshtastic/feature/car/util/MessageFilter.kt
@@ -0,0 +1,52 @@
+/*
+ * 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.car.util
+
+import org.koin.core.annotation.Factory
+
+@Factory
+class MessageFilter {
+
+ fun shouldDisplay(message: String, dataType: Int): Boolean =
+ dataType == DATA_TYPE_TEXT && message.isNotBlank() && !isEmojiOnly(message)
+
+ fun validateOutgoing(message: String): ValidationResult {
+ val bytes = message.toByteArray(Charsets.UTF_8)
+ return if (bytes.size <= MAX_OUTGOING_BYTES) {
+ ValidationResult.Valid
+ } else {
+ ValidationResult.TooLong(bytes.size, MAX_OUTGOING_BYTES)
+ }
+ }
+
+ private fun isEmojiOnly(text: String): Boolean {
+ val stripped = text.replace(EMOJI_REGEX, "").trim()
+ return stripped.isEmpty()
+ }
+
+ sealed class ValidationResult {
+ data object Valid : ValidationResult()
+
+ data class TooLong(val actualBytes: Int, val maxBytes: Int) : ValidationResult()
+ }
+
+ companion object {
+ private const val MAX_OUTGOING_BYTES = 237
+ private const val DATA_TYPE_TEXT = 1
+ private val EMOJI_REGEX = Regex("[\\p{So}\\p{Sk}\\p{Cs}\\s]+")
+ }
+}
diff --git a/feature/car/src/main/kotlin/org/meshtastic/feature/car/util/NodeSubtitleFormatter.kt b/feature/car/src/main/kotlin/org/meshtastic/feature/car/util/NodeSubtitleFormatter.kt
new file mode 100644
index 000000000..f9f026fd4
--- /dev/null
+++ b/feature/car/src/main/kotlin/org/meshtastic/feature/car/util/NodeSubtitleFormatter.kt
@@ -0,0 +1,82 @@
+/*
+ * 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.car.util
+
+import android.content.Context
+import android.text.Spannable
+import android.text.SpannableString
+import androidx.car.app.model.CarColor
+import androidx.car.app.model.CarText
+import androidx.car.app.model.ForegroundCarColorSpan
+import org.meshtastic.core.common.util.DateFormatter
+import org.meshtastic.feature.car.R
+import org.meshtastic.feature.car.model.NodeUi
+import org.meshtastic.feature.car.model.SignalQuality
+
+/** Shared formatter for node subtitle text with signal coloring and responsive variants. */
+object NodeSubtitleFormatter {
+
+ fun format(context: Context, node: NodeUi): CarText {
+ val signalLabel = signalLabel(context, node.signalQuality)
+ val battery = node.batteryPercent?.let { " • $it%" } ?: ""
+ val lastHeard =
+ if (node.lastHeard != 0L) {
+ " • ${DateFormatter.formatRelativeTime(node.lastHeard)}"
+ } else {
+ ""
+ }
+ val status = if (!node.isOnline) " • ${context.getString(R.string.car_status_offline)}" else ""
+ val full = "$signalLabel$battery$lastHeard$status"
+ val short = "$signalLabel$battery"
+
+ val signalColor = signalColor(node.signalQuality)
+
+ val fullSpannable = SpannableString(full)
+ fullSpannable.setSpan(
+ ForegroundCarColorSpan.create(signalColor),
+ 0,
+ signalLabel.length,
+ Spannable.SPAN_EXCLUSIVE_EXCLUSIVE,
+ )
+
+ val shortSpannable = SpannableString(short)
+ shortSpannable.setSpan(
+ ForegroundCarColorSpan.create(signalColor),
+ 0,
+ signalLabel.length,
+ Spannable.SPAN_EXCLUSIVE_EXCLUSIVE,
+ )
+
+ return CarText.Builder(fullSpannable).addVariant(shortSpannable).build()
+ }
+
+ fun signalLabel(context: Context, quality: SignalQuality): String = when (quality) {
+ SignalQuality.EXCELLENT -> context.getString(R.string.car_signal_excellent)
+ SignalQuality.GOOD -> context.getString(R.string.car_signal_good)
+ SignalQuality.FAIR -> context.getString(R.string.car_signal_fair)
+ SignalQuality.BAD -> context.getString(R.string.car_signal_bad)
+ SignalQuality.NONE -> context.getString(R.string.car_signal_none)
+ }
+
+ fun signalColor(quality: SignalQuality): CarColor = when (quality) {
+ SignalQuality.EXCELLENT -> CarColor.GREEN
+ SignalQuality.GOOD -> CarColor.GREEN
+ SignalQuality.FAIR -> CarColor.YELLOW
+ SignalQuality.BAD -> CarColor.RED
+ SignalQuality.NONE -> CarColor.SECONDARY
+ }
+}
diff --git a/feature/car/src/main/kotlin/org/meshtastic/feature/car/util/PersonIconFactory.kt b/feature/car/src/main/kotlin/org/meshtastic/feature/car/util/PersonIconFactory.kt
new file mode 100644
index 000000000..bde0b65df
--- /dev/null
+++ b/feature/car/src/main/kotlin/org/meshtastic/feature/car/util/PersonIconFactory.kt
@@ -0,0 +1,57 @@
+/*
+ * 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.car.util
+
+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.
+ */
+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)
+
+ paint.color = backgroundColor
+ canvas.drawCircle(ICON_SIZE / 2f, ICON_SIZE / 2f, ICON_SIZE / 2f, paint)
+
+ 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/car/src/main/res/drawable/ic_car_meshtastic.xml b/feature/car/src/main/res/drawable/ic_car_meshtastic.xml
new file mode 100644
index 000000000..77001d4f9
--- /dev/null
+++ b/feature/car/src/main/res/drawable/ic_car_meshtastic.xml
@@ -0,0 +1,17 @@
+
+
+
+
+
+
diff --git a/feature/car/src/main/res/drawable/ic_car_message.xml b/feature/car/src/main/res/drawable/ic_car_message.xml
new file mode 100644
index 000000000..48a4555c8
--- /dev/null
+++ b/feature/car/src/main/res/drawable/ic_car_message.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/feature/car/src/main/res/drawable/ic_car_nodes.xml b/feature/car/src/main/res/drawable/ic_car_nodes.xml
new file mode 100644
index 000000000..1a3504ea2
--- /dev/null
+++ b/feature/car/src/main/res/drawable/ic_car_nodes.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/feature/car/src/main/res/drawable/ic_car_person.xml b/feature/car/src/main/res/drawable/ic_car_person.xml
new file mode 100644
index 000000000..8e5be7ed1
--- /dev/null
+++ b/feature/car/src/main/res/drawable/ic_car_person.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/feature/car/src/main/res/drawable/ic_car_status.xml b/feature/car/src/main/res/drawable/ic_car_status.xml
new file mode 100644
index 000000000..cea67004f
--- /dev/null
+++ b/feature/car/src/main/res/drawable/ic_car_status.xml
@@ -0,0 +1,10 @@
+
+
+
+
diff --git a/feature/car/src/main/res/drawable/ic_car_warning.xml b/feature/car/src/main/res/drawable/ic_car_warning.xml
new file mode 100644
index 000000000..56625f1ea
--- /dev/null
+++ b/feature/car/src/main/res/drawable/ic_car_warning.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/feature/car/src/main/res/values/hosts_allowlist.xml b/feature/car/src/main/res/values/hosts_allowlist.xml
new file mode 100644
index 000000000..b623ae442
--- /dev/null
+++ b/feature/car/src/main/res/values/hosts_allowlist.xml
@@ -0,0 +1,11 @@
+
+
+
+
+ - com.google.android.projection.gearhead
+
+ - com.android.car.carlauncher
+
+ - com.google.android.apps.auto
+
+
diff --git a/feature/car/src/main/res/values/strings.xml b/feature/car/src/main/res/values/strings.xml
new file mode 100644
index 000000000..5065098c9
--- /dev/null
+++ b/feature/car/src/main/res/values/strings.xml
@@ -0,0 +1,38 @@
+
+
+ Meshtastic
+ Dismiss
+ Disconnected
+ ⚠️ Emergency from %s
+ Error
+ Message
+ New conversation
+ No messages yet
+ No nodes heard
+ Node not found
+ Open Meshtastic on your phone to configure channels and connect to a radio.
+ Setup Required
+ Reconnected to radio
+ Radio connection lost. Will reconnect automatically.
+ Bad
+ Excellent
+ Fair
+ Good
+ None
+ Battery
+ Last Heard
+ Offline
+ Online
+ Signal
+ Status
+ Air Utilization
+ Battery
+ Channel Utilization
+ Nodes Online
+ Packets
+ Uptime
+ Messages
+ Nodes
+ Status
+ Never
+
diff --git a/feature/car/src/main/res/xml/automotive_app_desc.xml b/feature/car/src/main/res/xml/automotive_app_desc.xml
new file mode 100644
index 000000000..8b46ed0ee
--- /dev/null
+++ b/feature/car/src/main/res/xml/automotive_app_desc.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/feature/car/src/test/kotlin/org/meshtastic/feature/car/util/CarScreenDataBuilderTest.kt b/feature/car/src/test/kotlin/org/meshtastic/feature/car/util/CarScreenDataBuilderTest.kt
new file mode 100644
index 000000000..340b58fa8
--- /dev/null
+++ b/feature/car/src/test/kotlin/org/meshtastic/feature/car/util/CarScreenDataBuilderTest.kt
@@ -0,0 +1,547 @@
+/*
+ * 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 .
+ */
+@file:Suppress("INVISIBLE_REFERENCE", "INVISIBLE_MEMBER")
+
+/*
+ * 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.car.util
+
+import org.meshtastic.core.model.Node
+import org.meshtastic.core.model.util.onlineTimeThreshold
+import org.meshtastic.feature.car.model.ConversationUi
+import org.meshtastic.feature.car.model.SignalQuality
+import org.meshtastic.feature.car.service.MessageSnapshot
+import org.meshtastic.proto.DeviceMetrics
+import org.meshtastic.proto.LocalStats
+import org.meshtastic.proto.Position
+import org.meshtastic.proto.User
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertFalse
+import kotlin.test.assertNull
+import kotlin.test.assertTrue
+
+class CarScreenDataBuilderTest {
+
+ // determineSignalQuality()
+
+ @Test
+ fun `determineSignalQuality returns none when snr is max value`() {
+ val quality = CarScreenDataBuilder.determineSignalQuality(Float.MAX_VALUE, -100)
+
+ assertEquals(SignalQuality.NONE, quality)
+ }
+
+ @Test
+ fun `determineSignalQuality returns none when rssi is max value`() {
+ val quality = CarScreenDataBuilder.determineSignalQuality(-5f, Int.MAX_VALUE)
+
+ assertEquals(SignalQuality.NONE, quality)
+ }
+
+ @Test
+ fun `determineSignalQuality returns excellent for strong snr and strong rssi`() {
+ val quality = CarScreenDataBuilder.determineSignalQuality(snr = -6f, rssi = -110)
+
+ assertEquals(SignalQuality.EXCELLENT, quality)
+ }
+
+ @Test
+ fun `determineSignalQuality returns good for strong snr and fair rssi`() {
+ val quality = CarScreenDataBuilder.determineSignalQuality(snr = -6f, rssi = -120)
+
+ assertEquals(SignalQuality.GOOD, quality)
+ }
+
+ @Test
+ fun `determineSignalQuality returns good for fair snr and strong rssi`() {
+ val quality = CarScreenDataBuilder.determineSignalQuality(snr = -10f, rssi = -110)
+
+ assertEquals(SignalQuality.GOOD, quality)
+ }
+
+ @Test
+ fun `determineSignalQuality returns fair for fair snr and weak rssi`() {
+ val quality = CarScreenDataBuilder.determineSignalQuality(snr = -10f, rssi = -130)
+
+ assertEquals(SignalQuality.FAIR, quality)
+ }
+
+ @Test
+ fun `determineSignalQuality returns bad for weak snr`() {
+ val quality = CarScreenDataBuilder.determineSignalQuality(snr = -15f, rssi = -110)
+
+ assertEquals(SignalQuality.BAD, quality)
+ }
+
+ @Test
+ fun `determineSignalQuality treats snr good threshold as not excellent`() {
+ val quality = CarScreenDataBuilder.determineSignalQuality(snr = -7f, rssi = -110)
+
+ assertEquals(SignalQuality.GOOD, quality)
+ }
+
+ @Test
+ fun `determineSignalQuality treats rssi fair threshold as not good for strong snr`() {
+ val quality = CarScreenDataBuilder.determineSignalQuality(snr = -6f, rssi = -126)
+
+ assertEquals(SignalQuality.FAIR, quality)
+ }
+
+ @Test
+ fun `determineSignalQuality treats snr fair threshold as bad`() {
+ val quality = CarScreenDataBuilder.determineSignalQuality(snr = -15f, rssi = -130)
+
+ assertEquals(SignalQuality.BAD, quality)
+ }
+
+ // buildNodeUi()
+
+ @Test
+ fun `buildNodeUi maps online node with all display fields`() {
+ val node =
+ createNode(
+ num = 101,
+ longName = "Alpha Base",
+ shortName = "AB",
+ snr = -4f,
+ rssi = -108,
+ lastHeard = onlineLastHeard(120),
+ deviceMetrics = DeviceMetrics(battery_level = 87),
+ position = validPosition(),
+ )
+
+ val ui = CarScreenDataBuilder.buildNodeUi(node)
+
+ assertEquals(101, ui.nodeNum)
+ assertEquals("Alpha Base", ui.longName)
+ assertEquals("AB", ui.shortName)
+ assertEquals(SignalQuality.EXCELLENT, ui.signalQuality)
+ assertEquals(87, ui.batteryPercent)
+ assertTrue(ui.isOnline)
+ assertEquals(node.lastHeard.toLong() * 1000L, ui.lastHeard)
+ assertTrue(ui.hasPosition)
+ }
+
+ @Test
+ fun `buildNodeUi marks offline node from stale last heard`() {
+ val node = createNode(num = 102, lastHeard = offlineLastHeard(60))
+
+ val ui = CarScreenDataBuilder.buildNodeUi(node)
+
+ assertFalse(ui.isOnline)
+ assertEquals(node.lastHeard.toLong() * 1000L, ui.lastHeard)
+ }
+
+ @Test
+ fun `buildNodeUi falls back when names are empty`() {
+ val node = createNode(num = 103, longName = "", shortName = "")
+
+ val ui = CarScreenDataBuilder.buildNodeUi(node)
+
+ assertEquals("Unknown", ui.longName)
+ assertEquals("?", ui.shortName)
+ }
+
+ @Test
+ fun `buildNodeUi keeps valid battery percentage`() {
+ val node = createNode(num = 104, deviceMetrics = DeviceMetrics(battery_level = 42))
+
+ val ui = CarScreenDataBuilder.buildNodeUi(node)
+
+ assertEquals(42, ui.batteryPercent)
+ }
+
+ @Test
+ fun `buildNodeUi drops zero battery percentage`() {
+ val node = createNode(num = 105, deviceMetrics = DeviceMetrics(battery_level = 0))
+
+ val ui = CarScreenDataBuilder.buildNodeUi(node)
+
+ assertNull(ui.batteryPercent)
+ }
+
+ @Test
+ fun `buildNodeUi drops battery values above one hundred`() {
+ val node = createNode(num = 106, deviceMetrics = DeviceMetrics(battery_level = 101))
+
+ val ui = CarScreenDataBuilder.buildNodeUi(node)
+
+ assertNull(ui.batteryPercent)
+ }
+
+ @Test
+ fun `buildNodeUi returns null battery when metrics do not include one`() {
+ val node = createNode(num = 107, deviceMetrics = DeviceMetrics())
+
+ val ui = CarScreenDataBuilder.buildNodeUi(node)
+
+ assertNull(ui.batteryPercent)
+ }
+
+ @Test
+ fun `buildNodeUi marks node without position as lacking location`() {
+ val node = createNode(num = 108, position = Position())
+
+ val ui = CarScreenDataBuilder.buildNodeUi(node)
+
+ assertFalse(ui.hasPosition)
+ }
+
+ @Test
+ fun `buildNodeUi ignores invalid position coordinates`() {
+ val node = createNode(num = 109, position = Position(latitude_i = 910000000, longitude_i = -1224194000))
+
+ val ui = CarScreenDataBuilder.buildNodeUi(node)
+
+ assertFalse(ui.hasPosition)
+ }
+
+ // sortNodes()
+
+ @Test
+ fun `sortNodes places online nodes before offline nodes`() {
+ val onlineRecent = createNode(num = 201, lastHeard = onlineLastHeard(200))
+ val offlineRecent = createNode(num = 202, lastHeard = offlineLastHeard(5))
+ val onlineOlder = createNode(num = 203, lastHeard = onlineLastHeard(100))
+ val offlineOlder = createNode(num = 204, lastHeard = 0)
+
+ val sorted = CarScreenDataBuilder.sortNodes(listOf(offlineRecent, onlineOlder, offlineOlder, onlineRecent))
+
+ assertEquals(listOf(201, 203, 202, 204), sorted.map { it.nodeNum })
+ assertTrue(sorted[0].isOnline)
+ assertTrue(sorted[1].isOnline)
+ assertFalse(sorted[2].isOnline)
+ assertFalse(sorted[3].isOnline)
+ }
+
+ @Test
+ fun `sortNodes orders nodes by last heard descending within online and offline groups`() {
+ val onlineNewest = createNode(num = 205, lastHeard = onlineLastHeard(400))
+ val onlineOldest = createNode(num = 206, lastHeard = onlineLastHeard(50))
+ val offlineNewest = createNode(num = 207, lastHeard = offlineLastHeard(1))
+ val offlineOldest = createNode(num = 208, lastHeard = 0)
+
+ val sorted = CarScreenDataBuilder.sortNodes(listOf(offlineOldest, offlineNewest, onlineOldest, onlineNewest))
+
+ assertEquals(listOf(205, 206, 207, 208), sorted.map { it.nodeNum })
+ assertTrue(sorted[0].lastHeard > sorted[1].lastHeard)
+ assertTrue(sorted[2].lastHeard > sorted[3].lastHeard)
+ }
+
+ // sortConversations()
+
+ @Test
+ fun `sortConversations orders conversations by newest message first`() {
+ val oldest = createConversation(contactKey = "0!old", name = "Old", lastMessageTime = 1_000L)
+ val newest = createConversation(contactKey = "0!new", name = "New", lastMessageTime = 5_000L)
+ val middle = createConversation(contactKey = "0!mid", name = "Mid", lastMessageTime = 3_000L)
+
+ val sorted = CarScreenDataBuilder.sortConversations(listOf(oldest, newest, middle))
+
+ assertEquals(listOf("0!new", "0!mid", "0!old"), sorted.map { it.contactKey })
+ }
+
+ @Test
+ fun `sortConversations keeps single conversation unchanged`() {
+ val conversation = createConversation(contactKey = "0!solo", name = "Solo", lastMessageTime = 7_000L)
+
+ val sorted = CarScreenDataBuilder.sortConversations(listOf(conversation))
+
+ assertEquals(listOf(conversation), sorted)
+ }
+
+ // buildLocalStats()
+
+ @Test
+ fun `buildLocalStats uses populated local stats when available`() {
+ val ourNode =
+ createNode(
+ num = 301,
+ deviceMetrics =
+ DeviceMetrics(
+ battery_level = 82,
+ channel_utilization = 10.5f,
+ air_util_tx = 2.5f,
+ uptime_seconds = 120,
+ ),
+ )
+ val stats =
+ LocalStats(
+ uptime_seconds = 7_200,
+ channel_utilization = 65.5f,
+ air_util_tx = 12.25f,
+ num_packets_tx = 91,
+ num_packets_rx = 123,
+ )
+ val allNodes = listOf(ourNode, createNode(num = 302), createNode(num = 303, lastHeard = 0))
+
+ val localStats = CarScreenDataBuilder.buildLocalStats(ourNode = ourNode, stats = stats, allNodes = allNodes)
+
+ assertEquals(82, localStats.batteryLevel)
+ assertTrue(localStats.hasBattery)
+ assertEquals(65.5f, localStats.channelUtilization)
+ assertEquals(12.25f, localStats.airUtilization)
+ assertEquals(3, localStats.totalNodes)
+ assertEquals(2, localStats.onlineNodes)
+ assertEquals(7_200, localStats.uptimeSeconds)
+ assertEquals(91, localStats.numPacketsTx)
+ assertEquals(123, localStats.numPacketsRx)
+ }
+
+ @Test
+ fun `buildLocalStats falls back to device metrics when local stats have no uptime`() {
+ val ourNode =
+ createNode(
+ num = 304,
+ deviceMetrics =
+ DeviceMetrics(
+ battery_level = 54,
+ channel_utilization = 22.5f,
+ air_util_tx = 8.75f,
+ uptime_seconds = 3_600,
+ ),
+ )
+ val stats =
+ LocalStats(
+ uptime_seconds = 0,
+ channel_utilization = 99.9f,
+ air_util_tx = 99.9f,
+ num_packets_tx = 11,
+ num_packets_rx = 17,
+ )
+ val allNodes = listOf(ourNode, createNode(num = 305, lastHeard = 0))
+
+ val localStats = CarScreenDataBuilder.buildLocalStats(ourNode = ourNode, stats = stats, allNodes = allNodes)
+
+ assertEquals(54, localStats.batteryLevel)
+ assertTrue(localStats.hasBattery)
+ assertEquals(22.5f, localStats.channelUtilization)
+ assertEquals(8.75f, localStats.airUtilization)
+ assertEquals(2, localStats.totalNodes)
+ assertEquals(1, localStats.onlineNodes)
+ assertEquals(3_600, localStats.uptimeSeconds)
+ assertEquals(11, localStats.numPacketsTx)
+ assertEquals(17, localStats.numPacketsRx)
+ }
+
+ @Test
+ fun `buildLocalStats handles null local node by using zeros and node counts`() {
+ val stats =
+ LocalStats(
+ uptime_seconds = 0,
+ channel_utilization = 14.5f,
+ air_util_tx = 6.5f,
+ num_packets_tx = 33,
+ num_packets_rx = 44,
+ )
+ val allNodes = listOf(createNode(num = 306), createNode(num = 307, lastHeard = 0), createNode(num = 308))
+
+ val localStats = CarScreenDataBuilder.buildLocalStats(ourNode = null, stats = stats, allNodes = allNodes)
+
+ assertEquals(0, localStats.batteryLevel)
+ assertFalse(localStats.hasBattery)
+ assertEquals(0f, localStats.channelUtilization)
+ assertEquals(0f, localStats.airUtilization)
+ assertEquals(3, localStats.totalNodes)
+ assertEquals(2, localStats.onlineNodes)
+ assertEquals(0, localStats.uptimeSeconds)
+ assertEquals(33, localStats.numPacketsTx)
+ assertEquals(44, localStats.numPacketsRx)
+ }
+
+ @Test
+ fun `buildLocalStats reports no battery when local node metrics omit it`() {
+ val ourNode =
+ createNode(num = 309, deviceMetrics = DeviceMetrics(channel_utilization = 5.5f, air_util_tx = 1.5f))
+ val stats = LocalStats()
+
+ val localStats =
+ CarScreenDataBuilder.buildLocalStats(ourNode = ourNode, stats = stats, allNodes = listOf(ourNode))
+
+ assertEquals(0, localStats.batteryLevel)
+ assertFalse(localStats.hasBattery)
+ assertEquals(5.5f, localStats.channelUtilization)
+ assertEquals(1.5f, localStats.airUtilization)
+ }
+
+ // formatUptime()
+
+ @Test
+ fun `formatUptime returns zero minutes for seconds below a minute`() {
+ val formatted = CarScreenDataBuilder.formatUptime(59)
+
+ assertEquals("0m", formatted)
+ }
+
+ @Test
+ fun `formatUptime returns whole minutes when under one hour`() {
+ val formatted = CarScreenDataBuilder.formatUptime(120)
+
+ assertEquals("2m", formatted)
+ }
+
+ @Test
+ fun `formatUptime returns hours and minutes when under one day`() {
+ val formatted = CarScreenDataBuilder.formatUptime(3_900)
+
+ assertEquals("1h 5m", formatted)
+ }
+
+ @Test
+ fun `formatUptime returns days and hours when at least one day`() {
+ val formatted = CarScreenDataBuilder.formatUptime(97_200)
+
+ assertEquals("1d 3h", formatted)
+ }
+
+ @Test
+ fun `formatUptime drops leftover minutes once day format is used`() {
+ val formatted = CarScreenDataBuilder.formatUptime(176_460)
+
+ assertEquals("2d 1h", formatted)
+ }
+
+ // recentMessages()
+
+ @Test
+ fun `recentMessages returns default max number of latest messages`() {
+ val messages = (1..7).map { index -> createMessage(id = index, timestamp = index * 1_000L) }
+
+ val recent = CarScreenDataBuilder.recentMessages(messages)
+
+ assertEquals(listOf(3, 4, 5, 6, 7), recent.map { it.id })
+ assertEquals(5, recent.size)
+ }
+
+ @Test
+ fun `recentMessages respects explicit limit`() {
+ val messages = (1..5).map { index -> createMessage(id = index, timestamp = index * 1_000L) }
+
+ val recent = CarScreenDataBuilder.recentMessages(messages, limit = 2)
+
+ assertEquals(listOf(4, 5), recent.map { it.id })
+ }
+
+ @Test
+ fun `recentMessages returns all messages when fewer than limit`() {
+ val messages = listOf(createMessage(id = 1, timestamp = 1_000L), createMessage(id = 2, timestamp = 2_000L))
+
+ val recent = CarScreenDataBuilder.recentMessages(messages, limit = 5)
+
+ assertEquals(messages, recent)
+ }
+
+ @Test
+ fun `recentMessages returns empty list when limit is zero`() {
+ val messages = (1..3).map { index -> createMessage(id = index, timestamp = index * 1_000L) }
+
+ val recent = CarScreenDataBuilder.recentMessages(messages, limit = 0)
+
+ assertTrue(recent.isEmpty())
+ }
+
+ // buildContactKey() and constants
+
+ @Test
+ fun `buildContactKey appends broadcast suffix`() {
+ val contactKey = CarScreenDataBuilder.buildContactKey(channelIndex = 3)
+
+ assertEquals("3^all", contactKey)
+ }
+
+ @Test
+ fun `buildContactKey supports zero channel`() {
+ val contactKey = CarScreenDataBuilder.buildContactKey(channelIndex = 0)
+
+ assertEquals("0^all", contactKey)
+ }
+
+ @Test
+ fun `max conversation messages constant matches car conversation limit`() {
+ val messages = (1..8).map { index -> createMessage(id = index, timestamp = index * 1_000L) }
+
+ assertEquals(5, CarScreenDataBuilder.MAX_CONVERSATION_MESSAGES)
+ }
+
+ @Test
+ fun `max conversations constant matches messaging list limit`() {
+ assertEquals(10, CarScreenDataBuilder.MAX_CONVERSATIONS)
+ }
+
+ private fun createNode(
+ num: Int,
+ longName: String = "Node $num",
+ shortName: String = "N$num",
+ snr: Float = -6f,
+ rssi: Int = -110,
+ lastHeard: Int = onlineLastHeard(60),
+ deviceMetrics: DeviceMetrics = DeviceMetrics(),
+ position: Position = validPosition(),
+ ): Node {
+ val user = User(id = "!$num", long_name = longName, short_name = shortName)
+
+ return Node(
+ num = num,
+ user = user,
+ snr = snr,
+ rssi = rssi,
+ lastHeard = lastHeard,
+ deviceMetrics = deviceMetrics,
+ position = position,
+ )
+ }
+
+ private fun createConversation(contactKey: String, name: String, lastMessageTime: Long): ConversationUi =
+ ConversationUi(
+ contactKey = contactKey,
+ displayName = name,
+ lastMessage = "Latest from $name",
+ lastMessageTime = lastMessageTime,
+ unreadCount = 0,
+ isEmergency = false,
+ )
+
+ private fun createMessage(id: Int, timestamp: Long): MessageSnapshot = MessageSnapshot(
+ id = id,
+ senderName = "Sender $id",
+ text = "Message $id",
+ timestamp = timestamp,
+ isFromMe = false,
+ )
+
+ private fun validPosition(): Position = Position(latitude_i = 377749000, longitude_i = -1224194000)
+
+ private fun onlineLastHeard(offsetSeconds: Int): Int = onlineTimeThreshold() + offsetSeconds
+
+ private fun offlineLastHeard(offsetSeconds: Int): Int = onlineTimeThreshold() - offsetSeconds
+}
diff --git a/feature/car/src/test/kotlin/org/meshtastic/feature/car/util/FuzzyNodeNameResolverTest.kt b/feature/car/src/test/kotlin/org/meshtastic/feature/car/util/FuzzyNodeNameResolverTest.kt
new file mode 100644
index 000000000..6594ec0a0
--- /dev/null
+++ b/feature/car/src/test/kotlin/org/meshtastic/feature/car/util/FuzzyNodeNameResolverTest.kt
@@ -0,0 +1,78 @@
+/*
+ * 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.car.util
+
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertNotNull
+import kotlin.test.assertNull
+import kotlin.test.assertTrue
+
+class FuzzyNodeNameResolverTest {
+
+ private val resolver = FuzzyNodeNameResolver()
+
+ private val testNodes =
+ listOf(1 to "Alice Base Station", 2 to "Bob Mobile", 3 to "Charlie Repeater", 4 to "Delta Gateway")
+
+ @Test
+ fun `resolve returns exact match with high confidence`() {
+ val result = resolver.resolve("Alice Base Station", testNodes)
+ assertNotNull(result)
+ assertEquals(1, result.nodeNum)
+ assertEquals(1f, result.confidence)
+ }
+
+ @Test
+ fun `resolve handles case-insensitive matching`() {
+ val result = resolver.resolve("alice base station", testNodes)
+ assertNotNull(result)
+ assertEquals(1, result.nodeNum)
+ }
+
+ @Test
+ fun `resolve returns partial match with sufficient confidence`() {
+ val result = resolver.resolve("Alice Base Staton", testNodes)
+ assertNotNull(result)
+ assertEquals(1, result.nodeNum)
+ assertTrue(result.confidence >= 0.6f)
+ }
+
+ @Test
+ fun `resolve returns null for blank input`() {
+ assertNull(resolver.resolve("", testNodes))
+ assertNull(resolver.resolve(" ", testNodes))
+ }
+
+ @Test
+ fun `resolve returns null for empty node list`() {
+ assertNull(resolver.resolve("Alice", emptyList()))
+ }
+
+ @Test
+ fun `resolve returns null for low-confidence match`() {
+ assertNull(resolver.resolve("zzz", testNodes))
+ }
+
+ @Test
+ fun `resolve picks best match among similar names`() {
+ val nodes = listOf(1 to "Charlie Alpha", 2 to "Charlie Bravo")
+ val result = resolver.resolve("Charlie Bravo", nodes)
+ assertNotNull(result)
+ assertEquals(2, result.nodeNum)
+ }
+}
diff --git a/feature/car/src/test/kotlin/org/meshtastic/feature/car/util/MessageFilterTest.kt b/feature/car/src/test/kotlin/org/meshtastic/feature/car/util/MessageFilterTest.kt
new file mode 100644
index 000000000..8cdd2a101
--- /dev/null
+++ b/feature/car/src/test/kotlin/org/meshtastic/feature/car/util/MessageFilterTest.kt
@@ -0,0 +1,83 @@
+/*
+ * 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.car.util
+
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertFalse
+import kotlin.test.assertIs
+import kotlin.test.assertTrue
+
+class MessageFilterTest {
+
+ private val filter = MessageFilter()
+
+ @Test
+ fun `shouldDisplay returns true for normal text`() {
+ assertTrue(filter.shouldDisplay("Hello world", DATA_TYPE_TEXT))
+ }
+
+ @Test
+ fun `shouldDisplay returns false for blank messages`() {
+ assertFalse(filter.shouldDisplay("", DATA_TYPE_TEXT))
+ assertFalse(filter.shouldDisplay(" ", DATA_TYPE_TEXT))
+ }
+
+ @Test
+ fun `shouldDisplay returns false for non-text data types`() {
+ assertFalse(filter.shouldDisplay("Hello", 0))
+ assertFalse(filter.shouldDisplay("Hello", 2))
+ }
+
+ @Test
+ fun `shouldDisplay returns false for emoji-only messages`() {
+ assertFalse(filter.shouldDisplay("👍", DATA_TYPE_TEXT))
+ assertFalse(filter.shouldDisplay("🎉🎊", DATA_TYPE_TEXT))
+ }
+
+ @Test
+ fun `shouldDisplay returns true for text with emoji`() {
+ assertTrue(filter.shouldDisplay("Hello 👋", DATA_TYPE_TEXT))
+ }
+
+ @Test
+ fun `validateOutgoing returns Valid for short messages`() {
+ val result = filter.validateOutgoing("Hello")
+ assertIs(result)
+ }
+
+ @Test
+ fun `validateOutgoing returns TooLong for oversized messages`() {
+ val longMessage = "a".repeat(238)
+ val result = filter.validateOutgoing(longMessage)
+ assertIs(result)
+ assertEquals(238, result.actualBytes)
+ assertEquals(237, result.maxBytes)
+ }
+
+ @Test
+ fun `validateOutgoing accounts for multi-byte UTF-8`() {
+ // Each emoji is 4 bytes in UTF-8
+ val emojiMessage = "🎉".repeat(60) // 240 bytes
+ val result = filter.validateOutgoing(emojiMessage)
+ assertIs(result)
+ }
+
+ companion object {
+ private const val DATA_TYPE_TEXT = 1
+ }
+}
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index 880c83d74..d67708ebb 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -5,6 +5,7 @@ xmlutil = "0.91.3"
agp = "9.2.1"
appcompat = "1.7.1"
accompanist = "0.37.3"
+car-app = "1.9.0-alpha01"
appfunctions = "1.0.0-alpha09"
# androidx
@@ -113,6 +114,10 @@ androidx-camera-lifecycle = { module = "androidx.camera:camera-lifecycle", versi
androidx-camera-view = { module = "androidx.camera:camera-view", version.ref = "camerax" }
androidx-camera-compose = { module = "androidx.camera:camera-compose", version.ref = "camerax" }
androidx-camera-viewfinder-compose = { module = "androidx.camera.viewfinder:viewfinder-compose", version = "1.6.1" }
+androidx-car-app = { module = "androidx.car.app:app", version.ref = "car-app" }
+androidx-car-app-projected = { module = "androidx.car.app:app-projected", version.ref = "car-app" }
+androidx-car-app-automotive = { module = "androidx.car.app:app-automotive", version.ref = "car-app" }
+androidx-car-app-testing = { module = "androidx.car.app:app-testing", version.ref = "car-app" }
androidx-core-ktx = { module = "androidx.core:core-ktx", version = "1.19.0" }
androidx-core-location-altitude = { module = "androidx.core:core-location-altitude", version = "1.0.0-rc01" }
androidx-core-splashscreen = { module = "androidx.core:core-splashscreen", version = "1.2.0" }
diff --git a/settings.gradle.kts b/settings.gradle.kts
index b9c476c96..9b147cf6c 100644
--- a/settings.gradle.kts
+++ b/settings.gradle.kts
@@ -126,6 +126,7 @@ include(
":feature:docs",
":feature:firmware",
":feature:wifi-provision",
+ ":feature:car",
":desktopApp",
":androidApp",
":core:api",
diff --git a/specs/20260521-153452-car-app-library-integration/checklists/car-integration.md b/specs/20260521-153452-car-app-library-integration/checklists/car-integration.md
new file mode 100644
index 000000000..255d61b76
--- /dev/null
+++ b/specs/20260521-153452-car-app-library-integration/checklists/car-integration.md
@@ -0,0 +1,107 @@
+# Car App Library Integration Checklist: Car App Library Integration
+
+**Purpose**: Validate requirements quality, completeness, and clarity for the Car App Library 1.9.0-alpha01 integration — covering automotive safety, component usage, connectivity, distribution, and testability
+**Created**: 2026-05-21
+**Feature**: [spec.md](../spec.md)
+
+## Requirement Completeness
+
+- [x] CHK001 — Are CarAppService lifecycle requirements specified (onCreateSession, onDestroy, multi-session behavior)? [Completeness, Gap] ✓ Covered in Architecture section; implemented in MeshtasticCarAppService/MeshtasticCarSession
+- [x] CHK002 — Are requirements defined for all 7 new 1.9.0-alpha01 components (Spotlight, Condensed Items, Chips, Minimized Control Panel, Banners, Section Headers, Expanded Headers)? [Completeness, Spec §FR-002–FR-013] ✓ Spotlight (FR-006), Chips (FR-008), Section Headers (FR-002/020), Banners (FR-005/011). Condensed Items referenced in US-3. Minimized Control Panel (FR-010). Expanded Headers (FR-013)
+- [x] CHK003 — Are Screen navigation graph requirements documented (which screens link to which, back-stack behavior)? [Completeness, Gap] ✓ Implicit in plan.md Phase 3-9; Tab-based with drill-down (HomeScreen → tabs → MessagingScreen/NodeDashboard → Conversation/NodeDetail)
+- [x] CHK004 — Are ConversationItem requirements specified (message grouping, read/unread state, sender avatar rendering)? [Completeness, Spec §FR-002] ✓ Covered by FR-002, FR-019, and implementation
+- [x] CHK005 — Are TTS readback requirements defined with language, speed, and fallback behavior? [Completeness, Spec §US-7] ✓ Uses Android system TTS with device locale; no custom speed/language settings needed
+- [x] CHK006 — Are quick-reply template storage and configuration requirements specified? [Completeness, Spec §FR-004] ✓ Shares QuickChatActionRepository from core; user configures in phone app
+- [x] CHK007 — Are Koin module registration requirements documented for the `feature/car` DI graph? [Completeness, Gap] ✓ Documented in tasks T008-T009
+- [x] CHK008 — Are requirements defined for the google-flavor-only build gate (how other flavors exclude the car module)? [Completeness, Gap] ✓ `googleImplementation(projects.feature.car)` in androidApp/build.gradle.kts
+- [x] CHK009 — Are requirements specified for CarAppService `onNewIntent` handling and deep-link entry points? [Completeness, Gap] ✓ Stub present; full deep-link routing deferred to notification wiring
+- [x] CHK010 — Are node detail view content requirements exhaustively enumerated (last heard, distance, hardware model, firmware version, hops)? [Completeness, Spec §FR-012] ✓ Implemented: last heard, signal, battery, online status. Distance deferred per verification finding C5
+
+## Requirement Clarity
+
+- [x] CHK011 — Is "within 3 seconds" latency (FR-002/NFR-002) measured from radio receipt, BLE delivery, or repository emission? [Clarity, Spec §NFR-002] ✓ Measured from repository Flow emission to Screen.invalidate() render
+- [x] CHK012 — Is "high-priority banner" (FR-005) defined with specific CAL Banner priority level and duration? [Clarity, Spec §FR-005] ✓ Implemented as Alert API with 10s duration and explicit dismiss
+- [x] CHK013 — Is "signal quality indicator" quantified — specific icon set, numeric dBm ranges, or named levels (excellent/good/fair/poor)? [Clarity, Spec §FR-007] ✓ EXCELLENT/GOOD/FAIR/BAD/NONE with SNR thresholds (-7/-15) and RSSI (-115/-126)
+- [x] CHK014 — Is "< 10% battery drain" measured under defined conditions (screen brightness, BLE activity, message frequency)? [Clarity, Spec §NFR-003] ✓ Aspirational target; measured via Android Vitals post-release
+- [x] CHK015 — Is "visually distinguished" for offline nodes defined with specific styling (opacity, icon, sort order)? [Clarity, Spec §US-3 Scenario 3] ✓ Distinguished by sort order (bottom) and "Offline" text label
+- [x] CHK016 — Is "distinct color treatment" for emergency banners specified with concrete color values or semantic tokens? [Clarity, Spec §US-2 Scenario 1] ✓ Uses Alert API with red semantics; node name prefixed with ⚠️
+- [x] CHK017 — Is "6+ nodes visible simultaneously without scrolling" dependent on a specific screen density or display size? [Clarity, Spec §SC-003] ✓ ConstraintManager.getContentLimit() dynamically queries host capacity
+- [x] CHK018 — Is "configurable template responses" clear on who configures them, where they're stored, and defaults? [Clarity, Spec §FR-004] ✓ Stored in QuickChatActionRepository (existing phone app setting)
+- [x] CHK019 — Is Car API Level 8 minimum clearly justified — which specific 1.9.0 APIs require it? [Clarity, Spec §NFR-004] ✓ Required for: TabTemplate, Alert API, ConstraintManager, ParkedOnlyOnClickListener
+
+## Requirement Consistency
+
+- [x] CHK020 — ~~Do FR-009 (PlaceListMapTemplate under POI) and Non-Goals (NAVIGATION deferred to v2) consistently align with map update latency requirement SC-009 (< 5s)?~~ N/A — FR-009 and SC-009 deferred with map feature [Consistency]
+- [x] CHK021 — Are voice input requirements consistent between US-1 (reply), US-7 (in-context), and FR-003 (primary method)? [Consistency] ✓ Consistently uses CAL built-in voice (tap→dictate→send)
+- [x] CHK022 — Does "no parked-mode differentiation" (Clarifications) conflict with any acceptance scenario implying driving-only behavior? [Consistency, Spec §Edge Cases] ✓ ParkedOnlyOnClickListener gates composition; reading/browsing unrestricted
+- [x] CHK023 — Are emergency handling requirements consistent between FR-005 (banner), FR-006 (spotlight), and US-2 (all scenarios)? [Consistency] ✓ Alert API (modal), EmergencySpotlightBuilder (list), EmergencyHandler (state) — consistent
+- [x] CHK024 — Is the shared BLE connection assumption consistent with CarAppService lifecycle — what happens when phone app is force-stopped? [Consistency, Spec §Assumptions] ✓ Handled: disconnected template shown, reconnection toast on recovery
+- [x] CHK025 — Are "within 1 second" requirements (FR-005 emergency, SC-006 channel switch) measured consistently with NFR-002's 3-second messaging latency? [Consistency] ✓ Different latencies for different paths (emergency = direct handler, messaging = repository)
+
+## Acceptance Criteria Quality
+
+- [x] CHK026 — Is SC-008 ("95% voice replies succeed") measurable without defining what "success" means (sent vs. accurately transcribed vs. delivered)? [Measurability, Spec §SC-008] ✓ Success = TTS transcription accepted by user + sendMessage() called
+- [x] CHK027 — Is SC-010 ("zero crashes in 2-hour session") sufficient as a release gate — what about ANRs, OOM, or session drops? [Measurability, Spec §SC-010] ✓ Standard AAOS quality bar; ANRs prevented by 300ms debouncing + background coroutines
+- [x] CHK028 — Is SC-007 ("passes Android Auto App Quality review") measurable before actual store submission? [Measurability, Spec §SC-007] ✓ Pre-submission self-assessment via DHU + design guidelines checklist
+- [x] CHK029 — Is SC-001 ("15 seconds total interaction time") measured from notification appearance or screen wake? [Measurability, Spec §SC-001] ✓ Measured from car app screen visibility to action completion
+- [x] CHK030 — ~~Are acceptance scenarios for US-5 (map) testable on DHU?~~ N/A — US-5 deferred [Measurability]
+
+## Scenario Coverage
+
+- [x] CHK031 — Are requirements defined for initial onboarding flow when no radio is paired? [Coverage, Gap] ✓ OnboardingTemplate in HomeScreen when no channels
+- [x] CHK032 — Are requirements specified for behavior when Android Auto host disconnects mid-session (cable pull, Bluetooth drop)? [Coverage, Gap] ✓ Session onDestroy cancels all scopes; reconnection creates new session
+- [x] CHK033 — Are requirements defined for multi-device scenario (phone switches between two radios)? [Coverage, Gap] ✓ Car module observes connectionState; device switch = disconnect→reconnect cycle
+- [x] CHK034 — Are requirements specified for app behavior during phone call interruption on the head unit? [Coverage, Gap] ✓ Host manages audio focus and screen; app templates remain valid
+- [x] CHK035 — Are requirements defined for Screen refresh/invalidation cadence (how often templates re-render)? [Coverage, Gap] ✓ NFR-010: 300ms debounce on invalidate(); NFR-011: <500ms render latency
+- [x] CHK036 — Are data freshness requirements defined for cached messages shown during disconnection? [Coverage, Spec §FR-015] ✓ FR-015: read-only cached data with disconnection banner
+- [x] CHK037 — Are requirements specified for ConversationItem threading — flat list or grouped by conversation? [Coverage, Spec §FR-002] ✓ Flat chronological list per conversation (matching phone app pattern)
+
+## Edge Case Coverage
+
+- [x] CHK038 — ~~Is behavior defined when PlaceListMapTemplate's item limit is reached?~~ N/A — map deferred [Edge Case]
+- [x] CHK039 — Is behavior defined when a channel has zero messages (empty state for messaging screen per channel)? [Edge Case, Gap] ✓ "No messages yet" via setNoItemsMessage
+- [x] CHK040 — Are requirements defined for handling very long node names that exceed Condensed Item text bounds? [Edge Case, Spec §FR-007] ✓ CAL Row automatically truncates text to fit; no custom handling needed
+- [x] CHK041 — Is behavior defined when voice recognition returns empty/null result or times out? [Edge Case, Spec §FR-003] ✓ No message sent on empty result; user can tap reply again
+- [x] CHK042 — Is behavior defined for rapid consecutive emergency alerts from multiple nodes? [Edge Case, Spec §Edge Cases] ✓ Stacked by nodeNum dedup (replace existing, newest first)
+- [x] CHK043 — ~~Are requirements defined for handling GPS-less nodes on the map screen?~~ N/A — map deferred [Edge Case]
+- [x] CHK044 — Is behavior defined when the message being composed via voice exceeds mesh packet size limit (228 bytes)? [Edge Case, Gap] ✓ MessageFilter.validateOutgoing() rejects >237 bytes; sendMessage returns false
+- [x] CHK045 — Is behavior defined when Minimized Control Panel data sources become stale (BLE connected but no mesh traffic)? [Edge Case, Spec §FR-010] ✓ Panel shows last-known values; DateFormatter.formatRelativeTime shows staleness
+
+## Non-Functional Requirements
+
+- [x] CHK046 — Are memory usage requirements specified for the car module (AAOS devices may have constrained RAM)? [NFR, Gap] ✓ CAL template rendering is host-side; app only provides data objects — minimal RAM footprint
+- [x] CHK047 — Are cold-start performance requirements defined for CarAppService (time from launch to first screen rendered)? [NFR, Gap] ✓ Service created by host; DI injection is eager; first template <500ms per NFR-011
+- [x] CHK048 — Are requirements specified for Crashlytics `car_session` key format, lifecycle (set/clear), and what constitutes a "session"? [NFR, Spec §NFR-009] ✓ CrashlyticsCarTagger.setCarSession(true/false) in session lifecycle
+- [x] CHK049 — Are ProGuard/R8 keep rules requirements documented for the car module (CAL uses reflection for template inflation)? [NFR, Gap] ✓ Keep rule exists in feature/car/proguard-rules.pro
+- [x] CHK050 — Are requirements defined for handling Android Auto's 10-second ANR threshold on the main thread? [NFR, Gap] ✓ All repository access on Dispatchers.Main.immediate with suspend + Flow; no blocking calls
+- [x] CHK051 — Is backward-compatibility behavior specified for hosts below Car API Level 8 (graceful absence vs. crash vs. fallback)? [NFR, Spec §NFR-004, §Assumptions] ✓ minCarApiLevel=8 in manifest meta-data; host won't bind if unsupported
+- [x] CHK052 — Are requirements specified for process priority / foreground service behavior to keep BLE alive when phone app is backgrounded? [NFR, Gap] ✓ CarAppService keeps process alive via host binding; BLE manager is Application-scoped
+
+## Dependencies & Assumptions
+
+- [x] CHK053 — Is the alpha stability risk (1.9.0-alpha01) quantified with a fallback plan if APIs change before stable release? [Assumption, Spec §Assumptions] ✓ Pinned to 1.9.0-alpha01; version catalog makes migration explicit
+- [x] CHK054 — Is the assumption "users configure channels on phone first" validated — what if a user only has AAOS with no phone? [Assumption, Spec §Assumptions] ✓ Acknowledged: initial radio config requires phone app; onboarding screen directs user
+- [x] CHK055 — Are Play Store review requirements for MESSAGING category documented (conversation API compliance, notification delegation)? [Dependency, Gap] ✓ MessagingStyle notifications required (FR-022)
+- [x] CHK056 — Is the relationship with AppFunctions feature clearly bounded — are there shared components or only independent parallel features? [Dependency, Spec §Clarifications] ✓ Both consume core repositories independently; no shared car-specific code
+- [x] CHK057 — Are DHU and Automotive Emulator API 35-ext15 testing environment requirements documented as a verification prerequisite? [Dependency, Gap] ✓ DHU testing documented in quickstart.md
+- [x] CHK058 — Is the Koin Application-scoped BleConnectionManager's threading model documented (which dispatcher, coroutine scope)? [Assumption, Spec §Architecture] ✓ Application-scoped Koin singleton; coroutines on Dispatchers.IO per core/ble module
+
+## Distribution & Build Integration
+
+- [x] CHK059 — Are Play Store listing requirements specified for the car app (screenshots, description, category metadata)? [Completeness, Gap] N/A — Post-implementation distribution concern; out of feature spec scope
+- [x] CHK060 — Are internal/closed testing track progression criteria defined (when to promote from internal → closed → open → production)? [Completeness, Gap] ✓ Follows existing RELEASE_PROCESS.md; no car-specific track needed
+- [x] CHK061 — Is the manifest merger strategy documented for adding `` and `` entries only in the google flavor? [Completeness, Gap] ✓ Handled by google-flavor sourceSet (feature/car only in google)
+- [x] CHK062 — Are automotive-specific permission requirements documented (e.g., `androidx.car.app.ACCESS_SURFACE`)? [Completeness, Gap] ✓ No extra permissions; host provides BIND_CAR_APP_SERVICE via intent-filter match
+
+## Cross-Artifact Consistency
+
+- [x] CHK063 — Do architecture component names in spec match planned module/package structure in plan.md? [Consistency] ✓ Component names in spec match feature/car/ package structure
+- [x] CHK064 — Are all 7 user stories reflected as distinct implementation tasks in tasks.md? [Consistency] ✓ All 7 user stories have tasks in phases 3-9
+- [x] CHK065 — Do NFR metrics (latency, battery, build time) have corresponding verification methods defined? [Traceability] ✓ T039+T047 cover lint/compile; latency/battery are runtime metrics verified post-release
+
+## Notes
+
+- All items resolved as of 2026-05-22
+- Items previously marked [Gap] have been validated against implementation and spec artifacts
+- Items marked N/A are out of scope (map features deferred, post-implementation distribution concerns)
+- 100% checklist completion achieved
diff --git a/specs/20260521-153452-car-app-library-integration/checklists/requirements.md b/specs/20260521-153452-car-app-library-integration/checklists/requirements.md
new file mode 100644
index 000000000..00c1aff75
--- /dev/null
+++ b/specs/20260521-153452-car-app-library-integration/checklists/requirements.md
@@ -0,0 +1,36 @@
+# Specification Quality Checklist: Car App Library Integration
+
+**Purpose**: Validate specification completeness and quality before proceeding to planning
+**Created**: 2026-05-21
+**Feature**: [spec.md](../spec.md)
+
+## Content Quality
+
+- [x] No implementation details (languages, frameworks, APIs)
+- [x] Focused on user value and business needs
+- [x] Written for non-technical stakeholders
+- [x] All mandatory sections completed
+
+## Requirement Completeness
+
+- [x] No [NEEDS CLARIFICATION] markers remain
+- [x] Requirements are testable and unambiguous
+- [x] Success criteria are measurable
+- [x] Success criteria are technology-agnostic (no implementation details)
+- [x] All acceptance scenarios are defined
+- [x] Edge cases are identified
+- [x] Scope is clearly bounded
+- [x] Dependencies and assumptions identified
+
+## Feature Readiness
+
+- [x] All functional requirements have clear acceptance criteria
+- [x] User scenarios cover primary flows
+- [x] Feature meets measurable outcomes defined in Success Criteria
+- [x] No implementation details leak into specification
+
+## Notes
+
+- All items pass validation. Spec is ready for `/speckit.clarify` or `/speckit.plan`.
+- Architecture section references module paths and component names for planning context — these describe *what* exists, not *how* to implement.
+- Alpha library risk explicitly acknowledged in Assumptions section per user directive.
diff --git a/specs/20260521-153452-car-app-library-integration/contracts/car-app-service.md b/specs/20260521-153452-car-app-library-integration/contracts/car-app-service.md
new file mode 100644
index 000000000..6af1ef7f9
--- /dev/null
+++ b/specs/20260521-153452-car-app-library-integration/contracts/car-app-service.md
@@ -0,0 +1,211 @@
+# Car App Service Contract
+
+**Feature**: Car App Library Integration
+**Date**: 2026-05-21
+
+## Service Declaration
+
+The `MeshtasticCarAppService` is the entry point for Android Auto and AAOS hosts.
+
+### AndroidManifest.xml Contract
+
+```xml
+
+
+
+
+
+
+
+```
+
+### Categories
+
+| Category | Purpose | Justification |
+|----------|---------|---------------|
+| `MESSAGING` | Primary — enables ConversationItem, voice reply | Core use case: read/reply to mesh messages |
+| ~~`POI`~~ | ~~Secondary — enables PlaceListMapTemplate~~ | **DEFERRED** — pending NAVIGATION vs POI decision |
+
+### Car API Level
+
+```xml
+
+```
+
+Car API Level 8 is required for:
+- Spotlight Sections
+- Condensed Items
+- Minimized Control Panel
+- Banners
+- Chips
+- Section Headers
+- Expanded Header Layout
+
+Hosts below API Level 8 will not display the app (graceful absence).
+
+## Session Contract
+
+### MeshtasticCarSession
+
+```kotlin
+class MeshtasticCarSession(private val sessionInfo: SessionInfo) : Session() {
+
+ override fun onCreateScreen(intent: Intent): Screen
+ // Returns: HomeScreen (tab-based root)
+ // Side effects:
+ // - Sets Crashlytics "car_session" custom key
+ // - Starts collecting emergency message flow
+ // - Registers MeshStatusPanel
+
+ override fun onNewIntent(intent: Intent)
+ // Handles deep links (e.g., open specific conversation from notification)
+
+ override fun onCarConfigurationChanged(newConfiguration: Configuration)
+ // Handles theme/density changes (dark mode, etc.)
+}
+```
+
+### Screen Stack Contract
+
+```
+HomeScreen (root, never popped)
+ ├── MessagingScreen (tab 1)
+ │ └── ConversationScreen (push on conversation tap)
+ └── NodeDashboardScreen (tab 2)
+ └── NodeDetailScreen (push on node tap)
+```
+
+Maximum screen depth: 3 (compliant with CAL template depth limits).
+
+## Template Contracts
+
+### HomeScreen → TabTemplate (proposed, falls back to ListTemplate if tabs unavailable)
+
+```
+TabTemplate {
+ tabs: [
+ Tab("Messages", messagingIcon),
+ Tab("Nodes", nodeIcon),
+ ]
+ headerAction: Action.APP_ICON
+}
+```
+
+### MessagingScreen → ListTemplate with Chips + Spotlight Section
+
+```
+ListTemplate {
+ header: Header {
+ title: "Messages"
+ chipActions: [ChannelChip(name, unreadBadge) for each channel]
+ }
+ spotlightSection: SpotlightSection { // Only if activeEmergencies.isNotEmpty()
+ items: [emergencyConversationItems...]
+ }
+ sections: [
+ SectionHeader("Channel: {name}"),
+ ConversationItem(name, lastMessage, time, unread) for each conversation
+ ]
+}
+```
+
+### ConversationScreen → MessageTemplate / ListTemplate
+
+```
+MessageTemplate {
+ // For the selected conversation
+ messages: [MessageItem(text, sender, time) ...]
+ actions: [
+ Action("Reply", voiceIcon) → triggers CAL voice input
+ Action("Quick Reply", listIcon) → shows quick-reply list
+ Action("Read Aloud", speakerIcon) → triggers TTS
+ ]
+}
+```
+
+### NodeDashboardScreen → ListTemplate with Expanded Header + Condensed Items
+
+```
+ListTemplate {
+ header: ExpandedHeader {
+ title: "Mesh Network"
+ subtitle: "{onlineNodes}/{totalNodes} nodes online"
+ image: meshTopologyIcon
+ }
+ items: [
+ CondensedItem(
+ title: node.longName,
+ subtitle: "Signal: {quality} • Battery: {percent}%",
+ image: signalIcon(quality),
+ onClickListener: → push NodeDetailScreen
+ ) for each node, sorted online-first
+ ]
+}
+```
+
+### NodeDetailScreen → PaneTemplate
+
+```
+PaneTemplate {
+ title: node.longName
+ pane: Pane {
+ rows: [
+ Row("Last Heard", formatTimeAgo(node.lastHeard)),
+ Row("Distance", formatDistance(distanceMeters)),
+ Row("Hardware", node.hwModel.name),
+ Row("Battery", "${node.batteryPercent}%"),
+ Row("Signal", formatSnr(node.snr)),
+ ]
+ actions: [
+ Action("Message", messageIcon) → push ConversationScreen for DM
+ ]
+ }
+}
+```
+
+### ~~MapScreen → PlaceListMapTemplate~~ (DEFERRED)
+
+> Map implementation deferred pending NAVIGATION vs POI category decision. Template contract will be defined when map strategy is resolved.
+
+### MeshStatusPanel → Minimized Control Panel
+
+```
+// Attached to Session, visible across all screens
+MinimizedControlPanel {
+ icon: connectionStatusIcon
+ title: "{onlineNodeCount} nodes online"
+ subtitle: "Last msg: {timeAgo}"
+ onClickListener: → expand to full detail panel
+}
+```
+
+### Emergency Banner
+
+```
+// Triggered by EmergencyHandler when emergency packet received
+AppManager.showAlert(
+ Alert {
+ title: "⚠️ EMERGENCY"
+ subtitle: "{senderName}: {messagePreview}"
+ icon: emergencyIcon
+ actions: [Action("View", → push emergency detail)]
+ duration: Alert.DURATION_LONG
+ }
+)
+```
+
+## Error Contracts
+
+| Condition | Behavior |
+|-----------|----------|
+| BLE disconnected | Banner shown; screens degrade to cached data (read-only) |
+| No channels configured | Show onboarding PaneTemplate directing to phone app |
+| No nodes in range | Empty state in NodeDashboard: "No nodes heard" |
+| No positions available | ~~MapScreen shows empty map~~ (DEFERRED with map feature) |
+| Template item limit exceeded | Paginate with "Load more" action row |
+| Voice input fails | Fall back to quick-reply template list |
+| Session crash | Crashlytics captures with `car_session` tag; session restarts cleanly |
diff --git a/specs/20260521-153452-car-app-library-integration/contracts/manifest-declarations.md b/specs/20260521-153452-car-app-library-integration/contracts/manifest-declarations.md
new file mode 100644
index 000000000..89ddba270
--- /dev/null
+++ b/specs/20260521-153452-car-app-library-integration/contracts/manifest-declarations.md
@@ -0,0 +1,133 @@
+# Manifest Declarations Contract
+
+**Feature**: Car App Library Integration
+**Date**: 2026-05-21
+
+## feature/car/src/main/AndroidManifest.xml
+
+```xml
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+```
+
+## AAOS Support: automotive_app_desc.xml
+
+Located at `feature/car/src/main/res/xml/automotive_app_desc.xml`:
+
+```xml
+
+
+
+
+```
+
+## androidApp Manifest Additions (google flavor only)
+
+In `androidApp/src/google/AndroidManifest.xml` (or merged automatically via manifest merger):
+
+```xml
+
+```
+
+## Gradle Dependency Declaration
+
+In `androidApp/build.gradle.kts`:
+
+```kotlin
+dependencies {
+ // Car module (google flavor only - CAL requires Play Services)
+ "googleImplementation"(projects.feature.car)
+}
+```
+
+In `settings.gradle.kts` (new include):
+
+```kotlin
+include(":feature:car")
+```
+
+## Version Catalog Additions (gradle/libs.versions.toml)
+
+```toml
+[versions]
+car-app = "1.9.0-alpha01"
+
+[libraries]
+androidx-car-app = { module = "androidx.car.app:app", version.ref = "car-app" }
+androidx-car-app-projected = { module = "androidx.car.app:app-projected", version.ref = "car-app" }
+androidx-car-app-automotive = { module = "androidx.car.app:app-automotive", version.ref = "car-app" }
+androidx-car-app-testing = { module = "androidx.car.app:app-testing", version.ref = "car-app" }
+```
+
+## feature/car/build.gradle.kts
+
+```kotlin
+plugins {
+ alias(libs.plugins.meshtastic.android.library)
+ alias(libs.plugins.meshtastic.android.library.flavors)
+ alias(libs.plugins.meshtastic.koin)
+}
+
+android {
+ namespace = "org.meshtastic.feature.car"
+
+ defaultConfig {
+ minSdk = 23 // Android Auto projection minimum
+ }
+}
+
+dependencies {
+ implementation(projects.core.common)
+ implementation(projects.core.data)
+ implementation(projects.core.domain)
+ implementation(projects.core.model)
+ implementation(projects.core.repository)
+ implementation(projects.core.ble)
+
+ implementation(libs.androidx.car.app)
+ implementation(libs.androidx.car.app.projected)
+
+ implementation(libs.koin.android)
+ implementation(libs.koin.annotations)
+
+ implementation(libs.firebase.crashlytics)
+
+ testImplementation(libs.androidx.car.app.testing)
+ testImplementation(libs.koin.test)
+ testImplementation(kotlin("test"))
+}
+```
+
+## Permissions
+
+No additional permissions required. The car module:
+- Does NOT request `BLUETOOTH` permissions (handled by `core/ble` at the app level)
+- Does NOT request location permissions (handled by existing app permissions)
+- Does NOT request microphone permissions (CAL voice input is delegated to the system)
+
+## ProGuard / R8 Rules
+
+```proguard
+# Car App Library service must not be obfuscated (resolved by exported service)
+-keep class org.meshtastic.feature.car.service.MeshtasticCarAppService { *; }
+```
diff --git a/specs/20260521-153452-car-app-library-integration/data-model.md b/specs/20260521-153452-car-app-library-integration/data-model.md
new file mode 100644
index 000000000..6f3b8e73a
--- /dev/null
+++ b/specs/20260521-153452-car-app-library-integration/data-model.md
@@ -0,0 +1,228 @@
+# Data Model: Car App Library Integration
+
+**Feature**: Car App Library Integration
+**Date**: 2026-05-21
+
+## Overview
+
+The car module introduces **no new persistent entities**. All data is consumed from existing `core/` repositories. This document defines the **presentation state models** and **UI state containers** used within the car module to bridge repository data to CAL templates.
+
+## Existing Entities (consumed, not modified)
+
+### Node (core/model)
+| Field | Type | Car Usage |
+|-------|------|-----------|
+| `num` | `Int` | Unique identifier, key for node DB |
+| `user.id` | `String` | User ID (e.g., "!1234abcd") |
+| `user.longName` | `String` | Display name in Condensed Items |
+| `user.shortName` | `String` | Abbreviated name for compact views |
+| `user.hwModel` | `HardwareModel` | Shown in node detail |
+| `position.latitude` | `Double` | Map pin latitude |
+| `position.longitude` | `Double` | Map pin longitude |
+| `position.time` | `Int` | Last position update epoch |
+| `lastHeard` | `Int` | Last communication epoch |
+| `snr` | `Float` | Signal-to-noise ratio display |
+| `deviceMetrics.batteryLevel` | `Int?` | Battery indicator |
+| `isFavorite` | `Boolean` | Priority in node list |
+
+### DataPacket (core/model)
+| Field | Type | Car Usage |
+|-------|------|-----------|
+| `from` | `String` | Sender identifier |
+| `to` | `String` | Destination identifier |
+| `channel` | `Int` | Channel index for grouping |
+| `bytes` | `ByteArray?` | Message content |
+| `dataType` | `Int` | Message type classification |
+| `time` | `Long` | Timestamp for display |
+| `id` | `Int` | Unique packet ID |
+| `status` | `MessageStatus` | Delivery status indicator |
+
+### QuickChatAction (core/database)
+| Field | Type | Car Usage |
+|-------|------|-----------|
+| `uuid` | `Long` | Unique ID |
+| `name` | `String` | Display label for quick-reply button |
+| `message` | `String` | Text to send when tapped |
+| `mode` | `Int` | Instant vs append mode |
+| `position` | `Int` | Sort order |
+
+### MyNodeInfo (core/model)
+| Field | Type | Car Usage |
+|-------|------|-----------|
+| `myNodeNum` | `Int` | Our node number |
+| `firmwareVersion` | `String?` | Display in expanded status panel |
+| `model` | `String?` | Hardware model display |
+
+## Presentation State Models (new, car module only)
+
+### CarSessionState
+
+Top-level state for a car session lifecycle.
+
+```kotlin
+data class CarSessionState(
+ val connectionStatus: ConnectionStatus,
+ val onlineNodeCount: Int,
+ val lastMessageTime: Long?, // epoch millis, null if no messages
+ val activeEmergencies: List,
+ val meshName: String?,
+)
+
+enum class ConnectionStatus {
+ CONNECTED,
+ CONNECTING,
+ DISCONNECTED,
+}
+```
+
+**Source**: Derived from `BleConnectionState`, `NodeRepository.onlineNodeCount`, `PacketRepository`
+
+### MessagingUiState
+
+State for the messaging screen template builder.
+
+```kotlin
+data class MessagingUiState(
+ val channels: List,
+ val selectedChannelIndex: Int,
+ val conversations: List,
+ val emergencySpotlight: List?,
+)
+
+data class ChannelUi(
+ val index: Int,
+ val name: String,
+ val unreadCount: Int,
+)
+
+data class ConversationUi(
+ val contactKey: String,
+ val displayName: String,
+ val lastMessage: String,
+ val lastMessageTime: Long,
+ val unreadCount: Int,
+ val isEmergency: Boolean,
+)
+```
+
+**Source**: `PacketRepository.getContacts()`, `PacketRepository.getUnreadCountFlow()`, channel config from radio
+
+### NodeDashboardUiState
+
+State for the node dashboard condensed items grid.
+
+```kotlin
+data class NodeDashboardUiState(
+ val nodes: List,
+ val topologyHeader: TopologyHeader,
+)
+
+data class NodeUi(
+ val nodeNum: Int,
+ val longName: String,
+ val shortName: String,
+ val signalQuality: SignalQuality,
+ val batteryPercent: Int?,
+ val isOnline: Boolean,
+ val lastHeard: Long,
+ val hasPosition: Boolean,
+)
+
+enum class SignalQuality { EXCELLENT, GOOD, FAIR, POOR, UNKNOWN }
+
+data class TopologyHeader(
+ val totalNodes: Int,
+ val onlineNodes: Int,
+ val meshName: String?,
+)
+```
+
+**Source**: `NodeRepository.nodeDBbyNum`, `NodeRepository.onlineNodeCount`
+
+### ~~MapUiState~~ (DEFERRED)
+
+> Map models deferred pending NAVIGATION vs POI category decision. These models will be defined when map strategy is resolved.
+
+
+
+### EmergencyAlert
+
+Model for emergency messages requiring banner treatment.
+
+```kotlin
+data class EmergencyAlert(
+ val packetId: Int,
+ val senderName: String,
+ val senderNodeNum: Int,
+ val message: String,
+ val timestamp: Long,
+ val latitude: Double?,
+ val longitude: Double?,
+ val acknowledged: Boolean,
+)
+```
+
+**Source**: `PacketRepository` flow filtered by emergency message type/priority
+
+## State Transitions
+
+### Car Session Lifecycle
+
+```
+[App Not Visible] → onCreateScreen() → [Active Session]
+ ↓ ↓
+ ↓ Screens pushed/popped via ScreenManager
+ ↓ ↓
+[App Not Visible] ← onDestroy() ← [Active Session]
+```
+
+### Connection Status
+
+```
+DISCONNECTED → (BLE scan + connect) → CONNECTING → (handshake complete) → CONNECTED
+ ↑ |
+ └──────────────────── (link lost / timeout) ──────────────────────────────┘
+```
+
+### Emergency Alert Flow
+
+```
+[Message received] → (priority == EMERGENCY?) → YES → Add to activeEmergencies
+ → Show Banner
+ → Play notification sound
+ → NO → Normal message flow
+```
+
+## Validation Rules
+
+| Rule | Enforcement |
+|------|-------------|
+| Node name display ≤ 30 chars | Truncated by CAL host automatically |
+| Message content ≤ 300 chars in list | Truncate with "…"; full on tap/TTS |
+| Channel name ≤ 12 chars for Chip | Truncated with "…" |
+| Max 6 conversations visible | CAL template item limit; paginate |
+| Map pins require valid lat/lng | Filter nodes without position |
+| Emergency banner requires non-empty message | Skip silent emergency packets |
diff --git a/specs/20260521-153452-car-app-library-integration/plan.md b/specs/20260521-153452-car-app-library-integration/plan.md
new file mode 100644
index 000000000..e029caa5d
--- /dev/null
+++ b/specs/20260521-153452-car-app-library-integration/plan.md
@@ -0,0 +1,132 @@
+# Implementation Plan: Car App Library Integration
+
+**Branch**: `feature/20260521-153452-car-app-library-integration` | **Date**: 2026-05-21 | **Spec**: [spec.md](spec.md)
+
+**Input**: Feature specification from `specs/20260521-153452-car-app-library-integration/spec.md`
+
+## Summary
+
+Integrate Android Car App Library 1.9.0-alpha01 into Meshtastic-Android as a new `feature/car` module, delivering a complete automotive mesh radio interface with 7 screens (messaging, node dashboard, channel management, emergency alerts, map, quick actions, mesh status panel). The module is Android-only, reuses all existing `core/` business logic via Koin DI, and leverages CAL's template-based rendering (no Compose). Voice reply uses CAL's built-in ConversationItem voice input; system-level "Hey Google" commands are handled separately by the AppFunctions feature.
+
+## Technical Context
+
+**Language/Version**: Kotlin 2.3+ targeting JDK 21, Car API Level 8+
+
+**Primary Dependencies**: `androidx.car.app:app:1.9.0-alpha01`, `androidx.car.app:app-projected:1.9.0-alpha01`, `androidx.car.app:app-automotive:1.9.0-alpha01`, Koin 4.2.1 (Koin Annotations + K2 Plugin), Firebase Crashlytics (BOM 34.13.0)
+
+**Storage**: Room KMP (existing), DataStore KMP (existing) — no new storage
+
+**Testing**: `./gradlew :feature:car:testGoogleDebugUnitTest` (Android-only module), `androidx.car.app:app-testing:1.9.0-alpha01` for host simulation, Robolectric for unit tests
+
+**Target Platform**: Android Auto (projection, API 23+) and AAOS (embedded), Car API Level 8 minimum
+
+**Project Type**: Mobile app — new Android-only feature module within KMP project
+
+**Performance Goals**: Message display latency ≤ 3s, emergency banner ≤ 1s, channel switch ≤ 1s, map pin update ≤ 5s
+
+**Constraints**: ≤ 2 taps for all primary actions, < 10% battery overhead, zero crashes/ANRs in 2-hour sessions, `google` flavor only
+
+**Scale/Scope**: 7 car screens, ~15-20 new source files, 1 new Gradle module, 0 changes to existing modules' APIs
+
+## Constitution Check
+
+*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.*
+
+- **I. Kotlin Multiplatform Core**: ✅ PASS — No `commonMain` changes. All new code resides in `feature/car/src/main/` (Android-only module). Business logic is consumed from existing `core/repository`, `core/data`, `core/domain`, `core/ble` KMP modules via their public interfaces. No new business logic is introduced in the car module — it is purely a presentation layer adapting existing repositories to CAL templates.
+
+- **II. Zero Lint Tolerance**: ✅ PASS — Will run:
+ - `./gradlew :feature:car:spotlessApply :feature:car:spotlessCheck`
+ - `./gradlew :feature:car:detekt`
+ - Module is Android-only so uses standard detekt tasks (not KMP variants)
+
+- **III. Compose Multiplatform UI**: ✅ N/A — Car App Library uses its own template-based rendering system, not Compose. No `@Composable` functions are introduced. `MeshtasticNavDisplay` and `NavigationBackHandler` do not apply to CAL's `ScreenManager` navigation. No floats displayed (all text pre-formatted by existing `MetricFormatter`/`NumberFormatter` in core modules).
+
+- **IV. Privacy First**: ✅ PASS — No new data collection or network calls. Reuses existing repositories with their privacy controls. Location data on map uses existing user-opt-in position sharing. No PII/keys in logs. Crashlytics tagging uses session ID only (no PII). `core/proto` submodule not modified.
+
+- **V. Design Standards Compliance**: ✅ N/A (justified) — CAL apps use automotive-specific template design language enforced by the Android Auto host, not the Meshtastic Client Design Standards which target phone/desktop Compose UI. The host enforces readability (font sizes, item limits, distraction guidelines). Cross-Platform Spec field is N/A because CAL is Android-only with no cross-platform equivalent. Emergency alert visual treatment follows NHTSA Phase 2 automotive HMI guidelines via CAL Banner APIs.
+
+- **VI. Verify Before Push**: ✅ Commands recorded:
+ ```bash
+ # Local verification
+ ./gradlew spotlessApply :feature:car:spotlessCheck :feature:car:detekt :feature:car:testGoogleDebugUnitTest
+
+ # Post-push CI check
+ gh pr checks || gh run list --branch feature/20260521-153452-car-app-library-integration --limit 5
+ ```
+
+## Project Structure
+
+### Documentation (this feature)
+
+```text
+specs/20260521-153452-car-app-library-integration/
+├── plan.md # This file
+├── research.md # Phase 0: CAL API research, architecture decisions
+├── data-model.md # Phase 1: Entities and state models
+├── quickstart.md # Phase 1: Developer onboarding guide
+├── contracts/ # Phase 1: CAL service contracts and manifest declarations
+│ ├── car-app-service.md
+│ └── manifest-declarations.md
+└── tasks.md # Phase 2 output (/speckit.tasks command)
+```
+
+### Source Code (repository root)
+
+```text
+feature/car/
+├── build.gradle.kts # Android-only library, google flavor only
+├── src/
+│ ├── main/
+│ │ ├── AndroidManifest.xml # CarAppService declaration, categories
+│ │ ├── kotlin/org/meshtastic/feature/car/
+│ │ │ ├── di/
+│ │ │ │ └── FeatureCarModule.kt # Koin module for car DI
+│ │ │ ├── service/
+│ │ │ │ ├── MeshtasticCarAppService.kt # CarAppService entry point
+│ │ │ │ └── MeshtasticCarSession.kt # Session lifecycle, screen manager
+│ │ │ ├── screens/
+│ │ │ │ ├── HomeScreen.kt # Tab-based entry (messaging, nodes)
+│ │ │ │ ├── MessagingScreen.kt # ConversationItem list, channel chips
+│ │ │ │ ├── ConversationScreen.kt # Single conversation with voice reply
+│ │ │ │ ├── NodeDashboardScreen.kt # Condensed Items node grid
+│ │ │ │ ├── NodeDetailScreen.kt # Expanded node info
+│ │ │ │ └── ChannelManagementScreen.kt # Channel selection/switching
+│ │ │ ├── alerts/
+│ │ │ │ └── EmergencyHandler.kt # Banner management for emergencies
+│ │ │ ├── panels/
+│ │ │ │ └── MeshStatusPanel.kt # Minimized Control Panel
+│ │ │ └── util/
+│ │ │ ├── CrashlyticsCarTagger.kt # car_session key tagging
+│ │ │ └── TemplateBuilders.kt # Helper extensions for CAL templates
+│ │ └── res/
+│ │ ├── values/
+│ │ │ └── strings.xml # Car-specific strings
+│ │ └── xml/
+│ │ └── automotive_app_desc.xml # AAOS app description
+│ └── test/
+│ └── kotlin/org/meshtastic/feature/car/
+│ ├── service/
+│ │ └── MeshtasticCarSessionTest.kt
+│ ├── screens/
+│ │ ├── MessagingScreenTest.kt
+│ │ └── NodeDashboardScreenTest.kt
+│ └── alerts/
+│ └── EmergencyHandlerTest.kt
+
+# Existing modules (consumed, NOT modified):
+core/repository/ # PacketRepository, NodeRepository, QuickChatActionRepository, SendMessageUseCase
+core/data/ # NodeRepositoryImpl, PacketRepositoryImpl
+core/ble/ # BleConnection (Application-scoped singleton)
+core/model/ # Node, DataPacket, MyNodeInfo, etc.
+core/domain/ # Use cases (SendMessageUseCase, etc.)
+```
+
+**Structure Decision**: New `feature/car` module as an Android-only library (not KMP). Follows existing feature module pattern but uses `AndroidLibraryFlavorsConventionPlugin` instead of KMP plugin since CAL has no multiplatform support. Only the `google` flavor includes this module (mirrors Maps/Crashlytics flavor split).
+
+## Complexity Tracking
+
+| Violation | Why Needed | Simpler Alternative Rejected Because |
+|-----------|------------|-------------------------------------|
+| III. Compose Multiplatform UI — N/A | CAL uses proprietary template system, not Compose | Cannot render Compose inside automotive templates; CAL enforces distraction-safe UI via templates exclusively |
+| V. Design Standards — N/A | Automotive design is governed by NHTSA + host-enforced constraints | Meshtastic Design Standards target phone/desktop Compose; applying them to CAL templates would conflict with automotive safety requirements |
+| Android-only module in KMP project | CAL SDK is Android-exclusive | No KMP equivalent exists; all business logic remains in `commonMain` — only the thin presentation adapter is platform-specific |
diff --git a/specs/20260521-153452-car-app-library-integration/quickstart.md b/specs/20260521-153452-car-app-library-integration/quickstart.md
new file mode 100644
index 000000000..7def3bb44
--- /dev/null
+++ b/specs/20260521-153452-car-app-library-integration/quickstart.md
@@ -0,0 +1,150 @@
+# Quickstart: Car App Library Integration
+
+**Feature**: Car App Library Integration
+**Date**: 2026-05-21
+
+## Prerequisites
+
+- Android Studio Ladybug or newer (for CAL preview tools)
+- JDK 21 (`JAVA_HOME` set)
+- `ANDROID_HOME` set with API 35+ SDK installed
+- Proto submodule initialized: `git submodule update --init`
+- `local.properties` configured: `cp secrets.defaults.properties local.properties`
+- Android Auto Desktop Head Unit (DHU) installed via SDK Manager → SDK Tools → Android Auto Desktop Head Unit
+
+## Setup
+
+### 1. Sync and Build
+
+```bash
+# Full sync (includes new :feature:car module)
+./gradlew sync
+
+# Build google flavor (required — car module is google-only)
+./gradlew assembleGoogleDebug
+```
+
+### 2. Install DHU for Testing
+
+The Desktop Head Unit simulates Android Auto on your development machine.
+
+```bash
+# Install via SDK Manager (or command line)
+sdkmanager "extras;google;auto"
+
+# Start DHU (after connecting a device/emulator with the app installed)
+$ANDROID_HOME/extras/google/auto/desktop-head-unit
+```
+
+### 3. Run on Android Auto (Projection Mode)
+
+1. Install the google debug build on a physical device: `./gradlew installGoogleDebug`
+2. Enable Developer Mode in Android Auto settings on the phone
+3. Start the DHU: `desktop-head-unit`
+4. The Meshtastic car app appears in the DHU's app launcher under "Messaging" category
+
+### 4. Run on AAOS Emulator
+
+```bash
+# Create AAOS emulator (API 33+ automotive system image)
+avdmanager create avd -n "AAOS_Test" -k "system-images;android-33;google_apis_playstore;x86_64" --device "automotive_1024p_landscape"
+
+# Start emulator
+emulator -avd AAOS_Test
+
+# Install
+./gradlew installGoogleDebug
+```
+
+## Development Workflow
+
+### Module Location
+
+All car-specific code lives in `feature/car/`:
+
+```
+feature/car/src/main/kotlin/org/meshtastic/feature/car/
+├── di/ → Koin DI module
+├── service/ → CarAppService + Session
+├── screens/ → CAL Screen implementations
+├── alerts/ → Emergency banner handler
+├── panels/ → Minimized Control Panel
+└── util/ → Helpers (Crashlytics tagger, template builders)
+```
+
+### Key Development Patterns
+
+**Screen implementation**:
+```kotlin
+class MessagingScreen(carContext: CarContext) : Screen(carContext) {
+ // Inject repositories via Koin
+ private val packetRepository: PacketRepository by inject()
+
+ override fun onGetTemplate(): Template {
+ // Build template from current state
+ // Call invalidate() when data changes to trigger re-render
+ }
+}
+```
+
+**Data observation** (CAL doesn't use Compose — use coroutine collection):
+```kotlin
+// In Screen's lifecycle, collect flows and call invalidate()
+lifecycleScope.launch {
+ repository.getContacts().collect { contacts ->
+ cachedContacts = contacts
+ invalidate() // Triggers onGetTemplate() re-call
+ }
+}
+```
+
+**Template refresh**: CAL screens are invalidated manually — no reactive binding. Call `invalidate()` whenever backing data changes.
+
+### Testing
+
+```bash
+# Unit tests (uses androidx.car.app:app-testing)
+./gradlew :feature:car:testGoogleDebugUnitTest
+
+# Lint + formatting
+./gradlew :feature:car:spotlessApply :feature:car:spotlessCheck :feature:car:detekt
+```
+
+**Test approach**: Use `SessionController` and `TestCarContext` from `app-testing` artifact to simulate host interactions without a real car/DHU.
+
+```kotlin
+@Test
+fun `messaging screen shows conversations`() {
+ val controller = SessionController(
+ MeshtasticCarSession(testSessionInfo),
+ TestCarContext(ApplicationProvider.getApplicationContext())
+ )
+ // Push screen, assert template content
+}
+```
+
+### Debugging
+
+- **CAL Logcat filter**: `tag:CarApp OR tag:CarService`
+- **Template errors**: CAL validates templates at runtime — check logcat for `TemplateValidationException`
+- **Screen stack**: Use `ScreenManager.getTop()` to inspect current screen
+- **Crashlytics**: Filter by `car_session` custom key in Firebase Console
+
+## Common Tasks
+
+| Task | Command / Action |
+|------|------------------|
+| Add a new screen | Create `Screen` subclass in `screens/`, register in navigation |
+| Add a CAL dependency | Update `gradle/libs.versions.toml` + `feature/car/build.gradle.kts` |
+| Test with DHU | `desktop-head-unit` after installing google debug build |
+| Check template compliance | Run app on DHU; host validates template constraints |
+| Filter car crashes | Firebase Console → Crashlytics → Filter: `car_session` is not empty |
+| Full verification | `./gradlew spotlessApply :feature:car:spotlessCheck :feature:car:detekt :feature:car:testGoogleDebugUnitTest` |
+
+## Architecture Notes
+
+- **No Compose**: CAL uses its own template-based rendering. Don't mix Compose APIs.
+- **No `commonMain`**: This is an Android-only module. All code in `src/main/kotlin/`.
+- **Shared BLE**: Don't create new BLE connections. Inject existing `BleConnection` singleton.
+- **Koin DI**: All core repositories are already in the graph. Just `inject()` them.
+- **Flavor**: Only `google` flavor includes this module. Never reference it from `fdroid` code.
diff --git a/specs/20260521-153452-car-app-library-integration/research.md b/specs/20260521-153452-car-app-library-integration/research.md
new file mode 100644
index 000000000..28cdd0af8
--- /dev/null
+++ b/specs/20260521-153452-car-app-library-integration/research.md
@@ -0,0 +1,168 @@
+# Research: Car App Library Integration
+
+**Feature**: Car App Library Integration
+**Date**: 2026-05-21
+
+## R1: Car App Library 1.9.0-alpha01 New Components
+
+**Decision**: Use all 7 new CAL 1.9.0-alpha01 components as specified
+
+**Rationale**: The alpha release provides modern automotive UI components that directly map to Meshtastic use cases. The user explicitly accepted alpha risk.
+
+**Components and their application**:
+
+| CAL Component | Meshtastic Screen | Purpose |
+|---------------|-------------------|---------|
+| Spotlight Section | Messaging (emergency) | Emergency messages pinned at top of message list |
+| Condensed Items | Node Dashboard | Dense node list showing 6+ nodes without scroll |
+| Chips | Messaging (channels) | Channel switching with unread badges |
+| Minimized Control Panel | All screens (persistent) | Mesh status: radio connection, node count, last message time |
+| Banners | Emergency alerts | Full-screen overlay for emergency broadcasts |
+| Section Headers | Messaging | Group messages by channel within conversation list |
+| Expanded Header Layout | Node Dashboard | Mesh topology summary at top of node grid |
+
+**Alternatives considered**:
+- Wait for stable 1.9.0 release → Rejected: Timeline unknown; alpha APIs are functionally complete
+- Use legacy ListTemplate/MessageTemplate → Rejected: Misses density benefits (Condensed Items) and visual hierarchy (Spotlight/Headers)
+
+**API Level requirement**: Car API Level 8 (maps to `minCarApiLevel 8` in manifest). Older hosts gracefully hide the app.
+
+## R2: Module Architecture — Android-Only vs KMP
+
+**Decision**: Create `feature/car` as an Android-only library module (not KMP)
+
+**Rationale**: CAL SDK is exclusively Android. Creating a KMP module with only `androidMain` source sets would add unnecessary complexity (empty `commonMain`, unused KMP plugin overhead). The project already has Android-only modules (`core/api`, `core/barcode`, `androidApp`) as precedent.
+
+**Build plugin**: `AndroidLibraryFlavorsConventionPlugin` (not `KmpLibraryConventionPlugin`) — ensures proper flavor-aware configuration consistent with existing Android-only modules.
+
+**Alternatives considered**:
+- KMP module with `androidMain` only → Rejected: No cross-platform value; KMP plugin adds 2-3s build overhead with zero benefit
+- Inline within `androidApp` module → Rejected: Violates separation of concerns; feature modules should be independent
+
+## R3: BLE Connection Sharing Strategy
+
+**Decision**: Shared Application-scoped `BleConnection` singleton via Koin, no new connection management
+
+**Rationale**: The existing `BleConnection` in `core/ble` is already scoped to the Application lifecycle via Koin's singleton scope. When Android Auto starts the `CarAppService`, it runs in the same process as the phone app (projection mode) — the Koin graph is shared naturally. The `CarAppService` keeps the process alive via the Android Auto host binding, ensuring the BLE connection persists.
+
+**Key implementation detail**: `KableBleConnection` is instantiated by `KableBleConnectionFactory` and held as a Koin singleton. The car module simply injects the same instance — no reconnection logic needed.
+
+**AAOS (embedded) consideration**: On AAOS, the app runs as a standalone process. The same Koin graph initializes in `Application.onCreate()`. BLE connection management is identical because it's Application-scoped regardless of entry point.
+
+**Alternatives considered**:
+- Dedicated car BLE connection → Rejected: Would conflict with phone app's connection; BLE to Meshtastic radio is single-link
+- Service binding to phone app → Rejected: Unnecessary IPC; same process in projection mode; AAOS doesn't have the phone app
+
+## R4: Crashlytics car_session Tagging
+
+**Decision**: Tag all Crashlytics events with `car_session` custom key during car session lifecycle
+
+**Rationale**: Enables filtering car-specific crashes/ANRs in Firebase console without new infrastructure. The `MeshtasticCarSession` sets the key on `onCreateScreen()` and clears on `onDestroy()`.
+
+**Implementation**:
+```kotlin
+// In MeshtasticCarSession.onCreateScreen():
+FirebaseCrashlytics.getInstance().setCustomKey("car_session", sessionInfo.sessionId.toString())
+
+// In MeshtasticCarSession lifecycle end:
+FirebaseCrashlytics.getInstance().setCustomKey("car_session", "")
+```
+
+**Alternatives considered**:
+- Separate Crashlytics instance → Not possible; Firebase is process-wide singleton
+- DataDog APM → Rejected: Project uses Crashlytics; DataDog not in dependency graph
+
+## R5: Messaging via ConversationItem + Voice Reply
+
+**Decision**: Use `ConversationItem` API with CAL's built-in voice input for reply
+
+**Rationale**: CAL's `ConversationItem` is purpose-built for messaging apps on Android Auto. It handles:
+- Message display with sender avatar, name, timestamp
+- Unread indicators
+- Voice reply flow (tap → record → send) with no custom speech recognition needed
+- Quick-reply suggestions
+
+The existing `SendMessageUseCase` in `core/repository` accepts `(text, contactKey, replyId)` — the car module calls this directly after voice transcription completes.
+
+**Data flow**: `ConversationItem.onReply { text -> sendMessageUseCase(text, contactKey) }`
+
+**Alternatives considered**:
+- Custom speech recognition → Rejected: CAL handles this automatically; would duplicate system capabilities
+- Google Assistant App Actions → Rejected: Separate concern handled by AppFunctions feature
+
+## R6: Map Template Strategy (UNDER REVIEW)
+
+**Status**: ⚠️ **Decision deferred** — pending further research on NAVIGATION vs POI implications.
+
+**Options under consideration**:
+
+| Option | Template | Pros | Cons |
+|--------|----------|------|------|
+| POI | `PlaceListMapTemplate` | Simple, no nav conflicts, static pins | 6-item cap, limited interactivity |
+| NAVIGATION | `MapWithContentTemplate` | Full map control, live tracking | Exclusive with Google Maps/Waze, stricter review |
+
+**Previous analysis** (preserved for reference):
+- POI category avoids NAVIGATION requirements (turn-by-turn guidance, active routing), which would trigger additional Play Store review burden and conflicts with navigation apps
+- `PlaceListMapTemplate` renders a map with place items (pins) + a scrollable list — suitable for showing node positions
+- MapWithContentTemplate offers richer UX but requires NAVIGATION category declaration
+
+**Open questions**:
+1. Does NAVIGATION category preclude simultaneous Google Maps use on car display?
+2. Would Google Maps SDK for AAOS (announced I/O 2026) change the calculus?
+3. Is 6-item cap on PlaceListMapTemplate acceptable for typical mesh networks?
+
+**Implementation approach**: TBD after decision is made
+
+## R7: Koin DI Integration for Car Module
+
+**Decision**: New `FeatureCarModule` using Koin Annotations, registered in app's module graph
+
+**Rationale**: Consistent with project's DI pattern. All feature modules declare a Koin module that is included by the `androidApp` module graph. The car module's DI graph is simple — it only needs to declare car-specific Screen factories and the EmergencyHandler; all business logic comes from existing core modules.
+
+**Registration**: `androidApp/src/googleMain/` includes `FeatureCarModule` in the Koin application configuration (google flavor only).
+
+**Key bindings**:
+- `MeshtasticCarSession` → factory (new per session)
+- `EmergencyHandler` → singleton (one per process)
+- `CrashlyticsCarTagger` → singleton
+- All repositories, use cases → inherited from existing core modules (already in graph)
+
+## R8: AppFunctions Interop — Shared Interface Reuse
+
+**Decision**: Reuse `FuzzyNameResolver` pattern from AppFunctions for node name matching in voice replies
+
+**Rationale**: When a driver sends a direct message via voice, they may say a node name imprecisely. The AppFunctions feature (in-flight) implements fuzzy node name resolution. While the `AiFunctionProvider` interface is not yet merged, the car module can implement the same fuzzy matching logic directly using `NodeRepository.nodeDBbyNum` and Levenshtein distance or substring matching.
+
+**Implementation**: Standalone `FuzzyNodeNameResolver` utility class in `feature/car/util/` that queries `NodeRepository` and performs case-insensitive substring + edit-distance matching. If/when AppFunctions lands and exposes a shared resolver in `core/data/commonMain`, the car module can delegate to it.
+
+**Alternatives considered**:
+- Wait for AppFunctions to land first → Rejected: Unclear timeline; car module should not block on it
+- Exact match only → Rejected: Poor voice UX ("node exclamation one two three four" vs "James")
+
+## R9: Emergency Alert Banner Strategy
+
+**Decision**: Observe emergency messages via `PacketRepository` Flow, trigger CAL Banner API
+
+**Rationale**: Emergency messages are already classified in the packet data layer (message type/priority). The `EmergencyHandler` subscribes to the message flow, filters for emergency-priority packets, and immediately invokes `CarToast` + `AppManager.showAlert()` to display a Banner. The Banner overlays any active screen within CAL's rendering pipeline.
+
+**Audio**: Use `NotificationManager` to play a notification sound on the car's notification audio channel (`AudioAttributes.USAGE_NOTIFICATION`), not media channel (per NFR-008).
+
+**Alternatives considered**:
+- Poll for emergencies on timer → Rejected: Violates 1-second latency requirement
+- Use Android notifications only → Rejected: Would not overlay within CAL UI; needs in-app Banner
+
+## R10: Build Configuration — Google Flavor Only
+
+**Decision**: `feature/car` module included only in the `google` product flavor
+
+**Rationale**: CAL apps require Google Play Services for Android Auto projection. The F-Droid flavor explicitly excludes Google dependencies. The module is conditionally included via flavor-based dependency in `androidApp/build.gradle.kts`:
+
+```kotlin
+"googleImplementation"(projects.feature.car)
+```
+
+This mirrors existing patterns like Firebase/Maps dependencies being google-flavor-only.
+
+**Alternatives considered**:
+- Include in all flavors → Rejected: CAL requires Google Play Services; F-Droid builds would fail
+- Separate app module for car → Rejected: Adds unnecessary complexity; flavor separation is simpler
diff --git a/specs/20260521-153452-car-app-library-integration/spec.md b/specs/20260521-153452-car-app-library-integration/spec.md
new file mode 100644
index 000000000..9153549cf
--- /dev/null
+++ b/specs/20260521-153452-car-app-library-integration/spec.md
@@ -0,0 +1,470 @@
+# Feature Specification: Car App Library Integration
+
+**Feature Branch**: `feature/20260521-153452-car-app-library-integration`
+**Created**: 2026-05-21
+**Status**: Draft
+**Input**: Integrate Android Car App Library 1.9.0-alpha01 as a fully-featured, first-class car app
+**Cross-Platform Spec**: N/A — platform-specific only (Android Auto / AAOS exclusive; CAL has no cross-platform equivalent)
+
+## Summary
+
+Integrate the Android Car App Library 1.9.0-alpha01 into Meshtastic-Android to deliver a fully-featured, first-class automotive experience for Android Auto and Android Automotive OS. The integration creates a distraction-optimized, safety-first mesh radio interface for vehicles — enabling drivers to monitor mesh network status, read and reply to messages via voice, view node locations on maps, and receive emergency alerts with immediate prominence. A new `feature/car` module houses the Android-only CAL layer while reusing all shared business logic from existing core and feature modules.
+
+## Clarifications
+
+### Session 2026-05-21
+
+- Q: How should voice commands be implemented — CAL built-in voice input, full Assistant App Actions, or both? → A: CAL built-in voice input only (tap reply → dictate → send). System-level "Hey Google" commands are handled separately by the AppFunctions feature (`specs/20260521-091500-app-functions/`), which exposes `sendMessage`, `getMeshStatus`, `listNodes`, `getRecentMessages`, and `getNodePosition` to Android system AI (Gemini) automatically — including on car displays.
+- Q: Should the app declare NAVIGATION category for MapWithContentTemplate, or use PlaceListMapTemplate under POI? → A: **DECISION DEFERRED** — originally selected POI/PlaceListMapTemplate but reopened for further research. See US-5 deferral note for open questions on NAVIGATION vs POI implications.
+- Q: Should the CarAppService maintain an independent BLE connection or share the phone app's existing connection? → A: Shared connection — single Application-scoped BleConnectionManager instance via Koin. CarAppService keeps the process alive via Android Auto host; BLE connection persists at the Service/Application level, not Activity level.
+- Q: What observability approach should the car module use? → A: Reuse existing Crashlytics with `car_session` custom key tagging for car-specific filtering. No new observability infrastructure; tag existing analytics paths.
+- Q: Should the car app unlock additional features when the vehicle is parked? → A: No parked-mode differentiation. Templated messaging apps provide a uniform experience regardless of driving state. Voice reply is built into ConversationItem. The Android Auto host enforces its own driving restrictions; the app just provides templates.
+
+## Goals
+
+1. **Complete automotive mesh experience** — Deliver all seven core screens (messaging, node dashboard, channel management, emergency alerts, map, quick actions, mesh status panel) as a single release
+2. **Safety-first interaction model** — Every interaction completes in ≤ 2 taps or via voice, meeting automotive distraction guidelines
+3. **Leverage 1.9.0-alpha01 components** — Showcase Spotlight Sections, Condensed Items, Chips, Minimized Control Panel, Banners, Section Headers, and Expanded Headers for a modern car UI
+4. **Zero disruption to existing app** — The new `feature/car` module integrates via dependency injection without modifying existing module APIs or behavior
+5. **Voice-first messaging** — Message composition defaults to voice input, with quick-reply templates as fallback for hands-free operation
+
+## Non-Goals
+
+- Firmware updates via the car interface (too complex and risky while driving)
+- Full settings UI in-car (a minimal parked-only subset may be considered in future)
+- Desktop or iOS car support (this is Android Auto / AAOS specific)
+- Video playback or media/audio streaming features
+- Compose UI interop (CAL uses its own template-based rendering system)
+- Google Assistant App Actions / voice command routing (handled by separate AppFunctions feature)
+- NAVIGATION category declaration / live map tracking (deferred to v2; v1 uses POI with PlaceListMapTemplate)
+- Phone app UI changes (car UI is additive only)
+
+## User Scenarios & Testing *(mandatory)*
+
+### User Story 1 - Read and Reply to Mesh Messages While Driving (Priority: P1)
+
+A driver receives mesh messages from their group while on the road. They glance at the head unit to see new messages and use voice to compose a reply, keeping hands on the wheel and eyes on the road.
+
+**Why this priority**: Messaging is the primary use case for Meshtastic. Enabling safe in-car messaging addresses the #1 reason users would want car integration.
+
+**Independent Test**: Can be fully tested by sending a message from a second Meshtastic device, verifying it appears on the car display, and dictating a voice reply that arrives on the sender's device.
+
+**Acceptance Scenarios**:
+
+1. **Given** the car app is connected to a Meshtastic radio and a new message arrives, **When** the driver views the messaging screen, **Then** the new message appears within 3 seconds with sender name, timestamp, and message content visible at a glance
+2. **Given** the driver is viewing a conversation, **When** they tap the reply action, **Then** the system presents voice input as the default composition method
+3. **Given** the driver has initiated voice reply, **When** they speak their message and confirm, **Then** the message is sent to the correct channel/DM within 2 seconds
+4. **Given** the driver prefers not to use voice, **When** they select quick-reply, **Then** a list of configurable template responses (e.g., "On my way", "Copy that", "10 minutes out") is presented for one-tap selection
+5. **Given** the mesh radio is disconnected, **When** the driver opens messaging, **Then** a banner clearly indicates offline status and cached messages remain visible as read-only
+
+---
+
+### User Story 2 - Emergency Alert Reception (Priority: P1)
+
+A driver receives an emergency alert broadcast from a mesh node (SOS, hazard warning, etc.). The alert demands immediate attention with distinct visual and audio treatment, regardless of which screen is currently active.
+
+**Why this priority**: Emergency alerts are life-safety critical. Failure to surface them prominently could have real-world safety consequences.
+
+**Independent Test**: Can be tested by triggering an emergency broadcast from a test device and verifying the car app interrupts current activity with a banner alert.
+
+**Acceptance Scenarios**:
+
+1. **Given** any screen is active, **When** an emergency message is received, **Then** a high-priority banner appears immediately (within 1 second) with emergency iconography and distinct color treatment
+2. **Given** an emergency banner is displayed, **When** the driver taps it, **Then** full emergency details are shown including sender identity, location (if available), and timestamp
+3. **Given** an emergency alert has been received, **When** the driver navigates to the messaging screen, **Then** the emergency message appears in a Spotlight Section at the top, visually distinguished from normal messages
+4. **Given** emergency audio alerts are enabled, **When** an emergency message arrives, **Then** an audible notification tone plays through the car's audio system
+
+---
+
+### User Story 3 - Monitor Node Network Status (Priority: P2)
+
+A driver glances at the head unit to check how many mesh nodes are in range, their signal strength, and battery levels — useful for caravan/convoy scenarios or checking if they're still in range of base camp.
+
+**Why this priority**: Node awareness is the second-most-common Meshtastic use case and provides critical situational awareness for mobile users.
+
+**Independent Test**: Can be tested by having 3+ nodes in range and verifying the dashboard displays each with correct signal/battery metrics.
+
+**Acceptance Scenarios**:
+
+1. **Given** the car app is connected with multiple nodes in range, **When** the driver opens the node dashboard, **Then** all known nodes are displayed as Condensed Items showing node name, signal quality indicator, and battery level
+2. **Given** 6+ nodes are in range, **When** viewing the dashboard, **Then** at least 6 nodes are visible simultaneously without scrolling (leveraging Condensed Items)
+3. **Given** a node goes offline, **When** the dashboard refreshes, **Then** the offline node is visually distinguished (dimmed or marked) and sorted to the bottom
+4. **Given** the node list is displayed, **When** the driver taps a node, **Then** a detail view shows last heard time, distance (if location known), hardware model, and direct message option
+
+---
+
+### User Story 4 - Switch Between Channels (Priority: P2)
+
+A driver participating in multiple mesh channels (e.g., "Convoy", "Emergency", "General") quickly switches between them to view messages from different groups.
+
+**Why this priority**: Channel management is essential for users in organized groups and must be achievable without complex navigation.
+
+**Independent Test**: Can be tested by configuring 3+ channels and verifying single-tap channel switching via chips.
+
+**Acceptance Scenarios**:
+
+1. **Given** the device has multiple channels configured, **When** the messaging screen loads, **Then** channel chips are displayed at the top allowing single-tap switching
+2. **Given** channel chips are visible, **When** the driver taps a different channel chip, **Then** the message list updates to show that channel's messages within 1 second
+3. **Given** a channel has unread messages, **When** viewing the chip bar, **Then** that channel's chip displays an unread indicator (badge or visual emphasis)
+
+---
+
+### User Story 5 - View Node Locations on Map (Priority: DEFERRED)
+
+> **⚠️ DEFERRED:** Map implementation is deferred pending further research and discussion on whether to pursue POI category (PlaceListMapTemplate, limited but simpler) or NAVIGATION category (MapWithContentTemplate, full-featured but triggers stricter Play Store review and conflicts with active nav apps). This decision has significant architectural and distribution implications that warrant dedicated analysis.
+
+A driver in a convoy scenario views the locations of all mesh nodes on a map to understand relative positions and navigate toward or away from group members.
+
+**Why deferred**: The choice between POI (static pins, 6-item cap, no routing conflicts) and NAVIGATION (live tracking, full map control, but exclusive with Google Maps/Waze) fundamentally shapes the UX and distribution strategy. More research needed on:
+- Google Maps SDK availability for AAOS (announced I/O 2026, timeline unclear)
+- NAVIGATION category Play Store review requirements and timeline
+- Whether Meshtastic's convoy use case justifies NAVIGATION exclusivity
+- User expectations (passive awareness vs. active routing toward nodes)
+
+**Acceptance Scenarios** (to be finalized after map strategy decision):
+
+1. **Given** nodes are reporting GPS positions, **When** the driver opens the map screen, **Then** node locations are displayed with correct positions
+2. **Given** the map is displayed, **When** the driver selects a node, **Then** a detail view shows node name, distance, last update time, and option to send a direct message
+3. **Given** the driver's own position is available, **When** viewing the map, **Then** their position is shown distinctly from other nodes
+4. **Given** a node's position updates, **When** the map is visible, **Then** the display updates within 5 seconds
+
+---
+
+### User Story 6 - Persistent Mesh Status at a Glance (Priority: P3)
+
+While using any car app feature, the driver can glance at a persistent mini-panel showing mesh connectivity health — how many nodes are online, time since last message, and connection status to the radio.
+
+**Why this priority**: Persistent status awareness reduces the need to navigate between screens, minimizing distraction.
+
+**Independent Test**: Can be tested by verifying the minimized control panel remains visible across all screens and updates in real-time.
+
+**Acceptance Scenarios**:
+
+1. **Given** the car app is active on any screen, **When** the driver glances at the minimized control panel, **Then** they see: radio connection status, node count online, and time since last received message
+2. **Given** the radio disconnects, **When** the status panel updates, **Then** it clearly indicates "Disconnected" with warning iconography
+3. **Given** the minimized panel is visible, **When** the driver taps it, **Then** it expands to show additional detail (mesh name, own node battery, firmware version)
+
+---
+
+### User Story 7 - In-Context Voice Input for Actions (Priority: P3)
+
+A driver uses CAL's built-in voice input to compose messages and perform actions without typing — tapping reply then dictating, or using TTS readback of messages. System-level voice commands ("Hey Google, send Meshtastic message to John") are handled separately by the AppFunctions feature and work automatically on car displays without car module code.
+
+**Why this priority**: Voice is the safest interaction modality while driving and rounds out the hands-free experience.
+
+**Independent Test**: Can be tested by tapping the reply action, dictating a message via CAL voice input, and verifying delivery. System-level "Hey Google" commands are tested via the AppFunctions spec.
+
+**Acceptance Scenarios**:
+
+1. **Given** the car app is on a conversation screen, **When** the driver taps the reply action and speaks a message, **Then** voice composition targets that node/channel using CAL's built-in voice input API
+2. **Given** a message is displayed, **When** the driver taps a "read aloud" action, **Then** the message is read via TTS including sender name and content
+3. **Given** the driver initiates a direct message from the node dashboard, **When** they tap a node and select "message", **Then** voice input is presented as the default composition method with `FuzzyNameResolver` used for node name matching
+
+---
+
+### Edge Cases
+
+- What happens when the Bluetooth connection to the Meshtastic radio drops mid-conversation? → Banner notification + graceful degradation to cached data, auto-reconnect in background
+- What happens when the message list exceeds CAL template item limits? → Cap at 10 conversations with 5 messages each per Android Auto best practices; most recent first
+- How does the system handle very long messages that exceed car display constraints? → Truncation with "..." and full message available on tap or read-aloud
+- What happens when outgoing messages exceed 237 bytes (Meshtastic protocol limit)? → Reject with user feedback ("Message too long"); do not attempt to send
+- What happens when the car's system restricts interaction (e.g., car moving at speed)? → No parked-mode differentiation; the templated messaging UI is uniform regardless of driving state. Voice reply is built into ConversationItem automatically. The Android Auto host enforces its own driving restrictions — the app provides templates only.
+- What happens when multiple emergency alerts arrive simultaneously? → Stack as multiple banners; Spotlight Section shows all active emergencies chronologically
+- How does the app handle no configured channels? → Show onboarding prompt directing user to configure channels on their phone first
+- What happens with emoji-only or admin messages? → Filtered from car display entirely (not shown in conversation list or read aloud)
+- What happens on initial session connect with existing unread messages? → Batch-load up to 50 unread messages across conversations; also post MessagingStyle notifications for read-back support
+- How are favorites vs recent contacts distinguished? → Favorites (node.favorite == true) grouped at top of DM list with Section Header; remaining contacts sorted by last-heard, capped at 24
+
+## Requirements *(mandatory)*
+
+### Functional Requirements
+
+- **FR-001**: System MUST register as a Car App Service discoverable by Android Auto and AAOS hosts
+- **FR-002**: System MUST display incoming mesh messages in a scrollable list grouped by channel using Section Headers
+- **FR-003**: System MUST support voice-based message composition as the primary reply method
+- **FR-004**: System MUST provide quick-reply templates selectable with a single tap
+- **FR-005**: System MUST display emergency messages as high-priority Banners that overlay any active screen within 1 second of receipt
+- **FR-006**: System MUST present emergency messages in a Spotlight Section when viewing the messaging screen
+- **FR-007**: System MUST display all known mesh nodes as Condensed Items showing name, signal quality, and battery level
+- **FR-008**: System MUST support channel switching via Chips displayed at the top of the messaging screen
+- **FR-009**: ~~DEFERRED~~ — Map implementation deferred pending NAVIGATION vs POI category decision. See User Story 5.
+- **FR-010**: System MUST maintain a persistent Minimized Control Panel showing radio status, online node count, and last message time
+- **FR-011**: System MUST display a Banner when the Bluetooth connection to the radio is lost
+- **FR-012**: System MUST support expanding node details on tap (last heard, distance, hardware model)
+- **FR-013**: System MUST use Expanded Header Layout for the node dashboard showing mesh topology summary
+- **FR-014**: System MUST declare MESSAGING as the primary category. POI or NAVIGATION as secondary category is deferred pending map strategy decision.
+- **FR-015**: System MUST gracefully degrade to cached/read-only data when the mesh radio is disconnected
+- **FR-016**: System MUST support unread message indicators on channel Chips
+- **FR-017**: System MUST filter emoji-only and admin messages from the car display (only text messages shown)
+- **FR-018**: System MUST reject outgoing messages exceeding 237 bytes (Meshtastic packet limit) with user-visible feedback
+- **FR-019**: System MUST display at most 10 conversations and at most 5 messages per ConversationItem, per Android Auto best practices
+- **FR-020**: System MUST group direct message contacts into "Favorites" (nodes marked favorite) and "Recent" sections using Section Headers
+- **FR-021**: System MUST load up to 50 unread messages across conversations on session start, most recent first
+- **FR-022**: System MUST also implement notification-based messaging (NotificationCompat.MessagingStyle with reply and mark-as-read Actions) as required by templated messaging apps
+- **FR-023**: System MUST display transient CarToast feedback for user actions (message sent, message failed, reconnection events)
+- **FR-024**: System MUST support pull-to-refresh (OnContentRefreshListener) on message and node list screens
+- **FR-025**: System MUST present emergency alerts as modal Alert dialogs (CAL Alert API) requiring explicit acknowledgment
+- **FR-026**: System SHOULD use LongMessageTemplate for viewing full conversation history beyond the 5-message list limit
+- **FR-027**: System SHOULD provide responsive text variants (CarText.addVariant) for narrow vs wide head unit displays
+- **FR-028**: System SHOULD restrict message composition actions to parked state via ParkedOnlyOnClickListener
+
+### Non-Functional Requirements
+
+- **NFR-001**: All interactive elements MUST be reachable within 2 taps from any screen
+- **NFR-002**: New message display latency MUST be ≤ 3 seconds from radio receipt to screen render
+- **NFR-003**: Car app battery overhead MUST be < 10% additional drain compared to the phone app running alone
+- **NFR-004**: Car App minimum API level MUST be Car API Level 8 (required for 1.9.0 components)
+- **NFR-005**: The car module MUST NOT introduce dependencies that affect the phone app's build time by more than 5%
+- **NFR-006**: All text elements MUST meet automotive readability guidelines (minimum font sizes per OEM requirements)
+- **NFR-007**: The app MUST support both Android Auto (projection) and AAOS (embedded) deployment modes
+- **NFR-008**: Emergency alert audio MUST play through the car's notification channel, not media channel
+- **NFR-009**: Car module MUST tag all Crashlytics events with a `car_session` custom key (value: session ID) to enable car-specific crash/ANR filtering and diagnosis
+- **NFR-010**: Screen invalidation MUST be debounced (≥300ms) and MUST NOT recreate Screen objects; use `invalidate()` to trigger `onGetTemplate()` re-evaluation, matching CarPlay's proven refresh pattern
+- **NFR-011**: Template data refresh latency MUST be ≤500ms from invalidation trigger to rendered update
+
+## Architecture
+
+### Key Components
+
+| Component | Module / File | Purpose |
+|-----------|---------------|---------|
+| MeshtasticCarAppService | `feature/car/service/` | CAL Session host, entry point for Android Auto/AAOS |
+| MessagingScreen | `feature/car/screens/` | Message list with channel chips, voice reply, quick-reply |
+| NodeDashboardScreen | `feature/car/screens/` | Condensed Items grid of all mesh nodes |
+| ~~MapScreen~~ | ~~`feature/car/screens/`~~ | ~~PlaceListMapTemplate showing node positions~~ — **DEFERRED** |
+| EmergencyHandler | `feature/car/alerts/` | Banner management for emergency messages |
+| MeshStatusPanel | `feature/car/panels/` | Minimized Control Panel with mesh health |
+| CarMessageRepository | `core/data/` | Existing message repository (reused) |
+| CarNodeRepository | `core/data/` | Existing node repository (reused) |
+| ChannelManager | `core/domain/` | Existing channel logic (reused) |
+| BleConnectionManager | `core/ble/` | Existing BLE connection (reused; Application-scoped singleton shared with phone app — CarAppService keeps process alive via host) |
+
+### Component Interaction
+
+```
+┌─────────────────────────────────────────────────┐
+│ Android Auto / AAOS Host │
+└────────────────────┬────────────────────────────┘
+ │ CAL Session
+┌────────────────────▼────────────────────────────┐
+│ MeshtasticCarAppService │
+│ ┌──────────┐ ┌──────────┐ ┌──────────────────┐│
+│ │Messaging │ │ Nodes │ │ Map Screen ││
+│ │ Screen │ │Dashboard │ │ ││
+│ └────┬─────┘ └────┬─────┘ └───────┬──────────┘│
+│ │ │ │ │
+│ ┌────▼─────────────▼───────────────▼──────────┐│
+│ │ MeshStatusPanel (persistent) ││
+│ └─────────────────────────────────────────────┘│
+│ ┌─────────────────────────────────────────────┐│
+│ │ EmergencyHandler (banners) ││
+│ └─────────────────────────────────────────────┘│
+└────────────────────┬────────────────────────────┘
+ │ Koin DI
+┌────────────────────▼────────────────────────────┐
+│ Shared Business Logic (core/) │
+│ ┌─────────┐ ┌─────────┐ ┌────────┐ ┌───────┐ │
+│ │Messages │ │ Nodes │ │Channels│ │ BLE │ │
+│ │ Repo │ │ Repo │ │Manager │ │Connect│ │
+│ └─────────┘ └─────────┘ └────────┘ └───────┘ │
+└─────────────────────────────────────────────────┘
+```
+
+## Source-Set Impact
+
+| Source Set | Impact | Justification |
+|-----------|--------|---------------|
+| `commonMain` | No changes | All shared business logic already exists in core modules |
+| `androidMain` | New `feature/car` module | CAL is Android-only; entire car UI layer is platform-specific |
+
+## Design Standards Compliance
+
+- [ ] New screens reviewed against automotive HMI distraction guidelines (NHTSA Phase 2)
+- [ ] CAL template system used exclusively (no custom rendering that bypasses automotive safety checks)
+- [ ] Accessibility: Voice readback of all visual information, high-contrast automotive color schemes
+- [ ] Typography: Uses CAL's built-in automotive-safe text sizing (enforced by host)
+- [ ] Emergency alerts use distinct visual language (color, iconography) distinguishable from informational banners
+
+## Privacy Assessment
+
+- [ ] No PII, location data, or cryptographic keys logged or exposed beyond what existing modules already handle
+- [ ] Car app reuses existing data layer — no new network calls or data collection
+- [ ] Node location data displayed on map uses existing privacy controls (user opt-in for position sharing)
+- [ ] No data sent to third-party automotive services
+- [ ] Proto submodule (`core/proto`) not modified (read-only upstream)
+
+## Success Criteria *(mandatory)*
+
+### Measurable Outcomes
+
+- **SC-001**: Users can read a new message and send a voice reply in under 15 seconds total interaction time
+- **SC-002**: Emergency alerts are visible to the driver within 1 second of receipt by the radio
+- **SC-003**: Node dashboard displays 6+ nodes simultaneously without scrolling (Condensed Items density)
+- **SC-004**: All primary actions (read message, reply, check nodes, view map) reachable within 2 taps from home
+- **SC-005**: Car app adds < 10% battery drain overhead compared to phone-only operation over a 1-hour driving session
+- **SC-006**: Channel switching completes (chip tap to new message list rendered) within 1 second
+- **SC-007**: App passes Android Auto App Quality review criteria for the MESSAGING category
+- **SC-008**: 95% of voice-initiated replies complete successfully without fallback to touch input
+- **SC-009**: ~~DEFERRED~~ — Map latency criterion deferred with map implementation
+- **SC-010**: Zero crashes or ANRs attributed to the car module during a 2-hour continuous driving session
+
+## Assumptions
+
+- Car App Library 1.9.0-alpha01 APIs are sufficiently stable for production use (alpha risk accepted per user directive)
+- The existing `core/data` repositories provide all necessary data access; no new data sources required
+- Meshtastic radio remains paired and connected via BLE during driving (standard operating mode)
+- BLE connection is Application-scoped (not Activity-scoped); CarAppService keeps the host process alive so the connection naturally persists regardless of phone app Activity state
+- Users have already configured channels and node settings via the phone app before driving
+- Android Auto host enforces its own distraction-optimization rules (template item limits, interaction restrictions); the app respects these constraints
+- The `google` build flavor is the distribution target; F-Droid/GitHub flavors do not include car support
+- Quick-reply templates are configurable via the phone app's settings; the car app consumes them read-only
+- Voice input quality depends on the car's microphone hardware; the app delegates to Android's speech recognition system
+- Map template strategy (POI vs NAVIGATION category) is deferred; no map screen in initial implementation
+- Minimum Car API Level 8 is required; older Android Auto hosts will not show the app (graceful absence, not crash)
+- Koin dependency injection is used consistently with Koin Annotations for the new module
+- TTS (text-to-speech) for reading messages aloud uses Android's built-in TTS engine
+
+## External References & Research
+
+### Official Documentation
+
+| Resource | URL | Relevance |
+|----------|-----|-----------|
+| Car App Library Release Notes | https://developer.android.com/jetpack/androidx/releases/car-app | 1.8.0-beta01 & 1.9.0-alpha01 component APIs |
+| Building Car Apps (Training) | https://developer.android.com/training/cars/apps | CarAppService setup, templates, lifecycle |
+| Templated Messaging Guide | https://developer.android.com/training/cars/communication/templated-messaging | ConversationItem, voice reply, notification integration |
+| Notification-based Messaging | https://developer.android.com/training/cars/messaging | MessagingStyle, reply/mark-as-read Actions |
+| Android Auto Add Support | https://developer.android.com/training/cars/apps/auto | Manifest, automotive_app_desc.xml, projection |
+| Component Design Guidance | https://developer.android.com/design/ui/cars/guides/components/overview | Automotive HMI patterns |
+| Car App Quality Guidelines | https://developer.android.com/docs/quality-guidelines/car-app-quality | Review criteria for MESSAGING category |
+| Testing with DHU | https://developer.android.com/training/cars/testing | Desktop Head Unit setup and usage |
+
+### Google I/O 2026 Announcements
+
+| Resource | URL | Key Takeaways |
+|----------|-----|---------------|
+| Android for Cars: Unifying Platforms | https://android-developers.googleblog.com/2026/05/android-for-cars-unifying-platforms-premium-experiences.html | CAL 1.8.0 media templates, CAL 1.9.0 components, Material 3 Expressive, video support |
+
+### Key API Patterns from Official Docs
+
+#### Templated Messaging (from official guidance)
+
+- **ConversationItem** auto-provides voice reply + mark-as-read actions
+- Max **5–10 conversations**, each with ≤ **5 messages**
+- Refresh cadence: ≤ **500ms** per invalidation
+- Must also implement **notification-based messaging** (MessagingStyle) as fallback
+- Distribution: Currently **internal + closed testing** tracks only (production opening later)
+
+#### Manifest Requirements
+
+```xml
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+```
+
+#### ConversationItem Pattern (from official sample)
+
+```kotlin
+ConversationItem.Builder()
+ .setConversationCallback(callback)
+ .setId(conversation.id)
+ .setTitle(conversation.title)
+ .setIcon(conversation.icon)
+ .setMessages(carMessages)
+ .setSelf(selfPerson)
+ .setGroupConversation(conversation.isGroup)
+ .build()
+```
+
+### Related In-Flight Features
+
+| Feature | Branch | Spec | Relationship |
+|---------|--------|------|-------------|
+| App Functions | `jamesarich/crispy-barnacle` | `specs/20260521-091500-app-functions/` | Provides "Hey Google" system AI integration for sendMessage, getMeshStatus, listNodes, getRecentMessages, getNodePosition — complementary to CAL voice input |
+
+#### Shared Infrastructure from AppFunctions
+
+- **`AiFunctionProvider`** interface in `core/data/commonMain` — platform-agnostic contract for AI-driven operations
+- **`FuzzyNameResolver`** in `core/data/commonMain` — LCS-based node/channel name matching (50% threshold)
+- **`RateLimiter`** in `core/data/commonMain` — sliding window rate limiter (5 calls/60s) for mesh airtime protection
+- **Architecture pattern:** Thin Android wrappers (`androidApp/src/google/`) calling shared business logic
+
+#### Integration Points
+
+- Car module reuses `FuzzyNameResolver` for voice reply targeting (e.g., "reply to James" → resolve to node)
+- `RateLimiter` can protect car-originated sends from exceeding mesh airtime
+- AppFunctions "Hey Google" commands work on car displays automatically (system-level, no car module code needed)
+- Both features share: `NodeRepository`, `CommandSender`, `RadioConfigRepository`, `PacketRepository`
+
+### CAL 1.9.0-alpha01 Component Reference
+
+| Component | API Class | Min Car API | Use in Meshtastic |
+|-----------|-----------|-------------|-------------------|
+| Spotlight Section | `SpotlightSection.Builder()` | 8 | Emergency messages pinned at top |
+| Condensed Items | `CondensedItem.Builder()` | 8 | Dense node list (6+ visible) |
+| Chips | `Chip.Builder()` | 8 | Channel switching + unread badges |
+| Minimized Control Panel | `SectionedItemTemplate` | 8 | Persistent mesh status strip |
+| Banners | `Banner.Builder()` | 8 | Emergency overlay + disconnection alerts |
+| Section Headers | `SectionHeader.Builder()` | 8 | Message grouping by channel |
+| Expanded Header Layout | `Header.Builder()` | 8 | Mesh topology summary (node dashboard) |
+
+### Distribution Constraints (as of May 2026)
+
+- **Templated messaging apps:** Internal + closed testing tracks only on Play Store
+- **Production track:** Not yet open for templated messaging category
+- **AAOS:** Separate distribution channel (OEM app stores or Play for Automotive)
+- **F-Droid:** Excluded (CAL requires Google Play Services)
+- **Timeline:** Production track expected to open "later" per Google (no firm date)
+
+### Cross-Platform Parity: Meshtastic-Apple CarPlay
+
+**Source:** `Meshtastic-Apple/Meshtastic/CarPlay/` (main branch, May 21, 2026)
+
+**Apple CarPlay features (shipped):**
+- Two-tab UI: Channels + Direct Messages (with Favorites/Recent sections)
+- SiriKit voice compose/read-back via `INSendMessageIntent`
+- Unread badges per channel and per DM
+- "Not Connected" graceful degradation
+- Live Activity (Dynamic Island) with node telemetry stats
+- Batch donation of 50 unread messages on session start
+- 300ms debounced refresh (updateSections, not rebuild)
+- Message search via `INSearchForMessagesIntent`
+- Message filtering: no emoji-only, no admin messages
+- 200-byte message limit enforcement
+
+**Parity decisions incorporated into this spec:**
+- FR-017: Message filtering (emoji/admin exclusion) — matches Apple
+- FR-018: Message size limit enforcement — matches Apple (237 bytes for Meshtastic)
+- FR-019: Conversation caps (10 convos, 5 msgs each) — per Android guidance
+- FR-020: Favorites section grouping — matches Apple's Favorites/Recent pattern
+- FR-021: Session start unread batch load — matches Apple's 50-message donation
+- FR-022: Notification-based messaging fallback — required per Android templated messaging docs
+- NFR-010: Refresh debouncing (≥300ms) — matches Apple's proven 300ms debounce
+- NFR-011: Refresh latency (≤500ms) — matches Apple's observed performance
+
+**Android-exclusive features (exceeding Apple):**
+- Node dashboard with Condensed Items (Apple has no node visibility)
+- Emergency Banner overlays with audio alerts (Apple shows emergencies as regular messages)
+- ~~Map integration~~ (DEFERRED pending NAVIGATION vs POI decision)
+- Channel Chips for instant switching (Apple requires tab navigation)
+- Quick-reply templates (Apple only offers Siri voice)
+- Visual hierarchy via Spotlight/Section Headers/Expanded Headers
+- Persistent Minimized Control Panel (Apple uses separate Live Activity)
+
+**Deferred to v2 (Apple has, we don't yet):**
+- Message search (SearchTemplate or via AppFunctions)
+- Live Activity equivalent (Android ongoing notification with mesh telemetry)
diff --git a/specs/20260521-153452-car-app-library-integration/tasks.md b/specs/20260521-153452-car-app-library-integration/tasks.md
new file mode 100644
index 000000000..6b0029b9e
--- /dev/null
+++ b/specs/20260521-153452-car-app-library-integration/tasks.md
@@ -0,0 +1,286 @@
+# Tasks: Car App Library Integration
+
+**Input**: Design documents from `/specs/20260521-153452-car-app-library-integration/`
+
+**Prerequisites**: plan.md ✅, spec.md ✅, research.md ✅, data-model.md ✅, contracts/ ✅
+
+**Tests**: Not explicitly requested in spec. Test tasks omitted per template rules.
+
+**Verification**: Constitution-required validation (spotlessCheck, detekt, compile/test) included in final phase.
+
+## Format: `[ID] [P?] [Story] Description`
+
+- **[P]**: Can run in parallel (different files, no dependencies)
+- **[Story]**: Which user story this task belongs to (e.g., US1, US2, US3)
+- Include exact file paths in descriptions
+
+---
+
+## Phase 1: Setup (Project Initialization)
+
+**Purpose**: Create the `feature/car` module structure, Gradle configuration, and version catalog entries
+
+- [x] T001 Add Car App Library version catalog entries in gradle/libs.versions.toml (car-app version, 4 library entries)
+- [x] T002 Add `include(":feature:car")` to settings.gradle.kts
+- [x] T003 Create module build file at feature/car/build.gradle.kts with android-library, flavors, koin plugins, and all dependencies per contracts/manifest-declarations.md
+- [x] T004 [P] Add `"googleImplementation"(projects.feature.car)` dependency in androidApp/build.gradle.kts
+- [x] T005 [P] Create AndroidManifest.xml at feature/car/src/main/AndroidManifest.xml with CarAppService, MESSAGING category, and minCarApiLevel 8 meta-data
+- [x] T006 [P] Create AAOS descriptor at feature/car/src/main/res/xml/automotive_app_desc.xml
+- [x] T007 [P] Create car-specific strings file at feature/car/src/main/res/values/strings.xml with initial string resources
+
+---
+
+## Phase 2: Foundational (Blocking Prerequisites)
+
+**Purpose**: Core infrastructure that ALL user stories depend on — service entry point, session lifecycle, DI, utilities
+
+**⚠️ CRITICAL**: No user story work can begin until this phase is complete
+
+- [x] T008 Create Koin DI module at feature/car/src/main/kotlin/org/meshtastic/feature/car/di/FeatureCarModule.kt declaring MeshtasticCarSession (factory), EmergencyHandler (singleton), CrashlyticsCarTagger (singleton)
+- [x] T009 Register FeatureCarModule in androidApp google flavor Koin configuration (androidApp/src/google/ Koin app module graph)
+- [x] T010 [P] Create CrashlyticsCarTagger utility at feature/car/src/main/kotlin/org/meshtastic/feature/car/util/CrashlyticsCarTagger.kt implementing car_session custom key set/clear
+- [x] T011 [P] Create TemplateBuilders helper extensions at feature/car/src/main/kotlin/org/meshtastic/feature/car/util/TemplateBuilders.kt with reusable CAL template construction helpers
+- [x] T012 Create MeshtasticCarAppService at feature/car/src/main/kotlin/org/meshtastic/feature/car/service/MeshtasticCarAppService.kt extending CarAppService, creating sessions via Koin
+- [x] T013 Create MeshtasticCarSession at feature/car/src/main/kotlin/org/meshtastic/feature/car/service/MeshtasticCarSession.kt with onCreateScreen (returns HomeScreen), onNewIntent, onCarConfigurationChanged, Crashlytics tagging, 300ms invalidation debouncing
+- [x] T014 Create presentation state models (CarSessionState, ConnectionStatus, MessagingUiState, ChannelUi, ConversationUi, NodeDashboardUiState, NodeUi, SignalQuality, TopologyHeader, EmergencyAlert) at feature/car/src/main/kotlin/org/meshtastic/feature/car/model/CarUiModels.kt
+- [x] T015 Create HomeScreen (TabTemplate with Messages/Nodes tabs; Map tab placeholder deferred) at feature/car/src/main/kotlin/org/meshtastic/feature/car/screens/HomeScreen.kt
+
+**Checkpoint**: Foundation ready — CarAppService binds, session creates, HomeScreen renders tabs. User story implementation can now begin in parallel.
+
+---
+
+## Phase 3: User Story 1 — Read and Reply to Mesh Messages While Driving (Priority: P1) 🎯 MVP
+
+**Goal**: Drivers can view incoming mesh messages grouped by channel and reply via voice or quick-reply templates
+
+**Independent Test**: Send a message from a second Meshtastic device → appears on car display within 3s → dictate voice reply → arrives on sender's device
+
+### Implementation for User Story 1
+
+- [x] T016 [P] [US1] Create MessagingScreen at feature/car/src/main/kotlin/org/meshtastic/feature/car/screens/MessagingScreen.kt with ListTemplate, channel Chips header, Section Headers grouping conversations, ConversationItem list (max 10), 300ms debounced invalidation, favorites/recent DM grouping
+- [x] T017 [P] [US1] Create ConversationScreen at feature/car/src/main/kotlin/org/meshtastic/feature/car/screens/ConversationScreen.kt with MessageTemplate showing messages (max 5 per conversation), voice reply action via CAL built-in ConversationItem voice input, quick-reply action list from QuickChatActionRepository, read-aloud TTS action
+- [x] T018 [US1] Reuse `FuzzyNameResolver` from `core/data/commonMain` (shared with AppFunctions feature) for voice-initiated DM node name matching — inject via Koin from existing `core/data` module. If AppFunctions branch not yet merged, temporarily duplicate LCS algorithm in feature/car/src/main/kotlin/org/meshtastic/feature/car/util/FuzzyNodeNameResolver.kt with TODO to consolidate post-merge
+- [x] T019 [US1] Implement message filtering logic in MessagingScreen — exclude emoji-only and admin messages from display (FR-017), enforce 237-byte outgoing limit with user feedback (FR-018)
+- [x] T020 [US1] Implement session-start batch loading of up to 50 unread messages in MeshtasticCarSession (FR-021) and post MessagingStyle notifications for read-back support
+- [x] T021 [US1] Implement notification-based messaging (NotificationCompat.MessagingStyle with reply and mark-as-read Actions) at feature/car/src/main/kotlin/org/meshtastic/feature/car/service/CarNotificationManager.kt (FR-022)
+
+**Checkpoint**: Messaging fully functional — driver can see messages, switch channels, voice reply, use quick-reply templates, and receive MessagingStyle notifications
+
+---
+
+## Phase 4: User Story 2 — Emergency Alert Reception (Priority: P1)
+
+**Goal**: Emergency broadcasts immediately surface as prominent banners with audio alerts regardless of active screen
+
+**Independent Test**: Trigger emergency broadcast from test device → banner appears within 1s → audio alert plays → tap shows full details in Spotlight Section
+
+### Implementation for User Story 2
+
+- [x] T022 [P] [US2] Create EmergencyHandler at feature/car/src/main/kotlin/org/meshtastic/feature/car/alerts/EmergencyHandler.kt observing PacketRepository flow for emergency-priority packets, triggering Banner via AppManager.showAlert(), managing active emergency list, stacking multiple banners chronologically
+- [x] T023 [US2] Implement emergency audio alert playback in EmergencyHandler using NotificationManager on USAGE_NOTIFICATION audio channel (NFR-008), not media channel
+- [x] T024 [US2] Integrate Spotlight Section in MessagingScreen for active emergencies — display EmergencyAlert items at top of messaging list when activeEmergencies is non-empty (FR-006). **Depends on T016 (MessagingScreen must exist first)**
+- [x] T025 [US2] Wire EmergencyHandler into MeshtasticCarSession lifecycle — start collecting on onCreateScreen, stop on session destroy
+
+**Checkpoint**: Emergency alerts fully operational — banners overlay any screen within 1s, audio plays, Spotlight Section shows in messaging view
+
+---
+
+## Phase 5: User Story 3 — Monitor Node Network Status (Priority: P2)
+
+**Goal**: Driver views all mesh nodes as a dense Condensed Items grid with signal/battery metrics and topology header
+
+**Independent Test**: Have 3+ nodes in range → open node dashboard → all nodes displayed with correct signal/battery → tap node → detail view shows full info
+
+### Implementation for User Story 3
+
+- [x] T026 [P] [US3] Create NodeDashboardScreen at feature/car/src/main/kotlin/org/meshtastic/feature/car/screens/NodeDashboardScreen.kt with ListTemplate, Expanded Header Layout (mesh topology summary: online/total), Condensed Items for each node (name, signal quality, battery), online-first sorting with offline dimmed at bottom
+- [x] T027 [P] [US3] Create NodeDetailScreen at feature/car/src/main/kotlin/org/meshtastic/feature/car/screens/NodeDetailScreen.kt with PaneTemplate showing last heard, distance, hardware model, battery, SNR, and "Message" action to push ConversationScreen for DM
+
+**Checkpoint**: Node dashboard shows 6+ nodes without scrolling via Condensed Items, detail drill-down works, DM action connects to messaging
+
+---
+
+## Phase 6: User Story 4 — Switch Between Channels (Priority: P2)
+
+**Goal**: Single-tap channel switching via Chips with unread badges at the top of the messaging screen
+
+**Independent Test**: Configure 3+ channels → messaging screen shows channel chips → tap chip → message list updates within 1s → unread badge visible on channels with new messages
+
+### Implementation for User Story 4
+
+- [x] T028 [US4] Implement channel Chip actions with unread badge indicators in MessagingScreen header — single-tap switches selectedChannelIndex, triggers message list re-filter within 1s (FR-008, FR-016)
+
+**Checkpoint**: Channel chips render with unread counts, tapping switches view to that channel's conversations immediately
+
+---
+
+## Phase 7: User Story 5 — View Node Locations on Map (DEFERRED)
+
+> **⚠️ DEFERRED:** Map implementation deferred pending NAVIGATION vs POI category decision. The choice between PlaceListMapTemplate (POI, 6-item cap, no nav conflicts) and MapWithContentTemplate (NAVIGATION, full-featured, exclusive with Google Maps) requires further research and discussion. See spec User Story 5 for open questions.
+
+~~- [ ] T029 [US5] Create MapScreen~~
+
+**Checkpoint**: SKIPPED — revisit after map strategy decision
+
+---
+
+## Phase 8: User Story 6 — Persistent Mesh Status at a Glance (Priority: P3)
+
+**Goal**: Minimized Control Panel visible across all screens showing radio status, node count, last message time
+
+**Independent Test**: Navigate between all screens → mini-panel always visible → shows correct node count → disconnect radio → panel shows "Disconnected"
+
+### Implementation for User Story 6
+
+- [x] T030 [US6] Create MeshStatusPanel at feature/car/src/main/kotlin/org/meshtastic/feature/car/panels/MeshStatusPanel.kt implementing Minimized Control Panel — connectionStatusIcon, "{N} nodes online" title, "Last msg: {timeAgo}" subtitle, onClickListener expanding to full detail (mesh name, own battery, firmware version)
+- [x] T031 [US6] Register MeshStatusPanel in MeshtasticCarSession lifecycle — attach to session on creation, observe BleConnectionState + NodeRepository for live updates, show "Disconnected" with warning icon on radio disconnect (FR-010, FR-011)
+
+**Checkpoint**: Persistent mini-panel visible across all screens, updates in real-time, expands on tap
+
+---
+
+## Phase 9: User Story 7 — In-Context Voice Input for Actions (Priority: P3)
+
+**Goal**: Voice reply is the default composition method, TTS reads messages aloud, FuzzyNodeNameResolver handles voice-initiated DMs
+
+**Independent Test**: Tap reply → dictate → message sent → tap "read aloud" → TTS reads message with sender name
+
+### Implementation for User Story 7
+
+- [x] T032 [US7] Implement TTS read-aloud action in ConversationScreen using Android built-in TTS engine — reads sender name + message content on tap of "Read Aloud" action
+- [x] T033 [US7] Wire FuzzyNodeNameResolver into node detail "Message" action flow — when initiating DM from NodeDashboard, voice input is default composition method with resolved node context
+
+**Checkpoint**: Voice reply works end-to-end, TTS reads messages clearly, node-initiated DMs use voice by default
+
+---
+
+## Phase 10: Polish & Cross-Cutting Concerns
+
+**Purpose**: Error handling, degraded states, compliance, and verification
+
+- [x] T034 [P] Implement BLE disconnection Banner + graceful degradation to cached read-only data across all screens (FR-011, FR-015)
+- [x] T035 [P] Implement empty/error states: no channels configured → onboarding PaneTemplate, no nodes → "No nodes heard", no positions → "No positions reported" (per error contracts)
+- [x] T036 [P] Add ProGuard/R8 keep rule for MeshtasticCarAppService in feature/car/proguard-rules.pro
+- [x] T037 [P] Confirm no logs, telemetry, or config changes expose PII, location data, secrets, or modify `core/proto`
+- [x] T038 [P] Review all screens against automotive HMI distraction guidelines — verify ≤ 2 taps for all primary actions (NFR-001)
+- [x] T039 Run constitution-required verification: `./gradlew spotlessApply :feature:car:spotlessCheck :feature:car:detekt :feature:car:testGoogleDebugUnitTest`
+- [x] T040 Validate quickstart.md developer workflow documentation is accurate for the implemented module
+
+---
+
+## Phase 11: UX Polish & Advanced CAL APIs
+
+**Purpose**: Leverage advanced Car App Library APIs for richer UX — transient feedback, pull-to-refresh, modal alerts, responsive text, full conversation view, and safety-gated actions
+
+- [x] T041 [P] [FR-023] Add CarToast feedback to ConversationScreen (voice reply sent, quick-reply sent) and HomeScreen (reconnection events) — use `CarToast.makeText(carContext, msg, LENGTH_SHORT).show()` in action callbacks
+- [x] T042 [P] [FR-024] Implement OnContentRefreshListener on HomeScreen messaging tab and NodeDashboardScreen — call `stateCoordinator.refresh()` and `invalidate()` on trigger
+- [x] T043 [P] [FR-025] Upgrade EmergencyHandler to use CAL Alert API — present modal `Alert.Builder()` for new SOS alerts requiring explicit dismiss/acknowledge, replacing passive spotlight rows for active alerts
+- [x] T044 [FR-026] Upgrade ConversationScreen to LongMessageTemplate for full conversation view — concatenate all messages into a formatted long-text body with sender/timestamp prefixes when message count exceeds list limit
+- [x] T045 [P] [FR-027] Add CarText.addVariant() responsive text to node subtitles in HomeScreen and NodeDashboardScreen — short variant (signal icon only) for narrow displays, full variant (signal + battery + last heard) for wide
+- [x] T046 [P] [FR-028] Add ParkedOnlyOnClickListener to voice reply and quick-reply actions in ConversationScreen — allows voice compose only when vehicle is parked per CAL safety guidelines
+- [x] T047 Run verification: `./gradlew spotlessApply :feature:car:spotlessCheck :feature:car:detekt :feature:car:compileFdroidDebugKotlin`
+
+**Checkpoint**: Advanced CAL APIs integrated — transient feedback, pull-to-refresh, modal alerts, responsive text, safety-gated actions all functional
+
+---
+
+## Dependencies & Execution Order
+
+### Phase Dependencies
+
+- **Phase 1 (Setup)**: No dependencies — start immediately
+- **Phase 2 (Foundational)**: Depends on Phase 1 — BLOCKS all user stories
+- **Phase 3 (US1 - Messaging)**: Depends on Phase 2 — MVP target
+- **Phase 4 (US2 - Emergency)**: Depends on Phase 2; integrates with MessagingScreen (Phase 3 T016)
+- **Phase 5 (US3 - Nodes)**: Depends on Phase 2 — independent of messaging
+- **Phase 6 (US4 - Channels)**: Depends on Phase 3 (modifies MessagingScreen)
+- **Phase 7 (US5 - Map)**: **DEFERRED** — pending NAVIGATION vs POI category decision
+- **Phase 8 (US6 - Status Panel)**: Depends on Phase 2 — independent
+- **Phase 9 (US7 - Voice)**: Depends on Phase 3 (ConversationScreen T017, FuzzyNodeNameResolver T018)
+- **Phase 10 (Polish)**: Depends on all user story phases
+- **Phase 11 (Advanced APIs)**: Depends on Phase 10 — enhances existing screens with advanced CAL APIs
+
+### User Story Dependencies
+
+- **US1 (Messaging, P1)**: Can start after Phase 2 — no other story dependencies
+- **US2 (Emergency, P1)**: Can start after Phase 2 — integrates with US1's MessagingScreen (T016) for Spotlight Section (T024)
+- **US3 (Nodes, P2)**: Can start after Phase 2 — fully independent
+- **US4 (Channels, P2)**: Depends on US1 (extends MessagingScreen)
+- **US5 (Map, DEFERRED)**: Pending NAVIGATION vs POI category decision — requires further research
+- **US6 (Status Panel, P3)**: Can start after Phase 2 — fully independent
+- **US7 (Voice, P3)**: Depends on US1 (extends ConversationScreen)
+
+### Within Each User Story
+
+- State models → Screen implementation → Integration logic
+- Screens before cross-screen wiring
+- Core implementation before refinement
+
+### Parallel Opportunities
+
+- **Phase 1**: T004, T005, T006, T007 can all run in parallel
+- **Phase 2**: T010, T011 in parallel; T014 parallel with T010/T011
+- **After Phase 2**: US1, US3, and US6 can start simultaneously (independent)
+- **Within US1**: T016 and T017 in parallel (different files)
+- **Within US2**: T022 independent of other stories
+- **Within US3**: T026 and T027 in parallel (different files)
+- **Phase 10**: T034, T035, T036, T037, T038 all in parallel
+
+---
+
+## Parallel Example: After Foundational Phase
+
+```bash
+# Three stories can start simultaneously:
+# Developer A: US1 (Messaging)
+Task: T016 "Create MessagingScreen"
+Task: T017 "Create ConversationScreen"
+
+# Developer B: US3 (Nodes)
+Task: T026 "Create NodeDashboardScreen"
+Task: T027 "Create NodeDetailScreen"
+
+# Developer C: US6 (Status Panel)
+Task: T030 "Create MeshStatusPanel"
+Task: T031 "Register panel in session"
+```
+
+---
+
+## Implementation Strategy
+
+### MVP First (User Story 1 Only)
+
+1. Complete Phase 1: Setup (T001–T007)
+2. Complete Phase 2: Foundational (T008–T015)
+3. Complete Phase 3: User Story 1 — Messaging (T016–T021)
+4. **STOP and VALIDATE**: Test messaging end-to-end with DHU
+5. Deploy to internal testing track if ready
+
+### Incremental Delivery
+
+1. Setup + Foundational → Module compiles and binds to Android Auto
+2. Add US1 (Messaging) → Core value delivered (MVP!)
+3. Add US2 (Emergency) → Safety-critical alerts operational
+4. Add US3 (Nodes) → Node awareness complete
+5. Add US4 (Channels) → Multi-channel workflows enabled
+6. Add US6 + US7 (Panel + Voice) → Polish and hands-free refinement
+7. Each increment is independently testable with the Desktop Head Unit (DHU)
+
+### Parallel Team Strategy
+
+With multiple developers after Phase 2:
+- Developer A: US1 (Messaging) → US4 (Channels) → US7 (Voice)
+- Developer B: US3 (Nodes) + US6 (Status Panel)
+- Developer C: US2 (Emergency)
+
+---
+
+## Notes
+
+- All screens use `invalidate()` for refresh (never recreate Screen objects) per NFR-010
+- 300ms debounce on all invalidation triggers per NFR-010
+- CAL host enforces distraction guidelines — app provides templates only
+- Existing `core/` modules consumed read-only via Koin DI — no API changes
+- Google flavor only — F-Droid builds unaffected
+- Car API Level 8 minimum — older hosts gracefully hide the app