diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index 52ffd9593..74e926098 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -235,6 +235,7 @@ dependencies {
implementation(projects.feature.settings)
implementation(projects.feature.firmware)
implementation(projects.feature.wifiProvision)
+ implementation(projects.feature.auto)
implementation(projects.feature.widget)
implementation(libs.jetbrains.compose.material3.adaptive)
@@ -270,7 +271,6 @@ 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 758a266e0..f7d2ce900 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -153,25 +153,6 @@
android:name="google_analytics_default_allow_analytics_storage"
android:value="false" />
-
-
-
-
-
-
-
-
-
-
-
-
-
- nodes.values.filter { it.isFavorite && !it.isIgnored }
- .sortedBy { it.user.long_name }
- }
- .distinctUntilChanged()
+ 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.filterIndexed { index, settings ->
- settings.name.isNotEmpty() || index == 0
- }
- }
- .distinctUntilChanged()
+ val channelsFlow =
+ radioConfigRepository.channelSetFlow
+ .map { cs ->
+ cs.settings.filterIndexed { index, settings -> settings.name.isNotEmpty() || index == 0 }
+ }
+ .distinctUntilChanged()
- combine(favoritesFlow, channelsFlow) { favorites, channels ->
- favorites to channels
- }.collect { (favorites, channels) ->
- publishShortcuts(favorites, channels)
+ combine(favoritesFlow, channelsFlow) { favorites, channels -> favorites to channels }
+ .collect { (favorites, channels) -> publishShortcuts(favorites, channels) }
}
- }
}
/** Stops the observation coroutine. Call from [MeshService.onDestroy]. */
@@ -104,25 +102,27 @@ class ConversationShortcutManager(
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 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()
+ 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(ShortcutInfo.SHORTCUT_CATEGORY_CONVERSATION))
+ .setIntent(
+ Intent(Intent.ACTION_VIEW, "$DEEP_LINK_BASE_URI/messages/$contactKey".toUri()).apply {
+ setPackage(context.packageName)
+ },
+ )
+ .build()
shortcuts.add(shortcut)
}
@@ -131,32 +131,44 @@ class ConversationShortcutManager(
for ((index, channelSettings) in channels.withIndex()) {
val contactKey = "${index}${DataPacket.ID_BROADCAST}"
val channelName = channelSettings.name.ifEmpty { "Primary Channel" }
- val person = Person.Builder()
- .setName(channelName)
- .setKey("channel-$index")
- .build()
+ 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()
+ val shortcut =
+ ShortcutInfoCompat.Builder(context, contactKey)
+ .setShortLabel(channelName)
+ .setLongLabel(channelName)
+ .setLocusId(LocusIdCompat(contactKey))
+ .setPerson(person)
+ .setLongLived(true)
+ .setCategories(setOf(ShortcutInfo.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)" }
+ val limit = ShortcutManagerCompat.getMaxShortcutCountPerActivity(context)
+ // Remove shortcuts for conversations that are no longer in favorites/channels,
+ // so stale entries don't clutter the share sheet.
+ 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)
+ }
+ // Push each shortcut individually to preserve usage/ranking history.
+ // pushDynamicShortcut upserts without wiping other shortcuts.
+ for (shortcut in shortcuts.take(limit)) {
+ ShortcutManagerCompat.pushDynamicShortcut(context, shortcut)
+ }
+ val published = shortcuts.size.coerceAtMost(limit)
+ Logger.d {
+ "Published $published conversation shortcuts (${favorites.size} favorites, ${channels.size} channels)"
+ }
} catch (e: Exception) {
Logger.e(e) { "Failed to publish conversation shortcuts" }
}
@@ -174,12 +186,13 @@ class ConversationShortcutManager(
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 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)
diff --git a/feature/auto/build.gradle.kts b/feature/auto/build.gradle.kts
new file mode 100644
index 000000000..721e844be
--- /dev/null
+++ b/feature/auto/build.gradle.kts
@@ -0,0 +1,41 @@
+/*
+ * 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.koin)
+}
+
+android {
+ namespace = "org.meshtastic.feature.auto"
+ resourcePrefix = "auto_"
+
+ // Car App Library requires API 23+; bump above the app's default minSdk
+ // so we can use conversation shortcuts and LocusId APIs cleanly.
+ defaultConfig { minSdk = 23 }
+}
+
+dependencies {
+ implementation(projects.core.common)
+ implementation(projects.core.model)
+ implementation(projects.core.proto)
+ implementation(projects.core.repository)
+
+ implementation(libs.androidx.car.app)
+ implementation(libs.kermit)
+ implementation(libs.koin.annotations)
+}
diff --git a/feature/auto/src/main/AndroidManifest.xml b/feature/auto/src/main/AndroidManifest.xml
new file mode 100644
index 000000000..a6c4ed78f
--- /dev/null
+++ b/feature/auto/src/main/AndroidManifest.xml
@@ -0,0 +1,41 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/kotlin/org/meshtastic/app/auto/MeshtasticCarAppService.kt b/feature/auto/src/main/kotlin/org/meshtastic/feature/auto/MeshtasticCarAppService.kt
similarity index 64%
rename from app/src/main/kotlin/org/meshtastic/app/auto/MeshtasticCarAppService.kt
rename to feature/auto/src/main/kotlin/org/meshtastic/feature/auto/MeshtasticCarAppService.kt
index 43f9c97ba..b6bd73793 100644
--- a/app/src/main/kotlin/org/meshtastic/app/auto/MeshtasticCarAppService.kt
+++ b/feature/auto/src/main/kotlin/org/meshtastic/feature/auto/MeshtasticCarAppService.kt
@@ -14,8 +14,9 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see .
*/
-package org.meshtastic.app.auto
+package org.meshtastic.feature.auto
+import android.content.pm.ApplicationInfo
import androidx.car.app.CarAppService
import androidx.car.app.Session
import androidx.car.app.SessionInfo
@@ -24,14 +25,21 @@ 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.
+ * 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 =
- HostValidator.ALLOW_ALL_HOSTS_VALIDATOR
+ override fun createHostValidator(): HostValidator {
+ val isDebug = applicationContext.applicationInfo.flags and ApplicationInfo.FLAG_DEBUGGABLE != 0
+ return if (isDebug) {
+ HostValidator.ALLOW_ALL_HOSTS_VALIDATOR
+ } else {
+ HostValidator.Builder(applicationContext)
+ .addAllowedHosts(androidx.car.app.R.array.hosts_allowlist_sample)
+ .build()
+ }
+ }
- override fun onCreateSession(sessionInfo: SessionInfo): Session =
- MeshtasticCarSession()
+ override fun onCreateSession(sessionInfo: SessionInfo): Session = MeshtasticCarSession()
}
diff --git a/app/src/main/kotlin/org/meshtastic/app/auto/MeshtasticCarScreen.kt b/feature/auto/src/main/kotlin/org/meshtastic/feature/auto/MeshtasticCarScreen.kt
similarity index 57%
rename from app/src/main/kotlin/org/meshtastic/app/auto/MeshtasticCarScreen.kt
rename to feature/auto/src/main/kotlin/org/meshtastic/feature/auto/MeshtasticCarScreen.kt
index 7096cd0c2..c90344a60 100644
--- a/app/src/main/kotlin/org/meshtastic/app/auto/MeshtasticCarScreen.kt
+++ b/feature/auto/src/main/kotlin/org/meshtastic/feature/auto/MeshtasticCarScreen.kt
@@ -14,7 +14,7 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see .
*/
-package org.meshtastic.app.auto
+package org.meshtastic.feature.auto
import androidx.car.app.CarContext
import androidx.car.app.Screen
@@ -33,6 +33,8 @@ import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
+import kotlinx.coroutines.flow.flatMapLatest
+import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
import org.koin.core.component.KoinComponent
@@ -86,110 +88,97 @@ class MeshtasticCarScreen(carContext: CarContext) :
private fun startObserving() {
observeJob?.cancel()
- observeJob = scope.launch {
- val stateFlow = serviceRepository.connectionState
- .distinctUntilChanged()
+ observeJob =
+ scope.launch {
+ // serviceRepository.connectionState is a StateFlow — distinctUntilChanged is a no-op on it.
+ val stateFlow = serviceRepository.connectionState
- 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 }
+ 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)
}
- .distinctUntilChanged()
+ .flatMapLatest { (state, favorites, chs) ->
+ // Build per-conversation unread flows so the car screen invalidates
+ // on new messages, not just on topology/channel changes.
+ val contactKeys =
+ favorites.map { "0${it.user.id}" } +
+ chs.mapIndexed { i, _ -> "${i}${DataPacket.ID_BROADCAST}" }
- val channelsFlow = radioConfigRepository.channelSetFlow
- .map { cs ->
- cs.settings.filterIndexed { index, settings ->
- index == 0 || settings.name.isNotEmpty()
+ if (contactKeys.isEmpty()) {
+ flowOf(Triple(state, favorites, chs) to emptyMap())
+ } else {
+ val unreadFlows =
+ contactKeys.map { key ->
+ packetRepository.getUnreadCountFlow(key).map { count -> key to count }
+ }
+ combine(unreadFlows) { pairs -> Triple(state, favorites, chs) to pairs.toMap() }
+ }
+ }
+ .collect { (triple, counts) ->
+ val (state, favorites, chs) = triple
+ connectionState = state
+ favoriteNodes = favorites
+ channels = chs
+ unreadCounts = counts
+ invalidate()
}
- }
- .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",
- ),
- )
+ listBuilder.addSectionedList(SectionedItemList.create(buildStatusSection(), "Status"))
- // Favorites section
val favoritesSection = buildFavoritesSection()
if (favoritesSection.items.isNotEmpty()) {
- listBuilder.addSectionedList(
- SectionedItemList.create(
- favoritesSection,
- "Favorites",
- ),
- )
+ listBuilder.addSectionedList(SectionedItemList.create(favoritesSection, "Favorites"))
}
- // Channels section
val channelsSection = buildChannelsSection()
if (channelsSection.items.isNotEmpty()) {
- listBuilder.addSectionedList(
- SectionedItemList.create(
- channelsSection,
- "Channels",
- ),
- )
+ listBuilder.addSectionedList(SectionedItemList.create(channelsSection, "Channels"))
}
- return listBuilder
- .setTitle("Meshtastic")
- .setHeaderAction(Action.APP_ICON)
- .build()
+ return listBuilder.setTitle("Meshtastic").setHeaderAction(Action.APP_ICON).build()
}
private fun buildStatusSection(): ItemList {
- val statusText = when (connectionState) {
- is ConnectionState.Connected -> "Connected"
- is ConnectionState.Disconnected -> "Disconnected"
- is ConnectionState.DeviceSleep -> "Device Sleeping"
- is ConnectionState.Connecting -> "Connecting..."
- }
+ val statusText =
+ when (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()
+ val row =
+ Row.Builder()
+ .setTitle(statusText)
+ .apply { if (subtitle != null) addText(subtitle) }
+ .setBrowsable(false)
+ .build()
- return ItemList.Builder()
- .addItem(row)
- .build()
+ return ItemList.Builder().addItem(row).build()
}
private fun buildFavoritesSection(): ItemList {
@@ -205,12 +194,7 @@ class MeshtasticCarScreen(carContext: CarContext) :
if (unread > 0) append(" · $unread unread")
}
- val row = Row.Builder()
- .setTitle(name)
- .addText(subtitle)
- .setBrowsable(false)
- .build()
-
+ val row = Row.Builder().setTitle(name).addText(subtitle).setBrowsable(false).build()
builder.addItem(row)
}
@@ -226,11 +210,12 @@ class MeshtasticCarScreen(carContext: CarContext) :
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()
+ val row =
+ Row.Builder()
+ .setTitle(channelName)
+ .apply { if (subtitle.isNotEmpty()) addText(subtitle) }
+ .setBrowsable(false)
+ .build()
builder.addItem(row)
}
@@ -240,10 +225,9 @@ class MeshtasticCarScreen(carContext: CarContext) :
companion object {
/**
- * Android Auto enforces a maximum item count per [ListTemplate] section.
- * Car API level 1 supports up to 6 items per section.
+ * Android Auto enforces a maximum item count per [ListTemplate] section. Car API level 1 supports up to 6 items
+ * per section.
*/
private const val MAX_LIST_ITEMS = 6
}
}
-
diff --git a/app/src/main/kotlin/org/meshtastic/app/auto/MeshtasticCarSession.kt b/feature/auto/src/main/kotlin/org/meshtastic/feature/auto/MeshtasticCarSession.kt
similarity index 78%
rename from app/src/main/kotlin/org/meshtastic/app/auto/MeshtasticCarSession.kt
rename to feature/auto/src/main/kotlin/org/meshtastic/feature/auto/MeshtasticCarSession.kt
index 4a405cafd..9fd816bbf 100644
--- a/app/src/main/kotlin/org/meshtastic/app/auto/MeshtasticCarSession.kt
+++ b/feature/auto/src/main/kotlin/org/meshtastic/feature/auto/MeshtasticCarSession.kt
@@ -14,18 +14,14 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see .
*/
-package org.meshtastic.app.auto
+package org.meshtastic.feature.auto
import android.content.Intent
import androidx.car.app.Screen
import androidx.car.app.Session
-/**
- * Android Auto session that hosts the [MeshtasticCarScreen] root screen.
- */
+/** Android Auto session that hosts the [MeshtasticCarScreen] root screen. */
class MeshtasticCarSession : Session() {
- override fun onCreateScreen(intent: Intent): Screen {
- return MeshtasticCarScreen(carContext)
- }
+ override fun onCreateScreen(intent: Intent): Screen = MeshtasticCarScreen(carContext)
}
diff --git a/app/src/main/res/xml/automotive_app_desc.xml b/feature/auto/src/main/res/xml/auto_app_desc.xml
similarity index 100%
rename from app/src/main/res/xml/automotive_app_desc.xml
rename to feature/auto/src/main/res/xml/auto_app_desc.xml
diff --git a/settings.gradle.kts b/settings.gradle.kts
index f9664baaa..dc24932b7 100644
--- a/settings.gradle.kts
+++ b/settings.gradle.kts
@@ -46,6 +46,7 @@ include(
":feature:settings",
":feature:firmware",
":feature:wifi-provision",
+ ":feature:auto",
":feature:widget",
":desktop",
)