diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index d239d0530..52ffd9593 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -270,6 +270,7 @@ dependencies {
implementation(libs.accompanist.permissions)
implementation(libs.kermit)
implementation(libs.kotlinx.datetime)
+ implementation(libs.androidx.car.app)
debugImplementation(libs.androidx.compose.ui.test.manifest)
debugImplementation(libs.androidx.glance.preview)
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index f7d2ce900..758a266e0 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -153,6 +153,25 @@
android:name="google_analytics_default_allow_analytics_storage"
android:value="false" />
+
+
+
+
+
+
+
+
+
+
+
+
+
.
+ */
+package org.meshtastic.app.auto
+
+import android.content.Intent
+import android.content.pm.ApplicationInfo
+import androidx.car.app.CarAppService
+import androidx.car.app.Session
+import androidx.car.app.SessionInfo
+import androidx.car.app.validation.HostValidator
+
+/**
+ * Entry point for the Meshtastic Android Auto experience.
+ *
+ * Registers with the Android Auto host to provide a browsable list of
+ * favorite contacts and active channels for messaging.
+ */
+class MeshtasticCarAppService : CarAppService() {
+
+ override fun createHostValidator(): HostValidator {
+ return if (applicationInfo.flags and ApplicationInfo.FLAG_DEBUGGABLE != 0) {
+ HostValidator.ALLOW_ALL_HOSTS_VALIDATOR
+ } else {
+ HostValidator.ALLOW_ALL_HOSTS_VALIDATOR
+ }
+ }
+
+ override fun onCreateSession(sessionInfo: SessionInfo): Session {
+ return MeshtasticCarSession()
+ }
+
+ @Suppress("DEPRECATION", "OVERRIDE_DEPRECATION")
+ override fun onCreateSession(): Session {
+ return MeshtasticCarSession()
+ }
+
+ override fun onNewIntent(intent: Intent) {
+ super.onNewIntent(intent)
+ }
+}
diff --git a/app/src/main/kotlin/org/meshtastic/app/auto/MeshtasticCarScreen.kt b/app/src/main/kotlin/org/meshtastic/app/auto/MeshtasticCarScreen.kt
new file mode 100644
index 000000000..e44a2d621
--- /dev/null
+++ b/app/src/main/kotlin/org/meshtastic/app/auto/MeshtasticCarScreen.kt
@@ -0,0 +1,241 @@
+/*
+ * 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.app.auto
+
+import androidx.car.app.CarContext
+import androidx.car.app.Screen
+import androidx.car.app.model.Action
+import androidx.car.app.model.CarIcon
+import androidx.car.app.model.ItemList
+import androidx.car.app.model.ListTemplate
+import androidx.car.app.model.Row
+import androidx.car.app.model.SectionedItemList
+import androidx.car.app.model.Template
+import androidx.lifecycle.DefaultLifecycleObserver
+import androidx.lifecycle.LifecycleOwner
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.SupervisorJob
+import kotlinx.coroutines.cancel
+import kotlinx.coroutines.flow.combine
+import kotlinx.coroutines.flow.distinctUntilChanged
+import kotlinx.coroutines.flow.map
+import kotlinx.coroutines.launch
+import org.koin.core.component.KoinComponent
+import org.koin.core.component.inject
+import org.meshtastic.core.model.ConnectionState
+import org.meshtastic.core.model.DataPacket
+import org.meshtastic.core.model.Node
+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.proto.ChannelSettings
+
+/**
+ * Root screen displayed in Android Auto.
+ *
+ * Shows three sections mirroring the iOS CarPlay implementation:
+ * - **Status**: Connection state and active device name
+ * - **Favorites**: Favorited mesh nodes with unread message counts
+ * - **Channels**: Active channels with unread message counts
+ */
+class MeshtasticCarScreen(carContext: CarContext) :
+ Screen(carContext),
+ KoinComponent,
+ DefaultLifecycleObserver {
+
+ private val nodeRepository: NodeRepository by inject()
+ private val radioConfigRepository: RadioConfigRepository by inject()
+ private val packetRepository: PacketRepository by inject()
+ private val serviceRepository: ServiceRepository by inject()
+
+ private val scope = CoroutineScope(Dispatchers.Main + SupervisorJob())
+ private var observeJob: Job? = null
+
+ private var connectionState: ConnectionState = ConnectionState.Disconnected()
+ private var favoriteNodes: List = emptyList()
+ private var channels: List = emptyList()
+ private var unreadCounts: Map = emptyMap()
+
+ init {
+ lifecycle.addObserver(this)
+ }
+
+ override fun onCreate(owner: LifecycleOwner) {
+ startObserving()
+ }
+
+ override fun onDestroy(owner: LifecycleOwner) {
+ scope.cancel()
+ }
+
+ private fun startObserving() {
+ observeJob?.cancel()
+ observeJob = scope.launch {
+ val stateFlow = serviceRepository.connectionState
+ .distinctUntilChanged()
+
+ val favoritesFlow = nodeRepository.nodeDBbyNum
+ .map { nodes ->
+ val myNum = nodeRepository.myNodeInfo.value?.myNodeNum
+ nodes.values
+ .filter { it.isFavorite && !it.isIgnored && it.num != myNum }
+ .sortedBy { it.user.long_name }
+ }
+ .distinctUntilChanged()
+
+ val channelsFlow = radioConfigRepository.channelSetFlow
+ .map { cs ->
+ cs.settings.filterIndexed { index, settings ->
+ index == 0 || settings.name.isNotEmpty()
+ }
+ }
+ .distinctUntilChanged()
+
+ combine(stateFlow, favoritesFlow, channelsFlow) { state, favorites, chs ->
+ Triple(state, favorites, chs)
+ }.collect { (state, favorites, chs) ->
+ connectionState = state
+ favoriteNodes = favorites
+ channels = chs
+
+ // Collect unread counts for all conversations
+ val counts = mutableMapOf()
+ for (node in favorites) {
+ val key = "0${node.user.id}"
+ counts[key] = packetRepository.getUnreadCount(key)
+ }
+ for ((index, _) in chs.withIndex()) {
+ val key = "${index}${DataPacket.ID_BROADCAST}"
+ counts[key] = packetRepository.getUnreadCount(key)
+ }
+ unreadCounts = counts
+
+ invalidate()
+ }
+ }
+ }
+
+ override fun onGetTemplate(): Template {
+ val listBuilder = ListTemplate.Builder()
+
+ // Status section
+ listBuilder.addSectionedList(
+ SectionedItemList.create(
+ buildStatusSection(),
+ "Status",
+ ),
+ )
+
+ // Favorites section
+ val favoritesSection = buildFavoritesSection()
+ if (favoritesSection.items.isNotEmpty()) {
+ listBuilder.addSectionedList(
+ SectionedItemList.create(
+ favoritesSection,
+ "Favorites",
+ ),
+ )
+ }
+
+ // Channels section
+ val channelsSection = buildChannelsSection()
+ if (channelsSection.items.isNotEmpty()) {
+ listBuilder.addSectionedList(
+ SectionedItemList.create(
+ channelsSection,
+ "Channels",
+ ),
+ )
+ }
+
+ return listBuilder
+ .setTitle("Meshtastic")
+ .setHeaderAction(Action.APP_ICON)
+ .build()
+ }
+
+ private fun buildStatusSection(): ItemList {
+ val statusText = when (val state = connectionState) {
+ is ConnectionState.Connected -> "Connected"
+ is ConnectionState.Disconnected -> "Disconnected"
+ is ConnectionState.DeviceSleep -> "Device Sleeping"
+ is ConnectionState.Connecting -> "Connecting..."
+ }
+
+ val deviceName = nodeRepository.ourNodeInfo.value?.user?.long_name ?: ""
+ val subtitle = if (deviceName.isNotEmpty()) deviceName else null
+
+ val row = Row.Builder()
+ .setTitle(statusText)
+ .apply { if (subtitle != null) addText(subtitle) }
+ .setBrowsable(false)
+ .build()
+
+ return ItemList.Builder()
+ .addItem(row)
+ .build()
+ }
+
+ private fun buildFavoritesSection(): ItemList {
+ val builder = ItemList.Builder()
+
+ for (node in favoriteNodes) {
+ val contactKey = "0${node.user.id}"
+ val unread = unreadCounts[contactKey] ?: 0
+ val name = node.user.long_name.ifEmpty { node.user.short_name }
+ val subtitle = buildString {
+ append(node.user.short_name)
+ if (node.hopsAway >= 0) append(" · ${node.hopsAway} hops")
+ if (unread > 0) append(" · $unread unread")
+ }
+
+ val row = Row.Builder()
+ .setTitle(name)
+ .addText(subtitle)
+ .setBrowsable(false)
+ .build()
+
+ builder.addItem(row)
+ }
+
+ return builder.build()
+ }
+
+ private fun buildChannelsSection(): ItemList {
+ val builder = ItemList.Builder()
+
+ for ((index, channelSettings) in channels.withIndex()) {
+ val contactKey = "${index}${DataPacket.ID_BROADCAST}"
+ val unread = unreadCounts[contactKey] ?: 0
+ val channelName = channelSettings.name.ifEmpty { "Primary Channel" }
+ val subtitle = if (unread > 0) "$unread unread" else ""
+
+ val row = Row.Builder()
+ .setTitle(channelName)
+ .apply { if (subtitle.isNotEmpty()) addText(subtitle) }
+ .setBrowsable(false)
+ .build()
+
+ builder.addItem(row)
+ }
+
+ return builder.build()
+ }
+}
diff --git a/app/src/main/kotlin/org/meshtastic/app/auto/MeshtasticCarSession.kt b/app/src/main/kotlin/org/meshtastic/app/auto/MeshtasticCarSession.kt
new file mode 100644
index 000000000..4a405cafd
--- /dev/null
+++ b/app/src/main/kotlin/org/meshtastic/app/auto/MeshtasticCarSession.kt
@@ -0,0 +1,31 @@
+/*
+ * 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.app.auto
+
+import android.content.Intent
+import androidx.car.app.Screen
+import androidx.car.app.Session
+
+/**
+ * Android Auto session that hosts the [MeshtasticCarScreen] root screen.
+ */
+class MeshtasticCarSession : Session() {
+
+ override fun onCreateScreen(intent: Intent): Screen {
+ return MeshtasticCarScreen(carContext)
+ }
+}
diff --git a/app/src/main/res/xml/automotive_app_desc.xml b/app/src/main/res/xml/automotive_app_desc.xml
new file mode 100644
index 000000000..b84cd9041
--- /dev/null
+++ b/app/src/main/res/xml/automotive_app_desc.xml
@@ -0,0 +1,22 @@
+
+
+
+
+
+
+
diff --git a/core/service/src/androidMain/kotlin/org/meshtastic/core/service/ConversationShortcutManager.kt b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/ConversationShortcutManager.kt
new file mode 100644
index 000000000..405a20f2c
--- /dev/null
+++ b/core/service/src/androidMain/kotlin/org/meshtastic/core/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.core.service
+
+import android.content.Context
+import android.content.Intent
+import android.graphics.Canvas
+import android.graphics.Paint
+import androidx.core.app.Person
+import androidx.core.content.LocusIdCompat
+import androidx.core.content.pm.ShortcutInfoCompat
+import androidx.core.content.pm.ShortcutManagerCompat
+import androidx.core.graphics.createBitmap
+import androidx.core.graphics.drawable.IconCompat
+import androidx.core.net.toUri
+import co.touchlab.kermit.Logger
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.flow.combine
+import kotlinx.coroutines.flow.distinctUntilChanged
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.flow.map
+import kotlinx.coroutines.launch
+import org.koin.core.annotation.Single
+import org.meshtastic.core.di.CoroutineDispatchers
+import org.meshtastic.core.model.Node
+import org.meshtastic.core.navigation.DEEP_LINK_BASE_URI
+import org.meshtastic.core.repository.NodeRepository
+import org.meshtastic.core.repository.RadioConfigRepository
+import org.meshtastic.proto.ChannelSettings
+
+/**
+ * Publishes dynamic shortcuts for favorited nodes and active channels.
+ *
+ * These shortcuts enable Android Auto (and the launcher) to surface Meshtastic conversations
+ * as share targets and messaging destinations. Each shortcut is linked to a conversation
+ * via [LocusIdCompat] so that notifications and the car messaging UI can associate them.
+ */
+@Single
+class ConversationShortcutManager(
+ private val context: Context,
+ private val nodeRepository: NodeRepository,
+ private val radioConfigRepository: RadioConfigRepository,
+ private val dispatchers: CoroutineDispatchers,
+) {
+
+ private var observeJob: Job? = null
+
+ /**
+ * Starts observing favorite nodes and active channels, publishing shortcuts whenever
+ * the data changes. Call from [MeshService.onCreate].
+ */
+ fun startObserving(scope: CoroutineScope) {
+ observeJob?.cancel()
+ observeJob = scope.launch(dispatchers.io) {
+ val favoritesFlow = nodeRepository.nodeDBbyNum
+ .map { nodes ->
+ nodes.values.filter { it.isFavorite && !it.isIgnored }
+ .sortedBy { it.user.long_name }
+ }
+ .distinctUntilChanged()
+
+ val channelsFlow = radioConfigRepository.channelSetFlow
+ .map { cs -> cs.settings.filter { it.name.isNotEmpty() || cs.settings.indexOf(it) == 0 } }
+ .distinctUntilChanged()
+
+ combine(favoritesFlow, channelsFlow) { favorites, channels ->
+ favorites to channels
+ }.collect { (favorites, channels) ->
+ publishShortcuts(favorites, channels)
+ }
+ }
+ }
+
+ /** Stops the observation coroutine. Call from [MeshService.onDestroy]. */
+ fun stopObserving() {
+ observeJob?.cancel()
+ observeJob = null
+ }
+
+ private fun publishShortcuts(favorites: List, channels: List) {
+ val myNodeNum = nodeRepository.myNodeInfo.value?.myNodeNum
+ val shortcuts = mutableListOf()
+
+ // Favorite node shortcuts (direct message conversations)
+ for (node in favorites) {
+ if (node.num == myNodeNum) continue
+ val contactKey = "0${node.user.id}"
+ val person = Person.Builder()
+ .setName(node.user.long_name)
+ .setKey(node.user.id)
+ .setIcon(createPersonIcon(node.user.short_name, node.colors.second, node.colors.first))
+ .build()
+
+ val shortcut = ShortcutInfoCompat.Builder(context, contactKey)
+ .setShortLabel(node.user.long_name.ifEmpty { node.user.short_name })
+ .setLongLabel(node.user.long_name.ifEmpty { node.user.short_name })
+ .setLocusId(LocusIdCompat(contactKey))
+ .setPerson(person)
+ .setLongLived(true)
+ .setCategories(setOf(ShortcutManagerCompat.SHORTCUT_CATEGORY_CONVERSATION))
+ .setIntent(
+ Intent(Intent.ACTION_VIEW, "$DEEP_LINK_BASE_URI/messages/$contactKey".toUri()).apply {
+ setPackage(context.packageName)
+ },
+ )
+ .build()
+
+ shortcuts.add(shortcut)
+ }
+
+ // Channel shortcuts (broadcast conversations)
+ for ((index, channelSettings) in channels.withIndex()) {
+ val contactKey = "${index}${org.meshtastic.core.model.DataPacket.ID_BROADCAST}"
+ val channelName = channelSettings.name.ifEmpty { "Primary Channel" }
+ val person = Person.Builder()
+ .setName(channelName)
+ .setKey("channel-$index")
+ .build()
+
+ val shortcut = ShortcutInfoCompat.Builder(context, contactKey)
+ .setShortLabel(channelName)
+ .setLongLabel(channelName)
+ .setLocusId(LocusIdCompat(contactKey))
+ .setPerson(person)
+ .setLongLived(true)
+ .setCategories(setOf(ShortcutManagerCompat.SHORTCUT_CATEGORY_CONVERSATION))
+ .setIntent(
+ Intent(Intent.ACTION_VIEW, "$DEEP_LINK_BASE_URI/messages/$contactKey".toUri()).apply {
+ setPackage(context.packageName)
+ },
+ )
+ .build()
+
+ shortcuts.add(shortcut)
+ }
+
+ try {
+ ShortcutManagerCompat.removeAllDynamicShortcuts(context)
+ ShortcutManagerCompat.addDynamicShortcuts(context, shortcuts)
+ Logger.d { "Published ${shortcuts.size} conversation shortcuts (${favorites.size} favorites, ${channels.size} channels)" }
+ } catch (e: Exception) {
+ Logger.e(e) { "Failed to publish conversation shortcuts" }
+ }
+ }
+
+ private fun createPersonIcon(name: String, backgroundColor: Int, foregroundColor: Int): IconCompat {
+ val size = ICON_SIZE
+ val bitmap = createBitmap(size, size)
+ val canvas = Canvas(bitmap)
+ val paint = Paint(Paint.ANTI_ALIAS_FLAG)
+
+ paint.color = backgroundColor
+ canvas.drawCircle(size / 2f, size / 2f, size / 2f, paint)
+
+ paint.color = foregroundColor
+ paint.textSize = size * TEXT_SIZE_RATIO
+ paint.textAlign = Paint.Align.CENTER
+ val initial = if (name.isNotEmpty()) {
+ val codePoint = name.codePointAt(0)
+ String(Character.toChars(codePoint)).uppercase()
+ } else {
+ "?"
+ }
+ val xPos = canvas.width / 2f
+ val yPos = (canvas.height / 2f - (paint.descent() + paint.ascent()) / 2f)
+ canvas.drawText(initial, xPos, yPos, paint)
+
+ return IconCompat.createWithBitmap(bitmap)
+ }
+
+ companion object {
+ private const val ICON_SIZE = 128
+ private const val TEXT_SIZE_RATIO = 0.5f
+ }
+}
diff --git a/core/service/src/androidMain/kotlin/org/meshtastic/core/service/MeshService.kt b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/MeshService.kt
index 5869ce94f..155d87839 100644
--- a/core/service/src/androidMain/kotlin/org/meshtastic/core/service/MeshService.kt
+++ b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/MeshService.kt
@@ -76,6 +76,8 @@ class MeshService : Service() {
private val notifications: MeshServiceNotifications by inject()
+ private val shortcutManager: ConversationShortcutManager by inject()
+
/** Android-typed accessor for the foreground service notification. */
private val androidNotifications: MeshServiceNotificationsImpl
get() = notifications as MeshServiceNotificationsImpl
@@ -118,6 +120,7 @@ class MeshService : Service() {
try {
orchestrator.start()
+ shortcutManager.startObserving(serviceScope)
isServiceInitialized = true
} catch (e: IllegalStateException) {
// Koin throws IllegalStateException when the DI graph is not yet initialized.
@@ -209,6 +212,7 @@ class MeshService : Service() {
Logger.i { "Destroying mesh service" }
ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_REMOVE)
if (isServiceInitialized) {
+ shortcutManager.stopObserving()
orchestrator.stop()
}
serviceJob.cancel()
diff --git a/core/service/src/androidMain/kotlin/org/meshtastic/core/service/MeshServiceNotificationsImpl.kt b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/MeshServiceNotificationsImpl.kt
index 211e3b9c4..d3a6dc590 100644
--- a/core/service/src/androidMain/kotlin/org/meshtastic/core/service/MeshServiceNotificationsImpl.kt
+++ b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/MeshServiceNotificationsImpl.kt
@@ -32,6 +32,7 @@ import android.media.RingtoneManager
import androidx.core.app.NotificationCompat
import androidx.core.app.Person
import androidx.core.app.RemoteInput
+import androidx.core.content.LocusIdCompat
import androidx.core.content.getSystemService
import androidx.core.graphics.createBitmap
import androidx.core.graphics.drawable.IconCompat
@@ -622,6 +623,8 @@ class MeshServiceNotificationsImpl(
.setAutoCancel(true)
.setStyle(style)
.setGroup(GROUP_KEY_MESSAGES)
+ .setShortcutId(contactKey)
+ .setLocusId(LocusIdCompat(contactKey))
.setVisibility(NotificationCompat.VISIBILITY_PRIVATE)
.setWhen(lastMessage.receivedTime)
.setShowWhen(true)
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index 668ed133a..5098b40b2 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -8,6 +8,7 @@ accompanist = "0.37.3"
# androidx
datastore = "1.2.1"
+car-app = "1.7.0"
glance = "1.2.0-rc01"
lifecycle = "2.10.0"
jetbrains-lifecycle = "2.11.0-alpha03"
@@ -92,6 +93,7 @@ androidx-camera-view = { module = "androidx.camera:camera-view", version.ref = "
androidx-camera-compose = { module = "androidx.camera:camera-compose", version.ref = "camerax" }
androidx-camera-viewfinder-compose = { module = "androidx.camera.viewfinder:viewfinder-compose", version = "1.6.0" }
androidx-core-ktx = { module = "androidx.core:core-ktx", version = "1.18.0" }
+androidx-car-app = { module = "androidx.car.app:app", version.ref = "car-app" }
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" }
androidx-datastore = { module = "androidx.datastore:datastore", version.ref = "datastore" }