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 ·