diff --git a/feature/auto/build.gradle.kts b/feature/auto/build.gradle.kts
index c9da00795..b6e788bf4 100644
--- a/feature/auto/build.gradle.kts
+++ b/feature/auto/build.gradle.kts
@@ -24,6 +24,7 @@ android {
dependencies {
implementation(projects.core.common)
+ implementation(projects.core.di)
implementation(projects.core.model)
implementation(projects.core.proto)
implementation(projects.core.repository)
@@ -36,6 +37,7 @@ dependencies {
implementation(libs.koin.core)
testImplementation(kotlin("test"))
+ testImplementation(kotlin("test-junit5"))
testImplementation(libs.kotest.assertions)
testImplementation(libs.kotlinx.coroutines.test)
}
diff --git a/feature/auto/src/main/AndroidManifest.xml b/feature/auto/src/main/AndroidManifest.xml
index e9fbb05ec..465f95d0e 100644
--- a/feature/auto/src/main/AndroidManifest.xml
+++ b/feature/auto/src/main/AndroidManifest.xml
@@ -17,6 +17,16 @@
-->
+
+
+
-
+
+ android:value="7" />
diff --git a/feature/auto/src/main/kotlin/org/meshtastic/feature/auto/CarContact.kt b/feature/auto/src/main/kotlin/org/meshtastic/feature/auto/CarContact.kt
index 7c9413c4f..bf394c01f 100644
--- a/feature/auto/src/main/kotlin/org/meshtastic/feature/auto/CarContact.kt
+++ b/feature/auto/src/main/kotlin/org/meshtastic/feature/auto/CarContact.kt
@@ -19,10 +19,13 @@ package org.meshtastic.feature.auto
/**
* Lightweight projection of a conversation used exclusively within [MeshtasticCarScreen].
*
- * [isBroadcast] and [channelIndex] drive ordering (channels before DMs, channels sorted by
- * index). [lastMessageTime] drives DM ordering (most-recent first).
- * [lastMessageText] mirrors `ContactsViewModel.contactList`'s `lastMessageText` — received
- * DMs are prefixed with the sender's short name, matching `ContactItem`'s ChatMetadata display.
+ * [isBroadcast] and [channelIndex] drive ordering (channels before DMs, channels sorted by index). [lastMessageTime]
+ * drives DM ordering (most-recent first). [lastMessageText] mirrors `ContactsViewModel.contactList`'s `lastMessageText`
+ * — received DMs are prefixed with the sender's short name, matching `ContactItem`'s ChatMetadata display.
+ *
+ * The `lastMessageRawText`, `lastMessageSenderName`, and `lastMessageFromSelf` fields carry the decomposed message data
+ * needed to construct a [CarMessage] for [ConversationItem] — the structured body and sender info that the host uses
+ * for TTS readout and reply attribution.
*/
internal data class CarContact(
val contactKey: String,
@@ -32,4 +35,7 @@ internal data class CarContact(
val channelIndex: Int,
val lastMessageTime: Long?,
val lastMessageText: String?,
+ val lastMessageRawText: String?,
+ val lastMessageSenderName: String?,
+ val lastMessageFromSelf: Boolean,
)
diff --git a/feature/auto/src/main/kotlin/org/meshtastic/feature/auto/CarLocalStats.kt b/feature/auto/src/main/kotlin/org/meshtastic/feature/auto/CarLocalStats.kt
index 182f74589..07174cd21 100644
--- a/feature/auto/src/main/kotlin/org/meshtastic/feature/auto/CarLocalStats.kt
+++ b/feature/auto/src/main/kotlin/org/meshtastic/feature/auto/CarLocalStats.kt
@@ -19,8 +19,8 @@ package org.meshtastic.feature.auto
/**
* Snapshot of local device statistics displayed in the Status tab.
*
- * Mirrors the key metrics shown by [org.meshtastic.feature.widget.LocalStatsWidget]:
- * battery, channel/air utilization, node counts, uptime, and traffic counters.
+ * Mirrors the key metrics shown by [org.meshtastic.feature.widget.LocalStatsWidget]: battery, channel/air utilization,
+ * node counts, uptime, and traffic counters.
*/
internal data class CarLocalStats(
val batteryLevel: Int = 0,
diff --git a/feature/auto/src/main/kotlin/org/meshtastic/feature/auto/CarScreenDataBuilder.kt b/feature/auto/src/main/kotlin/org/meshtastic/feature/auto/CarScreenDataBuilder.kt
index d54afb977..e449664ac 100644
--- a/feature/auto/src/main/kotlin/org/meshtastic/feature/auto/CarScreenDataBuilder.kt
+++ b/feature/auto/src/main/kotlin/org/meshtastic/feature/auto/CarScreenDataBuilder.kt
@@ -27,20 +27,18 @@ import org.meshtastic.proto.PortNum
import org.meshtastic.proto.User
/**
- * Pure-function helpers that convert domain models into [CarContact] and display strings for
- * [MeshtasticCarScreen].
+ * Pure-function helpers that convert domain models into [CarContact] and display strings for [MeshtasticCarScreen].
*
- * All methods are free of Car App Library dependencies, making them straightforwardly testable as
- * plain JVM unit tests without Robolectric.
+ * All methods are free of Car App Library dependencies, making them straightforwardly testable as plain JVM unit tests
+ * without Robolectric.
*/
internal object CarScreenDataBuilder {
/**
* Returns a map of `"^all" → placeholder DataPacket` for every configured channel.
*
- * Channel placeholders ensure every configured channel is always visible in the Messages
- * tab — even before any messages have been sent or received — mirroring the behaviour of
- * `ContactsViewModel.contactList`.
+ * Channel placeholders ensure every configured channel is always visible in the Messages tab — even before any
+ * messages have been sent or received — mirroring the behaviour of `ContactsViewModel.contactList`.
*/
fun buildChannelPlaceholders(channelSet: ChannelSet): Map =
(0 until channelSet.settings.size).associate { ch ->
@@ -51,16 +49,16 @@ internal object CarScreenDataBuilder {
/**
* Converts the merged DB + placeholder map into an ordered [CarContact] list.
*
- * Channels (keys ending with [DataPacket.ID_BROADCAST]) appear first sorted by channel index.
- * DM conversations follow sorted by [CarContact.lastMessageTime] descending — matching the
- * ordering used by the phone's Contacts screen.
+ * Channels (keys ending with [DataPacket.ID_BROADCAST]) appear first sorted by channel index. DM conversations
+ * follow sorted by [CarContact.lastMessageTime] descending — matching the ordering used by the phone's Contacts
+ * screen.
*
- * @param resolveUser Returns the [User] for a given node-ID string. The caller is responsible
- * for providing a null-safe fallback (typically [NodeRepository.getUser]).
- * @param channelLabel Produces the display name for a channel given its index.
- * Defaults to `"Channel N"`; callers can supply a localised string.
- * @param unknownLabel Fallback display name when neither long name nor short name is available.
- * Defaults to `"Unknown"`; callers can supply a localised string.
+ * @param resolveUser Returns the [User] for a given node-ID string. The caller is responsible for providing a
+ * null-safe fallback (typically [NodeRepository.getUser]).
+ * @param channelLabel Produces the display name for a channel given its index. Defaults to `"Channel N"`; callers
+ * can supply a localised string.
+ * @param unknownLabel Fallback display name when neither long name nor short name is available. Defaults to
+ * `"Unknown"`; callers can supply a localised string.
*/
fun buildCarContacts(
merged: Map,
@@ -70,58 +68,60 @@ internal object CarScreenDataBuilder {
channelLabel: (Int) -> String = { "Channel $it" },
unknownLabel: String = "Unknown",
): List {
- val all = merged.map { (contactKey, packet) ->
- val fromLocal = packet.from == DataPacket.ID_LOCAL || packet.from == myId
- val toBroadcast = packet.to == DataPacket.ID_BROADCAST
- val userId = if (fromLocal) packet.to else packet.from
+ val all =
+ merged.map { (contactKey, packet) ->
+ val fromLocal = packet.from == DataPacket.ID_LOCAL || packet.from == myId
+ val toBroadcast = packet.to == DataPacket.ID_BROADCAST
+ val userId = if (fromLocal) packet.to else packet.from
- // Resolve the user once; used for both displayName and message prefix.
- val user = resolveUser(userId ?: DataPacket.ID_BROADCAST)
+ // Resolve the user once; used for both displayName and message prefix.
+ val user = resolveUser(userId ?: DataPacket.ID_BROADCAST)
- val displayName = if (toBroadcast) {
- channelSet.getChannel(packet.channel)?.name?.takeIf { it.isNotEmpty() }
- ?: channelLabel(packet.channel)
- } else {
- // userId can be null for malformed packets (e.g. both `from` and `to` are null).
- // Fall back to a broadcast lookup which returns an "Unknown" user rather than crashing.
- user.long_name.ifEmpty { user.short_name }.ifEmpty { unknownLabel }
+ val displayName =
+ if (toBroadcast) {
+ channelSet.getChannel(packet.channel)?.name?.takeIf { it.isNotEmpty() }
+ ?: channelLabel(packet.channel)
+ } else {
+ // userId can be null for malformed packets (e.g. both `from` and `to` are null).
+ // Fall back to a broadcast lookup which returns an "Unknown" user rather than crashing.
+ user.long_name.ifEmpty { user.short_name }.ifEmpty { unknownLabel }
+ }
+
+ // Mirror ContactsViewModel: prefix received DM text with the sender's short name,
+ // matching how ContactItem's ChatMetadata renders lastMessageText.
+ val shortName = if (!toBroadcast) user.short_name else ""
+ val rawText = packet.text
+ val lastMessageText =
+ rawText?.let { text -> if (fromLocal || shortName.isEmpty()) text else "$shortName: $text" }
+
+ CarContact(
+ contactKey = contactKey,
+ displayName = displayName,
+ unreadCount = 0, // filled in reactively by the screen's flatMapLatest
+ isBroadcast = toBroadcast,
+ channelIndex = packet.channel,
+ lastMessageTime = if (packet.time != 0L) packet.time else null,
+ lastMessageText = lastMessageText,
+ lastMessageRawText = rawText,
+ lastMessageSenderName = shortName.takeIf { it.isNotEmpty() },
+ lastMessageFromSelf = fromLocal,
+ )
}
- // Mirror ContactsViewModel: prefix received DM text with the sender's short name,
- // matching how ContactItem's ChatMetadata renders lastMessageText.
- val shortName = if (!toBroadcast) user.short_name else ""
- val lastMessageText = packet.text?.let { text ->
- if (fromLocal || shortName.isEmpty()) text else "$shortName: $text"
- }
-
- CarContact(
- contactKey = contactKey,
- displayName = displayName,
- unreadCount = 0, // filled in reactively by the screen's flatMapLatest
- isBroadcast = toBroadcast,
- channelIndex = packet.channel,
- lastMessageTime = if (packet.time != 0L) packet.time else null,
- lastMessageText = lastMessageText,
- )
- }
-
// partition avoids iterating the list twice.
val (channels, dms) = all.partition { it.isBroadcast }
- return channels.sortedBy { it.channelIndex } +
- dms.sortedByDescending { it.lastMessageTime ?: 0L }
+ return channels.sortedBy { it.channelIndex } + dms.sortedByDescending { it.lastMessageTime ?: 0L }
}
/**
* Filters and sorts [nodes] to produce the Favorites tab list.
*
- * Only nodes with [Node.isFavorite] are included. They are sorted alphabetically by
- * [User.long_name], falling back to [User.short_name] when the long name is empty —
- * matching the alphabetical sort used by the phone's node list when filtered to favorites.
+ * Only nodes with [Node.isFavorite] are included. They are sorted alphabetically by [User.long_name], falling back
+ * to [User.short_name] when the long name is empty — matching the alphabetical sort used by the phone's node list
+ * when filtered to favorites.
*/
fun sortFavorites(nodes: Collection): List =
- nodes
- .filter { it.isFavorite }
- .sortedWith(compareBy { it.user.long_name.ifEmpty { it.user.short_name } })
+ nodes.filter { it.isFavorite }.sortedWith(compareBy { it.user.long_name.ifEmpty { it.user.short_name } })
/**
* Returns the primary status line for a favorite-node row (Text 1 in the Car UI row).
@@ -133,15 +133,15 @@ internal object CarScreenDataBuilder {
* - `"Offline · "` 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,
- ): CarLocalStats {
+ fun buildLocalStats(ourNode: Node?, stats: LocalStats, allNodes: Collection): CarLocalStats {
val metrics = ourNode?.deviceMetrics
val batteryLevel = metrics?.battery_level ?: 0
val hasStats = stats.uptime_seconds != 0
diff --git a/feature/auto/src/main/kotlin/org/meshtastic/feature/auto/MeshtasticCarAppService.kt b/feature/auto/src/main/kotlin/org/meshtastic/feature/auto/MeshtasticCarAppService.kt
index cc1a82ce2..b6bd73793 100644
--- a/feature/auto/src/main/kotlin/org/meshtastic/feature/auto/MeshtasticCarAppService.kt
+++ b/feature/auto/src/main/kotlin/org/meshtastic/feature/auto/MeshtasticCarAppService.kt
@@ -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()
}
}
diff --git a/feature/auto/src/main/kotlin/org/meshtastic/feature/auto/MeshtasticCarScreen.kt b/feature/auto/src/main/kotlin/org/meshtastic/feature/auto/MeshtasticCarScreen.kt
index 0d10e27b3..3ca793ff9 100644
--- a/feature/auto/src/main/kotlin/org/meshtastic/feature/auto/MeshtasticCarScreen.kt
+++ b/feature/auto/src/main/kotlin/org/meshtastic/feature/auto/MeshtasticCarScreen.kt
@@ -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 1–5 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 = 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 = 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 1–5 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 {
@@ -392,51 +382,47 @@ class MeshtasticCarScreen(carContext: CarContext) :
val rows = mutableListOf()
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.
*/
diff --git a/feature/auto/src/main/kotlin/org/meshtastic/feature/auto/MeshtasticCarSession.kt b/feature/auto/src/main/kotlin/org/meshtastic/feature/auto/MeshtasticCarSession.kt
index d9d42afbe..6e622c79b 100644
--- a/feature/auto/src/main/kotlin/org/meshtastic/feature/auto/MeshtasticCarSession.kt
+++ b/feature/auto/src/main/kotlin/org/meshtastic/feature/auto/MeshtasticCarSession.kt
@@ -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/`) 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/`) 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)
}
diff --git a/feature/auto/src/main/res/values/strings.xml b/feature/auto/src/main/res/values/strings.xml
index 222a5a152..1e9d5a187 100644
--- a/feature/auto/src/main/res/values/strings.xml
+++ b/feature/auto/src/main/res/values/strings.xml
@@ -23,7 +23,6 @@
Loading\u2026
- Meshtastic
No favorite nodes
No conversations
No messages yet
diff --git a/feature/auto/src/test/kotlin/org/meshtastic/feature/auto/CarScreenDataBuilderTest.kt b/feature/auto/src/test/kotlin/org/meshtastic/feature/auto/CarScreenDataBuilderTest.kt
index 7ef327295..c99201127 100644
--- a/feature/auto/src/test/kotlin/org/meshtastic/feature/auto/CarScreenDataBuilderTest.kt
+++ b/feature/auto/src/test/kotlin/org/meshtastic/feature/auto/CarScreenDataBuilderTest.kt
@@ -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,
)
}