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] 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 ·