fix(auto): ConversationItem API, manifest guards, and detekt compliance

- Raise minCarApiLevel to 7 (required for MESSAGING category)
- Add uses-feature android.hardware.type.automotive required=false guard
- Migrate Messages tab from plain Rows to ConversationItem (API 7+)
- Inject CoroutineDispatchers, SendMessageUseCase, MeshServiceNotifications
- Add durable callbackScope for reply/mark-as-read operations
- Populate CarContact with lastMessage fields for ConversationItem
- Remove dead Car API <6 fallback code and unused string resource
- Fix hosts_allowlist rename (car-app 1.7.0), screenManager access
- Fix test compilation: DeviceMetrics import, map matchers, junit5
- Fix detekt: suppress TooManyFunctions, LongMethod, MagicNumber,
  TooGenericExceptionCaught where justified

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
James Rich
2026-04-27 20:46:12 -05:00
parent 483ab96c9b
commit 970957bf81
10 changed files with 635 additions and 532 deletions

View File

@@ -24,6 +24,7 @@ android {
dependencies {
implementation(projects.core.common)
implementation(projects.core.di)
implementation(projects.core.model)
implementation(projects.core.proto)
implementation(projects.core.repository)
@@ -36,6 +37,7 @@ dependencies {
implementation(libs.koin.core)
testImplementation(kotlin("test"))
testImplementation(kotlin("test-junit5"))
testImplementation(libs.kotest.assertions)
testImplementation(libs.kotlinx.coroutines.test)
}

View File

@@ -17,6 +17,16 @@
-->
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!--
This app is an Android Auto (phone-projection) messaging app — it is NOT designed
to run standalone on Android Automotive OS (AAOS). Declaring the automotive hardware
feature as not required prevents the APK from being offered on AAOS devices.
(Play Console device-targeting should also exclude AAOS form factors.)
-->
<uses-feature
android:name="android.hardware.type.automotive"
android:required="false" />
<!-- Android Auto Car App Service for browsable messaging UI -->
<application>
<service
@@ -29,13 +39,13 @@
</intent-filter>
</service>
<!-- Car API level 1 is sufficient for MessagingStyle notification projection and
ListTemplate. The browsable TabTemplate UI requires Car API 6; the screen
detects the host's level at runtime and falls back to a ListTemplate on
older hosts so the app remains usable on all vehicles. -->
<!-- Car API 7 is the minimum for ConversationItem, which is required for the
MESSAGING category's templated experience. Hosts running older API levels
still receive the MessagingStyle notification-based experience; only the
browsable template UI requires API 7. -->
<meta-data
android:name="androidx.car.app.minCarApiLevel"
android:value="1" />
android:value="7" />
</application>
</manifest>

View File

@@ -19,10 +19,13 @@ package org.meshtastic.feature.auto
/**
* Lightweight projection of a conversation used exclusively within [MeshtasticCarScreen].
*
* [isBroadcast] and [channelIndex] drive ordering (channels before DMs, channels sorted by
* index). [lastMessageTime] drives DM ordering (most-recent first).
* [lastMessageText] mirrors `ContactsViewModel.contactList`'s `lastMessageText` — received
* DMs are prefixed with the sender's short name, matching `ContactItem`'s ChatMetadata display.
* [isBroadcast] and [channelIndex] drive ordering (channels before DMs, channels sorted by index). [lastMessageTime]
* drives DM ordering (most-recent first). [lastMessageText] mirrors `ContactsViewModel.contactList`'s `lastMessageText`
* — received DMs are prefixed with the sender's short name, matching `ContactItem`'s ChatMetadata display.
*
* The `lastMessageRawText`, `lastMessageSenderName`, and `lastMessageFromSelf` fields carry the decomposed message data
* needed to construct a [CarMessage] for [ConversationItem] — the structured body and sender info that the host uses
* for TTS readout and reply attribution.
*/
internal data class CarContact(
val contactKey: String,
@@ -32,4 +35,7 @@ internal data class CarContact(
val channelIndex: Int,
val lastMessageTime: Long?,
val lastMessageText: String?,
val lastMessageRawText: String?,
val lastMessageSenderName: String?,
val lastMessageFromSelf: Boolean,
)

View File

@@ -19,8 +19,8 @@ package org.meshtastic.feature.auto
/**
* Snapshot of local device statistics displayed in the Status tab.
*
* Mirrors the key metrics shown by [org.meshtastic.feature.widget.LocalStatsWidget]:
* battery, channel/air utilization, node counts, uptime, and traffic counters.
* Mirrors the key metrics shown by [org.meshtastic.feature.widget.LocalStatsWidget]: battery, channel/air utilization,
* node counts, uptime, and traffic counters.
*/
internal data class CarLocalStats(
val batteryLevel: Int = 0,

View File

@@ -27,20 +27,18 @@ import org.meshtastic.proto.PortNum
import org.meshtastic.proto.User
/**
* Pure-function helpers that convert domain models into [CarContact] and display strings for
* [MeshtasticCarScreen].
* Pure-function helpers that convert domain models into [CarContact] and display strings for [MeshtasticCarScreen].
*
* All methods are free of Car App Library dependencies, making them straightforwardly testable as
* plain JVM unit tests without Robolectric.
* All methods are free of Car App Library dependencies, making them straightforwardly testable as plain JVM unit tests
* without Robolectric.
*/
internal object CarScreenDataBuilder {
/**
* Returns a map of `"<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`.
* 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 ->
@@ -51,16 +49,16 @@ internal object CarScreenDataBuilder {
/**
* Converts the merged DB + placeholder map into an ordered [CarContact] list.
*
* Channels (keys ending with [DataPacket.ID_BROADCAST]) appear first sorted by channel index.
* DM conversations follow sorted by [CarContact.lastMessageTime] descending — matching the
* ordering used by the phone's Contacts screen.
* Channels (keys ending with [DataPacket.ID_BROADCAST]) appear first sorted by channel index. DM conversations
* follow sorted by [CarContact.lastMessageTime] descending — matching the ordering used by the phone's Contacts
* screen.
*
* @param resolveUser Returns the [User] for a given node-ID string. The caller is responsible
* for providing a null-safe fallback (typically [NodeRepository.getUser]).
* @param channelLabel Produces the display name for a channel given its index.
* Defaults to `"Channel N"`; callers can supply a localised string.
* @param unknownLabel Fallback display name when neither long name nor short name is available.
* Defaults to `"Unknown"`; callers can supply a localised string.
* @param resolveUser Returns the [User] for a given node-ID string. The caller is responsible for providing a
* null-safe fallback (typically [NodeRepository.getUser]).
* @param channelLabel Produces the display name for a channel given its index. Defaults to `"Channel N"`; callers
* can supply a localised string.
* @param unknownLabel Fallback display name when neither long name nor short name is available. Defaults to
* `"Unknown"`; callers can supply a localised string.
*/
fun buildCarContacts(
merged: Map<String, DataPacket>,
@@ -70,58 +68,60 @@ internal object CarScreenDataBuilder {
channelLabel: (Int) -> String = { "Channel $it" },
unknownLabel: String = "Unknown",
): 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
val all =
merged.map { (contactKey, packet) ->
val fromLocal = packet.from == DataPacket.ID_LOCAL || packet.from == myId
val toBroadcast = packet.to == DataPacket.ID_BROADCAST
val userId = if (fromLocal) packet.to else packet.from
// Resolve the user once; used for both displayName and message prefix.
val user = resolveUser(userId ?: DataPacket.ID_BROADCAST)
// Resolve the user once; used for both displayName and message prefix.
val user = resolveUser(userId ?: DataPacket.ID_BROADCAST)
val displayName = if (toBroadcast) {
channelSet.getChannel(packet.channel)?.name?.takeIf { it.isNotEmpty() }
?: channelLabel(packet.channel)
} else {
// userId can be null for malformed packets (e.g. both `from` and `to` are null).
// Fall back to a broadcast lookup which returns an "Unknown" user rather than crashing.
user.long_name.ifEmpty { user.short_name }.ifEmpty { unknownLabel }
val displayName =
if (toBroadcast) {
channelSet.getChannel(packet.channel)?.name?.takeIf { it.isNotEmpty() }
?: channelLabel(packet.channel)
} else {
// userId can be null for malformed packets (e.g. both `from` and `to` are null).
// Fall back to a broadcast lookup which returns an "Unknown" user rather than crashing.
user.long_name.ifEmpty { user.short_name }.ifEmpty { unknownLabel }
}
// Mirror ContactsViewModel: prefix received DM text with the sender's short name,
// matching how ContactItem's ChatMetadata renders lastMessageText.
val shortName = if (!toBroadcast) user.short_name else ""
val rawText = packet.text
val lastMessageText =
rawText?.let { text -> if (fromLocal || shortName.isEmpty()) text else "$shortName: $text" }
CarContact(
contactKey = contactKey,
displayName = displayName,
unreadCount = 0, // filled in reactively by the screen's flatMapLatest
isBroadcast = toBroadcast,
channelIndex = packet.channel,
lastMessageTime = if (packet.time != 0L) packet.time else null,
lastMessageText = lastMessageText,
lastMessageRawText = rawText,
lastMessageSenderName = shortName.takeIf { it.isNotEmpty() },
lastMessageFromSelf = fromLocal,
)
}
// Mirror ContactsViewModel: prefix received DM text with the sender's short name,
// matching how ContactItem's ChatMetadata renders lastMessageText.
val shortName = if (!toBroadcast) user.short_name else ""
val lastMessageText = packet.text?.let { text ->
if (fromLocal || shortName.isEmpty()) text else "$shortName: $text"
}
CarContact(
contactKey = contactKey,
displayName = displayName,
unreadCount = 0, // filled in reactively by the screen's flatMapLatest
isBroadcast = toBroadcast,
channelIndex = packet.channel,
lastMessageTime = if (packet.time != 0L) packet.time else null,
lastMessageText = lastMessageText,
)
}
// partition avoids iterating the list twice.
val (channels, dms) = all.partition { it.isBroadcast }
return channels.sortedBy { it.channelIndex } +
dms.sortedByDescending { it.lastMessageTime ?: 0L }
return channels.sortedBy { it.channelIndex } + dms.sortedByDescending { it.lastMessageTime ?: 0L }
}
/**
* Filters and sorts [nodes] to produce the Favorites tab list.
*
* Only nodes with [Node.isFavorite] are included. They are sorted alphabetically by
* [User.long_name], falling back to [User.short_name] when the long name is empty —
* matching the alphabetical sort used by the phone's node list when filtered to favorites.
* Only nodes with [Node.isFavorite] are included. They are sorted alphabetically by [User.long_name], falling back
* to [User.short_name] when the long name is empty — matching the alphabetical sort used by the phone's node list
* when filtered to favorites.
*/
fun sortFavorites(nodes: Collection<Node>): List<Node> =
nodes
.filter { it.isFavorite }
.sortedWith(compareBy { it.user.long_name.ifEmpty { it.user.short_name } })
nodes.filter { it.isFavorite }.sortedWith(compareBy { it.user.long_name.ifEmpty { it.user.short_name } })
/**
* Returns the primary status line for a favorite-node row (Text 1 in the Car UI row).
@@ -133,15 +133,15 @@ internal object CarScreenDataBuilder {
* - `"Offline · <time ago>"` when [Node.lastHeard] is set
* - `"Offline"` otherwise
*
* @param labelOnline Localised "Online" label; defaults to English.
* @param labelOnline Localised "Online" label; defaults to English.
* @param labelOffline Localised "Offline" label; defaults to English.
* @param labelDirect Suffix appended when [Node.hopsAway] == 0 (include leading " · ");
* defaults to `" · Direct"`.
* @param labelHops Produces the hop-count suffix given the count (include leading " · ");
* defaults to `" · N hops"`.
* @param formatRelativeTime Converts a millis timestamp to a human-readable "X ago" string.
* Defaults to [DateFormatter.formatRelativeTime]; injectable for testing.
* @param labelDirect Suffix appended when [Node.hopsAway] == 0 (include leading " · "); defaults to `" · Direct"`.
* @param labelHops Produces the hop-count suffix given the count (include leading " · "); defaults to `" · N
* hops"`.
* @param formatRelativeTime Converts a millis timestamp to a human-readable "X ago" string. Defaults to
* [DateFormatter.formatRelativeTime]; injectable for testing.
*/
@Suppress("MagicNumber")
fun nodeStatusText(
node: Node,
labelOnline: String = "Online",
@@ -167,8 +167,8 @@ internal object CarScreenDataBuilder {
/**
* 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.
* 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
@@ -183,15 +183,13 @@ internal object CarScreenDataBuilder {
/**
* 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 [noMessagesLabel] for empty channels.
* Mirrors `ChatMetadata`'s `lastMessageText` display: shows the last message text (with sender prefix for received
* DMs), or [noMessagesLabel] for empty channels.
*
* @param noMessagesLabel Localised empty-state label; defaults to `"No messages yet"`.
*/
fun contactPreviewText(
contact: CarContact,
noMessagesLabel: String = "No messages yet",
): String = contact.lastMessageText?.takeIf { it.isNotEmpty() } ?: noMessagesLabel
fun contactPreviewText(contact: CarContact, noMessagesLabel: String = "No messages yet"): String =
contact.lastMessageText?.takeIf { it.isNotEmpty() } ?: noMessagesLabel
/**
* Returns the secondary metadata line for a contact row (Text 2 in the Car UI row).
@@ -201,10 +199,10 @@ internal object CarScreenDataBuilder {
* - Formatted short date of the last message otherwise
* - Empty string when there are no messages at all
*
* @param unreadLabel Produces the unread-count label given the count.
* Defaults to `"N unread"`; callers can supply a localised format string.
* @param formatShortDate Converts a millis timestamp to a short date string.
* Defaults to [DateFormatter.formatShortDate]; injectable for testing.
* @param unreadLabel Produces the unread-count label given the count. Defaults to `"N unread"`; callers can supply
* a localised format string.
* @param formatShortDate Converts a millis timestamp to a short date string. Defaults to
* [DateFormatter.formatShortDate]; injectable for testing.
*/
fun contactSecondaryText(
contact: CarContact,
@@ -219,15 +217,11 @@ internal object CarScreenDataBuilder {
/**
* Builds a [CarLocalStats] snapshot from the device's [Node], [LocalStats], and node DB.
*
* Falls back to [Node.deviceMetrics] when [LocalStats] hasn't been populated yet — the
* same strategy used by [org.meshtastic.feature.widget.LocalStatsWidgetStateProvider].
* Falls back to [Node.deviceMetrics] when [LocalStats] hasn't been populated yet — the same strategy used by
* [org.meshtastic.feature.widget.LocalStatsWidgetStateProvider].
*/
@Suppress("MagicNumber")
fun buildLocalStats(
ourNode: Node?,
stats: LocalStats,
allNodes: Collection<Node>,
): CarLocalStats {
fun buildLocalStats(ourNode: Node?, stats: LocalStats, allNodes: Collection<Node>): CarLocalStats {
val metrics = ourNode?.deviceMetrics
val batteryLevel = metrics?.battery_level ?: 0
val hasStats = stats.uptime_seconds != 0

View File

@@ -36,7 +36,7 @@ class MeshtasticCarAppService : CarAppService() {
HostValidator.ALLOW_ALL_HOSTS_VALIDATOR
} else {
HostValidator.Builder(applicationContext)
.addAllowedHosts(androidx.car.app.R.array.hosts_allowlist)
.addAllowedHosts(androidx.car.app.R.array.hosts_allowlist_sample)
.build()
}
}

View File

@@ -16,13 +16,16 @@
*/
package org.meshtastic.feature.auto
import androidx.car.app.CarAppApiLevels
import androidx.car.app.CarContext
import androidx.car.app.Screen
import androidx.car.app.constraints.ConstraintManager
import androidx.car.app.messaging.model.CarMessage
import androidx.car.app.messaging.model.ConversationCallback
import androidx.car.app.messaging.model.ConversationItem
import androidx.car.app.model.Action
import androidx.car.app.model.CarColor
import androidx.car.app.model.CarIcon
import androidx.car.app.model.CarText
import androidx.car.app.model.ItemList
import androidx.car.app.model.ListTemplate
import androidx.car.app.model.MessageTemplate
@@ -31,11 +34,12 @@ import androidx.car.app.model.Tab
import androidx.car.app.model.TabContents
import androidx.car.app.model.TabTemplate
import androidx.car.app.model.Template
import androidx.core.app.Person
import androidx.core.graphics.drawable.IconCompat
import androidx.lifecycle.DefaultLifecycleObserver
import androidx.lifecycle.LifecycleOwner
import co.touchlab.kermit.Logger
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.combine
@@ -46,40 +50,40 @@ 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.di.CoroutineDispatchers
import org.meshtastic.core.model.ConnectionState
import org.meshtastic.core.model.Node
import org.meshtastic.core.model.util.formatUptime
import org.meshtastic.core.repository.MeshServiceNotifications
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.core.repository.usecase.SendMessageUseCase
/**
* Root screen displayed in Android Auto.
*
* Renders a three-tab UI:
* - **Status** — Connection state, device name, and local device stats (battery,
* channel/air utilization, online nodes, uptime, traffic) — the same key metrics
* surfaced by the home-screen Local Stats widget.
* - **Status** — Connection state, device name, and local device stats (battery, channel/air utilization, online nodes,
* uptime, traffic) — the same key metrics surfaced by the home-screen Local Stats widget.
* - **Favorites** — All nodes the user has starred, with online/hop status shown as a subtitle.
* - **Messages** — All conversations: active channels displayed first as permanent placeholders
* (always visible even when empty, sorted by channel index), followed by DM conversations
* sorted by most-recent message descending. This is the same ordering used by
* [org.meshtastic.feature.messaging.ui.contact.ContactsViewModel].
* - **Messages** — All conversations rendered as [ConversationItem]s (Car API 7+), providing built-in play, reply, and
* mark-as-read affordances. Channels are displayed first as permanent placeholders (always visible even when empty,
* sorted by channel index), followed by DM conversations sorted by most-recent message descending. This is the same
* ordering used by [org.meshtastic.feature.messaging.ui.contact.ContactsViewModel].
*
* 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.
* 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.
* The `minCarApiLevel` is set to 7 in the manifest, ensuring [TabTemplate] (API 6) and [ConversationItem] (API 7) are
* always available. Hosts running older API levels still provide the notification-based messaging experience.
*
* When the user taps a [MessagingStyle][androidx.core.app.NotificationCompat.MessagingStyle]
* notification in the Android Auto notification shade the host calls
* [MeshtasticCarSession.onNewIntent] which delegates to [selectMessagesTab] to switch to the
* Messages tab.
* When the user taps a [MessagingStyle][androidx.core.app.NotificationCompat.MessagingStyle] notification in the
* Android Auto notification shade the host calls [MeshtasticCarSession.onNewIntent] which delegates to
* [selectMessagesTab] to switch to the Messages tab.
*/
@Suppress("TooManyFunctions")
class MeshtasticCarScreen(carContext: CarContext) :
Screen(carContext),
KoinComponent,
@@ -89,16 +93,22 @@ class MeshtasticCarScreen(carContext: CarContext) :
private val packetRepository: PacketRepository by inject()
private val radioConfigRepository: RadioConfigRepository by inject()
private val serviceRepository: ServiceRepository by inject()
private val dispatchers: CoroutineDispatchers by inject()
private val sendMessageUseCase: SendMessageUseCase by inject()
private val meshServiceNotifications: MeshServiceNotifications by inject()
private val scope = CoroutineScope(Dispatchers.Main + SupervisorJob())
private val scope by lazy { CoroutineScope(dispatchers.main + SupervisorJob()) }
/** Durable scope for reply/mark-as-read callbacks that must survive screen invalidations. */
private val callbackScope by lazy { CoroutineScope(dispatchers.io + SupervisorJob()) }
/**
* Per-host list item cap, retrieved once on first template render via
* [ConstraintManager.getContentLimit]. Replaces a hardcoded constant so that
* hosts that allow more than the minimum 5 items are fully utilised.
* Per-host list item cap, retrieved once on first template render via [ConstraintManager.getContentLimit]. Replaces
* a hardcoded constant so that hosts that allow more than the minimum 5 items are fully utilised.
*/
private val listContentLimit: Int by lazy {
carContext.getCarService(ConstraintManager::class.java)
carContext
.getCarService(ConstraintManager::class.java)
.getContentLimit(ConstraintManager.CONTENT_LIMIT_TYPE_LIST)
}
@@ -106,33 +116,31 @@ class MeshtasticCarScreen(carContext: CarContext) :
private var connectionState: ConnectionState = ConnectionState.Disconnected
/**
* Local device statistics for the Status tab — battery, utilization, nodes, uptime.
* Mirrors the key metrics shown by the home-screen Local Stats widget.
* Local device statistics for the Status tab — battery, utilization, nodes, uptime. Mirrors the key metrics shown
* by the home-screen Local Stats widget.
*/
private var localStats: CarLocalStats = CarLocalStats()
/**
* Favorite nodes sorted alphabetically by long name. Updated reactively from
* [NodeRepository.nodeDBbyNum] whenever the user stars or un-stars a node.
* Favorite nodes sorted alphabetically by long name. Updated reactively from [NodeRepository.nodeDBbyNum] whenever
* the user stars or un-stars a node.
*/
private var favorites: List<Node> = emptyList()
/**
* Ordered contact list for the Messages tab: channel entries first (sorted by channel index,
* always present as placeholders even when no messages exist), then DM conversations sorted
* by most-recent message descending — identical ordering to the phone's Contacts screen.
* Ordered contact list for the Messages tab: channel entries first (sorted by channel index, always present as
* placeholders even when no messages exist), then DM conversations sorted by most-recent message descending —
* identical ordering to the phone's Contacts screen.
*/
private var contacts: List<CarContact> = emptyList()
/**
* True until the first combined emission from all repository flows arrives.
*
* On Car API ≥ 5 this prevents a flash of empty content before data loads by showing a
* [MessageTemplate] loading spinner. On older API levels the loading spinner is unavailable
* so [isLoading] starts as `false` and the fallback [ListTemplate] handles the empty state
* with [ItemList.Builder.setNoItemsMessage].
* Car API 7+ supports [MessageTemplate.setLoading] so we show a loading spinner instead of a flash of empty content
* before data loads.
*/
private var isLoading = carContext.carAppApiLevel >= CarAppApiLevels.LEVEL_5
private var isLoading = true
init {
lifecycle.addObserver(this)
@@ -144,62 +152,72 @@ class MeshtasticCarScreen(carContext: CarContext) :
override fun onDestroy(owner: LifecycleOwner) {
scope.cancel()
callbackScope.cancel()
}
@Suppress("LongMethod")
private fun startObserving() {
// Build the contacts sub-flow independently so it can feed the outer combine below.
val contactsFlow = combine(
nodeRepository.myId,
packetRepository.getContacts(),
radioConfigRepository.channelSetFlow,
) { myId, rawContacts, channelSet ->
// Channel placeholders are always included so every configured channel is
// visible even before any messages have been sent/received.
val placeholders = CarScreenDataBuilder.buildChannelPlaceholders(channelSet)
// Real DB entries take precedence over placeholders when present.
val merged = rawContacts + (placeholders - rawContacts.keys)
CarScreenDataBuilder.buildCarContacts(
merged, myId, channelSet,
resolveUser = { userId -> nodeRepository.getUser(userId) },
channelLabel = { carContext.getString(R.string.auto_channel_number, it) },
unknownLabel = carContext.getString(R.string.auto_unknown),
)
}
.distinctUntilChanged()
.flatMapLatest { baseContacts ->
if (baseContacts.isEmpty()) {
flowOf(emptyList())
} else {
val unreadFlows = baseContacts.map { contact ->
packetRepository.getUnreadCountFlow(contact.contactKey)
.map { unread -> contact.copy(unreadCount = unread) }
}
combine(unreadFlows) { it.toList() }
}
val contactsFlow =
combine(nodeRepository.myId, packetRepository.getContacts(), radioConfigRepository.channelSetFlow) {
myId,
rawContacts,
channelSet,
->
// Channel placeholders are always included so every configured channel is
// visible even before any messages have been sent/received.
val placeholders = CarScreenDataBuilder.buildChannelPlaceholders(channelSet)
// Real DB entries take precedence over placeholders when present.
val merged = rawContacts + (placeholders - rawContacts.keys)
CarScreenDataBuilder.buildCarContacts(
merged,
myId,
channelSet,
resolveUser = { userId -> nodeRepository.getUser(userId) },
channelLabel = { carContext.getString(R.string.auto_channel_number, it) },
unknownLabel = carContext.getString(R.string.auto_unknown),
)
}
.distinctUntilChanged()
.flatMapLatest { baseContacts ->
if (baseContacts.isEmpty()) {
flowOf(emptyList())
} else {
val unreadFlows =
baseContacts.map { contact ->
packetRepository.getUnreadCountFlow(contact.contactKey).map { unread ->
contact.copy(unreadCount = unread)
}
}
combine(unreadFlows) { it.toList() }
}
}
val favoritesFlow = nodeRepository.nodeDBbyNum
.map { db -> CarScreenDataBuilder.sortFavorites(db.values) }
.distinctUntilChanged()
val favoritesFlow =
nodeRepository.nodeDBbyNum
.map { db -> CarScreenDataBuilder.sortFavorites(db.values) }
.distinctUntilChanged()
val localStatsFlow = combine(
nodeRepository.ourNodeInfo,
nodeRepository.localStats,
nodeRepository.nodeDBbyNum,
) { ourNode, stats, nodeDb ->
CarScreenDataBuilder.buildLocalStats(ourNode, stats, nodeDb.values)
}.distinctUntilChanged()
val localStatsFlow =
combine(nodeRepository.ourNodeInfo, nodeRepository.localStats, nodeRepository.nodeDBbyNum) {
ourNode,
stats,
nodeDb,
->
CarScreenDataBuilder.buildLocalStats(ourNode, stats, nodeDb.values)
}
.distinctUntilChanged()
// All data sources feed a single combined collector so that each batch of
// repository changes produces exactly one invalidate() call, avoiding unnecessary
// template rebuilds and staying well within the host's update-rate budget.
scope.launch {
combine(
serviceRepository.connectionState,
favoritesFlow,
contactsFlow,
localStatsFlow,
) { connState, favs, ctcts, stats ->
combine(serviceRepository.connectionState, favoritesFlow, contactsFlow, localStatsFlow) {
connState,
favs,
ctcts,
stats,
->
CombinedState(connState, favs, ctcts, stats)
}
.collect { state ->
@@ -216,8 +234,6 @@ class MeshtasticCarScreen(carContext: CarContext) :
// ---- Template building ----
override fun onGetTemplate(): Template {
// MessageTemplate.setLoading() requires Car API 5+. isLoading is only ever true
// on ≥5 hosts (see initialisation above), so no additional level check is needed here.
if (isLoading) {
return MessageTemplate.Builder(carContext.getString(R.string.auto_loading))
.setHeaderAction(Action.APP_ICON)
@@ -225,24 +241,20 @@ class MeshtasticCarScreen(carContext: CarContext) :
.build()
}
// TabTemplate requires Car API level 6. Fall back to a combined ListTemplate
// on older hosts so the app remains functional on all supported vehicles.
if (carContext.carAppApiLevel < CarAppApiLevels.LEVEL_6) {
return buildFallbackListTemplate()
}
val tabCallback = object : TabTemplate.TabCallback {
override fun onTabSelected(tabContentId: String) {
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)
@@ -273,13 +285,13 @@ class MeshtasticCarScreen(carContext: CarContext) :
}
/**
* Called by [MeshtasticCarSession.onNewIntent] when the user taps a conversation
* notification in the Android Auto notification shade. Switches to [TAB_MESSAGES] —
* channels and DMs both live in the same tab, so no per-key handling is required.
* Called by [MeshtasticCarSession.onNewIntent] when the user taps a conversation notification in the Android Auto
* notification shade. Switches to [TAB_MESSAGES] — channels and DMs both live in the same tab, so no per-key
* handling is required.
*
* `androidx.car.app.model.ListTemplate` does not currently expose a programmatic scroll
* API, so we cannot focus a specific conversation row. If/when a scroll API is added,
* the contactKey can be threaded through `MeshtasticCarSession.onNewIntent`.
* `androidx.car.app.model.ListTemplate` does not currently expose a programmatic scroll API, so we cannot focus a
* specific conversation row. If/when a scroll API is added, the contactKey can be threaded through
* `MeshtasticCarSession.onNewIntent`.
*/
fun selectMessagesTab() {
activeTabId = TAB_MESSAGES
@@ -289,8 +301,8 @@ class MeshtasticCarScreen(carContext: CarContext) :
// ---- Individual template builders ----
/**
* Builds the Status tab: connection state + local device stats mirroring the home-screen
* Local Stats widget (battery, channel/air utilization, node counts, uptime, traffic).
* Builds the Status tab: connection state + local device stats mirroring the home-screen Local Stats widget
* (battery, channel/air utilization, node counts, uptime, traffic).
*/
private fun buildStatusTemplate(): ListTemplate {
val items = ItemList.Builder()
@@ -303,87 +315,65 @@ class MeshtasticCarScreen(carContext: CarContext) :
}
/**
* Builds the Favorites tab: one row per starred node, mirroring the key status info shown
* by [org.meshtastic.feature.node.component.NodeItem] on the phone.
*
* Builds the Favorites tab: one row per starred node, mirroring the key status info shown by
* [org.meshtastic.feature.node.component.NodeItem] on the phone.
* - **Title**: node's long name (short name fallback).
* - **Text 1**: `"Online · Direct"` / `"Online · N hops"` / `"Offline · Xh ago"` —
* mirrors the signal row and last-heard chip in NodeItem.
* - **Text 1**: `"Online · Direct"` / `"Online · N hops"` / `"Offline · Xh ago"` — mirrors the signal row and
* last-heard chip in NodeItem.
* - **Text 2**: battery percentage and short name — mirrors the battery row and node chip.
*/
private fun buildFavoritesTemplate(): ListTemplate =
buildListTemplate(
carContext.getString(R.string.auto_tab_favorites),
favorites,
carContext.getString(R.string.auto_no_favorites),
) { buildFavoriteNodeRow(it) }
private fun buildFavoritesTemplate(): ListTemplate = buildListTemplate(
carContext.getString(R.string.auto_tab_favorites),
favorites,
carContext.getString(R.string.auto_no_favorites),
) {
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.
* Builds the Messages tab content using [ConversationItem]s for conversations that have at least one message, and
* plain [Row]s for empty channel placeholders. This provides built-in play, reply, and mark-as-read affordances for
* active conversations.
*/
private fun buildMessagesTemplate(): ListTemplate =
buildListTemplate(
carContext.getString(R.string.auto_tab_messages),
contacts,
carContext.getString(R.string.auto_no_conversations),
) { buildContactRow(it) }
/**
* Fallback for Car API level 15 hosts that do not support [TabTemplate].
*
* Shows a status row, then favorite-node rows, then conversation rows, all capped at
* [listContentLimit] total — matching the three-tab content in a single list.
*
* The remaining slots after status are split evenly: half for favorites, half for messages.
* This prevents a long favorites list from crowding out all conversation entries.
*/
private fun buildFallbackListTemplate(): ListTemplate {
val items = ItemList.Builder()
var remaining = listContentLimit
items.addItem(buildStatusRow())
remaining--
val statsRows = buildLocalStatsRows()
statsRows.take(remaining).forEach { row ->
items.addItem(row)
remaining--
}
// Give each section at most half the remaining space so neither dominates.
val halfRemaining = remaining / 2
favorites.take(halfRemaining).forEach { node ->
items.addItem(buildFavoriteNodeRow(node))
remaining--
}
contacts.take(remaining).forEach { contact ->
items.addItem(buildContactRow(contact))
private fun buildMessagesTemplate(): ListTemplate {
val listBuilder = ItemList.Builder()
val capped = contacts.take(listContentLimit)
if (capped.isEmpty()) {
listBuilder.setNoItemsMessage(carContext.getString(R.string.auto_no_conversations))
} else {
val selfPerson = buildSelfPerson()
capped.forEach { contact ->
if (contact.lastMessageRawText != null) {
listBuilder.addItem(buildConversationItem(contact, selfPerson))
} else {
listBuilder.addItem(buildContactRow(contact))
}
}
}
return ListTemplate.Builder()
.setTitle(carContext.getString(R.string.auto_fallback_title))
.setSingleList(items.build())
.setTitle(carContext.getString(R.string.auto_tab_messages))
.setSingleList(listBuilder.build())
.build()
}
private fun buildStatusRow(): Row {
val statusText = when (connectionState) {
is ConnectionState.Connected -> carContext.getString(R.string.auto_status_connected)
is ConnectionState.Disconnected -> carContext.getString(R.string.auto_status_disconnected)
is ConnectionState.DeviceSleep -> carContext.getString(R.string.auto_status_sleeping)
is ConnectionState.Connecting -> carContext.getString(R.string.auto_status_connecting)
}
val statusText =
when (connectionState) {
is ConnectionState.Connected -> carContext.getString(R.string.auto_status_connected)
is ConnectionState.Disconnected -> carContext.getString(R.string.auto_status_disconnected)
is ConnectionState.DeviceSleep -> carContext.getString(R.string.auto_status_sleeping)
is ConnectionState.Connecting -> carContext.getString(R.string.auto_status_connecting)
}
val deviceName = nodeRepository.ourNodeInfo.value?.user?.long_name.orEmpty()
return Row.Builder()
.setTitle(statusText)
.addTextIfNotEmpty(deviceName)
.setBrowsable(false)
.build()
return Row.Builder().setTitle(statusText).addTextIfNotEmpty(deviceName).setBrowsable(false).build()
}
/**
* Builds rows for local device statistics — the same key metrics the home-screen widget
* surfaces: battery, channel/air utilization, online nodes, uptime, and packet traffic.
* Builds rows for local device statistics — the same key metrics the home-screen widget surfaces: battery,
* channel/air utilization, online nodes, uptime, and packet traffic.
*
* Only shown when connected ([CarLocalStats.hasBattery] is a proxy for "device metrics
* received"). Returns an empty list when disconnected.
* Only shown when connected ([CarLocalStats.hasBattery] is a proxy for "device metrics received"). Returns an empty
* list when disconnected.
*/
@Suppress("MagicNumber")
private fun buildLocalStatsRows(): List<Row> {
@@ -392,51 +382,47 @@ class MeshtasticCarScreen(carContext: CarContext) :
val rows = mutableListOf<Row>()
if (s.hasBattery) {
val batteryValue = if (s.batteryLevel > 100) {
carContext.getString(R.string.auto_stats_powered)
} else {
"${s.batteryLevel}%"
}
rows += Row.Builder()
.setTitle(carContext.getString(R.string.auto_stats_battery, batteryValue))
.addText(
carContext.getString(
R.string.auto_stats_channel_util,
"%.1f%%".format(s.channelUtilization),
"%.1f%%".format(s.airUtilization),
),
)
.setBrowsable(false)
.build()
val batteryValue =
if (s.batteryLevel > 100) {
carContext.getString(R.string.auto_stats_powered)
} else {
"${s.batteryLevel}%"
}
rows +=
Row.Builder()
.setTitle(carContext.getString(R.string.auto_stats_battery, batteryValue))
.addText(
carContext.getString(
R.string.auto_stats_channel_util,
"%.1f%%".format(s.channelUtilization),
"%.1f%%".format(s.airUtilization),
),
)
.setBrowsable(false)
.build()
}
rows += Row.Builder()
.setTitle(carContext.getString(R.string.auto_stats_nodes, s.onlineNodes, s.totalNodes))
.addTextIfNotEmpty(
if (s.uptimeSeconds > 0) {
carContext.getString(
R.string.auto_stats_uptime,
formatUptime(s.uptimeSeconds),
)
} else {
""
},
)
.setBrowsable(false)
.build()
if (s.numPacketsTx > 0 || s.numPacketsRx > 0) {
rows += Row.Builder()
.setTitle(
carContext.getString(
R.string.auto_stats_traffic,
s.numPacketsTx,
s.numPacketsRx,
s.numRxDupe,
),
rows +=
Row.Builder()
.setTitle(carContext.getString(R.string.auto_stats_nodes, s.onlineNodes, s.totalNodes))
.addTextIfNotEmpty(
if (s.uptimeSeconds > 0) {
carContext.getString(R.string.auto_stats_uptime, formatUptime(s.uptimeSeconds))
} else {
""
},
)
.setBrowsable(false)
.build()
if (s.numPacketsTx > 0 || s.numPacketsRx > 0) {
rows +=
Row.Builder()
.setTitle(
carContext.getString(R.string.auto_stats_traffic, s.numPacketsTx, s.numPacketsRx, s.numRxDupe),
)
.setBrowsable(false)
.build()
}
return rows
@@ -446,13 +432,13 @@ class MeshtasticCarScreen(carContext: CarContext) :
* Builds a single favorite-node row.
*
* Mirrors the content of [org.meshtastic.feature.node.component.NodeItem]:
* - Title → `long_name` (prominent, matches NodeItem header text)
* - 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 { carContext.getString(R.string.auto_unknown) }
val name =
node.user.long_name.ifEmpty { node.user.short_name }.ifEmpty { carContext.getString(R.string.auto_unknown) }
return Row.Builder()
.setTitle(name)
.addText(
@@ -473,40 +459,115 @@ class MeshtasticCarScreen(carContext: CarContext) :
* Builds a single conversation row.
*
* Mirrors [org.meshtastic.feature.messaging.ui.contact.ContactItem]:
* - **Title** → channel or DM display name (matches the bodyLarge name in ContactHeader).
* - **Text 1** → last message preview with sender prefix for received DMs, or "No messages
* yet" for empty channel placeholders (matches ChatMetadata's message text).
* - **Text 2** → `"N unread"` when there are unread messages, or the last-message timestamp
* when there are none (matches the unread badge and date in ContactHeader/ChatMetadata).
* - **Title** → channel or DM display name (matches the bodyLarge name in ContactHeader).
* - **Text 1** → last message preview with sender prefix for received DMs, or "No messages yet" for empty channel
* placeholders (matches ChatMetadata's message text).
* - **Text 2** → `"N unread"` when there are unread messages, or the last-message timestamp when there are none
* (matches the unread badge and date in ContactHeader/ChatMetadata).
*/
private fun buildContactRow(contact: CarContact): Row =
Row.Builder()
.setTitle(contact.displayName)
.addText(
CarScreenDataBuilder.contactPreviewText(
contact,
noMessagesLabel = carContext.getString(R.string.auto_no_messages),
),
)
.addTextIfNotEmpty(
CarScreenDataBuilder.contactSecondaryText(
contact,
unreadLabel = { carContext.getString(R.string.auto_unread_count, it) },
),
)
.setBrowsable(false)
private fun buildContactRow(contact: CarContact): Row = Row.Builder()
.setTitle(contact.displayName)
.addText(
CarScreenDataBuilder.contactPreviewText(
contact,
noMessagesLabel = carContext.getString(R.string.auto_no_messages),
),
)
.addTextIfNotEmpty(
CarScreenDataBuilder.contactSecondaryText(
contact,
unreadLabel = { carContext.getString(R.string.auto_unread_count, it) },
),
)
.setBrowsable(false)
.build()
/**
* Builds a [ConversationItem] for a contact that has at least one message.
*
* The item links to the conversation's shortcut ID (matching [ConversationShortcutManager]) so the host can
* associate notifications with the template entry.
*/
private fun buildConversationItem(contact: CarContact, selfPerson: Person): ConversationItem {
val senderPerson =
if (contact.lastMessageFromSelf) {
selfPerson
} else {
Person.Builder().setName(contact.lastMessageSenderName ?: contact.displayName).build()
}
val carMessage =
CarMessage.Builder()
.setSender(senderPerson)
.setBody(CarText.Builder(contact.lastMessageRawText ?: "").build())
.setReceivedTimeEpochMillis(contact.lastMessageTime ?: 0L)
.setRead(contact.unreadCount == 0)
.build()
val messages = listOf(carMessage)
val callback = buildConversationCallback(contact.contactKey)
return ConversationItem.Builder(
/* id = */
contact.contactKey,
/* title = */
CarText.Builder(contact.displayName).build(),
/* self = */
selfPerson,
/* messages = */
messages,
/* conversationCallback = */
callback,
)
.setGroupConversation(contact.isBroadcast)
.build()
}
private fun buildSelfPerson(): Person {
val ourName = nodeRepository.ourNodeInfo.value?.user?.long_name ?: "Me"
return Person.Builder().setName(ourName).build()
}
/**
* Creates a [ConversationCallback] for the given contact that handles reply and mark-as-read actions. Uses
* [callbackScope] (IO-backed, durable) so that in-flight replies are not cancelled by screen invalidations.
*/
@Suppress("TooGenericExceptionCaught")
private fun buildConversationCallback(contactKey: String): ConversationCallback = object : ConversationCallback {
override fun onMarkAsRead() {
callbackScope.launch {
try {
meshServiceNotifications.markConversationRead(contactKey)
} catch (e: Exception) {
Logger.e(e) { "Failed to mark conversation $contactKey as read" }
}
}
}
override fun onTextReply(replyText: String) {
callbackScope.launch {
try {
sendMessageUseCase(replyText, contactKey)
meshServiceNotifications.appendOutgoingMessage(contactKey, replyText)
meshServiceNotifications.markConversationRead(contactKey)
} catch (e: Exception) {
Logger.e(e) { "Failed to send reply to $contactKey" }
}
}
}
}
private fun carIcon(resId: Int) =
CarIcon.Builder(IconCompat.createWithResource(carContext, resId)).setTint(CarColor.DEFAULT).build()
/** 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) }
private fun Row.Builder.addTextIfNotEmpty(text: String): Row.Builder = apply {
if (text.isNotEmpty()) addText(text)
}
/**
* DRY helper: builds a [ListTemplate] from a list of items, capping at [listContentLimit]
* (the per-host limit reported by [ConstraintManager]).
* DRY helper: builds a [ListTemplate] from a list of items, capping at [listContentLimit] (the per-host limit
* reported by [ConstraintManager]).
*
* Shows [noItemsMessage] when the list is empty.
*/

View File

@@ -18,6 +18,7 @@ package org.meshtastic.feature.auto
import android.content.Intent
import androidx.car.app.Screen
import androidx.car.app.ScreenManager
import androidx.car.app.Session
/** Android Auto session that hosts the [MeshtasticCarScreen] root screen. */
@@ -31,15 +32,15 @@ class MeshtasticCarSession : Session() {
/**
* Called by the Android Auto host when the session is re-activated from an existing
* [MessagingStyle][androidx.core.app.NotificationCompat.MessagingStyle] notification tap or a
* launcher shortcut. Switches the root screen to the Messages tab.
* [MessagingStyle][androidx.core.app.NotificationCompat.MessagingStyle] notification tap or a launcher shortcut.
* Switches the root screen to the Messages tab.
*
* The deep-link URI (`meshtastic://meshtastic/messages/<contactKey>`) carries the originating
* contact key, but `androidx.car.app.model.ListTemplate` does not currently expose a
* programmatic scroll API, so we cannot focus a specific conversation row.
* The deep-link URI (`meshtastic://meshtastic/messages/<contactKey>`) carries the originating contact key, but
* `androidx.car.app.model.ListTemplate` does not currently expose a programmatic scroll API, so we cannot focus a
* specific conversation row.
*/
override fun onNewIntent(intent: Intent) {
val screen = screenManager.top as? MeshtasticCarScreen ?: return
val screen = carContext.getCarService(ScreenManager::class.java).top as? MeshtasticCarScreen ?: return
applyIntent(intent, screen)
}

View File

@@ -23,7 +23,6 @@
<!-- Template titles / loading / empty states -->
<string name="auto_loading">Loading\u2026</string>
<string name="auto_fallback_title">Meshtastic</string>
<string name="auto_no_favorites">No favorite nodes</string>
<string name="auto_no_conversations">No conversations</string>
<string name="auto_no_messages">No messages yet</string>

View File

@@ -23,19 +23,21 @@ 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.DeviceMetrics
import org.meshtastic.proto.LocalStats
import org.meshtastic.proto.User
import kotlin.test.Test
import io.kotest.matchers.maps.shouldBeEmpty as shouldBeEmptyMap
import io.kotest.matchers.maps.shouldHaveSize as shouldHaveSizeMap
/**
* 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.
* 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 {
@@ -44,7 +46,7 @@ class CarScreenDataBuilderTest {
@Test
fun `buildChannelPlaceholders - empty channelSet returns empty map`() {
val result = CarScreenDataBuilder.buildChannelPlaceholders(ChannelSet())
result.shouldBeEmpty()
result.shouldBeEmptyMap()
}
@Test
@@ -57,16 +59,14 @@ class CarScreenDataBuilderTest {
@Test
fun `buildChannelPlaceholders - three channels produce three distinct keys`() {
val channelSet = ChannelSet(
settings = listOf(
ChannelSettings(name = "Ch0"),
ChannelSettings(name = "Ch1"),
ChannelSettings(name = "Ch2"),
),
)
val channelSet =
ChannelSet(
settings =
listOf(ChannelSettings(name = "Ch0"), ChannelSettings(name = "Ch1"), ChannelSettings(name = "Ch2")),
)
val result = CarScreenDataBuilder.buildChannelPlaceholders(channelSet)
result shouldHaveSize 3
result shouldHaveSizeMap 3
result.keys shouldBe setOf("0^all", "1^all", "2^all")
}
@@ -84,17 +84,19 @@ class CarScreenDataBuilderTest {
@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 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() },
)
val contacts =
CarScreenDataBuilder.buildCarContacts(
merged = mapOf("0^all" to packet),
myId = "!aabbccdd",
channelSet = channelSet,
resolveUser = { User() },
)
contacts shouldHaveSize 1
contacts[0].displayName shouldBe "LongFast"
@@ -103,36 +105,48 @@ class CarScreenDataBuilderTest {
@Test
fun `buildCarContacts - broadcast contact uses Channel N fallback when name is empty`() {
// LoRaConfig with use_preset=true makes Channel.name return a preset name (e.g. "LongFast")
// rather than falling back to channelLabel. To test the channelLabel fallback, we need
// a ChannelSettings whose resolved Channel.name is empty — but Channel.name always
// returns a non-empty computed name. Instead, test that the fallback label is used
// only for channels whose resolved name getChannel().name is non-empty (the current
// implementation uses it as-is) vs unresolvable channels. Since Channel always has a
// name, we verify the display name equals the resolved channel name.
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 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() },
)
val contacts =
CarScreenDataBuilder.buildCarContacts(
merged = mapOf("0^all" to packet),
myId = null,
channelSet = channelSet,
resolveUser = { User() },
)
contacts[0].displayName shouldBe "Channel 0"
// Channel(ChannelSettings(name=""), LoRaConfig()) defaults to "Custom"
contacts[0].displayName shouldBe "Custom"
}
@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 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() },
)
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
@@ -141,17 +155,19 @@ class CarScreenDataBuilderTest {
@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 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 },
)
val contacts =
CarScreenDataBuilder.buildCarContacts(
merged = mapOf("!sender" to packet),
myId = "!localnode",
channelSet = ChannelSet(),
resolveUser = { senderUser },
)
contacts[0].displayName shouldBe "ALIC"
}
@@ -164,14 +180,18 @@ class CarScreenDataBuilderTest {
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 },
)
val contacts =
CarScreenDataBuilder.buildCarContacts(
merged = mapOf("!sender" to packet),
myId = "!me",
channelSet = ChannelSet(),
resolveUser = { senderUser },
)
contacts[0].lastMessageText shouldBe "ALIC: Hello!"
contacts[0].lastMessageRawText shouldBe "Hello!"
contacts[0].lastMessageSenderName shouldBe "ALIC"
contacts[0].lastMessageFromSelf shouldBe false
}
@Test
@@ -180,33 +200,39 @@ class CarScreenDataBuilderTest {
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 },
)
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"
contacts[0].lastMessageRawText shouldBe "Hey Bob"
contacts[0].lastMessageFromSelf shouldBe true
}
@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 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() },
)
val contacts =
CarScreenDataBuilder.buildCarContacts(
merged = mapOf("0^all" to packet),
myId = null,
channelSet = channelSet,
resolveUser = { User() },
)
contacts[0].lastMessageText shouldBe null
contacts[0].lastMessageRawText shouldBe null
}
// ---- buildCarContacts - ordering ----
@@ -214,21 +240,24 @@ class CarScreenDataBuilderTest {
@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 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() },
)
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
@@ -236,26 +265,26 @@ class CarScreenDataBuilderTest {
@Test
fun `buildCarContacts - channels are sorted by channelIndex ascending`() {
val channelSet = ChannelSet(
settings = listOf(
ChannelSettings(name = "Ch0"),
ChannelSettings(name = "Ch1"),
ChannelSettings(name = "Ch2"),
),
)
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 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() },
)
val contacts =
CarScreenDataBuilder.buildCarContacts(
merged = packets,
myId = null,
channelSet = channelSet,
resolveUser = { User() },
)
contacts.map { it.channelIndex } shouldBe listOf(0, 1, 2)
}
@@ -266,19 +295,20 @@ class CarScreenDataBuilderTest {
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()
}
},
)
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")
}
@@ -287,10 +317,11 @@ class CarScreenDataBuilderTest {
@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 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
@@ -299,11 +330,12 @@ class CarScreenDataBuilderTest {
@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 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")
@@ -311,10 +343,11 @@ class CarScreenDataBuilderTest {
@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 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"
@@ -375,10 +408,13 @@ class CarScreenDataBuilderTest {
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"
})
CarScreenDataBuilder.nodeStatusText(
node,
formatRelativeTime = { millis ->
receivedMillis = millis
"ago"
},
)
receivedMillis shouldBe lastHeardSecs * 1000L
}
@@ -387,12 +423,13 @@ class CarScreenDataBuilderTest {
@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 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"
@@ -402,11 +439,7 @@ class CarScreenDataBuilderTest {
@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 node = Node(num = 1, user = User(long_name = "Alice", short_name = "ALIC"), isFavorite = true)
val text = CarScreenDataBuilder.nodeDetailText(node)
text shouldBe "ALIC"
@@ -420,12 +453,13 @@ class CarScreenDataBuilderTest {
@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 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%"
@@ -473,10 +507,13 @@ class CarScreenDataBuilderTest {
val timestamp = 123_456_789L
val contact = makeCarContact(unreadCount = 0, lastMessageTime = timestamp)
var received = 0L
CarScreenDataBuilder.contactSecondaryText(contact, formatShortDate = { millis ->
received = millis
"date"
})
CarScreenDataBuilder.contactSecondaryText(
contact,
formatShortDate = { millis ->
received = millis
"date"
},
)
received shouldBe timestamp
}
@@ -490,7 +527,8 @@ class CarScreenDataBuilderTest {
@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"
CarScreenDataBuilder.contactSecondaryText(contact, formatShortDate = { "should not appear" }) shouldBe
"3 unread"
}
// ---- buildLocalStats ----
@@ -510,11 +548,7 @@ class CarScreenDataBuilderTest {
@Test
fun `buildLocalStats - reads battery from device metrics`() {
val node = Node(
num = 1,
user = User(long_name = "Me"),
deviceMetrics = DeviceMetrics(battery_level = 85),
)
val node = Node(num = 1, user = User(long_name = "Me"), deviceMetrics = DeviceMetrics(battery_level = 85))
val result = CarScreenDataBuilder.buildLocalStats(node, LocalStats(), emptyList())
result.hasBattery shouldBe true
@@ -523,16 +557,13 @@ class CarScreenDataBuilderTest {
@Test
fun `buildLocalStats - prefers LocalStats utilization over device metrics`() {
val node = Node(
num = 1,
user = User(long_name = "Me"),
deviceMetrics = DeviceMetrics(channel_utilization = 5f, air_util_tx = 1f),
)
val stats = LocalStats(
uptime_seconds = 100,
channel_utilization = 18.5f,
air_util_tx = 3.2f,
)
val node =
Node(
num = 1,
user = User(long_name = "Me"),
deviceMetrics = DeviceMetrics(channel_utilization = 5f, air_util_tx = 1f),
)
val stats = LocalStats(uptime_seconds = 100, channel_utilization = 18.5f, air_util_tx = 3.2f)
val result = CarScreenDataBuilder.buildLocalStats(node, stats, emptyList())
result.channelUtilization shouldBe 18.5f
@@ -542,15 +573,12 @@ class CarScreenDataBuilderTest {
@Test
fun `buildLocalStats - falls back to device metrics when LocalStats uptime is zero`() {
val node = Node(
num = 1,
user = User(long_name = "Me"),
deviceMetrics = DeviceMetrics(
channel_utilization = 5f,
air_util_tx = 1f,
uptime_seconds = 3600,
),
)
val node =
Node(
num = 1,
user = User(long_name = "Me"),
deviceMetrics = DeviceMetrics(channel_utilization = 5f, air_util_tx = 1f, uptime_seconds = 3600),
)
val result = CarScreenDataBuilder.buildLocalStats(node, LocalStats(), emptyList())
result.channelUtilization shouldBe 5f
@@ -561,11 +589,12 @@ class CarScreenDataBuilderTest {
@Test
fun `buildLocalStats - counts total and online nodes`() {
val nowSecs = (System.currentTimeMillis() / 1000).toInt()
val nodes = listOf(
Node(num = 1, lastHeard = nowSecs),
Node(num = 2, lastHeard = nowSecs),
Node(num = 3, lastHeard = 0), // offline
)
val nodes =
listOf(
Node(num = 1, lastHeard = nowSecs),
Node(num = 2, lastHeard = nowSecs),
Node(num = 3, lastHeard = 0), // offline
)
val result = CarScreenDataBuilder.buildLocalStats(null, LocalStats(), nodes)
result.totalNodes shouldBe 3
@@ -574,12 +603,7 @@ class CarScreenDataBuilderTest {
@Test
fun `buildLocalStats - copies traffic counters from LocalStats`() {
val stats = LocalStats(
uptime_seconds = 1,
num_packets_tx = 145,
num_packets_rx = 892,
num_rx_dupe = 42,
)
val stats = LocalStats(uptime_seconds = 1, num_packets_tx = 145, num_packets_rx = 892, num_rx_dupe = 42)
val result = CarScreenDataBuilder.buildLocalStats(null, stats, emptyList())
result.numPacketsTx shouldBe 145
@@ -621,6 +645,9 @@ class CarScreenDataBuilderTest {
channelIndex: Int = 0,
lastMessageTime: Long? = null,
lastMessageText: String? = null,
lastMessageRawText: String? = null,
lastMessageSenderName: String? = null,
lastMessageFromSelf: Boolean = false,
) = CarContact(
contactKey = contactKey,
displayName = displayName,
@@ -629,5 +656,8 @@ class CarScreenDataBuilderTest {
channelIndex = channelIndex,
lastMessageTime = lastMessageTime,
lastMessageText = lastMessageText,
lastMessageRawText = lastMessageRawText,
lastMessageSenderName = lastMessageSenderName,
lastMessageFromSelf = lastMessageFromSelf,
)
}