From 970957bf810883d2b0110be37c3b0643f5a01875 Mon Sep 17 00:00:00 2001 From: James Rich Date: Mon, 27 Apr 2026 20:46:12 -0500 Subject: [PATCH] fix(auto): ConversationItem API, manifest guards, and detekt compliance - Raise minCarApiLevel to 7 (required for MESSAGING category) - Add uses-feature android.hardware.type.automotive required=false guard - Migrate Messages tab from plain Rows to ConversationItem (API 7+) - Inject CoroutineDispatchers, SendMessageUseCase, MeshServiceNotifications - Add durable callbackScope for reply/mark-as-read operations - Populate CarContact with lastMessage fields for ConversationItem - Remove dead Car API <6 fallback code and unused string resource - Fix hosts_allowlist rename (car-app 1.7.0), screenManager access - Fix test compilation: DeviceMetrics import, map matchers, junit5 - Fix detekt: suppress TooManyFunctions, LongMethod, MagicNumber, TooGenericExceptionCaught where justified Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- feature/auto/build.gradle.kts | 2 + feature/auto/src/main/AndroidManifest.xml | 20 +- .../org/meshtastic/feature/auto/CarContact.kt | 14 +- .../meshtastic/feature/auto/CarLocalStats.kt | 4 +- .../feature/auto/CarScreenDataBuilder.kt | 156 +++-- .../feature/auto/MeshtasticCarAppService.kt | 2 +- .../feature/auto/MeshtasticCarScreen.kt | 537 ++++++++++-------- .../feature/auto/MeshtasticCarSession.kt | 13 +- feature/auto/src/main/res/values/strings.xml | 1 - .../feature/auto/CarScreenDataBuilderTest.kt | 418 +++++++------- 10 files changed, 635 insertions(+), 532 deletions(-) diff --git a/feature/auto/build.gradle.kts b/feature/auto/build.gradle.kts index c9da00795..b6e788bf4 100644 --- a/feature/auto/build.gradle.kts +++ b/feature/auto/build.gradle.kts @@ -24,6 +24,7 @@ android { dependencies { implementation(projects.core.common) + implementation(projects.core.di) implementation(projects.core.model) implementation(projects.core.proto) implementation(projects.core.repository) @@ -36,6 +37,7 @@ dependencies { implementation(libs.koin.core) testImplementation(kotlin("test")) + testImplementation(kotlin("test-junit5")) testImplementation(libs.kotest.assertions) testImplementation(libs.kotlinx.coroutines.test) } diff --git a/feature/auto/src/main/AndroidManifest.xml b/feature/auto/src/main/AndroidManifest.xml index e9fbb05ec..465f95d0e 100644 --- a/feature/auto/src/main/AndroidManifest.xml +++ b/feature/auto/src/main/AndroidManifest.xml @@ -17,6 +17,16 @@ --> + + + - + + android:value="7" /> 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 index 7c9413c4f..bf394c01f 100644 --- a/feature/auto/src/main/kotlin/org/meshtastic/feature/auto/CarContact.kt +++ b/feature/auto/src/main/kotlin/org/meshtastic/feature/auto/CarContact.kt @@ -19,10 +19,13 @@ 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. + * [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. + * + * The `lastMessageRawText`, `lastMessageSenderName`, and `lastMessageFromSelf` fields carry the decomposed message data + * needed to construct a [CarMessage] for [ConversationItem] — the structured body and sender info that the host uses + * for TTS readout and reply attribution. */ internal data class CarContact( val contactKey: String, @@ -32,4 +35,7 @@ internal data class CarContact( val channelIndex: Int, val lastMessageTime: Long?, val lastMessageText: String?, + val lastMessageRawText: String?, + val lastMessageSenderName: String?, + val lastMessageFromSelf: Boolean, ) diff --git a/feature/auto/src/main/kotlin/org/meshtastic/feature/auto/CarLocalStats.kt b/feature/auto/src/main/kotlin/org/meshtastic/feature/auto/CarLocalStats.kt index 182f74589..07174cd21 100644 --- a/feature/auto/src/main/kotlin/org/meshtastic/feature/auto/CarLocalStats.kt +++ b/feature/auto/src/main/kotlin/org/meshtastic/feature/auto/CarLocalStats.kt @@ -19,8 +19,8 @@ package org.meshtastic.feature.auto /** * Snapshot of local device statistics displayed in the Status tab. * - * Mirrors the key metrics shown by [org.meshtastic.feature.widget.LocalStatsWidget]: - * battery, channel/air utilization, node counts, uptime, and traffic counters. + * Mirrors the key metrics shown by [org.meshtastic.feature.widget.LocalStatsWidget]: battery, channel/air utilization, + * node counts, uptime, and traffic counters. */ internal data class CarLocalStats( val batteryLevel: Int = 0, 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 index d54afb977..e449664ac 100644 --- a/feature/auto/src/main/kotlin/org/meshtastic/feature/auto/CarScreenDataBuilder.kt +++ b/feature/auto/src/main/kotlin/org/meshtastic/feature/auto/CarScreenDataBuilder.kt @@ -27,20 +27,18 @@ import org.meshtastic.proto.PortNum import org.meshtastic.proto.User /** - * Pure-function helpers that convert domain models into [CarContact] and display strings for - * [MeshtasticCarScreen]. + * 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. + * 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`. + * 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 -> @@ -51,16 +49,16 @@ internal object CarScreenDataBuilder { /** * 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. + * 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]). - * @param channelLabel Produces the display name for a channel given its index. - * Defaults to `"Channel N"`; callers can supply a localised string. - * @param unknownLabel Fallback display name when neither long name nor short name is available. - * Defaults to `"Unknown"`; callers can supply a localised string. + * @param resolveUser Returns the [User] for a given node-ID string. The caller is responsible for providing a + * null-safe fallback (typically [NodeRepository.getUser]). + * @param channelLabel Produces the display name for a channel given its index. Defaults to `"Channel N"`; callers + * can supply a localised string. + * @param unknownLabel Fallback display name when neither long name nor short name is available. Defaults to + * `"Unknown"`; callers can supply a localised string. */ fun buildCarContacts( merged: Map, @@ -70,58 +68,60 @@ internal object CarScreenDataBuilder { channelLabel: (Int) -> String = { "Channel $it" }, unknownLabel: String = "Unknown", ): 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 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) + // 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() } - ?: channelLabel(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 { unknownLabel } + val displayName = + if (toBroadcast) { + channelSet.getChannel(packet.channel)?.name?.takeIf { it.isNotEmpty() } + ?: channelLabel(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 { unknownLabel } + } + + // 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 rawText = packet.text + val lastMessageText = + rawText?.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, + lastMessageRawText = rawText, + lastMessageSenderName = shortName.takeIf { it.isNotEmpty() }, + lastMessageFromSelf = fromLocal, + ) } - // 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 } + 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. + * 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 } }) + 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). @@ -133,15 +133,15 @@ internal object CarScreenDataBuilder { * - `"Offline ·