From c018ca60667a66f5d42679da2f2513e994047b77 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Fri, 17 Apr 2026 03:11:21 +0000
Subject: [PATCH 01/44] feat(auto): add Android Auto communications app with
notification and Car App Library support
Agent-Logs-Url: https://github.com/meshtastic/Meshtastic-Android/sessions/571f7a66-0c36-43f2-890e-c8ed87ec7164
Co-authored-by: garthvh <1795163+garthvh@users.noreply.github.com>
---
app/build.gradle.kts | 1 +
app/src/main/AndroidManifest.xml | 19 ++
.../app/auto/MeshtasticCarAppService.kt | 54 ++++
.../app/auto/MeshtasticCarScreen.kt | 241 ++++++++++++++++++
.../app/auto/MeshtasticCarSession.kt | 31 +++
app/src/main/res/xml/automotive_app_desc.xml | 22 ++
.../service/ConversationShortcutManager.kt | 190 ++++++++++++++
.../meshtastic/core/service/MeshService.kt | 4 +
.../service/MeshServiceNotificationsImpl.kt | 3 +
gradle/libs.versions.toml | 2 +
10 files changed, 567 insertions(+)
create mode 100644 app/src/main/kotlin/org/meshtastic/app/auto/MeshtasticCarAppService.kt
create mode 100644 app/src/main/kotlin/org/meshtastic/app/auto/MeshtasticCarScreen.kt
create mode 100644 app/src/main/kotlin/org/meshtastic/app/auto/MeshtasticCarSession.kt
create mode 100644 app/src/main/res/xml/automotive_app_desc.xml
create mode 100644 core/service/src/androidMain/kotlin/org/meshtastic/core/service/ConversationShortcutManager.kt
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" }
From e2700b26aa88bb9e0877ca3009f7074ed51b1b3c Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Fri, 17 Apr 2026 03:16:14 +0000
Subject: [PATCH 02/44] fix(auto): clean up imports and simplify CarAppService
Agent-Logs-Url: https://github.com/meshtastic/Meshtastic-Android/sessions/571f7a66-0c36-43f2-890e-c8ed87ec7164
Co-authored-by: garthvh <1795163+garthvh@users.noreply.github.com>
---
.../app/auto/MeshtasticCarAppService.kt | 25 +++++--------------
.../app/auto/MeshtasticCarScreen.kt | 3 +--
.../service/ConversationShortcutManager.kt | 4 +--
3 files changed, 9 insertions(+), 23 deletions(-)
diff --git a/app/src/main/kotlin/org/meshtastic/app/auto/MeshtasticCarAppService.kt b/app/src/main/kotlin/org/meshtastic/app/auto/MeshtasticCarAppService.kt
index a05c3da9b..ad3042405 100644
--- a/app/src/main/kotlin/org/meshtastic/app/auto/MeshtasticCarAppService.kt
+++ b/app/src/main/kotlin/org/meshtastic/app/auto/MeshtasticCarAppService.kt
@@ -16,8 +16,6 @@
*/
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
@@ -31,24 +29,13 @@ import androidx.car.app.validation.HostValidator
*/
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 createHostValidator(): HostValidator =
+ HostValidator.ALLOW_ALL_HOSTS_VALIDATOR
- override fun onCreateSession(sessionInfo: SessionInfo): Session {
- return MeshtasticCarSession()
- }
+ override fun onCreateSession(sessionInfo: SessionInfo): Session =
+ MeshtasticCarSession()
@Suppress("DEPRECATION", "OVERRIDE_DEPRECATION")
- override fun onCreateSession(): Session {
- return MeshtasticCarSession()
- }
-
- override fun onNewIntent(intent: Intent) {
- super.onNewIntent(intent)
- }
+ override fun onCreateSession(): Session =
+ MeshtasticCarSession()
}
diff --git a/app/src/main/kotlin/org/meshtastic/app/auto/MeshtasticCarScreen.kt b/app/src/main/kotlin/org/meshtastic/app/auto/MeshtasticCarScreen.kt
index e44a2d621..eb172c674 100644
--- a/app/src/main/kotlin/org/meshtastic/app/auto/MeshtasticCarScreen.kt
+++ b/app/src/main/kotlin/org/meshtastic/app/auto/MeshtasticCarScreen.kt
@@ -19,7 +19,6 @@ 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
@@ -172,7 +171,7 @@ class MeshtasticCarScreen(carContext: CarContext) :
}
private fun buildStatusSection(): ItemList {
- val statusText = when (val state = connectionState) {
+ val statusText = when (connectionState) {
is ConnectionState.Connected -> "Connected"
is ConnectionState.Disconnected -> "Disconnected"
is ConnectionState.DeviceSleep -> "Device Sleeping"
diff --git a/core/service/src/androidMain/kotlin/org/meshtastic/core/service/ConversationShortcutManager.kt b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/ConversationShortcutManager.kt
index 405a20f2c..524f0b335 100644
--- a/core/service/src/androidMain/kotlin/org/meshtastic/core/service/ConversationShortcutManager.kt
+++ b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/ConversationShortcutManager.kt
@@ -32,11 +32,11 @@ 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.DataPacket
import org.meshtastic.core.model.Node
import org.meshtastic.core.navigation.DEEP_LINK_BASE_URI
import org.meshtastic.core.repository.NodeRepository
@@ -125,7 +125,7 @@ class ConversationShortcutManager(
// Channel shortcuts (broadcast conversations)
for ((index, channelSettings) in channels.withIndex()) {
- val contactKey = "${index}${org.meshtastic.core.model.DataPacket.ID_BROADCAST}"
+ val contactKey = "${index}${DataPacket.ID_BROADCAST}"
val channelName = channelSettings.name.ifEmpty { "Primary Channel" }
val person = Person.Builder()
.setName(channelName)
From 6d7ddebbef29019307e6d5a1a44dab225ef76027 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Fri, 17 Apr 2026 03:17:21 +0000
Subject: [PATCH 03/44] =?UTF-8?q?fix(auto):=20address=20code=20review=20?=
=?UTF-8?q?=E2=80=94=20filterIndexed,=20remove=20deprecated=20override?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Agent-Logs-Url: https://github.com/meshtastic/Meshtastic-Android/sessions/571f7a66-0c36-43f2-890e-c8ed87ec7164
Co-authored-by: garthvh <1795163+garthvh@users.noreply.github.com>
---
.../org/meshtastic/app/auto/MeshtasticCarAppService.kt | 4 ----
.../meshtastic/core/service/ConversationShortcutManager.kt | 6 +++++-
2 files changed, 5 insertions(+), 5 deletions(-)
diff --git a/app/src/main/kotlin/org/meshtastic/app/auto/MeshtasticCarAppService.kt b/app/src/main/kotlin/org/meshtastic/app/auto/MeshtasticCarAppService.kt
index ad3042405..43f9c97ba 100644
--- a/app/src/main/kotlin/org/meshtastic/app/auto/MeshtasticCarAppService.kt
+++ b/app/src/main/kotlin/org/meshtastic/app/auto/MeshtasticCarAppService.kt
@@ -34,8 +34,4 @@ class MeshtasticCarAppService : CarAppService() {
override fun onCreateSession(sessionInfo: SessionInfo): Session =
MeshtasticCarSession()
-
- @Suppress("DEPRECATION", "OVERRIDE_DEPRECATION")
- override fun onCreateSession(): Session =
- MeshtasticCarSession()
}
diff --git a/core/service/src/androidMain/kotlin/org/meshtastic/core/service/ConversationShortcutManager.kt b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/ConversationShortcutManager.kt
index 524f0b335..6f9588e11 100644
--- a/core/service/src/androidMain/kotlin/org/meshtastic/core/service/ConversationShortcutManager.kt
+++ b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/ConversationShortcutManager.kt
@@ -75,7 +75,11 @@ class ConversationShortcutManager(
.distinctUntilChanged()
val channelsFlow = radioConfigRepository.channelSetFlow
- .map { cs -> cs.settings.filter { it.name.isNotEmpty() || cs.settings.indexOf(it) == 0 } }
+ .map { cs ->
+ cs.settings.filterIndexed { index, settings ->
+ settings.name.isNotEmpty() || index == 0
+ }
+ }
.distinctUntilChanged()
combine(favoritesFlow, channelsFlow) { favorites, channels ->
From 41b99fd079766e87ae7266825cf349bae0603179 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Fri, 17 Apr 2026 03:34:43 +0000
Subject: [PATCH 04/44] fix(auto): fix ConnectionState data object usage and
add list item limits
Agent-Logs-Url: https://github.com/meshtastic/Meshtastic-Android/sessions/d1af27de-bc74-4b77-bdb8-7ae8167ab336
Co-authored-by: garthvh <1795163+garthvh@users.noreply.github.com>
---
.../meshtastic/app/auto/MeshtasticCarScreen.kt | 17 +++++++++++++----
1 file changed, 13 insertions(+), 4 deletions(-)
diff --git a/app/src/main/kotlin/org/meshtastic/app/auto/MeshtasticCarScreen.kt b/app/src/main/kotlin/org/meshtastic/app/auto/MeshtasticCarScreen.kt
index eb172c674..7096cd0c2 100644
--- a/app/src/main/kotlin/org/meshtastic/app/auto/MeshtasticCarScreen.kt
+++ b/app/src/main/kotlin/org/meshtastic/app/auto/MeshtasticCarScreen.kt
@@ -67,7 +67,7 @@ class MeshtasticCarScreen(carContext: CarContext) :
private val scope = CoroutineScope(Dispatchers.Main + SupervisorJob())
private var observeJob: Job? = null
- private var connectionState: ConnectionState = ConnectionState.Disconnected()
+ private var connectionState: ConnectionState = ConnectionState.Disconnected
private var favoriteNodes: List = emptyList()
private var channels: List = emptyList()
private var unreadCounts: Map = emptyMap()
@@ -195,10 +195,10 @@ class MeshtasticCarScreen(carContext: CarContext) :
private fun buildFavoritesSection(): ItemList {
val builder = ItemList.Builder()
- for (node in favoriteNodes) {
+ for (node in favoriteNodes.take(MAX_LIST_ITEMS)) {
val contactKey = "0${node.user.id}"
val unread = unreadCounts[contactKey] ?: 0
- val name = node.user.long_name.ifEmpty { node.user.short_name }
+ val name = node.user.long_name.ifEmpty { node.user.short_name }.ifEmpty { "Unknown" }
val subtitle = buildString {
append(node.user.short_name)
if (node.hopsAway >= 0) append(" · ${node.hopsAway} hops")
@@ -220,7 +220,7 @@ class MeshtasticCarScreen(carContext: CarContext) :
private fun buildChannelsSection(): ItemList {
val builder = ItemList.Builder()
- for ((index, channelSettings) in channels.withIndex()) {
+ for ((index, channelSettings) in channels.take(MAX_LIST_ITEMS).withIndex()) {
val contactKey = "${index}${DataPacket.ID_BROADCAST}"
val unread = unreadCounts[contactKey] ?: 0
val channelName = channelSettings.name.ifEmpty { "Primary Channel" }
@@ -237,4 +237,13 @@ class MeshtasticCarScreen(carContext: CarContext) :
return builder.build()
}
+
+ companion object {
+ /**
+ * 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
+ }
}
+
From 0df6d70317c01a31593a85f3d938f68ead63e57d Mon Sep 17 00:00:00 2001
From: James Rich
Date: Fri, 17 Apr 2026 07:34:16 -0500
Subject: [PATCH 05/44] refactor(auto): extract Android Auto into feature:auto
module
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- Move MeshtasticCarAppService, CarSession, CarScreen from app/auto
to the new :feature:auto module (meshtastic.android.library)
- Move automotive_app_desc.xml → auto_app_desc.xml (respects resourcePrefix)
- Move manifest entries (service + meta-data) into feature module so they
merge into app rather than living in the app manifest directly
- Fix HostValidator: use ApplicationInfo.FLAG_DEBUGGABLE instead of
BuildConfig.DEBUG (library modules don't ship their own BuildConfig)
- Fix stale unread counts: replace point-in-time getUnreadCount() with
flatMapLatest + per-conversation getUnreadCountFlow() so the car screen
invalidates on new messages, not just topology changes
- Fix ConversationShortcutManager: replace removeAllDynamicShortcuts +
addDynamicShortcuts with pushDynamicShortcut per conversation to
preserve usage/ranking history; remove stale shortcuts individually;
respect getMaxShortcutCountPerActivity() limit
- Fix SHORTCUT_CATEGORY_CONVERSATION: constant lives on ShortcutInfo,
not ShortcutManagerCompat
- Remove androidx.car.app dependency from :app (now owned by :feature:auto)
- Add :feature:auto to settings.gradle.kts and app dependencies
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
---
app/build.gradle.kts | 2 +-
app/src/main/AndroidManifest.xml | 19 --
.../service/ConversationShortcutManager.kt | 149 +++++++++-------
feature/auto/build.gradle.kts | 41 +++++
feature/auto/src/main/AndroidManifest.xml | 41 +++++
.../feature}/auto/MeshtasticCarAppService.kt | 22 ++-
.../feature}/auto/MeshtasticCarScreen.kt | 168 ++++++++----------
.../feature}/auto/MeshtasticCarSession.kt | 10 +-
.../auto/src/main/res/xml/auto_app_desc.xml | 0
settings.gradle.kts | 1 +
10 files changed, 259 insertions(+), 194 deletions(-)
create mode 100644 feature/auto/build.gradle.kts
create mode 100644 feature/auto/src/main/AndroidManifest.xml
rename {app/src/main/kotlin/org/meshtastic/app => feature/auto/src/main/kotlin/org/meshtastic/feature}/auto/MeshtasticCarAppService.kt (64%)
rename {app/src/main/kotlin/org/meshtastic/app => feature/auto/src/main/kotlin/org/meshtastic/feature}/auto/MeshtasticCarScreen.kt (57%)
rename {app/src/main/kotlin/org/meshtastic/app => feature/auto/src/main/kotlin/org/meshtastic/feature}/auto/MeshtasticCarSession.kt (78%)
rename app/src/main/res/xml/automotive_app_desc.xml => feature/auto/src/main/res/xml/auto_app_desc.xml (100%)
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",
)
From 86bb9583b099305feef9ad21ea155012a0409ba2 Mon Sep 17 00:00:00 2001
From: James Rich
Date: Fri, 17 Apr 2026 07:56:34 -0500
Subject: [PATCH 06/44] fix(auto): extract shortcut builders to fix LongMethod
+ catch specific exceptions
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
---
.../service/ConversationShortcutManager.kt | 114 ++++++++----------
1 file changed, 48 insertions(+), 66 deletions(-)
diff --git a/core/service/src/androidMain/kotlin/org/meshtastic/core/service/ConversationShortcutManager.kt b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/ConversationShortcutManager.kt
index dbed0f1d8..5c820e188 100644
--- a/core/service/src/androidMain/kotlin/org/meshtastic/core/service/ConversationShortcutManager.kt
+++ b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/ConversationShortcutManager.kt
@@ -96,84 +96,66 @@ class ConversationShortcutManager(
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(ShortcutInfo.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}${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(ShortcutInfo.SHORTCUT_CATEGORY_CONVERSATION))
- .setIntent(
- Intent(Intent.ACTION_VIEW, "$DEEP_LINK_BASE_URI/messages/$contactKey".toUri()).apply {
- setPackage(context.packageName)
- },
- )
- .build()
-
- shortcuts.add(shortcut)
- }
+ val shortcuts =
+ favorites.filter { it.num != myNodeNum }.map { buildFavoriteShortcut(it) } +
+ channels.mapIndexed { index, settings -> buildChannelShortcut(settings, index) }
try {
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.
+ if (stale.isNotEmpty()) ShortcutManagerCompat.removeDynamicShortcuts(context, stale)
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.d { "Published ${shortcuts.size.coerceAtMost(limit)} conversation shortcuts" }
+ } catch (e: IllegalArgumentException) {
+ Logger.e(e) { "Failed to publish conversation shortcuts" }
+ } catch (e: IllegalStateException) {
Logger.e(e) { "Failed to publish conversation shortcuts" }
}
}
+ private fun buildFavoriteShortcut(node: Node): ShortcutInfoCompat {
+ val contactKey = "0${node.user.id}"
+ val label = node.user.long_name.ifEmpty { node.user.short_name }
+ 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()
+ return ShortcutInfoCompat.Builder(context, contactKey)
+ .setShortLabel(label)
+ .setLongLabel(label)
+ .setLocusId(LocusIdCompat(contactKey))
+ .setPerson(person)
+ .setLongLived(true)
+ .setCategories(setOf(ShortcutInfo.SHORTCUT_CATEGORY_CONVERSATION))
+ .setIntent(conversationIntent(contactKey))
+ .build()
+ }
+
+ private fun buildChannelShortcut(channelSettings: ChannelSettings, index: Int): ShortcutInfoCompat {
+ val contactKey = "${index}${DataPacket.ID_BROADCAST}"
+ val channelName = channelSettings.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, "$DEEP_LINK_BASE_URI/messages/$contactKey".toUri()).apply {
+ setPackage(context.packageName)
+ }
+
private fun createPersonIcon(name: String, backgroundColor: Int, foregroundColor: Int): IconCompat {
val size = ICON_SIZE
val bitmap = createBitmap(size, size)
From 36f770fd0baa3e090af57f29b045cf0ed76375de Mon Sep 17 00:00:00 2001
From: James Rich
Date: Fri, 17 Apr 2026 08:02:55 -0500
Subject: [PATCH 07/44] fix(auto): preserve raw channel index for
shortcut/unread contactKey
Notifications and message routing key channel conversations by the raw
protocol channel index (e.g. "2^all"), but publishShortcuts and the
car screen were re-indexing after filtering out unnamed channels, so
named channels after a gap would never match their notification's
shortcutId/locusId and their unread badge would stay at zero.
Preserve the original index via mapIndexedNotNull { index to settings }.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
---
.../core/service/ConversationShortcutManager.kt | 8 +++++---
.../meshtastic/feature/auto/MeshtasticCarScreen.kt | 13 ++++++-------
gradle/gradle-daemon-jvm.properties | 12 ++++++++++++
3 files changed, 23 insertions(+), 10 deletions(-)
diff --git a/core/service/src/androidMain/kotlin/org/meshtastic/core/service/ConversationShortcutManager.kt b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/ConversationShortcutManager.kt
index 5c820e188..2fc0dc083 100644
--- a/core/service/src/androidMain/kotlin/org/meshtastic/core/service/ConversationShortcutManager.kt
+++ b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/ConversationShortcutManager.kt
@@ -79,7 +79,9 @@ class ConversationShortcutManager(
val channelsFlow =
radioConfigRepository.channelSetFlow
.map { cs ->
- cs.settings.filterIndexed { index, settings -> settings.name.isNotEmpty() || index == 0 }
+ cs.settings.mapIndexedNotNull { index, settings ->
+ if (index == 0 || settings.name.isNotEmpty()) index to settings else null
+ }
}
.distinctUntilChanged()
@@ -94,11 +96,11 @@ class ConversationShortcutManager(
observeJob = null
}
- private fun publishShortcuts(favorites: List, channels: List) {
+ private fun publishShortcuts(favorites: List, channels: List>) {
val myNodeNum = nodeRepository.myNodeInfo.value?.myNodeNum
val shortcuts =
favorites.filter { it.num != myNodeNum }.map { buildFavoriteShortcut(it) } +
- channels.mapIndexed { index, settings -> buildChannelShortcut(settings, index) }
+ channels.map { (index, settings) -> buildChannelShortcut(settings, index) }
try {
val limit = ShortcutManagerCompat.getMaxShortcutCountPerActivity(context)
diff --git a/feature/auto/src/main/kotlin/org/meshtastic/feature/auto/MeshtasticCarScreen.kt b/feature/auto/src/main/kotlin/org/meshtastic/feature/auto/MeshtasticCarScreen.kt
index c90344a60..35c287f1c 100644
--- a/feature/auto/src/main/kotlin/org/meshtastic/feature/auto/MeshtasticCarScreen.kt
+++ b/feature/auto/src/main/kotlin/org/meshtastic/feature/auto/MeshtasticCarScreen.kt
@@ -71,7 +71,7 @@ class MeshtasticCarScreen(carContext: CarContext) :
private var connectionState: ConnectionState = ConnectionState.Disconnected
private var favoriteNodes: List = emptyList()
- private var channels: List = emptyList()
+ private var channels: List> = emptyList()
private var unreadCounts: Map = emptyMap()
init {
@@ -106,7 +106,9 @@ class MeshtasticCarScreen(carContext: CarContext) :
val channelsFlow =
radioConfigRepository.channelSetFlow
.map { cs ->
- cs.settings.filterIndexed { index, settings -> index == 0 || settings.name.isNotEmpty() }
+ cs.settings.mapIndexedNotNull { index, settings ->
+ if (index == 0 || settings.name.isNotEmpty()) index to settings else null
+ }
}
.distinctUntilChanged()
@@ -114,11 +116,8 @@ class MeshtasticCarScreen(carContext: CarContext) :
Triple(state, favorites, chs)
}
.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}" }
+ favorites.map { "0${it.user.id}" } + chs.map { (i, _) -> "${i}${DataPacket.ID_BROADCAST}" }
if (contactKeys.isEmpty()) {
flowOf(Triple(state, favorites, chs) to emptyMap())
@@ -204,7 +203,7 @@ class MeshtasticCarScreen(carContext: CarContext) :
private fun buildChannelsSection(): ItemList {
val builder = ItemList.Builder()
- for ((index, channelSettings) in channels.take(MAX_LIST_ITEMS).withIndex()) {
+ for ((index, channelSettings) in channels.take(MAX_LIST_ITEMS)) {
val contactKey = "${index}${DataPacket.ID_BROADCAST}"
val unread = unreadCounts[contactKey] ?: 0
val channelName = channelSettings.name.ifEmpty { "Primary Channel" }
diff --git a/gradle/gradle-daemon-jvm.properties b/gradle/gradle-daemon-jvm.properties
index 52234b5ce..bad5a1dc2 100644
--- a/gradle/gradle-daemon-jvm.properties
+++ b/gradle/gradle-daemon-jvm.properties
@@ -1 +1,13 @@
+#This file is generated by updateDaemonJvm
+toolchainUrl.FREE_BSD.AARCH64=https\://api.foojay.io/disco/v3.0/ids/4945f00643ec68e7c7a6b66f90124f89/redirect
+toolchainUrl.FREE_BSD.X86_64=https\://api.foojay.io/disco/v3.0/ids/93aeea858331bd6bb00ba94759830234/redirect
+toolchainUrl.LINUX.AARCH64=https\://api.foojay.io/disco/v3.0/ids/4945f00643ec68e7c7a6b66f90124f89/redirect
+toolchainUrl.LINUX.X86_64=https\://api.foojay.io/disco/v3.0/ids/93aeea858331bd6bb00ba94759830234/redirect
+toolchainUrl.MAC_OS.AARCH64=https\://api.foojay.io/disco/v3.0/ids/3426ffcaa54c3f62406beb1f1ab8b179/redirect
+toolchainUrl.MAC_OS.X86_64=https\://api.foojay.io/disco/v3.0/ids/f636c800fdb3f9ae33f019dfa048ba72/redirect
+toolchainUrl.UNIX.AARCH64=https\://api.foojay.io/disco/v3.0/ids/4945f00643ec68e7c7a6b66f90124f89/redirect
+toolchainUrl.UNIX.X86_64=https\://api.foojay.io/disco/v3.0/ids/93aeea858331bd6bb00ba94759830234/redirect
+toolchainUrl.WINDOWS.AARCH64=https\://api.foojay.io/disco/v3.0/ids/1e91f45234d88a64dafb961c93ddc75a/redirect
+toolchainUrl.WINDOWS.X86_64=https\://api.foojay.io/disco/v3.0/ids/0ef34dd9312b12d61ba1b8e66126d140/redirect
+toolchainVendor=ADOPTIUM
toolchainVersion=21
From 07772917c36e5148a815af15dd10b7f9fdab7a7c Mon Sep 17 00:00:00 2001
From: James Rich
Date: Fri, 17 Apr 2026 08:48:38 -0500
Subject: [PATCH 08/44] fix(auto): project messaging notifications to Android
Auto
Gearhead's MsgNotifParser was rejecting Meshtastic notifications with:
- 'No semantic reply action found'
- 'No semantic mark-as-read action found'
- 'added an invalid shortcut'
Fixes:
- Tag reply action with SEMANTIC_ACTION_REPLY + setShowsUserInterface(false)
+ setAllowGeneratedReplies(true) so Gearhead/Assistant can surface it.
- Tag mark-as-read action with SEMANTIC_ACTION_MARK_AS_READ.
- Publish an on-demand long-lived conversation shortcut whose id matches
the notification's setShortcutId(contactKey). Previously only favorites
+ channels at index 0 had shortcuts, so DMs received on a non-zero
channel referenced an unpublished shortcut and Android Auto refused to
project them.
Verified on Pixel 6a + DHU 2.0: notifications now carry matching
long-lived shortcuts and project as messaging HUNs with reply, mark-read
and reaction actions.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
---
.../service/ConversationShortcutManager.kt | 28 +++++++++++++
.../service/MeshServiceNotificationsImpl.kt | 41 ++++++++++++++++++-
2 files changed, 68 insertions(+), 1 deletion(-)
diff --git a/core/service/src/androidMain/kotlin/org/meshtastic/core/service/ConversationShortcutManager.kt b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/ConversationShortcutManager.kt
index 2fc0dc083..db6fd37d4 100644
--- a/core/service/src/androidMain/kotlin/org/meshtastic/core/service/ConversationShortcutManager.kt
+++ b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/ConversationShortcutManager.kt
@@ -158,6 +158,34 @@ class ConversationShortcutManager(
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 (e.g., an incoming DM on a non-primary channel,
+ * or from a non-favorite node). Android Auto requires a matching published shortcut to project the notification as
+ * a messaging HUN.
+ */
+ fun ensureConversationShortcut(contactKey: String, person: Person, label: String) {
+ val alreadyPublished = ShortcutManagerCompat.getDynamicShortcuts(context).any { it.id == contactKey }
+ if (alreadyPublished) return
+ val shortcut =
+ ShortcutInfoCompat.Builder(context, contactKey)
+ .setShortLabel(label)
+ .setLongLabel(label)
+ .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(e) { "Failed to publish on-demand shortcut $contactKey" }
+ } catch (e: IllegalStateException) {
+ Logger.e(e) { "Failed to publish on-demand shortcut $contactKey" }
+ }
+ }
+
private fun createPersonIcon(name: String, backgroundColor: Int, foregroundColor: Int): IconCompat {
val size = ICON_SIZE
val bitmap = createBitmap(size, size)
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 d3a6dc590..7fba7c087 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
@@ -110,6 +110,7 @@ class MeshServiceNotificationsImpl(
private val context: Context,
private val packetRepository: Lazy,
private val nodeRepository: Lazy,
+ private val shortcutManager: Lazy,
) : MeshServiceNotifications {
private val notificationManager = context.getSystemService()!!
@@ -618,6 +619,8 @@ class MeshServiceNotificationsImpl(
}
val lastMessage = history.last()
+ ensureShortcutForNotification(contactKey, isBroadcast, channelName, lastMessage)
+
builder
.setCategory(Notification.CATEGORY_MESSAGE)
.setAutoCancel(true)
@@ -773,6 +776,36 @@ class MeshServiceNotificationsImpl(
}
}
+ private fun ensureShortcutForNotification(
+ contactKey: String,
+ isBroadcast: Boolean,
+ channelName: String?,
+ lastMessage: Message,
+ ) {
+ val person =
+ if (isBroadcast) {
+ Person.Builder().setName(channelName ?: contactKey).setKey(contactKey).build()
+ } else {
+ Person.Builder()
+ .setName(lastMessage.node.user.long_name)
+ .setKey(lastMessage.node.user.id)
+ .setIcon(
+ createPersonIcon(
+ lastMessage.node.user.short_name,
+ lastMessage.node.colors.second,
+ lastMessage.node.colors.first,
+ ),
+ )
+ .build()
+ }
+ val label =
+ when {
+ isBroadcast -> channelName ?: contactKey
+ else -> lastMessage.node.user.long_name.ifEmpty { lastMessage.node.user.short_name }
+ }
+ shortcutManager.value.ensureConversationShortcut(contactKey, person, label)
+ }
+
private fun createReplyAction(contactKey: String): NotificationCompat.Action {
val replyLabel = getString(Res.string.reply)
val remoteInput = RemoteInput.Builder(KEY_TEXT_REPLY).setLabel(replyLabel).build()
@@ -792,6 +825,9 @@ class MeshServiceNotificationsImpl(
return NotificationCompat.Action.Builder(android.R.drawable.ic_menu_send, replyLabel, replyPendingIntent)
.addRemoteInput(remoteInput)
+ .setSemanticAction(NotificationCompat.Action.SEMANTIC_ACTION_REPLY)
+ .setShowsUserInterface(false)
+ .setAllowGeneratedReplies(true)
.build()
}
@@ -810,7 +846,10 @@ class MeshServiceNotificationsImpl(
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)
- return NotificationCompat.Action.Builder(android.R.drawable.ic_menu_view, label, pendingIntent).build()
+ return NotificationCompat.Action.Builder(android.R.drawable.ic_menu_view, label, pendingIntent)
+ .setSemanticAction(NotificationCompat.Action.SEMANTIC_ACTION_MARK_AS_READ)
+ .setShowsUserInterface(false)
+ .build()
}
private fun createReactionAction(
From d17e715a4577e8575e5b992d5fcc3048f614b097 Mon Sep 17 00:00:00 2001
From: James Rich
Date: Fri, 17 Apr 2026 08:53:11 -0500
Subject: [PATCH 09/44] fix(auto): clear unread count after inline reply
ReplyReceiver was only cancelling the notification after an inline
reply; PacketRepository.clearUnreadCount was never called, so the
message stayed 'unread' in the app (and in the Android Auto favorites
unread badges) even after the user replied from the HUN.
Mirror MarkAsReadReceiver by invoking clearUnreadCount with nowMillis
before cancelling the notification.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
---
.../kotlin/org/meshtastic/core/service/ReplyReceiver.kt | 5 +++++
1 file changed, 5 insertions(+)
diff --git a/core/service/src/androidMain/kotlin/org/meshtastic/core/service/ReplyReceiver.kt b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/ReplyReceiver.kt
index d7a943783..13fd92758 100644
--- a/core/service/src/androidMain/kotlin/org/meshtastic/core/service/ReplyReceiver.kt
+++ b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/ReplyReceiver.kt
@@ -25,10 +25,12 @@ import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
+import org.meshtastic.core.common.util.nowMillis
import org.meshtastic.core.di.CoroutineDispatchers
import org.meshtastic.core.model.DataPacket
import org.meshtastic.core.model.RadioController
import org.meshtastic.core.repository.MeshServiceNotifications
+import org.meshtastic.core.repository.PacketRepository
/**
* A [BroadcastReceiver] that handles inline replies from notifications.
@@ -44,6 +46,8 @@ class ReplyReceiver :
private val meshServiceNotifications: MeshServiceNotifications by inject()
+ private val packetRepository: PacketRepository by inject()
+
private val dispatchers: CoroutineDispatchers by inject()
private val scope by lazy { CoroutineScope(dispatchers.io + SupervisorJob()) }
@@ -65,6 +69,7 @@ class ReplyReceiver :
scope.launch {
try {
sendMessage(message, contactKey)
+ packetRepository.clearUnreadCount(contactKey, nowMillis)
meshServiceNotifications.cancelMessageNotification(contactKey)
} finally {
pendingResult.finish()
From 72e27e32ccf5032edc7340a622fe7c063f967471 Mon Sep 17 00:00:00 2001
From: James Rich
Date: Fri, 17 Apr 2026 09:02:20 -0500
Subject: [PATCH 10/44] chore(auto): log ReplyReceiver entry and completion
Temporary diagnostic logging added while investigating why Android Auto
inline replies don't appear to dismiss the conversation notification
for some users. Remove or downgrade to verbose once confirmed working.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
---
.../kotlin/org/meshtastic/core/service/ReplyReceiver.kt | 3 +++
1 file changed, 3 insertions(+)
diff --git a/core/service/src/androidMain/kotlin/org/meshtastic/core/service/ReplyReceiver.kt b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/ReplyReceiver.kt
index 13fd92758..f265a10fd 100644
--- a/core/service/src/androidMain/kotlin/org/meshtastic/core/service/ReplyReceiver.kt
+++ b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/ReplyReceiver.kt
@@ -20,6 +20,7 @@ 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.SupervisorJob
import kotlinx.coroutines.launch
@@ -64,6 +65,7 @@ class ReplyReceiver :
if (remoteInput != null) {
val contactKey = intent.getStringExtra(CONTACT_KEY) ?: ""
val message = remoteInput.getCharSequence(KEY_TEXT_REPLY)?.toString() ?: ""
+ Logger.d { "ReplyReceiver: onReceive contactKey='$contactKey' msgLen=${message.length}" }
val pendingResult = goAsync()
scope.launch {
@@ -71,6 +73,7 @@ class ReplyReceiver :
sendMessage(message, contactKey)
packetRepository.clearUnreadCount(contactKey, nowMillis)
meshServiceNotifications.cancelMessageNotification(contactKey)
+ Logger.d { "ReplyReceiver: reply flow complete for contactKey='$contactKey'" }
} finally {
pendingResult.finish()
}
From c1073f3e120758feffc823a3834227d1a876128f Mon Sep 17 00:00:00 2001
From: James Rich
Date: Fri, 17 Apr 2026 09:12:48 -0500
Subject: [PATCH 11/44] fix(auto): don't re-post conversation notif on outgoing
messages
rememberDataPacket() was invoking handlePacketNotification() for
outgoing packets too, which made our own reply race with the
cancel issued by ReplyReceiver and repost the conversation with
ourselves as the visible sender (lastMessage.node == ourNode).
Also harden ensureShortcutForNotification for DMs: the remote
contact is deterministic from contactKey (channel + nodeId), so
derive the shortcut Person from the resolved contact node rather
than whatever message happens to be newest in history. This keeps
the Android Auto HUN labelled correctly even if the message list
ends with an outgoing packet.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
---
.../core/data/manager/MeshDataHandlerImpl.kt | 2 +-
.../service/MeshServiceNotificationsImpl.kt | 27 ++++++++++++-------
.../meshtastic/core/service/ReplyReceiver.kt | 3 ---
3 files changed, 18 insertions(+), 14 deletions(-)
diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshDataHandlerImpl.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshDataHandlerImpl.kt
index 384f722d8..8b0ee1529 100644
--- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshDataHandlerImpl.kt
+++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshDataHandlerImpl.kt
@@ -357,7 +357,7 @@ class MeshDataHandlerImpl(
read = fromLocal || isFiltered,
filtered = isFiltered,
)
- if (!isFiltered) {
+ if (!isFiltered && !fromLocal) {
handlePacketNotification(dataPacket, contactKey, updateNotification)
}
}
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 7fba7c087..75ae47297 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
@@ -782,26 +782,33 @@ class MeshServiceNotificationsImpl(
channelName: String?,
lastMessage: Message,
) {
+ val contactNode =
+ if (isBroadcast) {
+ null
+ } else {
+ // contactKey format: "${channel}${nodeId}"; the remote contact is the node keyed by nodeId,
+ // which is stable regardless of whether the latest message in history is incoming or outgoing.
+ val nodeId = contactKey.drop(1)
+ nodeRepository.value.getNode(nodeId)
+ }
val person =
if (isBroadcast) {
Person.Builder().setName(channelName ?: contactKey).setKey(contactKey).build()
} else {
+ val node = contactNode ?: lastMessage.node
Person.Builder()
- .setName(lastMessage.node.user.long_name)
- .setKey(lastMessage.node.user.id)
- .setIcon(
- createPersonIcon(
- lastMessage.node.user.short_name,
- lastMessage.node.colors.second,
- lastMessage.node.colors.first,
- ),
- )
+ .setName(node.user.long_name)
+ .setKey(node.user.id)
+ .setIcon(createPersonIcon(node.user.short_name, node.colors.second, node.colors.first))
.build()
}
val label =
when {
isBroadcast -> channelName ?: contactKey
- else -> lastMessage.node.user.long_name.ifEmpty { lastMessage.node.user.short_name }
+ else -> {
+ val node = contactNode ?: lastMessage.node
+ node.user.long_name.ifEmpty { node.user.short_name }
+ }
}
shortcutManager.value.ensureConversationShortcut(contactKey, person, label)
}
diff --git a/core/service/src/androidMain/kotlin/org/meshtastic/core/service/ReplyReceiver.kt b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/ReplyReceiver.kt
index f265a10fd..13fd92758 100644
--- a/core/service/src/androidMain/kotlin/org/meshtastic/core/service/ReplyReceiver.kt
+++ b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/ReplyReceiver.kt
@@ -20,7 +20,6 @@ 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.SupervisorJob
import kotlinx.coroutines.launch
@@ -65,7 +64,6 @@ class ReplyReceiver :
if (remoteInput != null) {
val contactKey = intent.getStringExtra(CONTACT_KEY) ?: ""
val message = remoteInput.getCharSequence(KEY_TEXT_REPLY)?.toString() ?: ""
- Logger.d { "ReplyReceiver: onReceive contactKey='$contactKey' msgLen=${message.length}" }
val pendingResult = goAsync()
scope.launch {
@@ -73,7 +71,6 @@ class ReplyReceiver :
sendMessage(message, contactKey)
packetRepository.clearUnreadCount(contactKey, nowMillis)
meshServiceNotifications.cancelMessageNotification(contactKey)
- Logger.d { "ReplyReceiver: reply flow complete for contactKey='$contactKey'" }
} finally {
pendingResult.finish()
}
From b5a631ebd7e08ad9d85c1cd05a256c5629cbcb4c Mon Sep 17 00:00:00 2001
From: James Rich
Date: Fri, 17 Apr 2026 09:17:48 -0500
Subject: [PATCH 12/44] fix(auto): only include unread messages in conversation
notif
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The MIN_CONTEXT_MESSAGES fallback (pull 3 most recent history
messages when unread < 3) was injecting already-read historical
messages into the MessagingStyle. On Android Auto, Gearhead reads
every message in the style aloud and shows them stacked on the
HUN, so old context was announced alongside the new one.
Just show the unread messages (up to MAX_HISTORY_MESSAGES). If
nothing is unread, don't post at all — we only call this from
paths that already check for fresh inbound content.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
---
.../core/service/MeshServiceNotificationsImpl.kt | 11 ++---------
1 file changed, 2 insertions(+), 9 deletions(-)
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 75ae47297..6d02f83be 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
@@ -119,7 +119,6 @@ class MeshServiceNotificationsImpl(
const val MAX_BATTERY_LEVEL = 100
private val NOTIFICATION_LIGHT_COLOR = Color.BLUE
private const val MAX_HISTORY_MESSAGES = 10
- private const val MIN_CONTEXT_MESSAGES = 3
private const val SNIPPET_LENGTH = 30
private const val GROUP_KEY_MESSAGES = "com.geeksville.mesh.GROUP_MESSAGES"
private const val SUMMARY_ID = 1
@@ -426,14 +425,8 @@ class MeshServiceNotificationsImpl(
.first()
val unread = history.filter { !it.read }
- val displayHistory =
- if (unread.size < MIN_CONTEXT_MESSAGES) {
- history.take(MIN_CONTEXT_MESSAGES).reversed()
- } else {
- unread.take(MAX_HISTORY_MESSAGES).reversed()
- }
-
- if (displayHistory.isEmpty()) return
+ if (unread.isEmpty()) return
+ val displayHistory = unread.take(MAX_HISTORY_MESSAGES).reversed()
val notification =
createConversationNotification(
From fb606db06733d0ea0ae29d2874e2fd124e4e0730 Mon Sep 17 00:00:00 2001
From: James Rich
Date: Fri, 17 Apr 2026 09:21:37 -0500
Subject: [PATCH 13/44] fix(auto): refresh group summary when a conversation is
cancelled
After clearing a message notification we left the GROUP_KEY_MESSAGES
summary in place, which on Android Auto leaves a lingering HUN /
summary entry for the already-dismissed conversation. Cancel the
summary when no child notifications remain, and refresh it otherwise.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
---
.../core/service/MeshServiceNotificationsImpl.kt | 15 ++++++++++++++-
1 file changed, 14 insertions(+), 1 deletion(-)
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 6d02f83be..091d45ad2 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
@@ -507,7 +507,20 @@ class MeshServiceNotificationsImpl(
notificationManager.notify(clientNotification.toString().hashCode(), notification)
}
- override fun cancelMessageNotification(contactKey: String) = notificationManager.cancel(contactKey.hashCode())
+ override fun cancelMessageNotification(contactKey: String) {
+ notificationManager.cancel(contactKey.hashCode())
+ // Refresh (or remove) the group summary so the notification shade / Auto HUN doesn't
+ // continue surfacing a stale summary after the last child is dismissed.
+ val remainingChildren =
+ notificationManager.activeNotifications.count { sbn ->
+ sbn.id != SUMMARY_ID && sbn.notification.group == GROUP_KEY_MESSAGES
+ }
+ if (remainingChildren == 0) {
+ notificationManager.cancel(SUMMARY_ID)
+ } else {
+ showGroupSummary()
+ }
+ }
override fun cancelLowBatteryNotification(node: Node) = notificationManager.cancel(node.num)
From 9c75f5a3f40c8427e918441df42a18f8ceb53ac5 Mon Sep 17 00:00:00 2001
From: James Rich
Date: Fri, 17 Apr 2026 09:25:13 -0500
Subject: [PATCH 14/44] fix(auto): always cancel group summary when dismissing
a conversation
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Reading notificationManager.activeNotifications immediately after
cancel() races with NotificationManagerService, so the count of
remaining children was unreliable and the summary could linger.
Drop it unconditionally — the next inbound message rebuilds it via
showGroupSummary().
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
---
.../core/service/MeshServiceNotificationsImpl.kt | 15 ++++-----------
1 file changed, 4 insertions(+), 11 deletions(-)
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 091d45ad2..3d1684cb6 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
@@ -509,17 +509,10 @@ class MeshServiceNotificationsImpl(
override fun cancelMessageNotification(contactKey: String) {
notificationManager.cancel(contactKey.hashCode())
- // Refresh (or remove) the group summary so the notification shade / Auto HUN doesn't
- // continue surfacing a stale summary after the last child is dismissed.
- val remainingChildren =
- notificationManager.activeNotifications.count { sbn ->
- sbn.id != SUMMARY_ID && sbn.notification.group == GROUP_KEY_MESSAGES
- }
- if (remainingChildren == 0) {
- notificationManager.cancel(SUMMARY_ID)
- } else {
- showGroupSummary()
- }
+ // Always drop the group summary — reading notificationManager.activeNotifications right
+ // after cancel() races with NotificationManagerService, so we can't reliably count what's
+ // left. The next incoming message re-builds the summary via showGroupSummary().
+ notificationManager.cancel(SUMMARY_ID)
}
override fun cancelLowBatteryNotification(node: Node) = notificationManager.cancel(node.num)
From 6d70d154e69224e45dee4bc677135402fb8683e1 Mon Sep 17 00:00:00 2001
From: James Rich
Date: Fri, 17 Apr 2026 09:42:44 -0500
Subject: [PATCH 15/44] refactor(notifications): share markConversationRead
helper across receivers
Extract the 'clear unread count + cancel message notification' pair into
a single suspend helper on MeshServiceNotifications so ReplyReceiver,
MarkAsReadReceiver, and ReactionReceiver use one consistent code path.
ReactionReceiver now also clears unread and cancels the notification
once the reaction dispatch succeeds, matching the other receivers.
Receivers that only depended on PacketRepository for this pair drop
that injection.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
---
app/src/test/kotlin/org/meshtastic/app/service/Fakes.kt | 2 ++
.../meshtastic/core/repository/MeshServiceNotifications.kt | 7 +++++++
.../org/meshtastic/core/service/MarkAsReadReceiver.kt | 7 +------
.../core/service/MeshServiceNotificationsImpl.kt | 5 +++++
.../kotlin/org/meshtastic/core/service/ReactionReceiver.kt | 4 ++++
.../kotlin/org/meshtastic/core/service/ReplyReceiver.kt | 7 +------
.../core/testing/FakeMeshServiceNotifications.kt | 2 ++
.../notification/DesktopMeshServiceNotifications.kt | 4 ++++
8 files changed, 26 insertions(+), 12 deletions(-)
diff --git a/app/src/test/kotlin/org/meshtastic/app/service/Fakes.kt b/app/src/test/kotlin/org/meshtastic/app/service/Fakes.kt
index 37c19f477..46d5ed27c 100644
--- a/app/src/test/kotlin/org/meshtastic/app/service/Fakes.kt
+++ b/app/src/test/kotlin/org/meshtastic/app/service/Fakes.kt
@@ -74,6 +74,8 @@ class FakeMeshServiceNotifications : MeshServiceNotifications {
override fun cancelMessageNotification(contactKey: String) {}
+ override suspend fun markConversationRead(contactKey: String) {}
+
override fun cancelLowBatteryNotification(node: Node) {}
override fun clearClientNotification(notification: ClientNotification) {}
diff --git a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/MeshServiceNotifications.kt b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/MeshServiceNotifications.kt
index a68157943..1216f29a3 100644
--- a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/MeshServiceNotifications.kt
+++ b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/MeshServiceNotifications.kt
@@ -67,6 +67,13 @@ interface MeshServiceNotifications {
fun cancelMessageNotification(contactKey: String)
+ /**
+ * Marks the conversation for [contactKey] as read: clears its unread count in the packet repository and cancels the
+ * posted message notification (and the group summary). Intended for use by notification action receivers (reply,
+ * mark-as-read, reaction) to keep behavior consistent.
+ */
+ suspend fun markConversationRead(contactKey: String)
+
fun cancelLowBatteryNotification(node: Node)
fun clearClientNotification(notification: ClientNotification)
diff --git a/core/service/src/androidMain/kotlin/org/meshtastic/core/service/MarkAsReadReceiver.kt b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/MarkAsReadReceiver.kt
index 36c26c879..8eb3af994 100644
--- a/core/service/src/androidMain/kotlin/org/meshtastic/core/service/MarkAsReadReceiver.kt
+++ b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/MarkAsReadReceiver.kt
@@ -24,18 +24,14 @@ import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
-import org.meshtastic.core.common.util.nowMillis
import org.meshtastic.core.di.CoroutineDispatchers
import org.meshtastic.core.repository.MeshServiceNotifications
-import org.meshtastic.core.repository.PacketRepository
/** A [BroadcastReceiver] that handles "Mark as read" actions from notifications. */
class MarkAsReadReceiver :
BroadcastReceiver(),
KoinComponent {
- private val packetRepository: PacketRepository by inject()
-
private val serviceNotifications: MeshServiceNotifications by inject()
private val dispatchers: CoroutineDispatchers by inject()
@@ -54,8 +50,7 @@ class MarkAsReadReceiver :
scope.launch {
try {
- packetRepository.clearUnreadCount(contactKey, nowMillis)
- serviceNotifications.cancelMessageNotification(contactKey)
+ serviceNotifications.markConversationRead(contactKey)
} finally {
pendingResult.finish()
}
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 3d1684cb6..97ff46766 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
@@ -515,6 +515,11 @@ class MeshServiceNotificationsImpl(
notificationManager.cancel(SUMMARY_ID)
}
+ override suspend fun markConversationRead(contactKey: String) {
+ packetRepository.value.clearUnreadCount(contactKey, nowMillis)
+ cancelMessageNotification(contactKey)
+ }
+
override fun cancelLowBatteryNotification(node: Node) = notificationManager.cancel(node.num)
override fun clearClientNotification(notification: ClientNotification) =
diff --git a/core/service/src/androidMain/kotlin/org/meshtastic/core/service/ReactionReceiver.kt b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/ReactionReceiver.kt
index f4db74403..304dff076 100644
--- a/core/service/src/androidMain/kotlin/org/meshtastic/core/service/ReactionReceiver.kt
+++ b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/ReactionReceiver.kt
@@ -27,6 +27,7 @@ import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
import org.meshtastic.core.di.CoroutineDispatchers
import org.meshtastic.core.model.service.ServiceAction
+import org.meshtastic.core.repository.MeshServiceNotifications
import org.meshtastic.core.repository.ServiceRepository
/**
@@ -41,6 +42,8 @@ class ReactionReceiver :
private val serviceRepository: ServiceRepository by inject()
+ private val meshServiceNotifications: MeshServiceNotifications by inject()
+
private val dispatchers: CoroutineDispatchers by inject()
private val scope by lazy { CoroutineScope(SupervisorJob() + dispatchers.io) }
@@ -57,6 +60,7 @@ class ReactionReceiver :
scope.launch {
try {
serviceRepository.onServiceAction(ServiceAction.Reaction(reaction, replyId, contactKey))
+ meshServiceNotifications.markConversationRead(contactKey)
} catch (e: Exception) {
Logger.e(e) { "Error sending reaction" }
} finally {
diff --git a/core/service/src/androidMain/kotlin/org/meshtastic/core/service/ReplyReceiver.kt b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/ReplyReceiver.kt
index 13fd92758..8ab6590b7 100644
--- a/core/service/src/androidMain/kotlin/org/meshtastic/core/service/ReplyReceiver.kt
+++ b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/ReplyReceiver.kt
@@ -25,12 +25,10 @@ import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
-import org.meshtastic.core.common.util.nowMillis
import org.meshtastic.core.di.CoroutineDispatchers
import org.meshtastic.core.model.DataPacket
import org.meshtastic.core.model.RadioController
import org.meshtastic.core.repository.MeshServiceNotifications
-import org.meshtastic.core.repository.PacketRepository
/**
* A [BroadcastReceiver] that handles inline replies from notifications.
@@ -46,8 +44,6 @@ class ReplyReceiver :
private val meshServiceNotifications: MeshServiceNotifications by inject()
- private val packetRepository: PacketRepository by inject()
-
private val dispatchers: CoroutineDispatchers by inject()
private val scope by lazy { CoroutineScope(dispatchers.io + SupervisorJob()) }
@@ -69,8 +65,7 @@ class ReplyReceiver :
scope.launch {
try {
sendMessage(message, contactKey)
- packetRepository.clearUnreadCount(contactKey, nowMillis)
- meshServiceNotifications.cancelMessageNotification(contactKey)
+ meshServiceNotifications.markConversationRead(contactKey)
} finally {
pendingResult.finish()
}
diff --git a/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeMeshServiceNotifications.kt b/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeMeshServiceNotifications.kt
index 4f0a4b153..923d2e8aa 100644
--- a/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeMeshServiceNotifications.kt
+++ b/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeMeshServiceNotifications.kt
@@ -67,6 +67,8 @@ class FakeMeshServiceNotifications : MeshServiceNotifications {
override fun cancelMessageNotification(contactKey: String) {}
+ override suspend fun markConversationRead(contactKey: String) {}
+
override fun cancelLowBatteryNotification(node: Node) {}
override fun clearClientNotification(notification: ClientNotification) {}
diff --git a/desktop/src/main/kotlin/org/meshtastic/desktop/notification/DesktopMeshServiceNotifications.kt b/desktop/src/main/kotlin/org/meshtastic/desktop/notification/DesktopMeshServiceNotifications.kt
index 4cda00251..fd30a5be0 100644
--- a/desktop/src/main/kotlin/org/meshtastic/desktop/notification/DesktopMeshServiceNotifications.kt
+++ b/desktop/src/main/kotlin/org/meshtastic/desktop/notification/DesktopMeshServiceNotifications.kt
@@ -154,6 +154,10 @@ class DesktopMeshServiceNotifications(private val notificationManager: Notificat
notificationManager.cancel(contactKey.hashCode())
}
+ override suspend fun markConversationRead(contactKey: String) {
+ notificationManager.cancel(contactKey.hashCode())
+ }
+
override fun cancelLowBatteryNotification(node: Node) {
notificationManager.cancel(node.num)
}
From eb3a27a3d3f25b560b8e453dd1fcce728159dd73 Mon Sep 17 00:00:00 2001
From: James Rich
Date: Fri, 17 Apr 2026 09:44:08 -0500
Subject: [PATCH 16/44] feat(auto): append outgoing reply to MessagingStyle for
brief confirmation
Before cancelling a conversation notification in response to an inline
reply, post one final update that appends the outgoing text to the
MessagingStyle history, attributed to the local user. This gives
assistants such as Android Auto a tick to observe the sent message in
the notification's message history and surface a 'reply sent' style
confirmation before markConversationRead cancels the notification.
Extract the 'me' Person construction into buildMePerson() and share it
between showGroupSummary and createConversationNotification. The
conversation builder now optionally takes an extraOutgoingMessage which
is appended to the MessagingStyle (actions and when-timestamp continue
to be anchored on the last incoming message).
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
---
.../org/meshtastic/app/service/Fakes.kt | 2 +
.../repository/MeshServiceNotifications.kt | 7 ++
.../service/MeshServiceNotificationsImpl.kt | 65 ++++++++++++++-----
.../meshtastic/core/service/ReplyReceiver.kt | 1 +
.../testing/FakeMeshServiceNotifications.kt | 2 +
.../DesktopMeshServiceNotifications.kt | 4 ++
6 files changed, 65 insertions(+), 16 deletions(-)
diff --git a/app/src/test/kotlin/org/meshtastic/app/service/Fakes.kt b/app/src/test/kotlin/org/meshtastic/app/service/Fakes.kt
index 46d5ed27c..05562bb9d 100644
--- a/app/src/test/kotlin/org/meshtastic/app/service/Fakes.kt
+++ b/app/src/test/kotlin/org/meshtastic/app/service/Fakes.kt
@@ -76,6 +76,8 @@ class FakeMeshServiceNotifications : MeshServiceNotifications {
override suspend fun markConversationRead(contactKey: String) {}
+ override suspend fun appendOutgoingMessage(contactKey: String, text: String) {}
+
override fun cancelLowBatteryNotification(node: Node) {}
override fun clearClientNotification(notification: ClientNotification) {}
diff --git a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/MeshServiceNotifications.kt b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/MeshServiceNotifications.kt
index 1216f29a3..b73b44ab0 100644
--- a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/MeshServiceNotifications.kt
+++ b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/MeshServiceNotifications.kt
@@ -74,6 +74,13 @@ interface MeshServiceNotifications {
*/
suspend fun markConversationRead(contactKey: String)
+ /**
+ * Appends an outgoing [text] message attributed to the local user to the currently posted conversation notification
+ * for [contactKey]. Used so that assistants such as Android Auto can briefly observe the reply in the
+ * MessagingStyle history before the notification is cancelled. No-op when there is nothing to update.
+ */
+ suspend fun appendOutgoingMessage(contactKey: String, text: String)
+
fun cancelLowBatteryNotification(node: Node)
fun clearClientNotification(notification: ClientNotification)
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 97ff46766..bc982fa25 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
@@ -440,20 +440,23 @@ class MeshServiceNotificationsImpl(
showGroupSummary()
}
+ private fun buildMePerson(): Person {
+ val ourNode = nodeRepository.value.ourNodeInfo.value
+ val meName = ourNode?.user?.long_name ?: getString(Res.string.you)
+ return Person.Builder()
+ .setName(meName)
+ .setKey(ourNode?.user?.id ?: DataPacket.ID_LOCAL)
+ .apply { ourNode?.let { setIcon(createPersonIcon(meName, it.colors.second, it.colors.first)) } }
+ .build()
+ }
+
private fun showGroupSummary() {
val activeNotifications =
notificationManager.activeNotifications.filter {
it.id != SUMMARY_ID && it.notification.group == GROUP_KEY_MESSAGES
}
- val ourNode = nodeRepository.value.ourNodeInfo.value
- val meName = ourNode?.user?.long_name ?: getString(Res.string.you)
- val me =
- Person.Builder()
- .setName(meName)
- .setKey(ourNode?.user?.id ?: DataPacket.ID_LOCAL)
- .apply { ourNode?.let { setIcon(createPersonIcon(meName, it.colors.second, it.colors.first)) } }
- .build()
+ val me = buildMePerson()
val messagingStyle =
NotificationCompat.MessagingStyle(me)
@@ -520,6 +523,39 @@ class MeshServiceNotificationsImpl(
cancelMessageNotification(contactKey)
}
+ override suspend fun appendOutgoingMessage(contactKey: String, text: String) {
+ if (text.isEmpty()) return
+ val ourNode = nodeRepository.value.ourNodeInfo.value
+ val history =
+ packetRepository.value
+ .getMessagesFrom(contactKey, includeFiltered = false) { nodeId ->
+ if (nodeId == DataPacket.ID_LOCAL) {
+ ourNode ?: nodeRepository.value.getNode(nodeId)
+ } else {
+ nodeRepository.value.getNode(nodeId ?: "")
+ }
+ }
+ .first()
+
+ val unread = history.filter { !it.read }
+ if (unread.isEmpty()) return
+ val displayHistory = unread.take(MAX_HISTORY_MESSAGES).reversed()
+
+ val dest = if (contactKey.isNotEmpty()) contactKey.substring(1) else contactKey
+ val isBroadcast = dest == DataPacket.ID_BROADCAST
+
+ val notification =
+ createConversationNotification(
+ contactKey = contactKey,
+ isBroadcast = isBroadcast,
+ channelName = null,
+ history = displayHistory,
+ isSilent = true,
+ extraOutgoingMessage = text,
+ )
+ notificationManager.notify(contactKey.hashCode(), notification)
+ }
+
override fun cancelLowBatteryNotification(node: Node) = notificationManager.cancel(node.num)
override fun clearClientNotification(notification: ClientNotification) =
@@ -561,6 +597,7 @@ class MeshServiceNotificationsImpl(
channelName: String?,
history: List,
isSilent: Boolean = false,
+ extraOutgoingMessage: String? = null,
): Notification {
val type = if (isBroadcast) NotificationType.BroadcastMessage else NotificationType.DirectMessage
val builder = commonBuilder(type, createOpenMessageIntent(contactKey))
@@ -569,14 +606,7 @@ class MeshServiceNotificationsImpl(
builder.setSilent(true)
}
- val ourNode = nodeRepository.value.ourNodeInfo.value
- val meName = ourNode?.user?.long_name ?: getString(Res.string.you)
- val me =
- Person.Builder()
- .setName(meName)
- .setKey(ourNode?.user?.id ?: DataPacket.ID_LOCAL)
- .apply { ourNode?.let { setIcon(createPersonIcon(meName, it.colors.second, it.colors.first)) } }
- .build()
+ val me = buildMePerson()
val style =
NotificationCompat.MessagingStyle(me)
@@ -621,6 +651,9 @@ class MeshServiceNotificationsImpl(
)
}
}
+ if (!extraOutgoingMessage.isNullOrEmpty()) {
+ style.addMessage(extraOutgoingMessage, nowMillis, me)
+ }
val lastMessage = history.last()
ensureShortcutForNotification(contactKey, isBroadcast, channelName, lastMessage)
diff --git a/core/service/src/androidMain/kotlin/org/meshtastic/core/service/ReplyReceiver.kt b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/ReplyReceiver.kt
index 8ab6590b7..b00c3c255 100644
--- a/core/service/src/androidMain/kotlin/org/meshtastic/core/service/ReplyReceiver.kt
+++ b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/ReplyReceiver.kt
@@ -65,6 +65,7 @@ class ReplyReceiver :
scope.launch {
try {
sendMessage(message, contactKey)
+ meshServiceNotifications.appendOutgoingMessage(contactKey, message)
meshServiceNotifications.markConversationRead(contactKey)
} finally {
pendingResult.finish()
diff --git a/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeMeshServiceNotifications.kt b/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeMeshServiceNotifications.kt
index 923d2e8aa..e1c1c7659 100644
--- a/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeMeshServiceNotifications.kt
+++ b/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeMeshServiceNotifications.kt
@@ -69,6 +69,8 @@ class FakeMeshServiceNotifications : MeshServiceNotifications {
override suspend fun markConversationRead(contactKey: String) {}
+ override suspend fun appendOutgoingMessage(contactKey: String, text: String) {}
+
override fun cancelLowBatteryNotification(node: Node) {}
override fun clearClientNotification(notification: ClientNotification) {}
diff --git a/desktop/src/main/kotlin/org/meshtastic/desktop/notification/DesktopMeshServiceNotifications.kt b/desktop/src/main/kotlin/org/meshtastic/desktop/notification/DesktopMeshServiceNotifications.kt
index fd30a5be0..f2ad6ca3e 100644
--- a/desktop/src/main/kotlin/org/meshtastic/desktop/notification/DesktopMeshServiceNotifications.kt
+++ b/desktop/src/main/kotlin/org/meshtastic/desktop/notification/DesktopMeshServiceNotifications.kt
@@ -158,6 +158,10 @@ class DesktopMeshServiceNotifications(private val notificationManager: Notificat
notificationManager.cancel(contactKey.hashCode())
}
+ override suspend fun appendOutgoingMessage(contactKey: String, text: String) {
+ // No-op: desktop tray notifications don't carry MessagingStyle history to augment.
+ }
+
override fun cancelLowBatteryNotification(node: Node) {
notificationManager.cancel(node.num)
}
From dac4880e0f9d6cbb4e2ab624cbb8c491a115cb1e Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Fri, 17 Apr 2026 14:44:18 +0000
Subject: [PATCH 17/44] feat(auto): replace ListTemplate with TabTemplate for
iOS CarPlay parity
Agent-Logs-Url: https://github.com/meshtastic/Meshtastic-Android/sessions/67580c49-612a-450b-8452-9c88875df1c3
Co-authored-by: garthvh <1795163+garthvh@users.noreply.github.com>
---
feature/auto/src/main/AndroidManifest.xml | 3 +-
.../feature/auto/MeshtasticCarScreen.kt | 143 ++++++++++++------
.../main/res/drawable/auto_ic_channels.xml | 28 ++++
.../main/res/drawable/auto_ic_favorites.xml | 28 ++++
.../src/main/res/drawable/auto_ic_status.xml | 28 ++++
5 files changed, 180 insertions(+), 50 deletions(-)
create mode 100644 feature/auto/src/main/res/drawable/auto_ic_channels.xml
create mode 100644 feature/auto/src/main/res/drawable/auto_ic_favorites.xml
create mode 100644 feature/auto/src/main/res/drawable/auto_ic_status.xml
diff --git a/feature/auto/src/main/AndroidManifest.xml b/feature/auto/src/main/AndroidManifest.xml
index a6c4ed78f..472c4f5e3 100644
--- a/feature/auto/src/main/AndroidManifest.xml
+++ b/feature/auto/src/main/AndroidManifest.xml
@@ -33,9 +33,10 @@
+
+ android:value="2" />
diff --git a/feature/auto/src/main/kotlin/org/meshtastic/feature/auto/MeshtasticCarScreen.kt b/feature/auto/src/main/kotlin/org/meshtastic/feature/auto/MeshtasticCarScreen.kt
index 35c287f1c..cc7a2e309 100644
--- a/feature/auto/src/main/kotlin/org/meshtastic/feature/auto/MeshtasticCarScreen.kt
+++ b/feature/auto/src/main/kotlin/org/meshtastic/feature/auto/MeshtasticCarScreen.kt
@@ -19,11 +19,14 @@ package org.meshtastic.feature.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.Tab
+import androidx.car.app.model.TabTemplate
import androidx.car.app.model.Template
+import androidx.core.graphics.drawable.IconCompat
import androidx.lifecycle.DefaultLifecycleObserver
import androidx.lifecycle.LifecycleOwner
import kotlinx.coroutines.CoroutineScope
@@ -51,10 +54,12 @@ import org.meshtastic.proto.ChannelSettings
/**
* Root screen displayed in Android Auto.
*
- * Shows three sections mirroring the iOS CarPlay implementation:
+ * Shows three tabs mirroring the iOS CarPlay tab-based navigation:
* - **Status**: Connection state and active device name
* - **Favorites**: Favorited mesh nodes with unread message counts
* - **Channels**: Active channels with unread message counts
+ *
+ * Requires Car API level 2+ (androidx.car.app:app 1.2.0+) for [TabTemplate] support.
*/
class MeshtasticCarScreen(carContext: CarContext) :
Screen(carContext),
@@ -69,6 +74,7 @@ class MeshtasticCarScreen(carContext: CarContext) :
private val scope = CoroutineScope(Dispatchers.Main + SupervisorJob())
private var observeJob: Job? = null
+ private var activeTabId = TAB_STATUS
private var connectionState: ConnectionState = ConnectionState.Disconnected
private var favoriteNodes: List = emptyList()
private var channels: List> = emptyList()
@@ -141,24 +147,48 @@ class MeshtasticCarScreen(carContext: CarContext) :
}
override fun onGetTemplate(): Template {
- val listBuilder = ListTemplate.Builder()
-
- listBuilder.addSectionedList(SectionedItemList.create(buildStatusSection(), "Status"))
-
- val favoritesSection = buildFavoritesSection()
- if (favoritesSection.items.isNotEmpty()) {
- listBuilder.addSectionedList(SectionedItemList.create(favoritesSection, "Favorites"))
+ val tabCallback = TabTemplate.TabCallback { tabContentId ->
+ activeTabId = tabContentId
+ invalidate()
}
- val channelsSection = buildChannelsSection()
- if (channelsSection.items.isNotEmpty()) {
- listBuilder.addSectionedList(SectionedItemList.create(channelsSection, "Channels"))
+ val activeContent = when (activeTabId) {
+ TAB_FAVORITES -> TabTemplate.TabContents.Builder(buildFavoritesTemplate()).build()
+ TAB_CHANNELS -> TabTemplate.TabContents.Builder(buildChannelsTemplate()).build()
+ else -> TabTemplate.TabContents.Builder(buildStatusTemplate()).build()
}
- return listBuilder.setTitle("Meshtastic").setHeaderAction(Action.APP_ICON).build()
+ return TabTemplate.Builder(tabCallback)
+ .setHeaderAction(Action.APP_ICON)
+ .addTab(
+ Tab.Builder()
+ .setTitle("Status")
+ .setIcon(carIcon(R.drawable.auto_ic_status))
+ .setContentId(TAB_STATUS)
+ .build(),
+ )
+ .addTab(
+ Tab.Builder()
+ .setTitle("Favorites")
+ .setIcon(carIcon(R.drawable.auto_ic_favorites))
+ .setContentId(TAB_FAVORITES)
+ .build(),
+ )
+ .addTab(
+ Tab.Builder()
+ .setTitle("Channels")
+ .setIcon(carIcon(R.drawable.auto_ic_channels))
+ .setContentId(TAB_CHANNELS)
+ .build(),
+ )
+ .setTabContents(activeContent)
+ .setActiveTabContentId(activeTabId)
+ .build()
}
- private fun buildStatusSection(): ItemList {
+ private fun carIcon(resId: Int) = CarIcon.Builder(IconCompat.createWithResource(carContext, resId)).build()
+
+ private fun buildStatusTemplate(): ListTemplate {
val statusText =
when (connectionState) {
is ConnectionState.Connected -> "Connected"
@@ -177,55 +207,70 @@ class MeshtasticCarScreen(carContext: CarContext) :
.setBrowsable(false)
.build()
- return ItemList.Builder().addItem(row).build()
+ return ListTemplate.Builder()
+ .setTitle("Status")
+ .setSingleList(ItemList.Builder().addItem(row).build())
+ .build()
}
- private fun buildFavoritesSection(): ItemList {
- val builder = ItemList.Builder()
-
- for (node in favoriteNodes.take(MAX_LIST_ITEMS)) {
- val contactKey = "0${node.user.id}"
- val unread = unreadCounts[contactKey] ?: 0
- val name = node.user.long_name.ifEmpty { node.user.short_name }.ifEmpty { "Unknown" }
- val subtitle = buildString {
- append(node.user.short_name)
- if (node.hopsAway >= 0) append(" · ${node.hopsAway} hops")
- if (unread > 0) append(" · $unread unread")
+ private fun buildFavoritesTemplate(): ListTemplate {
+ val items = ItemList.Builder()
+ if (favoriteNodes.isEmpty()) {
+ items.setNoItemsMessage("No favorite contacts")
+ } else {
+ for (node in favoriteNodes.take(MAX_LIST_ITEMS)) {
+ val contactKey = "0${node.user.id}"
+ val unread = unreadCounts[contactKey] ?: 0
+ val name = node.user.long_name.ifEmpty { node.user.short_name }.ifEmpty { "Unknown" }
+ val subtitle = buildString {
+ append(node.user.short_name)
+ if (node.hopsAway >= 0) append(" · ${node.hopsAway} hops")
+ if (unread > 0) append(" · $unread unread")
+ }
+ items.addItem(Row.Builder().setTitle(name).addText(subtitle).setBrowsable(false).build())
}
-
- val row = Row.Builder().setTitle(name).addText(subtitle).setBrowsable(false).build()
- builder.addItem(row)
}
- return builder.build()
+ return ListTemplate.Builder()
+ .setTitle("Favorites")
+ .setSingleList(items.build())
+ .build()
}
- private fun buildChannelsSection(): ItemList {
- val builder = ItemList.Builder()
+ private fun buildChannelsTemplate(): ListTemplate {
+ val items = ItemList.Builder()
+ if (channels.isEmpty()) {
+ items.setNoItemsMessage("No active channels")
+ } else {
+ for ((index, channelSettings) in channels.take(MAX_LIST_ITEMS)) {
+ 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 ""
- for ((index, channelSettings) in channels.take(MAX_LIST_ITEMS)) {
- 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)
+ val row =
+ Row.Builder()
+ .setTitle(channelName)
+ .apply { if (subtitle.isNotEmpty()) addText(subtitle) }
+ .setBrowsable(false)
+ .build()
+ items.addItem(row)
+ }
}
- return builder.build()
+ return ListTemplate.Builder()
+ .setTitle("Channels")
+ .setSingleList(items.build())
+ .build()
}
companion object {
+ private const val TAB_STATUS = "status"
+ private const val TAB_FAVORITES = "favorites"
+ private const val TAB_CHANNELS = "channels"
+
/**
- * 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]. Car API level 2 supports up to 6 items.
*/
private const val MAX_LIST_ITEMS = 6
}
diff --git a/feature/auto/src/main/res/drawable/auto_ic_channels.xml b/feature/auto/src/main/res/drawable/auto_ic_channels.xml
new file mode 100644
index 000000000..80d446141
--- /dev/null
+++ b/feature/auto/src/main/res/drawable/auto_ic_channels.xml
@@ -0,0 +1,28 @@
+
+
+
+
+
+
+
diff --git a/feature/auto/src/main/res/drawable/auto_ic_favorites.xml b/feature/auto/src/main/res/drawable/auto_ic_favorites.xml
new file mode 100644
index 000000000..7df47eb31
--- /dev/null
+++ b/feature/auto/src/main/res/drawable/auto_ic_favorites.xml
@@ -0,0 +1,28 @@
+
+
+
+
+
+
+
diff --git a/feature/auto/src/main/res/drawable/auto_ic_status.xml b/feature/auto/src/main/res/drawable/auto_ic_status.xml
new file mode 100644
index 000000000..242d80eae
--- /dev/null
+++ b/feature/auto/src/main/res/drawable/auto_ic_status.xml
@@ -0,0 +1,28 @@
+
+
+
+
+
+
+
From 1d258dadedf1896921679c3643fbb126949ed1de Mon Sep 17 00:00:00 2001
From: James Rich
Date: Fri, 17 Apr 2026 09:50:52 -0500
Subject: [PATCH 18/44] test(notifications): add unit tests for
reply/markAsRead/reaction receivers
Adds Robolectric-based androidHostTest coverage for the three notification
BroadcastReceivers. Verifies:
- ReplyReceiver sends a DataPacket derived from the contactKey then calls
appendOutgoingMessage followed by markConversationRead in that order.
- MarkAsReadReceiver invokes markConversationRead, ignores wrong actions,
and drops intents missing the contact key.
- ReactionReceiver dispatches a ServiceAction.Reaction and, on success,
calls markConversationRead. Failures in dispatch short-circuit markRead.
Uses the existing FakeRadioController and FakeMeshServiceNotifications
(marked open so tests can record calls) plus mokkery for ServiceRepository,
mirroring the pattern in SendMessageWorkerTest. Fakes are wired through a
per-test Koin graph to match each receiver's KoinComponent injection.
Also fixes a pre-existing compile break in MeshServiceNotificationsImplTest
that was missing the shortcutManager constructor argument.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
---
.../core/service/MarkAsReadReceiverTest.kt | 101 +++++++++++
.../MeshServiceNotificationsImplTest.kt | 1 +
.../core/service/ReactionReceiverTest.kt | 157 ++++++++++++++++++
.../core/service/ReplyReceiverTest.kt | 125 ++++++++++++++
.../testing/FakeMeshServiceNotifications.kt | 2 +-
.../core/testing/FakeRadioController.kt | 2 +-
6 files changed, 386 insertions(+), 2 deletions(-)
create mode 100644 core/service/src/androidHostTest/kotlin/org/meshtastic/core/service/MarkAsReadReceiverTest.kt
create mode 100644 core/service/src/androidHostTest/kotlin/org/meshtastic/core/service/ReactionReceiverTest.kt
create mode 100644 core/service/src/androidHostTest/kotlin/org/meshtastic/core/service/ReplyReceiverTest.kt
diff --git a/core/service/src/androidHostTest/kotlin/org/meshtastic/core/service/MarkAsReadReceiverTest.kt b/core/service/src/androidHostTest/kotlin/org/meshtastic/core/service/MarkAsReadReceiverTest.kt
new file mode 100644
index 000000000..b5e76ed33
--- /dev/null
+++ b/core/service/src/androidHostTest/kotlin/org/meshtastic/core/service/MarkAsReadReceiverTest.kt
@@ -0,0 +1,101 @@
+/*
+ * 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 androidx.test.core.app.ApplicationProvider
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.test.UnconfinedTestDispatcher
+import org.junit.After
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.koin.core.context.startKoin
+import org.koin.core.context.stopKoin
+import org.koin.dsl.module
+import org.meshtastic.core.di.CoroutineDispatchers
+import org.meshtastic.core.repository.MeshServiceNotifications
+import org.robolectric.RobolectricTestRunner
+import org.robolectric.annotation.Config
+import kotlin.test.assertEquals
+
+@OptIn(ExperimentalCoroutinesApi::class)
+@RunWith(RobolectricTestRunner::class)
+@Config(sdk = [34])
+class MarkAsReadReceiverTest {
+
+ private lateinit var context: Context
+ private lateinit var notifications: RecordingNotifications
+
+ @Before
+ fun setUp() {
+ context = ApplicationProvider.getApplicationContext()
+ notifications = RecordingNotifications(mutableListOf())
+ val dispatcher = UnconfinedTestDispatcher()
+ startKoin {
+ modules(
+ module {
+ single { notifications }
+ single { CoroutineDispatchers(io = dispatcher, main = dispatcher, default = dispatcher) }
+ },
+ )
+ }
+ }
+
+ @After
+ fun tearDown() {
+ stopKoin()
+ }
+
+ @Test
+ fun `markAsRead action invokes markConversationRead`() {
+ val contactKey = "0!deadbeef"
+ val intent =
+ Intent(context, MarkAsReadReceiver::class.java).apply {
+ action = MarkAsReadReceiver.MARK_AS_READ_ACTION
+ putExtra(MarkAsReadReceiver.CONTACT_KEY, contactKey)
+ }
+
+ MarkAsReadReceiver().onReceive(context, intent)
+
+ assertEquals(listOf(contactKey), notifications.markReadCalls)
+ }
+
+ @Test
+ fun `missing contactKey does not invoke markConversationRead`() {
+ val intent =
+ Intent(context, MarkAsReadReceiver::class.java).apply { action = MarkAsReadReceiver.MARK_AS_READ_ACTION }
+
+ MarkAsReadReceiver().onReceive(context, intent)
+
+ assertEquals(emptyList(), notifications.markReadCalls)
+ }
+
+ @Test
+ fun `wrong action is ignored`() {
+ val intent =
+ Intent(context, MarkAsReadReceiver::class.java).apply {
+ action = "some.other.ACTION"
+ putExtra(MarkAsReadReceiver.CONTACT_KEY, "0!abcd")
+ }
+
+ MarkAsReadReceiver().onReceive(context, intent)
+
+ assertEquals(emptyList(), notifications.markReadCalls)
+ }
+}
diff --git a/core/service/src/androidHostTest/kotlin/org/meshtastic/core/service/MeshServiceNotificationsImplTest.kt b/core/service/src/androidHostTest/kotlin/org/meshtastic/core/service/MeshServiceNotificationsImplTest.kt
index a4a3b0fe3..de3f4e4b6 100644
--- a/core/service/src/androidHostTest/kotlin/org/meshtastic/core/service/MeshServiceNotificationsImplTest.kt
+++ b/core/service/src/androidHostTest/kotlin/org/meshtastic/core/service/MeshServiceNotificationsImplTest.kt
@@ -59,6 +59,7 @@ class MeshServiceNotificationsImplTest {
context = context,
packetRepository = lazy { error("Not used in this test") },
nodeRepository = lazy { error("Not used in this test") },
+ shortcutManager = lazy { error("Not used in this test") },
)
notifications.initChannels()
diff --git a/core/service/src/androidHostTest/kotlin/org/meshtastic/core/service/ReactionReceiverTest.kt b/core/service/src/androidHostTest/kotlin/org/meshtastic/core/service/ReactionReceiverTest.kt
new file mode 100644
index 000000000..30260ee25
--- /dev/null
+++ b/core/service/src/androidHostTest/kotlin/org/meshtastic/core/service/ReactionReceiverTest.kt
@@ -0,0 +1,157 @@
+/*
+ * 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 androidx.test.core.app.ApplicationProvider
+import dev.mokkery.MockMode
+import dev.mokkery.answering.calls
+import dev.mokkery.answering.returns
+import dev.mokkery.everySuspend
+import dev.mokkery.matcher.any
+import dev.mokkery.mock
+import dev.mokkery.verify.VerifyMode
+import dev.mokkery.verifySuspend
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.test.UnconfinedTestDispatcher
+import org.junit.After
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.koin.core.context.startKoin
+import org.koin.core.context.stopKoin
+import org.koin.dsl.module
+import org.meshtastic.core.di.CoroutineDispatchers
+import org.meshtastic.core.model.service.ServiceAction
+import org.meshtastic.core.repository.MeshServiceNotifications
+import org.meshtastic.core.repository.ServiceRepository
+import org.robolectric.RobolectricTestRunner
+import org.robolectric.annotation.Config
+import kotlin.test.assertEquals
+
+@OptIn(ExperimentalCoroutinesApi::class)
+@RunWith(RobolectricTestRunner::class)
+@Config(sdk = [34])
+class ReactionReceiverTest {
+
+ private lateinit var context: Context
+ private lateinit var notifications: RecordingNotifications
+ private lateinit var serviceRepository: ServiceRepository
+
+ @Before
+ fun setUp() {
+ context = ApplicationProvider.getApplicationContext()
+ notifications = RecordingNotifications(mutableListOf())
+ serviceRepository = mock(MockMode.autofill)
+ val dispatcher = UnconfinedTestDispatcher()
+ startKoin {
+ modules(
+ module {
+ single { serviceRepository }
+ single { notifications }
+ single { CoroutineDispatchers(io = dispatcher, main = dispatcher, default = dispatcher) }
+ },
+ )
+ }
+ }
+
+ @After
+ fun tearDown() {
+ stopKoin()
+ }
+
+ @Test
+ fun `reaction dispatches ServiceAction and marks conversation read`() {
+ val contactKey = "0!cafebabe"
+ val emoji = "👍"
+ val replyId = 42
+ everySuspend { serviceRepository.onServiceAction(any()) } returns Unit
+
+ val intent =
+ Intent(context, ReactionReceiver::class.java).apply {
+ action = ReactionReceiver.REACT_ACTION
+ putExtra(ReactionReceiver.EXTRA_CONTACT_KEY, contactKey)
+ putExtra(ReactionReceiver.EXTRA_EMOJI, emoji)
+ putExtra(ReactionReceiver.EXTRA_REPLY_ID, replyId)
+ }
+
+ ReactionReceiver().onReceive(context, intent)
+
+ verifySuspend(VerifyMode.exactly(1)) {
+ serviceRepository.onServiceAction(ServiceAction.Reaction(emoji, replyId, contactKey))
+ }
+ assertEquals(listOf(contactKey), notifications.markReadCalls)
+ }
+
+ @Test
+ fun `reaction does not markRead when ServiceAction dispatch throws`() {
+ val contactKey = "0!feedface"
+ val throwingRepo = mock(MockMode.autofill)
+ everySuspend { throwingRepo.onServiceAction(any()) } calls { throw IllegalStateException("boom") }
+ stopKoin()
+ val dispatcher = UnconfinedTestDispatcher()
+ startKoin {
+ modules(
+ module {
+ single { throwingRepo }
+ single { notifications }
+ single { CoroutineDispatchers(io = dispatcher, main = dispatcher, default = dispatcher) }
+ },
+ )
+ }
+
+ val intent =
+ Intent(context, ReactionReceiver::class.java).apply {
+ action = ReactionReceiver.REACT_ACTION
+ putExtra(ReactionReceiver.EXTRA_CONTACT_KEY, contactKey)
+ putExtra(ReactionReceiver.EXTRA_REACTION, "🎉")
+ putExtra(ReactionReceiver.EXTRA_PACKET_ID, 7)
+ }
+
+ ReactionReceiver().onReceive(context, intent)
+
+ assertEquals(emptyList(), notifications.markReadCalls)
+ }
+
+ @Test
+ fun `reaction without contactKey is dropped`() {
+ val intent =
+ Intent(context, ReactionReceiver::class.java).apply {
+ action = ReactionReceiver.REACT_ACTION
+ putExtra(ReactionReceiver.EXTRA_EMOJI, "👍")
+ }
+
+ ReactionReceiver().onReceive(context, intent)
+
+ assertEquals(emptyList(), notifications.markReadCalls)
+ }
+
+ @Test
+ fun `wrong action is ignored`() {
+ val intent =
+ Intent(context, ReactionReceiver::class.java).apply {
+ action = "other.ACTION"
+ putExtra(ReactionReceiver.EXTRA_CONTACT_KEY, "0!abcd")
+ putExtra(ReactionReceiver.EXTRA_EMOJI, "👍")
+ }
+
+ ReactionReceiver().onReceive(context, intent)
+
+ assertEquals(emptyList(), notifications.markReadCalls)
+ }
+}
diff --git a/core/service/src/androidHostTest/kotlin/org/meshtastic/core/service/ReplyReceiverTest.kt b/core/service/src/androidHostTest/kotlin/org/meshtastic/core/service/ReplyReceiverTest.kt
new file mode 100644
index 000000000..2cd651541
--- /dev/null
+++ b/core/service/src/androidHostTest/kotlin/org/meshtastic/core/service/ReplyReceiverTest.kt
@@ -0,0 +1,125 @@
+/*
+ * 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.os.Bundle
+import androidx.core.app.RemoteInput
+import androidx.test.core.app.ApplicationProvider
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.test.UnconfinedTestDispatcher
+import org.junit.After
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.koin.core.context.startKoin
+import org.koin.core.context.stopKoin
+import org.koin.dsl.module
+import org.meshtastic.core.di.CoroutineDispatchers
+import org.meshtastic.core.model.RadioController
+import org.meshtastic.core.repository.MeshServiceNotifications
+import org.meshtastic.core.testing.FakeRadioController
+import org.robolectric.RobolectricTestRunner
+import org.robolectric.annotation.Config
+import kotlin.test.assertEquals
+
+@OptIn(ExperimentalCoroutinesApi::class)
+@RunWith(RobolectricTestRunner::class)
+@Config(sdk = [34])
+class ReplyReceiverTest {
+
+ private lateinit var context: Context
+ private lateinit var radioController: FakeRadioController
+ private lateinit var notifications: RecordingNotifications
+ private val callLog = mutableListOf()
+
+ @Before
+ fun setUp() {
+ context = ApplicationProvider.getApplicationContext()
+ radioController = RecordingRadioController(callLog)
+ notifications = RecordingNotifications(callLog)
+ val dispatcher = UnconfinedTestDispatcher()
+ startKoin {
+ modules(
+ module {
+ single { radioController }
+ single { notifications }
+ single { CoroutineDispatchers(io = dispatcher, main = dispatcher, default = dispatcher) }
+ },
+ )
+ }
+ }
+
+ @After
+ fun tearDown() {
+ stopKoin()
+ }
+
+ @Test
+ fun `reply sends DataPacket and marks conversation read in order`() {
+ val contactKey = "2!abcd1234"
+ val replyText = "hello world"
+
+ val intent =
+ Intent(context, ReplyReceiver::class.java).apply {
+ action = ReplyReceiver.REPLY_ACTION
+ putExtra(ReplyReceiver.CONTACT_KEY, contactKey)
+ }
+ val results = Bundle().apply { putCharSequence(ReplyReceiver.KEY_TEXT_REPLY, replyText) }
+ RemoteInput.addResultsToIntent(
+ arrayOf(RemoteInput.Builder(ReplyReceiver.KEY_TEXT_REPLY).build()),
+ intent,
+ results,
+ )
+
+ ReplyReceiver().onReceive(context, intent)
+
+ assertEquals(1, radioController.sentPackets.size)
+ val sent = radioController.sentPackets.first()
+ assertEquals("!abcd1234", sent.to)
+ assertEquals(2, sent.channel)
+ assertEquals(replyText, sent.text)
+
+ assertEquals(listOf(contactKey to replyText), notifications.appendCalls)
+ assertEquals(listOf(contactKey), notifications.markReadCalls)
+ assertEquals(listOf("send", "append", "markRead"), callLog)
+ }
+}
+
+private class RecordingRadioController(private val callLog: MutableList) : FakeRadioController() {
+ override suspend fun sendMessage(packet: org.meshtastic.core.model.DataPacket) {
+ callLog.add("send")
+ super.sendMessage(packet)
+ }
+}
+
+internal class RecordingNotifications(private val callLog: MutableList) :
+ org.meshtastic.core.testing.FakeMeshServiceNotifications() {
+ val appendCalls = mutableListOf>()
+ val markReadCalls = mutableListOf()
+
+ override suspend fun appendOutgoingMessage(contactKey: String, text: String) {
+ callLog.add("append")
+ appendCalls.add(contactKey to text)
+ }
+
+ override suspend fun markConversationRead(contactKey: String) {
+ callLog.add("markRead")
+ markReadCalls.add(contactKey)
+ }
+}
diff --git a/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeMeshServiceNotifications.kt b/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeMeshServiceNotifications.kt
index e1c1c7659..fef69fdc7 100644
--- a/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeMeshServiceNotifications.kt
+++ b/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeMeshServiceNotifications.kt
@@ -24,7 +24,7 @@ import org.meshtastic.proto.Telemetry
/** A test double for [MeshServiceNotifications] that provides a no-op implementation. */
@Suppress("TooManyFunctions", "EmptyFunctionBlock")
-class FakeMeshServiceNotifications : MeshServiceNotifications {
+open class FakeMeshServiceNotifications : MeshServiceNotifications {
override fun clearNotifications() {}
override fun initChannels() {}
diff --git a/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeRadioController.kt b/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeRadioController.kt
index d23a7f1ec..9d2e490f5 100644
--- a/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeRadioController.kt
+++ b/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeRadioController.kt
@@ -31,7 +31,7 @@ import org.meshtastic.proto.User
* A test double for [RadioController] that provides a no-op implementation and tracks calls for assertions in tests.
*/
@Suppress("TooManyFunctions", "EmptyFunctionBlock")
-class FakeRadioController :
+open class FakeRadioController :
BaseFake(),
RadioController {
From 38b74441fb07cdf78be2eae53b4ccef5bc888ca5 Mon Sep 17 00:00:00 2001
From: James Rich
Date: Fri, 17 Apr 2026 10:26:15 -0500
Subject: [PATCH 19/44] fix(auto): align TabTemplate with required Car API
level 6 and tintable icons
- TabTemplate is @RequiresCarApi(6); bump manifest minCarApiLevel from 2
to 6 so the host doesn't reject the template at runtime.
- Use a proper anonymous TabCallback (androidx's TabCallback is not a
Kotlin fun interface) and import top-level TabContents (it is not a
nested type of TabTemplate); the lambda/nested references don't
compile.
- Mark tab CarIcons tintable (CarColor.DEFAULT) so day-mode AAOS themes
don't render white-on-white.
- Extract buildChannelRow to keep buildChannelsTemplate under the
NestedBlockDepth detekt threshold.
- Clarify MAX_LIST_ITEMS KDoc (per-ListTemplate host constraint, not an
API-level property).
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
---
feature/auto/src/main/AndroidManifest.xml | 5 +-
.../feature/auto/MeshtasticCarScreen.kt | 70 +++++++++----------
2 files changed, 38 insertions(+), 37 deletions(-)
diff --git a/feature/auto/src/main/AndroidManifest.xml b/feature/auto/src/main/AndroidManifest.xml
index 472c4f5e3..bda5f1114 100644
--- a/feature/auto/src/main/AndroidManifest.xml
+++ b/feature/auto/src/main/AndroidManifest.xml
@@ -33,10 +33,11 @@
-
+
+ android:value="6" />
diff --git a/feature/auto/src/main/kotlin/org/meshtastic/feature/auto/MeshtasticCarScreen.kt b/feature/auto/src/main/kotlin/org/meshtastic/feature/auto/MeshtasticCarScreen.kt
index cc7a2e309..812255f18 100644
--- a/feature/auto/src/main/kotlin/org/meshtastic/feature/auto/MeshtasticCarScreen.kt
+++ b/feature/auto/src/main/kotlin/org/meshtastic/feature/auto/MeshtasticCarScreen.kt
@@ -19,11 +19,13 @@ package org.meshtastic.feature.auto
import androidx.car.app.CarContext
import androidx.car.app.Screen
import androidx.car.app.model.Action
+import androidx.car.app.model.CarColor
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.Tab
+import androidx.car.app.model.TabContents
import androidx.car.app.model.TabTemplate
import androidx.car.app.model.Template
import androidx.core.graphics.drawable.IconCompat
@@ -147,16 +149,20 @@ class MeshtasticCarScreen(carContext: CarContext) :
}
override fun onGetTemplate(): Template {
- val tabCallback = TabTemplate.TabCallback { tabContentId ->
- activeTabId = tabContentId
- invalidate()
- }
+ val tabCallback =
+ object : TabTemplate.TabCallback {
+ override fun onTabSelected(tabContentId: String) {
+ activeTabId = tabContentId
+ invalidate()
+ }
+ }
- val activeContent = when (activeTabId) {
- TAB_FAVORITES -> TabTemplate.TabContents.Builder(buildFavoritesTemplate()).build()
- TAB_CHANNELS -> TabTemplate.TabContents.Builder(buildChannelsTemplate()).build()
- else -> TabTemplate.TabContents.Builder(buildStatusTemplate()).build()
- }
+ val activeContent =
+ when (activeTabId) {
+ TAB_FAVORITES -> TabContents.Builder(buildFavoritesTemplate()).build()
+ TAB_CHANNELS -> TabContents.Builder(buildChannelsTemplate()).build()
+ else -> TabContents.Builder(buildStatusTemplate()).build()
+ }
return TabTemplate.Builder(tabCallback)
.setHeaderAction(Action.APP_ICON)
@@ -186,7 +192,8 @@ class MeshtasticCarScreen(carContext: CarContext) :
.build()
}
- private fun carIcon(resId: Int) = CarIcon.Builder(IconCompat.createWithResource(carContext, resId)).build()
+ private fun carIcon(resId: Int) =
+ CarIcon.Builder(IconCompat.createWithResource(carContext, resId)).setTint(CarColor.DEFAULT).build()
private fun buildStatusTemplate(): ListTemplate {
val statusText =
@@ -207,10 +214,7 @@ class MeshtasticCarScreen(carContext: CarContext) :
.setBrowsable(false)
.build()
- return ListTemplate.Builder()
- .setTitle("Status")
- .setSingleList(ItemList.Builder().addItem(row).build())
- .build()
+ return ListTemplate.Builder().setTitle("Status").setSingleList(ItemList.Builder().addItem(row).build()).build()
}
private fun buildFavoritesTemplate(): ListTemplate {
@@ -231,10 +235,7 @@ class MeshtasticCarScreen(carContext: CarContext) :
}
}
- return ListTemplate.Builder()
- .setTitle("Favorites")
- .setSingleList(items.build())
- .build()
+ return ListTemplate.Builder().setTitle("Favorites").setSingleList(items.build()).build()
}
private fun buildChannelsTemplate(): ListTemplate {
@@ -242,25 +243,23 @@ class MeshtasticCarScreen(carContext: CarContext) :
if (channels.isEmpty()) {
items.setNoItemsMessage("No active channels")
} else {
- for ((index, channelSettings) in channels.take(MAX_LIST_ITEMS)) {
- 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()
- items.addItem(row)
+ channels.take(MAX_LIST_ITEMS).forEach { (index, settings) ->
+ items.addItem(buildChannelRow(index, settings))
}
}
- return ListTemplate.Builder()
- .setTitle("Channels")
- .setSingleList(items.build())
+ return ListTemplate.Builder().setTitle("Channels").setSingleList(items.build()).build()
+ }
+
+ private fun buildChannelRow(index: Int, channelSettings: ChannelSettings): Row {
+ 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 ""
+ return Row.Builder()
+ .setTitle(channelName)
+ .apply { if (subtitle.isNotEmpty()) addText(subtitle) }
+ .setBrowsable(false)
.build()
}
@@ -270,7 +269,8 @@ class MeshtasticCarScreen(carContext: CarContext) :
private const val TAB_CHANNELS = "channels"
/**
- * Android Auto enforces a maximum item count per [ListTemplate]. Car API level 2 supports up to 6 items.
+ * Android Auto enforces a per-[ListTemplate] item cap via [androidx.car.app.constraints.ConstraintManager]'s
+ * `CONTENT_LIMIT_TYPE_LIST`. 6 is the conservative floor across supported hosts.
*/
private const val MAX_LIST_ITEMS = 6
}
From 01b17595035251203372cba17db4737805fd4190 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Fri, 17 Apr 2026 16:13:09 +0000
Subject: [PATCH 20/44] =?UTF-8?q?feat(auto):=20spec-compliance=20=E2=80=94?=
=?UTF-8?q?=20minCarApiLevel=3D1,=20runtime=20API=20fallback,=20onNewInten?=
=?UTF-8?q?t,=20loading=20state?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Agent-Logs-Url: https://github.com/meshtastic/Meshtastic-Android/sessions/50f9540a-3ba0-4e05-8e06-83cc8c4c93aa
Co-authored-by: jamesarich <2199651+jamesarich@users.noreply.github.com>
---
feature/auto/src/main/AndroidManifest.xml | 8 +-
.../feature/auto/MeshtasticCarScreen.kt | 124 +++++++++++++++---
.../feature/auto/MeshtasticCarSession.kt | 28 +++-
3 files changed, 141 insertions(+), 19 deletions(-)
diff --git a/feature/auto/src/main/AndroidManifest.xml b/feature/auto/src/main/AndroidManifest.xml
index bda5f1114..8cf24a494 100644
--- a/feature/auto/src/main/AndroidManifest.xml
+++ b/feature/auto/src/main/AndroidManifest.xml
@@ -33,11 +33,13 @@
-
+
+ android:value="1" />
diff --git a/feature/auto/src/main/kotlin/org/meshtastic/feature/auto/MeshtasticCarScreen.kt b/feature/auto/src/main/kotlin/org/meshtastic/feature/auto/MeshtasticCarScreen.kt
index 812255f18..c80b9b18a 100644
--- a/feature/auto/src/main/kotlin/org/meshtastic/feature/auto/MeshtasticCarScreen.kt
+++ b/feature/auto/src/main/kotlin/org/meshtastic/feature/auto/MeshtasticCarScreen.kt
@@ -16,6 +16,7 @@
*/
package org.meshtastic.feature.auto
+import androidx.car.app.CarAppApiLevels
import androidx.car.app.CarContext
import androidx.car.app.Screen
import androidx.car.app.model.Action
@@ -23,6 +24,7 @@ import androidx.car.app.model.CarColor
import androidx.car.app.model.CarIcon
import androidx.car.app.model.ItemList
import androidx.car.app.model.ListTemplate
+import androidx.car.app.model.MessageTemplate
import androidx.car.app.model.Row
import androidx.car.app.model.Tab
import androidx.car.app.model.TabContents
@@ -56,12 +58,21 @@ import org.meshtastic.proto.ChannelSettings
/**
* Root screen displayed in Android Auto.
*
- * Shows three tabs mirroring the iOS CarPlay tab-based navigation:
- * - **Status**: Connection state and active device name
- * - **Favorites**: Favorited mesh nodes with unread message counts
- * - **Channels**: Active channels with unread message counts
+ * Renders a three-tab UI mirroring the iOS CarPlay tab-based navigation:
+ * - **Status** — Connection state and device name
+ * - **Favorites** — Favourited mesh nodes with unread message counts
+ * - **Channels** — Active channels with unread message counts
*
- * Requires Car API level 2+ (androidx.car.app:app 1.2.0+) for [TabTemplate] support.
+ * `TabTemplate` requires Car API level 6. On hosts running Car API level 1–5 the
+ * screen falls back to a single [ListTemplate] showing the same data (status row +
+ * favourites + channels) without tab chrome. The manifest declares
+ * `minCarApiLevel=1` so the app remains usable on all supported vehicles.
+ *
+ * When the user taps a [MessagingStyle][androidx.core.app.NotificationCompat.MessagingStyle]
+ * notification in the Android Auto notification shade, the host calls
+ * [MeshtasticCarSession.onNewIntent] with the conversation deep-link URI.
+ * The session delegates to [selectContactKey] so the correct tab is pre-selected
+ * before [onGetTemplate] fires.
*/
class MeshtasticCarScreen(carContext: CarContext) :
Screen(carContext),
@@ -82,6 +93,13 @@ class MeshtasticCarScreen(carContext: CarContext) :
private var channels: List> = emptyList()
private var unreadCounts: Map = emptyMap()
+ /**
+ * True until the first [collect] emission arrives from the repository flows.
+ * While loading, [onGetTemplate] returns a spinner [MessageTemplate] instead of
+ * an empty/disconnected screen.
+ */
+ private var isLoading = true
+
init {
lifecycle.addObserver(this)
}
@@ -143,12 +161,29 @@ class MeshtasticCarScreen(carContext: CarContext) :
favoriteNodes = favorites
channels = chs
unreadCounts = counts
+ isLoading = false
invalidate()
}
}
}
override fun onGetTemplate(): Template {
+ // MessageTemplate.setLoading() requires Car API 5+. On older hosts fall through
+ // to the ListTemplate fallback immediately (StateFlows emit their cached state
+ // near-instantly so the transient empty state is barely visible).
+ if (isLoading && carContext.carAppApiLevel >= CarAppApiLevels.LEVEL_5) {
+ return MessageTemplate.Builder("Loading…")
+ .setHeaderAction(Action.APP_ICON)
+ .setLoading(true)
+ .build()
+ }
+
+ // TabTemplate requires Car API level 6. Fall back to a combined ListTemplate
+ // on older hosts so the app remains functional on all supported vehicles.
+ if (carContext.carAppApiLevel < CarAppApiLevels.LEVEL_6) {
+ return buildFallbackListTemplate()
+ }
+
val tabCallback =
object : TabTemplate.TabCallback {
override fun onTabSelected(tabContentId: String) {
@@ -192,6 +227,61 @@ class MeshtasticCarScreen(carContext: CarContext) :
.build()
}
+ /**
+ * Called by [MeshtasticCarSession.onNewIntent] when the user taps a conversation
+ * notification in the Android Auto notification shade.
+ *
+ * Selects the [TAB_FAVORITES] tab if [contactKey] looks like a DM (starts with a
+ * channel digit followed by a node ID), or [TAB_CHANNELS] if it is a broadcast
+ * conversation. Triggers a template refresh so the correct tab is highlighted.
+ */
+ fun selectContactKey(contactKey: String) {
+ activeTabId = if (contactKey.endsWith(DataPacket.ID_BROADCAST)) TAB_CHANNELS else TAB_FAVORITES
+ invalidate()
+ }
+
+ /**
+ * Fallback template for Car API level 1–5 hosts that do not support [TabTemplate].
+ *
+ * Shows a single [ListTemplate] with the status row followed by all favourites
+ * and all channels — the same data as the tab UI but in a combined list.
+ */
+ private fun buildFallbackListTemplate(): ListTemplate {
+ val items = ItemList.Builder()
+
+ // Status row
+ 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.orEmpty()
+ items.addItem(
+ Row.Builder()
+ .setTitle(statusText)
+ .apply { if (deviceName.isNotEmpty()) addText(deviceName) }
+ .setBrowsable(false)
+ .build(),
+ )
+
+ // Favourite nodes
+ favoriteNodes.take(MAX_LIST_ITEMS).forEach { node ->
+ items.addItem(buildFavoriteNodeRow(node))
+ }
+
+ // Channels
+ channels.take(MAX_LIST_ITEMS).forEach { (index, settings) ->
+ items.addItem(buildChannelRow(index, settings))
+ }
+
+ return ListTemplate.Builder()
+ .setTitle("Meshtastic")
+ .setSingleList(items.build())
+ .build()
+ }
+
private fun carIcon(resId: Int) =
CarIcon.Builder(IconCompat.createWithResource(carContext, resId)).setTint(CarColor.DEFAULT).build()
@@ -222,16 +312,8 @@ class MeshtasticCarScreen(carContext: CarContext) :
if (favoriteNodes.isEmpty()) {
items.setNoItemsMessage("No favorite contacts")
} else {
- for (node in favoriteNodes.take(MAX_LIST_ITEMS)) {
- val contactKey = "0${node.user.id}"
- val unread = unreadCounts[contactKey] ?: 0
- val name = node.user.long_name.ifEmpty { node.user.short_name }.ifEmpty { "Unknown" }
- val subtitle = buildString {
- append(node.user.short_name)
- if (node.hopsAway >= 0) append(" · ${node.hopsAway} hops")
- if (unread > 0) append(" · $unread unread")
- }
- items.addItem(Row.Builder().setTitle(name).addText(subtitle).setBrowsable(false).build())
+ favoriteNodes.take(MAX_LIST_ITEMS).forEach { node ->
+ items.addItem(buildFavoriteNodeRow(node))
}
}
@@ -263,6 +345,18 @@ class MeshtasticCarScreen(carContext: CarContext) :
.build()
}
+ private fun buildFavoriteNodeRow(node: Node): Row {
+ val contactKey = "0${node.user.id}"
+ val unread = unreadCounts[contactKey] ?: 0
+ val name = node.user.long_name.ifEmpty { node.user.short_name }.ifEmpty { "Unknown" }
+ val subtitle = buildString {
+ append(node.user.short_name)
+ if (node.hopsAway >= 0) append(" · ${node.hopsAway} hops")
+ if (unread > 0) append(" · $unread unread")
+ }
+ return Row.Builder().setTitle(name).addText(subtitle).setBrowsable(false).build()
+ }
+
companion object {
private const val TAB_STATUS = "status"
private const val TAB_FAVORITES = "favorites"
diff --git a/feature/auto/src/main/kotlin/org/meshtastic/feature/auto/MeshtasticCarSession.kt b/feature/auto/src/main/kotlin/org/meshtastic/feature/auto/MeshtasticCarSession.kt
index 9fd816bbf..bcb0fb7d5 100644
--- a/feature/auto/src/main/kotlin/org/meshtastic/feature/auto/MeshtasticCarSession.kt
+++ b/feature/auto/src/main/kotlin/org/meshtastic/feature/auto/MeshtasticCarSession.kt
@@ -23,5 +23,31 @@ import androidx.car.app.Session
/** Android Auto session that hosts the [MeshtasticCarScreen] root screen. */
class MeshtasticCarSession : Session() {
- override fun onCreateScreen(intent: Intent): Screen = MeshtasticCarScreen(carContext)
+ override fun onCreateScreen(intent: Intent): Screen {
+ val screen = MeshtasticCarScreen(carContext)
+ handleIntent(intent, screen)
+ return screen
+ }
+
+ /**
+ * Called by the Android Auto host when the session is re-activated from an
+ * existing [MessagingStyle][androidx.core.app.NotificationCompat.MessagingStyle]
+ * notification tap or a launcher shortcut.
+ *
+ * Parses the conversation [contactKey] from the deep-link URI
+ * (`meshtastic://messages/`) and delegates to
+ * [MeshtasticCarScreen.selectContactKey] so the correct tab is pre-selected.
+ */
+ override fun onNewIntent(intent: Intent) {
+ val screen = screenManager.top as? MeshtasticCarScreen ?: return
+ handleIntent(intent, screen)
+ }
+
+ private fun handleIntent(intent: Intent, screen: MeshtasticCarScreen) {
+ // Deep-link URIs from MessagingStyle notifications look like:
+ // meshtastic://messages/0!abcd1234 (DM: channel=0, nodeId=!abcd1234)
+ // meshtastic://messages/2^all (channel broadcast, contactKey e.g. "2^all")
+ val contactKey = intent.data?.lastPathSegment ?: return
+ screen.selectContactKey(contactKey)
+ }
}
From 7c15c7bcb49db1e76334a701337f3a6ad53ce22f Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Fri, 17 Apr 2026 16:32:46 +0000
Subject: [PATCH 21/44] =?UTF-8?q?feat(auto):=20unified=20Messages=20tab=20?=
=?UTF-8?q?=E2=80=94=20channels=20+=20DMs,=20mirroring=20Contacts=20screen?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Agent-Logs-Url: https://github.com/meshtastic/Meshtastic-Android/sessions/8757a33e-0881-45a4-9c3b-5489642c413d
Co-authored-by: jamesarich <2199651+jamesarich@users.noreply.github.com>
---
.../feature/auto/MeshtasticCarScreen.kt | 388 +++++++++---------
.../feature/auto/MeshtasticCarSession.kt | 4 +-
2 files changed, 200 insertions(+), 192 deletions(-)
diff --git a/feature/auto/src/main/kotlin/org/meshtastic/feature/auto/MeshtasticCarScreen.kt b/feature/auto/src/main/kotlin/org/meshtastic/feature/auto/MeshtasticCarScreen.kt
index c80b9b18a..d9b87aa56 100644
--- a/feature/auto/src/main/kotlin/org/meshtastic/feature/auto/MeshtasticCarScreen.kt
+++ b/feature/auto/src/main/kotlin/org/meshtastic/feature/auto/MeshtasticCarScreen.kt
@@ -35,7 +35,6 @@ 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
@@ -48,31 +47,35 @@ 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.model.util.getChannel
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
+import org.meshtastic.proto.ChannelSet
+import org.meshtastic.proto.PortNum
/**
* Root screen displayed in Android Auto.
*
- * Renders a three-tab UI mirroring the iOS CarPlay tab-based navigation:
+ * Renders a two-tab UI that mirrors the app's Contacts screen:
* - **Status** — Connection state and device name
- * - **Favorites** — Favourited mesh nodes with unread message counts
- * - **Channels** — Active channels with unread message counts
+ * - **Messages** — All conversations: active channels displayed first as permanent placeholders
+ * (always visible even when empty, sorted by channel index), followed by DM conversations
+ * sorted by most-recent message descending. This is the same ordering used by
+ * [org.meshtastic.feature.messaging.ui.contact.ContactsViewModel].
*
- * `TabTemplate` requires Car API level 6. On hosts running Car API level 1–5 the
- * screen falls back to a single [ListTemplate] showing the same data (status row +
- * favourites + channels) without tab chrome. The manifest declares
- * `minCarApiLevel=1` so the app remains usable on all supported vehicles.
+ * Unlike the previous three-tab design (Status / Favorites / Channels), this view reflects
+ * every conversation in the database—not just favorited nodes—and correctly handles DMs
+ * on non-primary channels.
+ *
+ * `TabTemplate` requires Car API level 6. On hosts running Car API level 1–5 the screen falls
+ * back to a single [ListTemplate] that includes a status row followed by the same contact list.
*
* When the user taps a [MessagingStyle][androidx.core.app.NotificationCompat.MessagingStyle]
- * notification in the Android Auto notification shade, the host calls
- * [MeshtasticCarSession.onNewIntent] with the conversation deep-link URI.
- * The session delegates to [selectContactKey] so the correct tab is pre-selected
- * before [onGetTemplate] fires.
+ * notification in the Android Auto notification shade the host calls
+ * [MeshtasticCarSession.onNewIntent] which delegates to [selectContactKey] to switch to the
+ * Messages tab.
*/
class MeshtasticCarScreen(carContext: CarContext) :
Screen(carContext),
@@ -80,23 +83,25 @@ class MeshtasticCarScreen(carContext: CarContext) :
DefaultLifecycleObserver {
private val nodeRepository: NodeRepository by inject()
- private val radioConfigRepository: RadioConfigRepository by inject()
private val packetRepository: PacketRepository by inject()
+ private val radioConfigRepository: RadioConfigRepository by inject()
private val serviceRepository: ServiceRepository by inject()
private val scope = CoroutineScope(Dispatchers.Main + SupervisorJob())
- private var observeJob: Job? = null
private var activeTabId = TAB_STATUS
private var connectionState: ConnectionState = ConnectionState.Disconnected
- private var favoriteNodes: List = emptyList()
- private var channels: List> = emptyList()
- private var unreadCounts: Map = emptyMap()
/**
- * True until the first [collect] emission arrives from the repository flows.
- * While loading, [onGetTemplate] returns a spinner [MessageTemplate] instead of
- * an empty/disconnected screen.
+ * Ordered contact list for the Messages tab: channel entries first (sorted by channel index,
+ * always present as placeholders even when no messages exist), then DM conversations sorted
+ * by most-recent message descending — identical ordering to the phone's Contacts screen.
+ */
+ private var contacts: List = emptyList()
+
+ /**
+ * True until the first [collect] emission arrives from the repository flows, preventing a
+ * flash of an empty/disconnected screen on Car API ≥ 5 hosts.
*/
private var isLoading = true
@@ -113,64 +118,108 @@ class MeshtasticCarScreen(carContext: CarContext) :
}
private fun startObserving() {
- observeJob?.cancel()
- 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 }
- }
- .distinctUntilChanged()
-
- val channelsFlow =
- radioConfigRepository.channelSetFlow
- .map { cs ->
- cs.settings.mapIndexedNotNull { index, settings ->
- if (index == 0 || settings.name.isNotEmpty()) index to settings else null
- }
- }
- .distinctUntilChanged()
-
- combine(stateFlow, favoritesFlow, channelsFlow) { state, favorites, chs ->
- Triple(state, favorites, chs)
- }
- .flatMapLatest { (state, favorites, chs) ->
- val contactKeys =
- favorites.map { "0${it.user.id}" } + chs.map { (i, _) -> "${i}${DataPacket.ID_BROADCAST}" }
-
- 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
- isLoading = false
- invalidate()
- }
+ // Observe the contact list (channels + DMs) with reactive unread counts.
+ scope.launch {
+ combine(
+ nodeRepository.myId,
+ packetRepository.getContacts(),
+ radioConfigRepository.channelSetFlow,
+ ) { myId, rawContacts, channelSet ->
+ // Channel placeholders are always included so every configured channel is
+ // visible even before any messages have been sent/received — mirroring the
+ // behaviour of ContactsViewModel.contactList.
+ val placeholders = buildChannelPlaceholders(channelSet)
+ // Real DB entries take precedence over placeholders when present.
+ val merged = rawContacts + (placeholders - rawContacts.keys)
+ buildCarContacts(merged, myId, channelSet)
}
+ .distinctUntilChanged()
+ .flatMapLatest { baseContacts ->
+ if (baseContacts.isEmpty()) {
+ flowOf(emptyList())
+ } else {
+ val unreadFlows =
+ baseContacts.map { contact ->
+ packetRepository.getUnreadCountFlow(contact.contactKey)
+ .map { unread -> contact.copy(unreadCount = unread) }
+ }
+ combine(unreadFlows) { it.toList() }
+ }
+ }
+ .collect { updated ->
+ contacts = updated
+ isLoading = false
+ invalidate()
+ }
+ }
+
+ // Connection state is observed separately since it only affects the Status tab.
+ scope.launch {
+ serviceRepository.connectionState.collect { state ->
+ connectionState = state
+ invalidate()
+ }
+ }
}
+ /** Returns a map of `"^all" → placeholder DataPacket` for every configured channel. */
+ private fun buildChannelPlaceholders(channelSet: ChannelSet): Map =
+ (0 until channelSet.settings.size).associate { ch ->
+ // dataType uses PortNum.TEXT_MESSAGE_APP (value 1) to match the placeholder
+ // construction in ContactsViewModel and PacketRepository contact queries.
+ "${ch}${DataPacket.ID_BROADCAST}" to
+ DataPacket(bytes = null, dataType = PortNum.TEXT_MESSAGE_APP.value, time = 0L, channel = ch)
+ }
+
+ /**
+ * Converts the merged DB + placeholder map into an ordered [CarContact] list.
+ *
+ * Channels (keys ending with [DataPacket.ID_BROADCAST]) appear first sorted by channel index.
+ * DM conversations follow sorted by [CarContact.lastMessageTime] descending — matching the
+ * ordering used by the phone's Contacts screen.
+ */
+ private fun buildCarContacts(
+ merged: Map,
+ myId: String?,
+ channelSet: ChannelSet,
+ ): List {
+ val all =
+ merged.map { (contactKey, packet) ->
+ val fromLocal = packet.from == DataPacket.ID_LOCAL || packet.from == myId
+ val toBroadcast = packet.to == DataPacket.ID_BROADCAST
+ val userId = if (fromLocal) packet.to else packet.from
+
+ val displayName =
+ if (toBroadcast) {
+ channelSet.getChannel(packet.channel)?.name?.takeIf { it.isNotEmpty() }
+ ?: "Channel ${packet.channel}"
+ } else {
+ // userId can be null for malformed packets (e.g. both `from` and `to`
+ // are null). Fall back to a broadcast lookup which returns an "Unknown"
+ // user rather than crashing.
+ val user = nodeRepository.getUser(userId ?: DataPacket.ID_BROADCAST)
+ user.long_name.ifEmpty { user.short_name }.ifEmpty { "Unknown" }
+ }
+
+ CarContact(
+ contactKey = contactKey,
+ displayName = displayName,
+ unreadCount = 0, // filled in reactively by flatMapLatest below
+ isBroadcast = toBroadcast,
+ channelIndex = packet.channel,
+ lastMessageTime = if (packet.time != 0L) packet.time else null,
+ )
+ }
+
+ return all.filter { it.isBroadcast }.sortedBy { it.channelIndex } +
+ all.filter { !it.isBroadcast }.sortedByDescending { it.lastMessageTime ?: 0L }
+ }
+
+ // ---- Template building ----
+
override fun onGetTemplate(): Template {
// MessageTemplate.setLoading() requires Car API 5+. On older hosts fall through
- // to the ListTemplate fallback immediately (StateFlows emit their cached state
- // near-instantly so the transient empty state is barely visible).
+ // to the ListTemplate fallback (StateFlows emit their cached state near-instantly).
if (isLoading && carContext.carAppApiLevel >= CarAppApiLevels.LEVEL_5) {
return MessageTemplate.Builder("Loading…")
.setHeaderAction(Action.APP_ICON)
@@ -194,8 +243,7 @@ class MeshtasticCarScreen(carContext: CarContext) :
val activeContent =
when (activeTabId) {
- TAB_FAVORITES -> TabContents.Builder(buildFavoritesTemplate()).build()
- TAB_CHANNELS -> TabContents.Builder(buildChannelsTemplate()).build()
+ TAB_MESSAGES -> TabContents.Builder(buildMessagesTemplate()).build()
else -> TabContents.Builder(buildStatusTemplate()).build()
}
@@ -210,16 +258,9 @@ class MeshtasticCarScreen(carContext: CarContext) :
)
.addTab(
Tab.Builder()
- .setTitle("Favorites")
- .setIcon(carIcon(R.drawable.auto_ic_favorites))
- .setContentId(TAB_FAVORITES)
- .build(),
- )
- .addTab(
- Tab.Builder()
- .setTitle("Channels")
+ .setTitle("Messages")
.setIcon(carIcon(R.drawable.auto_ic_channels))
- .setContentId(TAB_CHANNELS)
+ .setContentId(TAB_MESSAGES)
.build(),
)
.setTabContents(activeContent)
@@ -228,28 +269,55 @@ class MeshtasticCarScreen(carContext: CarContext) :
}
/**
- * Called by [MeshtasticCarSession.onNewIntent] when the user taps a conversation
- * notification in the Android Auto notification shade.
+ * Called by [MeshtasticCarSession.onNewIntent] when the user taps a conversation notification
+ * in the Android Auto notification shade. Switches to [TAB_MESSAGES] regardless of whether
+ * the originating contact is a channel broadcast or a DM, because both appear in the same tab.
*
- * Selects the [TAB_FAVORITES] tab if [contactKey] looks like a DM (starts with a
- * channel digit followed by a node ID), or [TAB_CHANNELS] if it is a broadcast
- * conversation. Triggers a template refresh so the correct tab is highlighted.
+ * The [contactKey] parameter is accepted for API symmetry with the session and may be used in
+ * the future to scroll the Messages list to the tapped conversation.
*/
- fun selectContactKey(contactKey: String) {
- activeTabId = if (contactKey.endsWith(DataPacket.ID_BROADCAST)) TAB_CHANNELS else TAB_FAVORITES
+ fun selectContactKey(@Suppress("UNUSED_PARAMETER") contactKey: String) {
+ activeTabId = TAB_MESSAGES
invalidate()
}
+ // ---- Individual template builders ----
+
+ private fun buildStatusTemplate(): ListTemplate =
+ ListTemplate.Builder()
+ .setTitle("Status")
+ .setSingleList(ItemList.Builder().addItem(buildStatusRow()).build())
+ .build()
+
/**
- * Fallback template for Car API level 1–5 hosts that do not support [TabTemplate].
+ * Builds the Messages tab content: channels first (always present, even if empty), followed
+ * by DM conversations sorted by most-recent message — identical to the phone's Contacts screen.
+ */
+ private fun buildMessagesTemplate(): ListTemplate {
+ val items = ItemList.Builder()
+ val capped = contacts.take(MAX_LIST_ITEMS)
+ if (capped.isEmpty()) {
+ items.setNoItemsMessage("No conversations")
+ } else {
+ capped.forEach { contact -> items.addItem(buildContactRow(contact)) }
+ }
+ return ListTemplate.Builder().setTitle("Messages").setSingleList(items.build()).build()
+ }
+
+ /**
+ * Fallback for Car API level 1–5 hosts that do not support [TabTemplate].
*
- * Shows a single [ListTemplate] with the status row followed by all favourites
- * and all channels — the same data as the tab UI but in a combined list.
+ * Shows a status row followed by the combined contact list (channels first, then DMs) in a
+ * single [ListTemplate].
*/
private fun buildFallbackListTemplate(): ListTemplate {
val items = ItemList.Builder()
+ items.addItem(buildStatusRow())
+ contacts.take(MAX_LIST_ITEMS).forEach { contact -> items.addItem(buildContactRow(contact)) }
+ return ListTemplate.Builder().setTitle("Meshtastic").setSingleList(items.build()).build()
+ }
- // Status row
+ private fun buildStatusRow(): Row {
val statusText =
when (connectionState) {
is ConnectionState.Connected -> "Connected"
@@ -258,114 +326,52 @@ class MeshtasticCarScreen(carContext: CarContext) :
is ConnectionState.Connecting -> "Connecting…"
}
val deviceName = nodeRepository.ourNodeInfo.value?.user?.long_name.orEmpty()
- items.addItem(
- Row.Builder()
- .setTitle(statusText)
- .apply { if (deviceName.isNotEmpty()) addText(deviceName) }
- .setBrowsable(false)
- .build(),
- )
+ return Row.Builder()
+ .setTitle(statusText)
+ .apply { if (deviceName.isNotEmpty()) addText(deviceName) }
+ .setBrowsable(false)
+ .build()
+ }
- // Favourite nodes
- favoriteNodes.take(MAX_LIST_ITEMS).forEach { node ->
- items.addItem(buildFavoriteNodeRow(node))
- }
-
- // Channels
- channels.take(MAX_LIST_ITEMS).forEach { (index, settings) ->
- items.addItem(buildChannelRow(index, settings))
- }
-
- return ListTemplate.Builder()
- .setTitle("Meshtastic")
- .setSingleList(items.build())
+ private fun buildContactRow(contact: CarContact): Row {
+ val subtitle = if (contact.unreadCount > 0) "${contact.unreadCount} unread" else ""
+ return Row.Builder()
+ .setTitle(contact.displayName)
+ .apply { if (subtitle.isNotEmpty()) addText(subtitle) }
+ .setBrowsable(false)
.build()
}
private fun carIcon(resId: Int) =
CarIcon.Builder(IconCompat.createWithResource(carContext, resId)).setTint(CarColor.DEFAULT).build()
- private fun buildStatusTemplate(): ListTemplate {
- val statusText =
- when (connectionState) {
- is ConnectionState.Connected -> "Connected"
- is ConnectionState.Disconnected -> "Disconnected"
- is ConnectionState.DeviceSleep -> "Device Sleeping"
- is ConnectionState.Connecting -> "Connecting..."
- }
+ // ---- Internal model ----
- 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 ListTemplate.Builder().setTitle("Status").setSingleList(ItemList.Builder().addItem(row).build()).build()
- }
-
- private fun buildFavoritesTemplate(): ListTemplate {
- val items = ItemList.Builder()
- if (favoriteNodes.isEmpty()) {
- items.setNoItemsMessage("No favorite contacts")
- } else {
- favoriteNodes.take(MAX_LIST_ITEMS).forEach { node ->
- items.addItem(buildFavoriteNodeRow(node))
- }
- }
-
- return ListTemplate.Builder().setTitle("Favorites").setSingleList(items.build()).build()
- }
-
- private fun buildChannelsTemplate(): ListTemplate {
- val items = ItemList.Builder()
- if (channels.isEmpty()) {
- items.setNoItemsMessage("No active channels")
- } else {
- channels.take(MAX_LIST_ITEMS).forEach { (index, settings) ->
- items.addItem(buildChannelRow(index, settings))
- }
- }
-
- return ListTemplate.Builder().setTitle("Channels").setSingleList(items.build()).build()
- }
-
- private fun buildChannelRow(index: Int, channelSettings: ChannelSettings): Row {
- 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 ""
- return Row.Builder()
- .setTitle(channelName)
- .apply { if (subtitle.isNotEmpty()) addText(subtitle) }
- .setBrowsable(false)
- .build()
- }
-
- private fun buildFavoriteNodeRow(node: Node): Row {
- val contactKey = "0${node.user.id}"
- val unread = unreadCounts[contactKey] ?: 0
- val name = node.user.long_name.ifEmpty { node.user.short_name }.ifEmpty { "Unknown" }
- val subtitle = buildString {
- append(node.user.short_name)
- if (node.hopsAway >= 0) append(" · ${node.hopsAway} hops")
- if (unread > 0) append(" · $unread unread")
- }
- return Row.Builder().setTitle(name).addText(subtitle).setBrowsable(false).build()
- }
+ /**
+ * Lightweight projection of a conversation used exclusively within this screen.
+ *
+ * [isBroadcast] and [channelIndex] drive ordering (channels before DMs, channels sorted by
+ * index). [lastMessageTime] drives DM ordering (most-recent first).
+ */
+ private data class CarContact(
+ val contactKey: String,
+ val displayName: String,
+ val unreadCount: Int,
+ val isBroadcast: Boolean,
+ val channelIndex: Int,
+ val lastMessageTime: Long?,
+ )
companion object {
private const val TAB_STATUS = "status"
- private const val TAB_FAVORITES = "favorites"
- private const val TAB_CHANNELS = "channels"
+ private const val TAB_MESSAGES = "messages"
/**
- * Android Auto enforces a per-[ListTemplate] item cap via [androidx.car.app.constraints.ConstraintManager]'s
- * `CONTENT_LIMIT_TYPE_LIST`. 6 is the conservative floor across supported hosts.
+ * Car App Library enforces a per-[ListTemplate] item cap via
+ * `ConstraintManager.CONTENT_LIMIT_TYPE_LIST`. 6 is the conservative floor across all
+ * supported hosts.
*/
private const val MAX_LIST_ITEMS = 6
}
}
+
diff --git a/feature/auto/src/main/kotlin/org/meshtastic/feature/auto/MeshtasticCarSession.kt b/feature/auto/src/main/kotlin/org/meshtastic/feature/auto/MeshtasticCarSession.kt
index bcb0fb7d5..13b3c5d3b 100644
--- a/feature/auto/src/main/kotlin/org/meshtastic/feature/auto/MeshtasticCarSession.kt
+++ b/feature/auto/src/main/kotlin/org/meshtastic/feature/auto/MeshtasticCarSession.kt
@@ -46,7 +46,9 @@ class MeshtasticCarSession : Session() {
private fun handleIntent(intent: Intent, screen: MeshtasticCarScreen) {
// Deep-link URIs from MessagingStyle notifications look like:
// meshtastic://messages/0!abcd1234 (DM: channel=0, nodeId=!abcd1234)
- // meshtastic://messages/2^all (channel broadcast, contactKey e.g. "2^all")
+ // meshtastic://messages/2^all (channel broadcast, e.g. contactKey "2^all")
+ // Both channels and DMs now live in the same Messages tab, so we simply
+ // switch to that tab regardless of the contact type.
val contactKey = intent.data?.lastPathSegment ?: return
screen.selectContactKey(contactKey)
}
From 849aca797b0621394d4e77f0d668a608ef8a0714 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Fri, 17 Apr 2026 16:49:59 +0000
Subject: [PATCH 22/44] plan: align Auto node/message row UI with phone
NodeItem and ContactItem
Agent-Logs-Url: https://github.com/meshtastic/Meshtastic-Android/sessions/ebb54cc3-35cd-4c25-afd2-4b7fbaa05a5d
Co-authored-by: jamesarich <2199651+jamesarich@users.noreply.github.com>
---
.../feature/auto/MeshtasticCarScreen.kt | 38 +++++++++++++++----
1 file changed, 31 insertions(+), 7 deletions(-)
diff --git a/feature/auto/src/main/kotlin/org/meshtastic/feature/auto/MeshtasticCarScreen.kt b/feature/auto/src/main/kotlin/org/meshtastic/feature/auto/MeshtasticCarScreen.kt
index d9b87aa56..897eae987 100644
--- a/feature/auto/src/main/kotlin/org/meshtastic/feature/auto/MeshtasticCarScreen.kt
+++ b/feature/auto/src/main/kotlin/org/meshtastic/feature/auto/MeshtasticCarScreen.kt
@@ -47,6 +47,7 @@ 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.model.util.getChannel
import org.meshtastic.core.repository.NodeRepository
import org.meshtastic.core.repository.PacketRepository
@@ -58,19 +59,17 @@ import org.meshtastic.proto.PortNum
/**
* Root screen displayed in Android Auto.
*
- * Renders a two-tab UI that mirrors the app's Contacts screen:
- * - **Status** — Connection state and device name
+ * Renders a three-tab UI:
+ * - **Status** — Connection state and device name.
+ * - **Favorites** — All nodes the user has starred, with online/hop status shown as a subtitle.
* - **Messages** — All conversations: active channels displayed first as permanent placeholders
* (always visible even when empty, sorted by channel index), followed by DM conversations
* sorted by most-recent message descending. This is the same ordering used by
* [org.meshtastic.feature.messaging.ui.contact.ContactsViewModel].
*
- * Unlike the previous three-tab design (Status / Favorites / Channels), this view reflects
- * every conversation in the database—not just favorited nodes—and correctly handles DMs
- * on non-primary channels.
- *
* `TabTemplate` requires Car API level 6. On hosts running Car API level 1–5 the screen falls
- * back to a single [ListTemplate] that includes a status row followed by the same contact list.
+ * back to a single [ListTemplate] that includes a status row, favorite-node rows, and the
+ * contact list.
*
* When the user taps a [MessagingStyle][androidx.core.app.NotificationCompat.MessagingStyle]
* notification in the Android Auto notification shade the host calls
@@ -92,6 +91,12 @@ class MeshtasticCarScreen(carContext: CarContext) :
private var activeTabId = TAB_STATUS
private var connectionState: ConnectionState = ConnectionState.Disconnected
+ /**
+ * Favorite nodes sorted alphabetically by long name. Updated reactively from
+ * [NodeRepository.nodeDBbyNum] whenever the user stars or un-stars a node.
+ */
+ private var favorites: List = emptyList()
+
/**
* Ordered contact list for the Messages tab: channel entries first (sorted by channel index,
* always present as placeholders even when no messages exist), then DM conversations sorted
@@ -160,6 +165,17 @@ class MeshtasticCarScreen(carContext: CarContext) :
invalidate()
}
}
+
+ // Favorite nodes — filter nodeDBbyNum to isFavorite, sort alphabetically.
+ scope.launch {
+ nodeRepository.nodeDBbyNum
+ .map { db -> db.values.filter { it.isFavorite }.sortedBy { it.user.long_name.ifEmpty { it.user.short_name } } }
+ .distinctUntilChanged()
+ .collect { nodes ->
+ favorites = nodes
+ invalidate()
+ }
+ }
}
/** Returns a map of `"^all" → placeholder DataPacket` for every configured channel. */
@@ -243,6 +259,7 @@ class MeshtasticCarScreen(carContext: CarContext) :
val activeContent =
when (activeTabId) {
+ TAB_FAVORITES -> TabContents.Builder(buildFavoritesTemplate()).build()
TAB_MESSAGES -> TabContents.Builder(buildMessagesTemplate()).build()
else -> TabContents.Builder(buildStatusTemplate()).build()
}
@@ -256,6 +273,13 @@ class MeshtasticCarScreen(carContext: CarContext) :
.setContentId(TAB_STATUS)
.build(),
)
+ .addTab(
+ Tab.Builder()
+ .setTitle("Favorites")
+ .setIcon(carIcon(R.drawable.auto_ic_favorites))
+ .setContentId(TAB_FAVORITES)
+ .build(),
+ )
.addTab(
Tab.Builder()
.setTitle("Messages")
From 9f0ead25180f8edaf65a47a67f0cfe18909bdbff Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Fri, 17 Apr 2026 16:53:08 +0000
Subject: [PATCH 23/44] feat(auto): align Auto node/message row UI with phone
NodeItem and ContactItem
Agent-Logs-Url: https://github.com/meshtastic/Meshtastic-Android/sessions/ebb54cc3-35cd-4c25-afd2-4b7fbaa05a5d
Co-authored-by: jamesarich <2199651+jamesarich@users.noreply.github.com>
---
.../feature/auto/MeshtasticCarScreen.kt | 136 +++++++++++++++++-
1 file changed, 129 insertions(+), 7 deletions(-)
diff --git a/feature/auto/src/main/kotlin/org/meshtastic/feature/auto/MeshtasticCarScreen.kt b/feature/auto/src/main/kotlin/org/meshtastic/feature/auto/MeshtasticCarScreen.kt
index 897eae987..8135ce9f0 100644
--- a/feature/auto/src/main/kotlin/org/meshtastic/feature/auto/MeshtasticCarScreen.kt
+++ b/feature/auto/src/main/kotlin/org/meshtastic/feature/auto/MeshtasticCarScreen.kt
@@ -45,6 +45,7 @@ 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.common.util.DateFormatter
import org.meshtastic.core.model.ConnectionState
import org.meshtastic.core.model.DataPacket
import org.meshtastic.core.model.Node
@@ -169,7 +170,11 @@ class MeshtasticCarScreen(carContext: CarContext) :
// Favorite nodes — filter nodeDBbyNum to isFavorite, sort alphabetically.
scope.launch {
nodeRepository.nodeDBbyNum
- .map { db -> db.values.filter { it.isFavorite }.sortedBy { it.user.long_name.ifEmpty { it.user.short_name } } }
+ .map { db ->
+ db.values
+ .filter { it.isFavorite }
+ .sortedWith(compareBy { it.user.long_name.ifEmpty { it.user.short_name } })
+ }
.distinctUntilChanged()
.collect { nodes ->
favorites = nodes
@@ -205,6 +210,9 @@ class MeshtasticCarScreen(carContext: CarContext) :
val toBroadcast = packet.to == DataPacket.ID_BROADCAST
val userId = if (fromLocal) packet.to else packet.from
+ // Resolve the user once; used for both displayName and message prefix.
+ val user = nodeRepository.getUser(userId ?: DataPacket.ID_BROADCAST)
+
val displayName =
if (toBroadcast) {
channelSet.getChannel(packet.channel)?.name?.takeIf { it.isNotEmpty() }
@@ -213,10 +221,17 @@ class MeshtasticCarScreen(carContext: CarContext) :
// userId can be null for malformed packets (e.g. both `from` and `to`
// are null). Fall back to a broadcast lookup which returns an "Unknown"
// user rather than crashing.
- val user = nodeRepository.getUser(userId ?: DataPacket.ID_BROADCAST)
user.long_name.ifEmpty { user.short_name }.ifEmpty { "Unknown" }
}
+ // Mirror ContactsViewModel: prefix received DM text with the sender's short name,
+ // matching how ContactItem's ChatMetadata renders lastMessageText.
+ val shortName = if (!toBroadcast) user.short_name else ""
+ val lastMessageText =
+ packet.text?.let { text ->
+ if (fromLocal || shortName.isEmpty()) text else "$shortName: $text"
+ }
+
CarContact(
contactKey = contactKey,
displayName = displayName,
@@ -224,6 +239,7 @@ class MeshtasticCarScreen(carContext: CarContext) :
isBroadcast = toBroadcast,
channelIndex = packet.channel,
lastMessageTime = if (packet.time != 0L) packet.time else null,
+ lastMessageText = lastMessageText,
)
}
@@ -313,6 +329,74 @@ class MeshtasticCarScreen(carContext: CarContext) :
.setSingleList(ItemList.Builder().addItem(buildStatusRow()).build())
.build()
+ /**
+ * Builds the Favorites tab: one row per starred node, mirroring the key status info shown
+ * by [org.meshtastic.feature.node.component.NodeItem] on the phone.
+ *
+ * - **Title**: node's long name (short name fallback).
+ * - **Text 1**: `"Online · Direct"` / `"Online · N hops"` / `"Offline · Xh ago"` —
+ * mirrors the signal row and last-heard chip in NodeItem.
+ * - **Text 2**: battery percentage and short name — mirrors the battery row and node chip.
+ */
+ private fun buildFavoritesTemplate(): ListTemplate {
+ val items = ItemList.Builder()
+ val capped = favorites.take(MAX_LIST_ITEMS)
+ if (capped.isEmpty()) {
+ items.setNoItemsMessage("No favorite nodes")
+ } else {
+ capped.forEach { node -> items.addItem(buildFavoriteNodeRow(node)) }
+ }
+ return ListTemplate.Builder().setTitle("Favorites").setSingleList(items.build()).build()
+ }
+
+ /**
+ * Builds a single favorite-node row.
+ *
+ * Mirrors the content of [org.meshtastic.feature.node.component.NodeItem]:
+ * - Title → `long_name` (prominent, matches NodeItem header text)
+ * - Text 1 → online/offline + hop distance (matches NodeItem signal row)
+ * - Text 2 → battery level + short name chip equivalent (matches NodeItem battery row)
+ */
+ private fun buildFavoriteNodeRow(node: Node): Row {
+ val name = node.user.long_name.ifEmpty { node.user.short_name }.ifEmpty { "Unknown" }
+
+ // Mirror NodeItem's signal row: online status + hops / direct info.
+ val statusText = buildString {
+ if (node.isOnline) {
+ append("Online")
+ when {
+ node.hopsAway == 0 -> append(" · Direct")
+ node.hopsAway > 0 -> append(" · ${node.hopsAway} hops")
+ }
+ } else {
+ append("Offline")
+ if (node.lastHeard > 0) {
+ // DateFormatter.formatRelativeTime takes millis; lastHeard is in seconds.
+ val ago = DateFormatter.formatRelativeTime(node.lastHeard * 1000L)
+ append(" · $ago")
+ }
+ }
+ }
+
+ // Mirror NodeItem's battery row + node chip: "[SHORT] · 85%" or just "[SHORT]".
+ val detailText = buildString {
+ val shortName = node.user.short_name
+ if (shortName.isNotEmpty()) append(shortName)
+ val battery = node.batteryLevelStr
+ if (battery.isNotEmpty()) {
+ if (isNotEmpty()) append(" · ")
+ append(battery)
+ }
+ }
+
+ return Row.Builder()
+ .setTitle(name)
+ .addText(statusText)
+ .apply { if (detailText.isNotEmpty()) addText(detailText) }
+ .setBrowsable(false)
+ .build()
+ }
+
/**
* Builds the Messages tab content: channels first (always present, even if empty), followed
* by DM conversations sorted by most-recent message — identical to the phone's Contacts screen.
@@ -331,13 +415,26 @@ class MeshtasticCarScreen(carContext: CarContext) :
/**
* Fallback for Car API level 1–5 hosts that do not support [TabTemplate].
*
- * Shows a status row followed by the combined contact list (channels first, then DMs) in a
- * single [ListTemplate].
+ * Shows a status row, then favorite-node rows, then conversation rows, all capped at
+ * [MAX_LIST_ITEMS] total — matching the three-tab content in a single list.
+ *
+ * The remaining slots after status are split evenly: half for favorites, half for messages.
+ * This prevents a long favorites list from crowding out all conversation entries.
*/
private fun buildFallbackListTemplate(): ListTemplate {
val items = ItemList.Builder()
+ var remaining = MAX_LIST_ITEMS
items.addItem(buildStatusRow())
- contacts.take(MAX_LIST_ITEMS).forEach { contact -> items.addItem(buildContactRow(contact)) }
+ remaining--
+ // Give each section at most half the remaining space so neither dominates.
+ val halfRemaining = remaining / 2
+ favorites.take(halfRemaining).forEach { node ->
+ items.addItem(buildFavoriteNodeRow(node))
+ remaining--
+ }
+ contacts.take(remaining).forEach { contact ->
+ items.addItem(buildContactRow(contact))
+ }
return ListTemplate.Builder().setTitle("Meshtastic").setSingleList(items.build()).build()
}
@@ -357,11 +454,32 @@ class MeshtasticCarScreen(carContext: CarContext) :
.build()
}
+ /**
+ * Builds a single conversation row.
+ *
+ * Mirrors [org.meshtastic.feature.messaging.ui.contact.ContactItem]:
+ * - **Title** → channel or DM display name (matches the bodyLarge name in ContactHeader).
+ * - **Text 1** → last message preview with sender prefix for received DMs, or "No messages
+ * yet" for empty channel placeholders (matches ChatMetadata's message text).
+ * - **Text 2** → `"N unread"` when there are unread messages, or the last-message timestamp
+ * when there are none (matches the unread badge and date in ContactHeader/ChatMetadata).
+ */
private fun buildContactRow(contact: CarContact): Row {
- val subtitle = if (contact.unreadCount > 0) "${contact.unreadCount} unread" else ""
+ // Mirror ChatMetadata: show the last message text or a placeholder for empty channels.
+ val preview = contact.lastMessageText?.takeIf { it.isNotEmpty() } ?: "No messages yet"
+
+ // Mirror ContactItem header date + ChatMetadata unread badge.
+ val secondaryText = when {
+ contact.unreadCount > 0 -> "${contact.unreadCount} unread"
+ contact.lastMessageTime != null ->
+ DateFormatter.formatShortDate(contact.lastMessageTime)
+ else -> ""
+ }
+
return Row.Builder()
.setTitle(contact.displayName)
- .apply { if (subtitle.isNotEmpty()) addText(subtitle) }
+ .addText(preview)
+ .apply { if (secondaryText.isNotEmpty()) addText(secondaryText) }
.setBrowsable(false)
.build()
}
@@ -376,6 +494,8 @@ class MeshtasticCarScreen(carContext: CarContext) :
*
* [isBroadcast] and [channelIndex] drive ordering (channels before DMs, channels sorted by
* index). [lastMessageTime] drives DM ordering (most-recent first).
+ * [lastMessageText] mirrors `ContactsViewModel.contactList`'s `lastMessageText` — received
+ * DMs are prefixed with the sender's short name, matching [ContactItem]'s ChatMetadata.
*/
private data class CarContact(
val contactKey: String,
@@ -384,10 +504,12 @@ class MeshtasticCarScreen(carContext: CarContext) :
val isBroadcast: Boolean,
val channelIndex: Int,
val lastMessageTime: Long?,
+ val lastMessageText: String?,
)
companion object {
private const val TAB_STATUS = "status"
+ private const val TAB_FAVORITES = "favorites"
private const val TAB_MESSAGES = "messages"
/**
From 2e74af770b4a71729311ff0f88f20342628186e8 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Fri, 17 Apr 2026 17:19:47 +0000
Subject: [PATCH 24/44] feat(auto): polish - extract CarScreenDataBuilder, add
unit tests, fix batteryStr, DRY row builder
Agent-Logs-Url: https://github.com/meshtastic/Meshtastic-Android/sessions/e79e1ea2-bea6-4b71-acb3-13dbdbce363f
Co-authored-by: jamesarich <2199651+jamesarich@users.noreply.github.com>
---
feature/auto/build.gradle.kts | 4 +
.../org/meshtastic/feature/auto/CarContact.kt | 35 ++
.../feature/auto/CarScreenDataBuilder.kt | 193 +++++++
.../feature/auto/MeshtasticCarScreen.kt | 306 +++-------
.../feature/auto/CarScreenDataBuilderTest.kt | 538 ++++++++++++++++++
5 files changed, 851 insertions(+), 225 deletions(-)
create mode 100644 feature/auto/src/main/kotlin/org/meshtastic/feature/auto/CarContact.kt
create mode 100644 feature/auto/src/main/kotlin/org/meshtastic/feature/auto/CarScreenDataBuilder.kt
create mode 100644 feature/auto/src/test/kotlin/org/meshtastic/feature/auto/CarScreenDataBuilderTest.kt
diff --git a/feature/auto/build.gradle.kts b/feature/auto/build.gradle.kts
index 721e844be..54a33c302 100644
--- a/feature/auto/build.gradle.kts
+++ b/feature/auto/build.gradle.kts
@@ -38,4 +38,8 @@ dependencies {
implementation(libs.androidx.car.app)
implementation(libs.kermit)
implementation(libs.koin.annotations)
+
+ testImplementation(kotlin("test"))
+ testImplementation(libs.kotest.assertions)
+ testImplementation(libs.kotlinx.coroutines.test)
}
diff --git a/feature/auto/src/main/kotlin/org/meshtastic/feature/auto/CarContact.kt b/feature/auto/src/main/kotlin/org/meshtastic/feature/auto/CarContact.kt
new file mode 100644
index 000000000..7c9413c4f
--- /dev/null
+++ b/feature/auto/src/main/kotlin/org/meshtastic/feature/auto/CarContact.kt
@@ -0,0 +1,35 @@
+/*
+ * 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.auto
+
+/**
+ * Lightweight projection of a conversation used exclusively within [MeshtasticCarScreen].
+ *
+ * [isBroadcast] and [channelIndex] drive ordering (channels before DMs, channels sorted by
+ * index). [lastMessageTime] drives DM ordering (most-recent first).
+ * [lastMessageText] mirrors `ContactsViewModel.contactList`'s `lastMessageText` — received
+ * DMs are prefixed with the sender's short name, matching `ContactItem`'s ChatMetadata display.
+ */
+internal data class CarContact(
+ val contactKey: String,
+ val displayName: String,
+ val unreadCount: Int,
+ val isBroadcast: Boolean,
+ val channelIndex: Int,
+ val lastMessageTime: Long?,
+ val lastMessageText: String?,
+)
diff --git a/feature/auto/src/main/kotlin/org/meshtastic/feature/auto/CarScreenDataBuilder.kt b/feature/auto/src/main/kotlin/org/meshtastic/feature/auto/CarScreenDataBuilder.kt
new file mode 100644
index 000000000..fa6a883ae
--- /dev/null
+++ b/feature/auto/src/main/kotlin/org/meshtastic/feature/auto/CarScreenDataBuilder.kt
@@ -0,0 +1,193 @@
+/*
+ * 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.auto
+
+import org.meshtastic.core.common.util.DateFormatter
+import org.meshtastic.core.model.DataPacket
+import org.meshtastic.core.model.Node
+import org.meshtastic.core.model.util.getChannel
+import org.meshtastic.proto.ChannelSet
+import org.meshtastic.proto.PortNum
+import org.meshtastic.proto.User
+
+/**
+ * Pure-function helpers that convert domain models into [CarContact] and display strings for
+ * [MeshtasticCarScreen].
+ *
+ * All methods are free of Car App Library dependencies, making them straightforwardly testable as
+ * plain JVM unit tests without Robolectric.
+ */
+internal object CarScreenDataBuilder {
+
+ /**
+ * Returns a map of `"^all" → placeholder DataPacket` for every configured channel.
+ *
+ * Channel placeholders ensure every configured channel is always visible in the Messages
+ * tab — even before any messages have been sent or received — mirroring the behaviour of
+ * `ContactsViewModel.contactList`.
+ */
+ fun buildChannelPlaceholders(channelSet: ChannelSet): Map =
+ (0 until channelSet.settings.size).associate { ch ->
+ "${ch}${DataPacket.ID_BROADCAST}" to
+ DataPacket(bytes = null, dataType = PortNum.TEXT_MESSAGE_APP.value, time = 0L, channel = ch)
+ }
+
+ /**
+ * Converts the merged DB + placeholder map into an ordered [CarContact] list.
+ *
+ * Channels (keys ending with [DataPacket.ID_BROADCAST]) appear first sorted by channel index.
+ * DM conversations follow sorted by [CarContact.lastMessageTime] descending — matching the
+ * ordering used by the phone's Contacts screen.
+ *
+ * @param resolveUser Returns the [User] for a given node-ID string. The caller is responsible
+ * for providing a null-safe fallback (typically [NodeRepository.getUser]).
+ */
+ fun buildCarContacts(
+ merged: Map,
+ myId: String?,
+ channelSet: ChannelSet,
+ resolveUser: (String) -> User,
+ ): List {
+ val all = merged.map { (contactKey, packet) ->
+ val fromLocal = packet.from == DataPacket.ID_LOCAL || packet.from == myId
+ val toBroadcast = packet.to == DataPacket.ID_BROADCAST
+ val userId = if (fromLocal) packet.to else packet.from
+
+ // Resolve the user once; used for both displayName and message prefix.
+ val user = resolveUser(userId ?: DataPacket.ID_BROADCAST)
+
+ val displayName = if (toBroadcast) {
+ channelSet.getChannel(packet.channel)?.name?.takeIf { it.isNotEmpty() }
+ ?: "Channel ${packet.channel}"
+ } else {
+ // userId can be null for malformed packets (e.g. both `from` and `to` are null).
+ // Fall back to a broadcast lookup which returns an "Unknown" user rather than crashing.
+ user.long_name.ifEmpty { user.short_name }.ifEmpty { "Unknown" }
+ }
+
+ // Mirror ContactsViewModel: prefix received DM text with the sender's short name,
+ // matching how ContactItem's ChatMetadata renders lastMessageText.
+ val shortName = if (!toBroadcast) user.short_name else ""
+ val lastMessageText = packet.text?.let { text ->
+ if (fromLocal || shortName.isEmpty()) text else "$shortName: $text"
+ }
+
+ CarContact(
+ contactKey = contactKey,
+ displayName = displayName,
+ unreadCount = 0, // filled in reactively by the screen's flatMapLatest
+ isBroadcast = toBroadcast,
+ channelIndex = packet.channel,
+ lastMessageTime = if (packet.time != 0L) packet.time else null,
+ lastMessageText = lastMessageText,
+ )
+ }
+
+ // partition avoids iterating the list twice.
+ val (channels, dms) = all.partition { it.isBroadcast }
+ return channels.sortedBy { it.channelIndex } +
+ dms.sortedByDescending { it.lastMessageTime ?: 0L }
+ }
+
+ /**
+ * Filters and sorts [nodes] to produce the Favorites tab list.
+ *
+ * Only nodes with [Node.isFavorite] are included. They are sorted alphabetically by
+ * [User.long_name], falling back to [User.short_name] when the long name is empty —
+ * matching the alphabetical sort used by the phone's node list when filtered to favorites.
+ */
+ fun sortFavorites(nodes: Collection): List =
+ nodes
+ .filter { it.isFavorite }
+ .sortedWith(compareBy { it.user.long_name.ifEmpty { it.user.short_name } })
+
+ /**
+ * Returns the primary status line for a favorite-node row (Text 1 in the Car UI row).
+ *
+ * Mirrors NodeItem's signal row:
+ * - `"Online · Direct"` when [Node.hopsAway] == 0
+ * - `"Online · N hops"` when [Node.hopsAway] > 0
+ * - `"Online"` when hop distance is unknown
+ * - `"Offline ·