mirror of
https://github.com/meshtastic/Meshtastic-Android.git
synced 2026-04-20 22:23:37 +00:00
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:
parent
9f0ead2518
commit
2e74af770b
5 changed files with 851 additions and 225 deletions
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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?,
|
||||
)
|
||||
|
|
@ -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 -> ""
|
||||
}
|
||||
}
|
||||
|
|
@ -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 1–5 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 1–5 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
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue