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>
This commit is contained in:
copilot-swe-agent[bot] 2026-04-17 17:19:47 +00:00 committed by GitHub
parent 9f0ead2518
commit 2e74af770b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 851 additions and 225 deletions

View file

@ -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)
}

View file

@ -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 <https://www.gnu.org/licenses/>.
*/
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?,
)

View file

@ -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 <https://www.gnu.org/licenses/>.
*/
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 `"<ch>^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<String, DataPacket> =
(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<String, DataPacket>,
myId: String?,
channelSet: ChannelSet,
resolveUser: (String) -> User,
): List<CarContact> {
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<Node>): List<Node> =
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 · <time ago>"` when [Node.lastHeard] is set
* - `"Offline"` otherwise
*
* @param formatRelativeTime Converts a millis timestamp to a human-readable "X ago" string.
* Defaults to [DateFormatter.formatRelativeTime]; injectable for testing.
*/
fun nodeStatusText(
node: Node,
formatRelativeTime: (Long) -> String = DateFormatter::formatRelativeTime,
): String = 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) {
append(" · ${formatRelativeTime(node.lastHeard * 1000L)}")
}
}
}
/**
* Returns the secondary detail line for a favorite-node row (Text 2 in the Car UI row).
*
* Mirrors NodeItem's battery row + node chip: `"NODE · 85%"`.
* Returns an empty string when neither short name nor battery level is available.
*/
fun nodeDetailText(node: Node): String = buildString {
val shortName = node.user.short_name
if (shortName.isNotEmpty()) append(shortName)
val battery = node.batteryStr
if (battery.isNotEmpty()) {
if (isNotEmpty()) append(" · ")
append(battery)
}
}
/**
* Returns the message preview line for a contact row (Text 1 in the Car UI row).
*
* Mirrors `ChatMetadata`'s `lastMessageText` display: shows the last message text
* (with sender prefix for received DMs), or `"No messages yet"` for empty channels.
*/
fun contactPreviewText(contact: CarContact): String =
contact.lastMessageText?.takeIf { it.isNotEmpty() } ?: "No messages yet"
/**
* Returns the secondary metadata line for a contact row (Text 2 in the Car UI row).
*
* Mirrors ContactItem's unread badge + date header:
* - `"N unread"` when there are unread messages
* - Formatted short date of the last message otherwise
* - Empty string when there are no messages at all
*
* @param formatShortDate Converts a millis timestamp to a short date string.
* Defaults to [DateFormatter.formatShortDate]; injectable for testing.
*/
fun contactSecondaryText(
contact: CarContact,
formatShortDate: (Long) -> String = DateFormatter::formatShortDate,
): String = when {
contact.unreadCount > 0 -> "${contact.unreadCount} unread"
contact.lastMessageTime != null -> formatShortDate(contact.lastMessageTime)
else -> ""
}
}

View file

@ -45,17 +45,12 @@ 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
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.ChannelSet
import org.meshtastic.proto.PortNum
/**
* Root screen displayed in Android Auto.
@ -68,6 +63,10 @@ import org.meshtastic.proto.PortNum
* sorted by most-recent message descending. This is the same ordering used by
* [org.meshtastic.feature.messaging.ui.contact.ContactsViewModel].
*
* Pure business-logic (contact ordering, row text, favourites sorting) is separated into
* [CarScreenDataBuilder], which is free of Car App Library dependencies and is unit-tested
* independently.
*
* `TabTemplate` requires Car API level 6. On hosts running Car API level 15 the screen falls
* back to a single [ListTemplate] that includes a status row, favorite-node rows, and the
* contact list.
@ -132,23 +131,23 @@ class MeshtasticCarScreen(carContext: CarContext) :
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)
// visible even before any messages have been sent/received.
val placeholders = CarScreenDataBuilder.buildChannelPlaceholders(channelSet)
// Real DB entries take precedence over placeholders when present.
val merged = rawContacts + (placeholders - rawContacts.keys)
buildCarContacts(merged, myId, channelSet)
CarScreenDataBuilder.buildCarContacts(merged, myId, channelSet) { userId ->
nodeRepository.getUser(userId)
}
}
.distinctUntilChanged()
.flatMapLatest { baseContacts ->
if (baseContacts.isEmpty()) {
flowOf(emptyList())
} else {
val unreadFlows =
baseContacts.map { contact ->
packetRepository.getUnreadCountFlow(contact.contactKey)
.map { unread -> contact.copy(unreadCount = unread) }
}
val unreadFlows = baseContacts.map { contact ->
packetRepository.getUnreadCountFlow(contact.contactKey)
.map { unread -> contact.copy(unreadCount = unread) }
}
combine(unreadFlows) { it.toList() }
}
}
@ -167,14 +166,10 @@ class MeshtasticCarScreen(carContext: CarContext) :
}
}
// Favorite nodes — filter nodeDBbyNum to isFavorite, sort alphabetically.
// Favorite nodes — filter to isFavorite only, sort alphabetically.
scope.launch {
nodeRepository.nodeDBbyNum
.map { db ->
db.values
.filter { it.isFavorite }
.sortedWith(compareBy { it.user.long_name.ifEmpty { it.user.short_name } })
}
.map { db -> CarScreenDataBuilder.sortFavorites(db.values) }
.distinctUntilChanged()
.collect { nodes ->
favorites = nodes
@ -183,70 +178,6 @@ class MeshtasticCarScreen(carContext: CarContext) :
}
}
/** Returns a map of `"<ch>^all" → placeholder DataPacket` for every configured channel. */
private fun buildChannelPlaceholders(channelSet: ChannelSet): Map<String, DataPacket> =
(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<String, DataPacket>,
myId: String?,
channelSet: ChannelSet,
): List<CarContact> {
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 = nodeRepository.getUser(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 flatMapLatest below
isBroadcast = toBroadcast,
channelIndex = packet.channel,
lastMessageTime = if (packet.time != 0L) packet.time else null,
lastMessageText = lastMessageText,
)
}
return all.filter { it.isBroadcast }.sortedBy { it.channelIndex } +
all.filter { !it.isBroadcast }.sortedByDescending { it.lastMessageTime ?: 0L }
}
// ---- Template building ----
override fun onGetTemplate(): Template {
@ -265,44 +196,24 @@ class MeshtasticCarScreen(carContext: CarContext) :
return buildFallbackListTemplate()
}
val tabCallback =
object : TabTemplate.TabCallback {
override fun onTabSelected(tabContentId: String) {
activeTabId = tabContentId
invalidate()
}
val tabCallback = object : TabTemplate.TabCallback {
override fun onTabSelected(tabContentId: String) {
activeTabId = tabContentId
invalidate()
}
}
val activeContent =
when (activeTabId) {
TAB_FAVORITES -> TabContents.Builder(buildFavoritesTemplate()).build()
TAB_MESSAGES -> TabContents.Builder(buildMessagesTemplate()).build()
else -> TabContents.Builder(buildStatusTemplate()).build()
}
val activeContent = when (activeTabId) {
TAB_FAVORITES -> TabContents.Builder(buildFavoritesTemplate()).build()
TAB_MESSAGES -> TabContents.Builder(buildMessagesTemplate()).build()
else -> TabContents.Builder(buildStatusTemplate()).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("Messages")
.setIcon(carIcon(R.drawable.auto_ic_channels))
.setContentId(TAB_MESSAGES)
.build(),
)
.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("Messages").setIcon(carIcon(R.drawable.auto_ic_channels)).setContentId(TAB_MESSAGES).build())
.setTabContents(activeContent)
.setActiveTabContentId(activeTabId)
.build()
@ -338,79 +249,15 @@ class MeshtasticCarScreen(carContext: CarContext) :
* 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()
}
private fun buildFavoritesTemplate(): ListTemplate =
buildListTemplate("Favorites", favorites, "No favorite nodes") { buildFavoriteNodeRow(it) }
/**
* 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()
}
private fun buildMessagesTemplate(): ListTemplate =
buildListTemplate("Messages", contacts, "No conversations") { buildContactRow(it) }
/**
* Fallback for Car API level 15 hosts that do not support [TabTemplate].
@ -439,17 +286,34 @@ class MeshtasticCarScreen(carContext: CarContext) :
}
private fun buildStatusRow(): Row {
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.orEmpty()
return Row.Builder()
.setTitle(statusText)
.apply { if (deviceName.isNotEmpty()) addText(deviceName) }
.addTextIfNotEmpty(deviceName)
.setBrowsable(false)
.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" }
return Row.Builder()
.setTitle(name)
.addText(CarScreenDataBuilder.nodeStatusText(node))
.addTextIfNotEmpty(CarScreenDataBuilder.nodeDetailText(node))
.setBrowsable(false)
.build()
}
@ -464,48 +328,41 @@ class MeshtasticCarScreen(carContext: CarContext) :
* - **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 {
// 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()
private fun buildContactRow(contact: CarContact): Row =
Row.Builder()
.setTitle(contact.displayName)
.addText(preview)
.apply { if (secondaryText.isNotEmpty()) addText(secondaryText) }
.addText(CarScreenDataBuilder.contactPreviewText(contact))
.addTextIfNotEmpty(CarScreenDataBuilder.contactSecondaryText(contact))
.setBrowsable(false)
.build()
}
private fun carIcon(resId: Int) =
CarIcon.Builder(IconCompat.createWithResource(carContext, resId)).setTint(CarColor.DEFAULT).build()
// ---- Internal model ----
/** Adds [text] as a new text line only when it is non-empty, avoiding blank Car UI rows. */
private fun Row.Builder.addTextIfNotEmpty(text: String): Row.Builder =
apply { if (text.isNotEmpty()) addText(text) }
/**
* Lightweight projection of a conversation used exclusively within this screen.
* DRY helper: builds a [ListTemplate] from a list of items, capping at [MAX_LIST_ITEMS].
*
* [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.
* Shows [noItemsMessage] when the list is empty.
*/
private data class CarContact(
val contactKey: String,
val displayName: String,
val unreadCount: Int,
val isBroadcast: Boolean,
val channelIndex: Int,
val lastMessageTime: Long?,
val lastMessageText: String?,
)
private fun <T> buildListTemplate(
title: String,
items: List<T>,
noItemsMessage: String,
buildRow: (T) -> Row,
): ListTemplate {
val listBuilder = ItemList.Builder()
val capped = items.take(MAX_LIST_ITEMS)
if (capped.isEmpty()) {
listBuilder.setNoItemsMessage(noItemsMessage)
} else {
capped.forEach { listBuilder.addItem(buildRow(it)) }
}
return ListTemplate.Builder().setTitle(title).setSingleList(listBuilder.build()).build()
}
companion object {
private const val TAB_STATUS = "status"
@ -520,4 +377,3 @@ class MeshtasticCarScreen(carContext: CarContext) :
private const val MAX_LIST_ITEMS = 6
}
}

View file

@ -0,0 +1,538 @@
/*
* 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 <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.feature.auto
import io.kotest.matchers.collections.shouldBeEmpty
import io.kotest.matchers.collections.shouldHaveSize
import io.kotest.matchers.shouldBe
import io.kotest.matchers.string.shouldBeEmpty
import io.kotest.matchers.string.shouldContain
import io.kotest.matchers.string.shouldNotContain
import org.meshtastic.core.model.DataPacket
import org.meshtastic.core.model.DeviceMetrics
import org.meshtastic.core.model.Node
import org.meshtastic.proto.ChannelSet
import org.meshtastic.proto.ChannelSettings
import org.meshtastic.proto.User
import kotlin.test.Test
/**
* Unit tests for [CarScreenDataBuilder].
*
* All tests are pure JVM no Android framework or Car App Library dependencies required.
* Time formatters are injected as lambdas returning fixed strings to keep assertions deterministic.
*/
class CarScreenDataBuilderTest {
// ---- buildChannelPlaceholders ----
@Test
fun `buildChannelPlaceholders - empty channelSet returns empty map`() {
val result = CarScreenDataBuilder.buildChannelPlaceholders(ChannelSet())
result.shouldBeEmpty()
}
@Test
fun `buildChannelPlaceholders - single channel produces correct contact key`() {
val channelSet = ChannelSet(settings = listOf(ChannelSettings(name = "LongFast")))
val result = CarScreenDataBuilder.buildChannelPlaceholders(channelSet)
result.keys shouldBe setOf("0${DataPacket.ID_BROADCAST}")
}
@Test
fun `buildChannelPlaceholders - three channels produce three distinct keys`() {
val channelSet = ChannelSet(
settings = listOf(
ChannelSettings(name = "Ch0"),
ChannelSettings(name = "Ch1"),
ChannelSettings(name = "Ch2"),
),
)
val result = CarScreenDataBuilder.buildChannelPlaceholders(channelSet)
result shouldHaveSize 3
result.keys shouldBe setOf("0^all", "1^all", "2^all")
}
@Test
fun `buildChannelPlaceholders - placeholder packets have zero time`() {
val channelSet = ChannelSet(settings = listOf(ChannelSettings(name = "Test")))
val packet = CarScreenDataBuilder.buildChannelPlaceholders(channelSet).values.first()
packet.time shouldBe 0L
packet.to shouldBe DataPacket.ID_BROADCAST
}
// ---- buildCarContacts - display names ----
@Test
fun `buildCarContacts - broadcast contact uses channel name from channelSet`() {
val channelSet = ChannelSet(settings = listOf(ChannelSettings(name = "LongFast")))
val packet = DataPacket(bytes = null, dataType = 1, time = 1000L, channel = 0).apply {
to = DataPacket.ID_BROADCAST
from = DataPacket.ID_LOCAL
}
val contacts = CarScreenDataBuilder.buildCarContacts(
merged = mapOf("0^all" to packet),
myId = "!aabbccdd",
channelSet = channelSet,
resolveUser = { User() },
)
contacts shouldHaveSize 1
contacts[0].displayName shouldBe "LongFast"
contacts[0].isBroadcast shouldBe true
}
@Test
fun `buildCarContacts - broadcast contact uses Channel N fallback when name is empty`() {
val channelSet = ChannelSet(settings = listOf(ChannelSettings(name = "")))
val packet = DataPacket(bytes = null, dataType = 1, time = 1000L, channel = 0).apply {
to = DataPacket.ID_BROADCAST
from = DataPacket.ID_LOCAL
}
val contacts = CarScreenDataBuilder.buildCarContacts(
merged = mapOf("0^all" to packet),
myId = null,
channelSet = channelSet,
resolveUser = { User() },
)
contacts[0].displayName shouldBe "Channel 0"
}
@Test
fun `buildCarContacts - DM contact uses sender long name`() {
val senderUser = User(id = "!sender", long_name = "Alice Tester", short_name = "ALIC")
val packet = DataPacket(bytes = null, dataType = 1, time = 2000L, channel = 0).apply {
to = "!localnode"
from = "!sender"
}
val contacts = CarScreenDataBuilder.buildCarContacts(
merged = mapOf("!sender" to packet),
myId = "!localnode",
channelSet = ChannelSet(),
resolveUser = { if (it == "!sender") senderUser else User() },
)
contacts[0].displayName shouldBe "Alice Tester"
contacts[0].isBroadcast shouldBe false
}
@Test
fun `buildCarContacts - DM contact falls back to short name when long name is blank`() {
val senderUser = User(id = "!sender", long_name = "", short_name = "ALIC")
val packet = DataPacket(bytes = null, dataType = 1, time = 2000L, channel = 0).apply {
to = "!localnode"
from = "!sender"
}
val contacts = CarScreenDataBuilder.buildCarContacts(
merged = mapOf("!sender" to packet),
myId = "!localnode",
channelSet = ChannelSet(),
resolveUser = { senderUser },
)
contacts[0].displayName shouldBe "ALIC"
}
// ---- buildCarContacts - lastMessageText ----
@Test
fun `buildCarContacts - received DM prefixes lastMessageText with sender short name`() {
val senderUser = User(id = "!sender", long_name = "Alice", short_name = "ALIC")
val packet = DataPacket(to = "!me", channel = 0, text = "Hello!")
packet.from = "!sender"
val contacts = CarScreenDataBuilder.buildCarContacts(
merged = mapOf("!sender" to packet),
myId = "!me",
channelSet = ChannelSet(),
resolveUser = { senderUser },
)
contacts[0].lastMessageText shouldBe "ALIC: Hello!"
}
@Test
fun `buildCarContacts - sent DM does not prefix lastMessageText`() {
val recipientUser = User(id = "!bob", long_name = "Bob", short_name = "BOB")
val packet = DataPacket(to = "!bob", channel = 0, text = "Hey Bob")
packet.from = "!me"
val contacts = CarScreenDataBuilder.buildCarContacts(
merged = mapOf("!bob" to packet),
myId = "!me",
channelSet = ChannelSet(),
resolveUser = { recipientUser },
)
// Sent message — no prefix
contacts[0].lastMessageText shouldBe "Hey Bob"
}
@Test
fun `buildCarContacts - null packet text yields null lastMessageText`() {
val packet = DataPacket(bytes = null, dataType = 1, time = 1000L, channel = 0).apply {
to = DataPacket.ID_BROADCAST
from = DataPacket.ID_LOCAL
}
val channelSet = ChannelSet(settings = listOf(ChannelSettings(name = "Ch0")))
val contacts = CarScreenDataBuilder.buildCarContacts(
merged = mapOf("0^all" to packet),
myId = null,
channelSet = channelSet,
resolveUser = { User() },
)
contacts[0].lastMessageText shouldBe null
}
// ---- buildCarContacts - ordering ----
@Test
fun `buildCarContacts - channel contacts appear before DM contacts`() {
val channelSet = ChannelSet(settings = listOf(ChannelSettings(name = "Ch0")))
val channelPacket = DataPacket(bytes = null, dataType = 1, time = 1000L, channel = 0).apply {
to = DataPacket.ID_BROADCAST
from = DataPacket.ID_LOCAL
}
val dmPacket = DataPacket(bytes = null, dataType = 1, time = 2000L, channel = 0).apply {
to = "!me"
from = "!alice"
}
val contacts = CarScreenDataBuilder.buildCarContacts(
merged = mapOf("!alice" to dmPacket, "0^all" to channelPacket),
myId = "!me",
channelSet = channelSet,
resolveUser = { User() },
)
contacts[0].isBroadcast shouldBe true
contacts[1].isBroadcast shouldBe false
}
@Test
fun `buildCarContacts - channels are sorted by channelIndex ascending`() {
val channelSet = ChannelSet(
settings = listOf(
ChannelSettings(name = "Ch0"),
ChannelSettings(name = "Ch1"),
ChannelSettings(name = "Ch2"),
),
)
// Insert in reverse order to verify sorting is applied
val packets = mapOf(
"2^all" to makeChannelPacket(ch = 2),
"0^all" to makeChannelPacket(ch = 0),
"1^all" to makeChannelPacket(ch = 1),
)
val contacts = CarScreenDataBuilder.buildCarContacts(
merged = packets,
myId = null,
channelSet = channelSet,
resolveUser = { User() },
)
contacts.map { it.channelIndex } shouldBe listOf(0, 1, 2)
}
@Test
fun `buildCarContacts - DMs are sorted by lastMessageTime descending`() {
val dmOld = makeDmPacket(from = "!alice", to = "!me", time = 1_000L)
val dmNew = makeDmPacket(from = "!bob", to = "!me", time = 3_000L)
val dmMid = makeDmPacket(from = "!carol", to = "!me", time = 2_000L)
val contacts = CarScreenDataBuilder.buildCarContacts(
merged = mapOf("!alice" to dmOld, "!carol" to dmMid, "!bob" to dmNew),
myId = "!me",
channelSet = ChannelSet(),
resolveUser = { userId ->
when (userId) {
"!alice" -> User(id = "!alice", long_name = "Alice")
"!bob" -> User(id = "!bob", long_name = "Bob")
"!carol" -> User(id = "!carol", long_name = "Carol")
else -> User()
}
},
)
contacts.map { it.displayName } shouldBe listOf("Bob", "Carol", "Alice")
}
// ---- sortFavorites ----
@Test
fun `sortFavorites - excludes non-favorite nodes`() {
val nodes = listOf(
Node(num = 1, user = User(long_name = "Alice"), isFavorite = false),
Node(num = 2, user = User(long_name = "Bob"), isFavorite = true),
)
val result = CarScreenDataBuilder.sortFavorites(nodes)
result shouldHaveSize 1
result[0].user.long_name shouldBe "Bob"
}
@Test
fun `sortFavorites - results are sorted alphabetically by long name`() {
val nodes = listOf(
Node(num = 3, user = User(long_name = "Charlie"), isFavorite = true),
Node(num = 1, user = User(long_name = "Alice"), isFavorite = true),
Node(num = 2, user = User(long_name = "Bob"), isFavorite = true),
)
val result = CarScreenDataBuilder.sortFavorites(nodes)
result.map { it.user.long_name } shouldBe listOf("Alice", "Bob", "Charlie")
}
@Test
fun `sortFavorites - falls back to short name when long name is empty`() {
val nodes = listOf(
Node(num = 2, user = User(long_name = "", short_name = "ZZZ"), isFavorite = true),
Node(num = 1, user = User(long_name = "", short_name = "AAA"), isFavorite = true),
)
val result = CarScreenDataBuilder.sortFavorites(nodes)
result[0].user.short_name shouldBe "AAA"
result[1].user.short_name shouldBe "ZZZ"
}
@Test
fun `sortFavorites - empty collection returns empty list`() {
CarScreenDataBuilder.sortFavorites(emptyList()).shouldBeEmpty()
}
// ---- nodeStatusText ----
@Test
fun `nodeStatusText - online node with hopsAway 0 shows Direct`() {
val node = onlineNode(hopsAway = 0)
val text = CarScreenDataBuilder.nodeStatusText(node)
text shouldContain "Online"
text shouldContain "Direct"
}
@Test
fun `nodeStatusText - online node with 2 hops shows hops count`() {
val node = onlineNode(hopsAway = 2)
val text = CarScreenDataBuilder.nodeStatusText(node)
text shouldContain "Online"
text shouldContain "2 hops"
}
@Test
fun `nodeStatusText - online node with unknown hops shows just Online`() {
val node = onlineNode(hopsAway = -1)
val text = CarScreenDataBuilder.nodeStatusText(node)
text shouldBe "Online"
}
@Test
fun `nodeStatusText - offline node with no lastHeard shows just Offline`() {
val node = Node(num = 1, user = User(long_name = "Test"), isFavorite = true, lastHeard = 0)
val text = CarScreenDataBuilder.nodeStatusText(node)
text shouldBe "Offline"
}
@Test
fun `nodeStatusText - offline node with lastHeard calls formatRelativeTime`() {
val node = Node(num = 1, user = User(long_name = "Test"), isFavorite = true, lastHeard = 12345)
val text = CarScreenDataBuilder.nodeStatusText(node, formatRelativeTime = { "3h ago" })
text shouldBe "Offline · 3h ago"
}
@Test
fun `nodeStatusText - formatRelativeTime receives lastHeard in millis`() {
val lastHeardSecs = 100_000
var receivedMillis = 0L
val node = Node(num = 1, user = User(long_name = "Test"), isFavorite = true, lastHeard = lastHeardSecs)
CarScreenDataBuilder.nodeStatusText(node, formatRelativeTime = { millis ->
receivedMillis = millis
"ago"
})
receivedMillis shouldBe lastHeardSecs * 1000L
}
// ---- nodeDetailText ----
@Test
fun `nodeDetailText - shows short name and battery separated by bullet`() {
val node = Node(
num = 1,
user = User(long_name = "Alice", short_name = "ALIC"),
isFavorite = true,
deviceMetrics = DeviceMetrics(battery_level = 85),
)
val text = CarScreenDataBuilder.nodeDetailText(node)
text shouldContain "ALIC"
text shouldContain "85%"
text shouldContain "·"
}
@Test
fun `nodeDetailText - shows only short name when no battery data`() {
val node = Node(
num = 1,
user = User(long_name = "Alice", short_name = "ALIC"),
isFavorite = true,
)
val text = CarScreenDataBuilder.nodeDetailText(node)
text shouldBe "ALIC"
}
@Test
fun `nodeDetailText - returns empty string when no short name and no battery`() {
val node = Node(num = 1, user = User(long_name = "Alice", short_name = ""), isFavorite = true)
CarScreenDataBuilder.nodeDetailText(node).shouldBeEmpty()
}
@Test
fun `nodeDetailText - shows only battery when short name is blank`() {
val node = Node(
num = 1,
user = User(long_name = "Alice", short_name = ""),
isFavorite = true,
deviceMetrics = DeviceMetrics(battery_level = 72),
)
val text = CarScreenDataBuilder.nodeDetailText(node)
text shouldBe "72%"
text shouldNotContain "·"
}
// ---- contactPreviewText ----
@Test
fun `contactPreviewText - returns lastMessageText when present`() {
val contact = makeCarContact(lastMessageText = "Hello world")
CarScreenDataBuilder.contactPreviewText(contact) shouldBe "Hello world"
}
@Test
fun `contactPreviewText - returns No messages yet when lastMessageText is null`() {
val contact = makeCarContact(lastMessageText = null)
CarScreenDataBuilder.contactPreviewText(contact) shouldBe "No messages yet"
}
@Test
fun `contactPreviewText - returns No messages yet when lastMessageText is empty`() {
val contact = makeCarContact(lastMessageText = "")
CarScreenDataBuilder.contactPreviewText(contact) shouldBe "No messages yet"
}
// ---- contactSecondaryText ----
@Test
fun `contactSecondaryText - shows N unread when unreadCount is positive`() {
val contact = makeCarContact(unreadCount = 5)
CarScreenDataBuilder.contactSecondaryText(contact) shouldBe "5 unread"
}
@Test
fun `contactSecondaryText - calls formatShortDate when no unread but time is set`() {
val contact = makeCarContact(unreadCount = 0, lastMessageTime = 999_999L)
val text = CarScreenDataBuilder.contactSecondaryText(contact, formatShortDate = { "Jan 1" })
text shouldBe "Jan 1"
}
@Test
fun `contactSecondaryText - formatShortDate receives the exact lastMessageTime`() {
val timestamp = 123_456_789L
val contact = makeCarContact(unreadCount = 0, lastMessageTime = timestamp)
var received = 0L
CarScreenDataBuilder.contactSecondaryText(contact, formatShortDate = { millis ->
received = millis
"date"
})
received shouldBe timestamp
}
@Test
fun `contactSecondaryText - returns empty string when no unread and no lastMessageTime`() {
val contact = makeCarContact(unreadCount = 0, lastMessageTime = null)
CarScreenDataBuilder.contactSecondaryText(contact).shouldBeEmpty()
}
@Test
fun `contactSecondaryText - unread takes precedence over lastMessageTime`() {
val contact = makeCarContact(unreadCount = 3, lastMessageTime = 500L)
CarScreenDataBuilder.contactSecondaryText(contact, formatShortDate = { "should not appear" }) shouldBe "3 unread"
}
// ---- helpers ----
/** Returns a node guaranteed to be online (lastHeard == now). */
private fun onlineNode(hopsAway: Int): Node {
val nowSecs = (System.currentTimeMillis() / 1000).toInt()
return Node(
num = 1,
user = User(long_name = "Test"),
isFavorite = true,
lastHeard = nowSecs,
hopsAway = hopsAway,
)
}
private fun makeChannelPacket(ch: Int): DataPacket =
DataPacket(bytes = null, dataType = 1, time = 1000L, channel = ch).apply {
to = DataPacket.ID_BROADCAST
from = DataPacket.ID_LOCAL
}
private fun makeDmPacket(from: String, to: String, time: Long): DataPacket =
DataPacket(bytes = null, dataType = 1, time = time, channel = 0).apply {
this.from = from
this.to = to
}
private fun makeCarContact(
contactKey: String = "!test",
displayName: String = "Test",
unreadCount: Int = 0,
isBroadcast: Boolean = false,
channelIndex: Int = 0,
lastMessageTime: Long? = null,
lastMessageText: String? = null,
) = CarContact(
contactKey = contactKey,
displayName = displayName,
unreadCount = unreadCount,
isBroadcast = isBroadcast,
channelIndex = channelIndex,
lastMessageTime = lastMessageTime,
lastMessageText = lastMessageText,
)
}