diff --git a/feature/auto/build.gradle.kts b/feature/auto/build.gradle.kts
index 721e844be..54a33c302 100644
--- a/feature/auto/build.gradle.kts
+++ b/feature/auto/build.gradle.kts
@@ -38,4 +38,8 @@ dependencies {
implementation(libs.androidx.car.app)
implementation(libs.kermit)
implementation(libs.koin.annotations)
+
+ testImplementation(kotlin("test"))
+ testImplementation(libs.kotest.assertions)
+ testImplementation(libs.kotlinx.coroutines.test)
}
diff --git a/feature/auto/src/main/kotlin/org/meshtastic/feature/auto/CarContact.kt b/feature/auto/src/main/kotlin/org/meshtastic/feature/auto/CarContact.kt
new file mode 100644
index 000000000..7c9413c4f
--- /dev/null
+++ b/feature/auto/src/main/kotlin/org/meshtastic/feature/auto/CarContact.kt
@@ -0,0 +1,35 @@
+/*
+ * Copyright (c) 2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+package org.meshtastic.feature.auto
+
+/**
+ * Lightweight projection of a conversation used exclusively within [MeshtasticCarScreen].
+ *
+ * [isBroadcast] and [channelIndex] drive ordering (channels before DMs, channels sorted by
+ * index). [lastMessageTime] drives DM ordering (most-recent first).
+ * [lastMessageText] mirrors `ContactsViewModel.contactList`'s `lastMessageText` — received
+ * DMs are prefixed with the sender's short name, matching `ContactItem`'s ChatMetadata display.
+ */
+internal data class CarContact(
+ val contactKey: String,
+ val displayName: String,
+ val unreadCount: Int,
+ val isBroadcast: Boolean,
+ val channelIndex: Int,
+ val lastMessageTime: Long?,
+ val lastMessageText: String?,
+)
diff --git a/feature/auto/src/main/kotlin/org/meshtastic/feature/auto/CarScreenDataBuilder.kt b/feature/auto/src/main/kotlin/org/meshtastic/feature/auto/CarScreenDataBuilder.kt
new file mode 100644
index 000000000..fa6a883ae
--- /dev/null
+++ b/feature/auto/src/main/kotlin/org/meshtastic/feature/auto/CarScreenDataBuilder.kt
@@ -0,0 +1,193 @@
+/*
+ * Copyright (c) 2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+package org.meshtastic.feature.auto
+
+import org.meshtastic.core.common.util.DateFormatter
+import org.meshtastic.core.model.DataPacket
+import org.meshtastic.core.model.Node
+import org.meshtastic.core.model.util.getChannel
+import org.meshtastic.proto.ChannelSet
+import org.meshtastic.proto.PortNum
+import org.meshtastic.proto.User
+
+/**
+ * Pure-function helpers that convert domain models into [CarContact] and display strings for
+ * [MeshtasticCarScreen].
+ *
+ * All methods are free of Car App Library dependencies, making them straightforwardly testable as
+ * plain JVM unit tests without Robolectric.
+ */
+internal object CarScreenDataBuilder {
+
+ /**
+ * Returns a map of `"^all" → placeholder DataPacket` for every configured channel.
+ *
+ * Channel placeholders ensure every configured channel is always visible in the Messages
+ * tab — even before any messages have been sent or received — mirroring the behaviour of
+ * `ContactsViewModel.contactList`.
+ */
+ fun buildChannelPlaceholders(channelSet: ChannelSet): Map =
+ (0 until channelSet.settings.size).associate { ch ->
+ "${ch}${DataPacket.ID_BROADCAST}" to
+ DataPacket(bytes = null, dataType = PortNum.TEXT_MESSAGE_APP.value, time = 0L, channel = ch)
+ }
+
+ /**
+ * Converts the merged DB + placeholder map into an ordered [CarContact] list.
+ *
+ * Channels (keys ending with [DataPacket.ID_BROADCAST]) appear first sorted by channel index.
+ * DM conversations follow sorted by [CarContact.lastMessageTime] descending — matching the
+ * ordering used by the phone's Contacts screen.
+ *
+ * @param resolveUser Returns the [User] for a given node-ID string. The caller is responsible
+ * for providing a null-safe fallback (typically [NodeRepository.getUser]).
+ */
+ fun buildCarContacts(
+ merged: Map,
+ myId: String?,
+ channelSet: ChannelSet,
+ resolveUser: (String) -> User,
+ ): List {
+ val all = merged.map { (contactKey, packet) ->
+ val fromLocal = packet.from == DataPacket.ID_LOCAL || packet.from == myId
+ val toBroadcast = packet.to == DataPacket.ID_BROADCAST
+ val userId = if (fromLocal) packet.to else packet.from
+
+ // Resolve the user once; used for both displayName and message prefix.
+ val user = resolveUser(userId ?: DataPacket.ID_BROADCAST)
+
+ val displayName = if (toBroadcast) {
+ channelSet.getChannel(packet.channel)?.name?.takeIf { it.isNotEmpty() }
+ ?: "Channel ${packet.channel}"
+ } else {
+ // userId can be null for malformed packets (e.g. both `from` and `to` are null).
+ // Fall back to a broadcast lookup which returns an "Unknown" user rather than crashing.
+ user.long_name.ifEmpty { user.short_name }.ifEmpty { "Unknown" }
+ }
+
+ // Mirror ContactsViewModel: prefix received DM text with the sender's short name,
+ // matching how ContactItem's ChatMetadata renders lastMessageText.
+ val shortName = if (!toBroadcast) user.short_name else ""
+ val lastMessageText = packet.text?.let { text ->
+ if (fromLocal || shortName.isEmpty()) text else "$shortName: $text"
+ }
+
+ CarContact(
+ contactKey = contactKey,
+ displayName = displayName,
+ unreadCount = 0, // filled in reactively by the screen's flatMapLatest
+ isBroadcast = toBroadcast,
+ channelIndex = packet.channel,
+ lastMessageTime = if (packet.time != 0L) packet.time else null,
+ lastMessageText = lastMessageText,
+ )
+ }
+
+ // partition avoids iterating the list twice.
+ val (channels, dms) = all.partition { it.isBroadcast }
+ return channels.sortedBy { it.channelIndex } +
+ dms.sortedByDescending { it.lastMessageTime ?: 0L }
+ }
+
+ /**
+ * Filters and sorts [nodes] to produce the Favorites tab list.
+ *
+ * Only nodes with [Node.isFavorite] are included. They are sorted alphabetically by
+ * [User.long_name], falling back to [User.short_name] when the long name is empty —
+ * matching the alphabetical sort used by the phone's node list when filtered to favorites.
+ */
+ fun sortFavorites(nodes: Collection): List =
+ nodes
+ .filter { it.isFavorite }
+ .sortedWith(compareBy { it.user.long_name.ifEmpty { it.user.short_name } })
+
+ /**
+ * Returns the primary status line for a favorite-node row (Text 1 in the Car UI row).
+ *
+ * Mirrors NodeItem's signal row:
+ * - `"Online · Direct"` when [Node.hopsAway] == 0
+ * - `"Online · N hops"` when [Node.hopsAway] > 0
+ * - `"Online"` when hop distance is unknown
+ * - `"Offline · "` when [Node.lastHeard] is set
+ * - `"Offline"` otherwise
+ *
+ * @param formatRelativeTime Converts a millis timestamp to a human-readable "X ago" string.
+ * Defaults to [DateFormatter.formatRelativeTime]; injectable for testing.
+ */
+ fun nodeStatusText(
+ node: Node,
+ formatRelativeTime: (Long) -> String = DateFormatter::formatRelativeTime,
+ ): String = buildString {
+ if (node.isOnline) {
+ append("Online")
+ when {
+ node.hopsAway == 0 -> append(" · Direct")
+ node.hopsAway > 0 -> append(" · ${node.hopsAway} hops")
+ }
+ } else {
+ append("Offline")
+ if (node.lastHeard > 0) {
+ append(" · ${formatRelativeTime(node.lastHeard * 1000L)}")
+ }
+ }
+ }
+
+ /**
+ * Returns the secondary detail line for a favorite-node row (Text 2 in the Car UI row).
+ *
+ * Mirrors NodeItem's battery row + node chip: `"NODE · 85%"`.
+ * Returns an empty string when neither short name nor battery level is available.
+ */
+ fun nodeDetailText(node: Node): String = buildString {
+ val shortName = node.user.short_name
+ if (shortName.isNotEmpty()) append(shortName)
+ val battery = node.batteryStr
+ if (battery.isNotEmpty()) {
+ if (isNotEmpty()) append(" · ")
+ append(battery)
+ }
+ }
+
+ /**
+ * Returns the message preview line for a contact row (Text 1 in the Car UI row).
+ *
+ * Mirrors `ChatMetadata`'s `lastMessageText` display: shows the last message text
+ * (with sender prefix for received DMs), or `"No messages yet"` for empty channels.
+ */
+ fun contactPreviewText(contact: CarContact): String =
+ contact.lastMessageText?.takeIf { it.isNotEmpty() } ?: "No messages yet"
+
+ /**
+ * Returns the secondary metadata line for a contact row (Text 2 in the Car UI row).
+ *
+ * Mirrors ContactItem's unread badge + date header:
+ * - `"N unread"` when there are unread messages
+ * - Formatted short date of the last message otherwise
+ * - Empty string when there are no messages at all
+ *
+ * @param formatShortDate Converts a millis timestamp to a short date string.
+ * Defaults to [DateFormatter.formatShortDate]; injectable for testing.
+ */
+ fun contactSecondaryText(
+ contact: CarContact,
+ formatShortDate: (Long) -> String = DateFormatter::formatShortDate,
+ ): String = when {
+ contact.unreadCount > 0 -> "${contact.unreadCount} unread"
+ contact.lastMessageTime != null -> formatShortDate(contact.lastMessageTime)
+ else -> ""
+ }
+}
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 8135ce9f0..da1983be3 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
@@ -45,17 +45,12 @@ import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
-import org.meshtastic.core.common.util.DateFormatter
import org.meshtastic.core.model.ConnectionState
-import org.meshtastic.core.model.DataPacket
import org.meshtastic.core.model.Node
-import org.meshtastic.core.model.util.getChannel
import org.meshtastic.core.repository.NodeRepository
import org.meshtastic.core.repository.PacketRepository
import org.meshtastic.core.repository.RadioConfigRepository
import org.meshtastic.core.repository.ServiceRepository
-import org.meshtastic.proto.ChannelSet
-import org.meshtastic.proto.PortNum
/**
* Root screen displayed in Android Auto.
@@ -68,6 +63,10 @@ import org.meshtastic.proto.PortNum
* sorted by most-recent message descending. This is the same ordering used by
* [org.meshtastic.feature.messaging.ui.contact.ContactsViewModel].
*
+ * Pure business-logic (contact ordering, row text, favourites sorting) is separated into
+ * [CarScreenDataBuilder], which is free of Car App Library dependencies and is unit-tested
+ * independently.
+ *
* `TabTemplate` requires Car API level 6. On hosts running Car API level 1–5 the screen falls
* back to a single [ListTemplate] that includes a status row, favorite-node rows, and the
* contact list.
@@ -132,23 +131,23 @@ class MeshtasticCarScreen(carContext: CarContext) :
radioConfigRepository.channelSetFlow,
) { myId, rawContacts, channelSet ->
// Channel placeholders are always included so every configured channel is
- // visible even before any messages have been sent/received — mirroring the
- // behaviour of ContactsViewModel.contactList.
- val placeholders = buildChannelPlaceholders(channelSet)
+ // visible even before any messages have been sent/received.
+ val placeholders = CarScreenDataBuilder.buildChannelPlaceholders(channelSet)
// Real DB entries take precedence over placeholders when present.
val merged = rawContacts + (placeholders - rawContacts.keys)
- buildCarContacts(merged, myId, channelSet)
+ CarScreenDataBuilder.buildCarContacts(merged, myId, channelSet) { userId ->
+ nodeRepository.getUser(userId)
+ }
}
.distinctUntilChanged()
.flatMapLatest { baseContacts ->
if (baseContacts.isEmpty()) {
flowOf(emptyList())
} else {
- val unreadFlows =
- baseContacts.map { contact ->
- packetRepository.getUnreadCountFlow(contact.contactKey)
- .map { unread -> contact.copy(unreadCount = unread) }
- }
+ val unreadFlows = baseContacts.map { contact ->
+ packetRepository.getUnreadCountFlow(contact.contactKey)
+ .map { unread -> contact.copy(unreadCount = unread) }
+ }
combine(unreadFlows) { it.toList() }
}
}
@@ -167,14 +166,10 @@ class MeshtasticCarScreen(carContext: CarContext) :
}
}
- // Favorite nodes — filter nodeDBbyNum to isFavorite, sort alphabetically.
+ // Favorite nodes — filter to isFavorite only, sort alphabetically.
scope.launch {
nodeRepository.nodeDBbyNum
- .map { db ->
- db.values
- .filter { it.isFavorite }
- .sortedWith(compareBy { it.user.long_name.ifEmpty { it.user.short_name } })
- }
+ .map { db -> CarScreenDataBuilder.sortFavorites(db.values) }
.distinctUntilChanged()
.collect { nodes ->
favorites = nodes
@@ -183,70 +178,6 @@ class MeshtasticCarScreen(carContext: CarContext) :
}
}
- /** Returns a map of `"^all" → placeholder DataPacket` for every configured channel. */
- private fun buildChannelPlaceholders(channelSet: ChannelSet): Map =
- (0 until channelSet.settings.size).associate { ch ->
- // dataType uses PortNum.TEXT_MESSAGE_APP (value 1) to match the placeholder
- // construction in ContactsViewModel and PacketRepository contact queries.
- "${ch}${DataPacket.ID_BROADCAST}" to
- DataPacket(bytes = null, dataType = PortNum.TEXT_MESSAGE_APP.value, time = 0L, channel = ch)
- }
-
- /**
- * Converts the merged DB + placeholder map into an ordered [CarContact] list.
- *
- * Channels (keys ending with [DataPacket.ID_BROADCAST]) appear first sorted by channel index.
- * DM conversations follow sorted by [CarContact.lastMessageTime] descending — matching the
- * ordering used by the phone's Contacts screen.
- */
- private fun buildCarContacts(
- merged: Map,
- myId: String?,
- channelSet: ChannelSet,
- ): List {
- val all =
- merged.map { (contactKey, packet) ->
- val fromLocal = packet.from == DataPacket.ID_LOCAL || packet.from == myId
- val toBroadcast = packet.to == DataPacket.ID_BROADCAST
- val userId = if (fromLocal) packet.to else packet.from
-
- // Resolve the user once; used for both displayName and message prefix.
- val user = nodeRepository.getUser(userId ?: DataPacket.ID_BROADCAST)
-
- val displayName =
- if (toBroadcast) {
- channelSet.getChannel(packet.channel)?.name?.takeIf { it.isNotEmpty() }
- ?: "Channel ${packet.channel}"
- } else {
- // userId can be null for malformed packets (e.g. both `from` and `to`
- // are null). Fall back to a broadcast lookup which returns an "Unknown"
- // user rather than crashing.
- user.long_name.ifEmpty { user.short_name }.ifEmpty { "Unknown" }
- }
-
- // Mirror ContactsViewModel: prefix received DM text with the sender's short name,
- // matching how ContactItem's ChatMetadata renders lastMessageText.
- val shortName = if (!toBroadcast) user.short_name else ""
- val lastMessageText =
- packet.text?.let { text ->
- if (fromLocal || shortName.isEmpty()) text else "$shortName: $text"
- }
-
- CarContact(
- contactKey = contactKey,
- displayName = displayName,
- unreadCount = 0, // filled in reactively by flatMapLatest below
- isBroadcast = toBroadcast,
- channelIndex = packet.channel,
- lastMessageTime = if (packet.time != 0L) packet.time else null,
- lastMessageText = lastMessageText,
- )
- }
-
- return all.filter { it.isBroadcast }.sortedBy { it.channelIndex } +
- all.filter { !it.isBroadcast }.sortedByDescending { it.lastMessageTime ?: 0L }
- }
-
// ---- Template building ----
override fun onGetTemplate(): Template {
@@ -265,44 +196,24 @@ class MeshtasticCarScreen(carContext: CarContext) :
return buildFallbackListTemplate()
}
- val tabCallback =
- object : TabTemplate.TabCallback {
- override fun onTabSelected(tabContentId: String) {
- activeTabId = tabContentId
- invalidate()
- }
+ val tabCallback = object : TabTemplate.TabCallback {
+ override fun onTabSelected(tabContentId: String) {
+ activeTabId = tabContentId
+ invalidate()
}
+ }
- val activeContent =
- when (activeTabId) {
- TAB_FAVORITES -> TabContents.Builder(buildFavoritesTemplate()).build()
- TAB_MESSAGES -> TabContents.Builder(buildMessagesTemplate()).build()
- else -> TabContents.Builder(buildStatusTemplate()).build()
- }
+ val activeContent = when (activeTabId) {
+ TAB_FAVORITES -> TabContents.Builder(buildFavoritesTemplate()).build()
+ TAB_MESSAGES -> TabContents.Builder(buildMessagesTemplate()).build()
+ else -> TabContents.Builder(buildStatusTemplate()).build()
+ }
return TabTemplate.Builder(tabCallback)
.setHeaderAction(Action.APP_ICON)
- .addTab(
- Tab.Builder()
- .setTitle("Status")
- .setIcon(carIcon(R.drawable.auto_ic_status))
- .setContentId(TAB_STATUS)
- .build(),
- )
- .addTab(
- Tab.Builder()
- .setTitle("Favorites")
- .setIcon(carIcon(R.drawable.auto_ic_favorites))
- .setContentId(TAB_FAVORITES)
- .build(),
- )
- .addTab(
- Tab.Builder()
- .setTitle("Messages")
- .setIcon(carIcon(R.drawable.auto_ic_channels))
- .setContentId(TAB_MESSAGES)
- .build(),
- )
+ .addTab(Tab.Builder().setTitle("Status").setIcon(carIcon(R.drawable.auto_ic_status)).setContentId(TAB_STATUS).build())
+ .addTab(Tab.Builder().setTitle("Favorites").setIcon(carIcon(R.drawable.auto_ic_favorites)).setContentId(TAB_FAVORITES).build())
+ .addTab(Tab.Builder().setTitle("Messages").setIcon(carIcon(R.drawable.auto_ic_channels)).setContentId(TAB_MESSAGES).build())
.setTabContents(activeContent)
.setActiveTabContentId(activeTabId)
.build()
@@ -338,79 +249,15 @@ class MeshtasticCarScreen(carContext: CarContext) :
* mirrors the signal row and last-heard chip in NodeItem.
* - **Text 2**: battery percentage and short name — mirrors the battery row and node chip.
*/
- private fun buildFavoritesTemplate(): ListTemplate {
- val items = ItemList.Builder()
- val capped = favorites.take(MAX_LIST_ITEMS)
- if (capped.isEmpty()) {
- items.setNoItemsMessage("No favorite nodes")
- } else {
- capped.forEach { node -> items.addItem(buildFavoriteNodeRow(node)) }
- }
- return ListTemplate.Builder().setTitle("Favorites").setSingleList(items.build()).build()
- }
-
- /**
- * Builds a single favorite-node row.
- *
- * Mirrors the content of [org.meshtastic.feature.node.component.NodeItem]:
- * - Title → `long_name` (prominent, matches NodeItem header text)
- * - Text 1 → online/offline + hop distance (matches NodeItem signal row)
- * - Text 2 → battery level + short name chip equivalent (matches NodeItem battery row)
- */
- private fun buildFavoriteNodeRow(node: Node): Row {
- val name = node.user.long_name.ifEmpty { node.user.short_name }.ifEmpty { "Unknown" }
-
- // Mirror NodeItem's signal row: online status + hops / direct info.
- val statusText = buildString {
- if (node.isOnline) {
- append("Online")
- when {
- node.hopsAway == 0 -> append(" · Direct")
- node.hopsAway > 0 -> append(" · ${node.hopsAway} hops")
- }
- } else {
- append("Offline")
- if (node.lastHeard > 0) {
- // DateFormatter.formatRelativeTime takes millis; lastHeard is in seconds.
- val ago = DateFormatter.formatRelativeTime(node.lastHeard * 1000L)
- append(" · $ago")
- }
- }
- }
-
- // Mirror NodeItem's battery row + node chip: "[SHORT] · 85%" or just "[SHORT]".
- val detailText = buildString {
- val shortName = node.user.short_name
- if (shortName.isNotEmpty()) append(shortName)
- val battery = node.batteryLevelStr
- if (battery.isNotEmpty()) {
- if (isNotEmpty()) append(" · ")
- append(battery)
- }
- }
-
- return Row.Builder()
- .setTitle(name)
- .addText(statusText)
- .apply { if (detailText.isNotEmpty()) addText(detailText) }
- .setBrowsable(false)
- .build()
- }
+ private fun buildFavoritesTemplate(): ListTemplate =
+ buildListTemplate("Favorites", favorites, "No favorite nodes") { buildFavoriteNodeRow(it) }
/**
* Builds the Messages tab content: channels first (always present, even if empty), followed
* by DM conversations sorted by most-recent message — identical to the phone's Contacts screen.
*/
- private fun buildMessagesTemplate(): ListTemplate {
- val items = ItemList.Builder()
- val capped = contacts.take(MAX_LIST_ITEMS)
- if (capped.isEmpty()) {
- items.setNoItemsMessage("No conversations")
- } else {
- capped.forEach { contact -> items.addItem(buildContactRow(contact)) }
- }
- return ListTemplate.Builder().setTitle("Messages").setSingleList(items.build()).build()
- }
+ private fun buildMessagesTemplate(): ListTemplate =
+ buildListTemplate("Messages", contacts, "No conversations") { buildContactRow(it) }
/**
* Fallback for Car API level 1–5 hosts that do not support [TabTemplate].
@@ -439,17 +286,34 @@ class MeshtasticCarScreen(carContext: CarContext) :
}
private fun buildStatusRow(): Row {
- val statusText =
- when (connectionState) {
- is ConnectionState.Connected -> "Connected"
- is ConnectionState.Disconnected -> "Disconnected"
- is ConnectionState.DeviceSleep -> "Device Sleeping"
- is ConnectionState.Connecting -> "Connecting…"
- }
+ val statusText = when (connectionState) {
+ is ConnectionState.Connected -> "Connected"
+ is ConnectionState.Disconnected -> "Disconnected"
+ is ConnectionState.DeviceSleep -> "Device Sleeping"
+ is ConnectionState.Connecting -> "Connecting…"
+ }
val deviceName = nodeRepository.ourNodeInfo.value?.user?.long_name.orEmpty()
return Row.Builder()
.setTitle(statusText)
- .apply { if (deviceName.isNotEmpty()) addText(deviceName) }
+ .addTextIfNotEmpty(deviceName)
+ .setBrowsable(false)
+ .build()
+ }
+
+ /**
+ * Builds a single favorite-node row.
+ *
+ * Mirrors the content of [org.meshtastic.feature.node.component.NodeItem]:
+ * - Title → `long_name` (prominent, matches NodeItem header text)
+ * - Text 1 → online/offline + hop distance (matches NodeItem signal row)
+ * - Text 2 → battery level + short name chip equivalent (matches NodeItem battery row)
+ */
+ private fun buildFavoriteNodeRow(node: Node): Row {
+ val name = node.user.long_name.ifEmpty { node.user.short_name }.ifEmpty { "Unknown" }
+ return Row.Builder()
+ .setTitle(name)
+ .addText(CarScreenDataBuilder.nodeStatusText(node))
+ .addTextIfNotEmpty(CarScreenDataBuilder.nodeDetailText(node))
.setBrowsable(false)
.build()
}
@@ -464,48 +328,41 @@ class MeshtasticCarScreen(carContext: CarContext) :
* - **Text 2** → `"N unread"` when there are unread messages, or the last-message timestamp
* when there are none (matches the unread badge and date in ContactHeader/ChatMetadata).
*/
- private fun buildContactRow(contact: CarContact): Row {
- // Mirror ChatMetadata: show the last message text or a placeholder for empty channels.
- val preview = contact.lastMessageText?.takeIf { it.isNotEmpty() } ?: "No messages yet"
-
- // Mirror ContactItem header date + ChatMetadata unread badge.
- val secondaryText = when {
- contact.unreadCount > 0 -> "${contact.unreadCount} unread"
- contact.lastMessageTime != null ->
- DateFormatter.formatShortDate(contact.lastMessageTime)
- else -> ""
- }
-
- return Row.Builder()
+ private fun buildContactRow(contact: CarContact): Row =
+ Row.Builder()
.setTitle(contact.displayName)
- .addText(preview)
- .apply { if (secondaryText.isNotEmpty()) addText(secondaryText) }
+ .addText(CarScreenDataBuilder.contactPreviewText(contact))
+ .addTextIfNotEmpty(CarScreenDataBuilder.contactSecondaryText(contact))
.setBrowsable(false)
.build()
- }
private fun carIcon(resId: Int) =
CarIcon.Builder(IconCompat.createWithResource(carContext, resId)).setTint(CarColor.DEFAULT).build()
- // ---- Internal model ----
+ /** Adds [text] as a new text line only when it is non-empty, avoiding blank Car UI rows. */
+ private fun Row.Builder.addTextIfNotEmpty(text: String): Row.Builder =
+ apply { if (text.isNotEmpty()) addText(text) }
/**
- * Lightweight projection of a conversation used exclusively within this screen.
+ * DRY helper: builds a [ListTemplate] from a list of items, capping at [MAX_LIST_ITEMS].
*
- * [isBroadcast] and [channelIndex] drive ordering (channels before DMs, channels sorted by
- * index). [lastMessageTime] drives DM ordering (most-recent first).
- * [lastMessageText] mirrors `ContactsViewModel.contactList`'s `lastMessageText` — received
- * DMs are prefixed with the sender's short name, matching [ContactItem]'s ChatMetadata.
+ * Shows [noItemsMessage] when the list is empty.
*/
- private data class CarContact(
- val contactKey: String,
- val displayName: String,
- val unreadCount: Int,
- val isBroadcast: Boolean,
- val channelIndex: Int,
- val lastMessageTime: Long?,
- val lastMessageText: String?,
- )
+ private fun buildListTemplate(
+ title: String,
+ items: List,
+ noItemsMessage: String,
+ buildRow: (T) -> Row,
+ ): ListTemplate {
+ val listBuilder = ItemList.Builder()
+ val capped = items.take(MAX_LIST_ITEMS)
+ if (capped.isEmpty()) {
+ listBuilder.setNoItemsMessage(noItemsMessage)
+ } else {
+ capped.forEach { listBuilder.addItem(buildRow(it)) }
+ }
+ return ListTemplate.Builder().setTitle(title).setSingleList(listBuilder.build()).build()
+ }
companion object {
private const val TAB_STATUS = "status"
@@ -520,4 +377,3 @@ class MeshtasticCarScreen(carContext: CarContext) :
private const val MAX_LIST_ITEMS = 6
}
}
-
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
new file mode 100644
index 000000000..2d753de65
--- /dev/null
+++ b/feature/auto/src/test/kotlin/org/meshtastic/feature/auto/CarScreenDataBuilderTest.kt
@@ -0,0 +1,538 @@
+/*
+ * Copyright (c) 2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+package org.meshtastic.feature.auto
+
+import io.kotest.matchers.collections.shouldBeEmpty
+import io.kotest.matchers.collections.shouldHaveSize
+import io.kotest.matchers.shouldBe
+import io.kotest.matchers.string.shouldBeEmpty
+import io.kotest.matchers.string.shouldContain
+import io.kotest.matchers.string.shouldNotContain
+import org.meshtastic.core.model.DataPacket
+import org.meshtastic.core.model.DeviceMetrics
+import org.meshtastic.core.model.Node
+import org.meshtastic.proto.ChannelSet
+import org.meshtastic.proto.ChannelSettings
+import org.meshtastic.proto.User
+import kotlin.test.Test
+
+/**
+ * Unit tests for [CarScreenDataBuilder].
+ *
+ * All tests are pure JVM — no Android framework or Car App Library dependencies required.
+ * Time formatters are injected as lambdas returning fixed strings to keep assertions deterministic.
+ */
+class CarScreenDataBuilderTest {
+
+ // ---- buildChannelPlaceholders ----
+
+ @Test
+ fun `buildChannelPlaceholders - empty channelSet returns empty map`() {
+ val result = CarScreenDataBuilder.buildChannelPlaceholders(ChannelSet())
+ result.shouldBeEmpty()
+ }
+
+ @Test
+ fun `buildChannelPlaceholders - single channel produces correct contact key`() {
+ val channelSet = ChannelSet(settings = listOf(ChannelSettings(name = "LongFast")))
+ val result = CarScreenDataBuilder.buildChannelPlaceholders(channelSet)
+
+ result.keys shouldBe setOf("0${DataPacket.ID_BROADCAST}")
+ }
+
+ @Test
+ fun `buildChannelPlaceholders - three channels produce three distinct keys`() {
+ val channelSet = ChannelSet(
+ settings = listOf(
+ ChannelSettings(name = "Ch0"),
+ ChannelSettings(name = "Ch1"),
+ ChannelSettings(name = "Ch2"),
+ ),
+ )
+ val result = CarScreenDataBuilder.buildChannelPlaceholders(channelSet)
+
+ result shouldHaveSize 3
+ result.keys shouldBe setOf("0^all", "1^all", "2^all")
+ }
+
+ @Test
+ fun `buildChannelPlaceholders - placeholder packets have zero time`() {
+ val channelSet = ChannelSet(settings = listOf(ChannelSettings(name = "Test")))
+ val packet = CarScreenDataBuilder.buildChannelPlaceholders(channelSet).values.first()
+
+ packet.time shouldBe 0L
+ packet.to shouldBe DataPacket.ID_BROADCAST
+ }
+
+ // ---- buildCarContacts - display names ----
+
+ @Test
+ fun `buildCarContacts - broadcast contact uses channel name from channelSet`() {
+ val channelSet = ChannelSet(settings = listOf(ChannelSettings(name = "LongFast")))
+ val packet = DataPacket(bytes = null, dataType = 1, time = 1000L, channel = 0).apply {
+ to = DataPacket.ID_BROADCAST
+ from = DataPacket.ID_LOCAL
+ }
+
+ val contacts = CarScreenDataBuilder.buildCarContacts(
+ merged = mapOf("0^all" to packet),
+ myId = "!aabbccdd",
+ channelSet = channelSet,
+ resolveUser = { User() },
+ )
+
+ contacts shouldHaveSize 1
+ contacts[0].displayName shouldBe "LongFast"
+ contacts[0].isBroadcast shouldBe true
+ }
+
+ @Test
+ fun `buildCarContacts - broadcast contact uses Channel N fallback when name is empty`() {
+ val channelSet = ChannelSet(settings = listOf(ChannelSettings(name = "")))
+ val packet = DataPacket(bytes = null, dataType = 1, time = 1000L, channel = 0).apply {
+ to = DataPacket.ID_BROADCAST
+ from = DataPacket.ID_LOCAL
+ }
+
+ val contacts = CarScreenDataBuilder.buildCarContacts(
+ merged = mapOf("0^all" to packet),
+ myId = null,
+ channelSet = channelSet,
+ resolveUser = { User() },
+ )
+
+ contacts[0].displayName shouldBe "Channel 0"
+ }
+
+ @Test
+ fun `buildCarContacts - DM contact uses sender long name`() {
+ val senderUser = User(id = "!sender", long_name = "Alice Tester", short_name = "ALIC")
+ val packet = DataPacket(bytes = null, dataType = 1, time = 2000L, channel = 0).apply {
+ to = "!localnode"
+ from = "!sender"
+ }
+
+ val contacts = CarScreenDataBuilder.buildCarContacts(
+ merged = mapOf("!sender" to packet),
+ myId = "!localnode",
+ channelSet = ChannelSet(),
+ resolveUser = { if (it == "!sender") senderUser else User() },
+ )
+
+ contacts[0].displayName shouldBe "Alice Tester"
+ contacts[0].isBroadcast shouldBe false
+ }
+
+ @Test
+ fun `buildCarContacts - DM contact falls back to short name when long name is blank`() {
+ val senderUser = User(id = "!sender", long_name = "", short_name = "ALIC")
+ val packet = DataPacket(bytes = null, dataType = 1, time = 2000L, channel = 0).apply {
+ to = "!localnode"
+ from = "!sender"
+ }
+
+ val contacts = CarScreenDataBuilder.buildCarContacts(
+ merged = mapOf("!sender" to packet),
+ myId = "!localnode",
+ channelSet = ChannelSet(),
+ resolveUser = { senderUser },
+ )
+
+ contacts[0].displayName shouldBe "ALIC"
+ }
+
+ // ---- buildCarContacts - lastMessageText ----
+
+ @Test
+ fun `buildCarContacts - received DM prefixes lastMessageText with sender short name`() {
+ val senderUser = User(id = "!sender", long_name = "Alice", short_name = "ALIC")
+ val packet = DataPacket(to = "!me", channel = 0, text = "Hello!")
+ packet.from = "!sender"
+
+ val contacts = CarScreenDataBuilder.buildCarContacts(
+ merged = mapOf("!sender" to packet),
+ myId = "!me",
+ channelSet = ChannelSet(),
+ resolveUser = { senderUser },
+ )
+
+ contacts[0].lastMessageText shouldBe "ALIC: Hello!"
+ }
+
+ @Test
+ fun `buildCarContacts - sent DM does not prefix lastMessageText`() {
+ val recipientUser = User(id = "!bob", long_name = "Bob", short_name = "BOB")
+ val packet = DataPacket(to = "!bob", channel = 0, text = "Hey Bob")
+ packet.from = "!me"
+
+ val contacts = CarScreenDataBuilder.buildCarContacts(
+ merged = mapOf("!bob" to packet),
+ myId = "!me",
+ channelSet = ChannelSet(),
+ resolveUser = { recipientUser },
+ )
+
+ // Sent message — no prefix
+ contacts[0].lastMessageText shouldBe "Hey Bob"
+ }
+
+ @Test
+ fun `buildCarContacts - null packet text yields null lastMessageText`() {
+ val packet = DataPacket(bytes = null, dataType = 1, time = 1000L, channel = 0).apply {
+ to = DataPacket.ID_BROADCAST
+ from = DataPacket.ID_LOCAL
+ }
+ val channelSet = ChannelSet(settings = listOf(ChannelSettings(name = "Ch0")))
+
+ val contacts = CarScreenDataBuilder.buildCarContacts(
+ merged = mapOf("0^all" to packet),
+ myId = null,
+ channelSet = channelSet,
+ resolveUser = { User() },
+ )
+
+ contacts[0].lastMessageText shouldBe null
+ }
+
+ // ---- buildCarContacts - ordering ----
+
+ @Test
+ fun `buildCarContacts - channel contacts appear before DM contacts`() {
+ val channelSet = ChannelSet(settings = listOf(ChannelSettings(name = "Ch0")))
+ val channelPacket = DataPacket(bytes = null, dataType = 1, time = 1000L, channel = 0).apply {
+ to = DataPacket.ID_BROADCAST
+ from = DataPacket.ID_LOCAL
+ }
+ val dmPacket = DataPacket(bytes = null, dataType = 1, time = 2000L, channel = 0).apply {
+ to = "!me"
+ from = "!alice"
+ }
+
+ val contacts = CarScreenDataBuilder.buildCarContacts(
+ merged = mapOf("!alice" to dmPacket, "0^all" to channelPacket),
+ myId = "!me",
+ channelSet = channelSet,
+ resolveUser = { User() },
+ )
+
+ contacts[0].isBroadcast shouldBe true
+ contacts[1].isBroadcast shouldBe false
+ }
+
+ @Test
+ fun `buildCarContacts - channels are sorted by channelIndex ascending`() {
+ val channelSet = ChannelSet(
+ settings = listOf(
+ ChannelSettings(name = "Ch0"),
+ ChannelSettings(name = "Ch1"),
+ ChannelSettings(name = "Ch2"),
+ ),
+ )
+ // Insert in reverse order to verify sorting is applied
+ val packets = mapOf(
+ "2^all" to makeChannelPacket(ch = 2),
+ "0^all" to makeChannelPacket(ch = 0),
+ "1^all" to makeChannelPacket(ch = 1),
+ )
+
+ val contacts = CarScreenDataBuilder.buildCarContacts(
+ merged = packets,
+ myId = null,
+ channelSet = channelSet,
+ resolveUser = { User() },
+ )
+
+ contacts.map { it.channelIndex } shouldBe listOf(0, 1, 2)
+ }
+
+ @Test
+ fun `buildCarContacts - DMs are sorted by lastMessageTime descending`() {
+ val dmOld = makeDmPacket(from = "!alice", to = "!me", time = 1_000L)
+ val dmNew = makeDmPacket(from = "!bob", to = "!me", time = 3_000L)
+ val dmMid = makeDmPacket(from = "!carol", to = "!me", time = 2_000L)
+
+ val contacts = CarScreenDataBuilder.buildCarContacts(
+ merged = mapOf("!alice" to dmOld, "!carol" to dmMid, "!bob" to dmNew),
+ myId = "!me",
+ channelSet = ChannelSet(),
+ resolveUser = { userId ->
+ when (userId) {
+ "!alice" -> User(id = "!alice", long_name = "Alice")
+ "!bob" -> User(id = "!bob", long_name = "Bob")
+ "!carol" -> User(id = "!carol", long_name = "Carol")
+ else -> User()
+ }
+ },
+ )
+
+ contacts.map { it.displayName } shouldBe listOf("Bob", "Carol", "Alice")
+ }
+
+ // ---- sortFavorites ----
+
+ @Test
+ fun `sortFavorites - excludes non-favorite nodes`() {
+ val nodes = listOf(
+ Node(num = 1, user = User(long_name = "Alice"), isFavorite = false),
+ Node(num = 2, user = User(long_name = "Bob"), isFavorite = true),
+ )
+ val result = CarScreenDataBuilder.sortFavorites(nodes)
+
+ result shouldHaveSize 1
+ result[0].user.long_name shouldBe "Bob"
+ }
+
+ @Test
+ fun `sortFavorites - results are sorted alphabetically by long name`() {
+ val nodes = listOf(
+ Node(num = 3, user = User(long_name = "Charlie"), isFavorite = true),
+ Node(num = 1, user = User(long_name = "Alice"), isFavorite = true),
+ Node(num = 2, user = User(long_name = "Bob"), isFavorite = true),
+ )
+ val result = CarScreenDataBuilder.sortFavorites(nodes)
+
+ result.map { it.user.long_name } shouldBe listOf("Alice", "Bob", "Charlie")
+ }
+
+ @Test
+ fun `sortFavorites - falls back to short name when long name is empty`() {
+ val nodes = listOf(
+ Node(num = 2, user = User(long_name = "", short_name = "ZZZ"), isFavorite = true),
+ Node(num = 1, user = User(long_name = "", short_name = "AAA"), isFavorite = true),
+ )
+ val result = CarScreenDataBuilder.sortFavorites(nodes)
+
+ result[0].user.short_name shouldBe "AAA"
+ result[1].user.short_name shouldBe "ZZZ"
+ }
+
+ @Test
+ fun `sortFavorites - empty collection returns empty list`() {
+ CarScreenDataBuilder.sortFavorites(emptyList()).shouldBeEmpty()
+ }
+
+ // ---- nodeStatusText ----
+
+ @Test
+ fun `nodeStatusText - online node with hopsAway 0 shows Direct`() {
+ val node = onlineNode(hopsAway = 0)
+ val text = CarScreenDataBuilder.nodeStatusText(node)
+
+ text shouldContain "Online"
+ text shouldContain "Direct"
+ }
+
+ @Test
+ fun `nodeStatusText - online node with 2 hops shows hops count`() {
+ val node = onlineNode(hopsAway = 2)
+ val text = CarScreenDataBuilder.nodeStatusText(node)
+
+ text shouldContain "Online"
+ text shouldContain "2 hops"
+ }
+
+ @Test
+ fun `nodeStatusText - online node with unknown hops shows just Online`() {
+ val node = onlineNode(hopsAway = -1)
+ val text = CarScreenDataBuilder.nodeStatusText(node)
+
+ text shouldBe "Online"
+ }
+
+ @Test
+ fun `nodeStatusText - offline node with no lastHeard shows just Offline`() {
+ val node = Node(num = 1, user = User(long_name = "Test"), isFavorite = true, lastHeard = 0)
+ val text = CarScreenDataBuilder.nodeStatusText(node)
+
+ text shouldBe "Offline"
+ }
+
+ @Test
+ fun `nodeStatusText - offline node with lastHeard calls formatRelativeTime`() {
+ val node = Node(num = 1, user = User(long_name = "Test"), isFavorite = true, lastHeard = 12345)
+ val text = CarScreenDataBuilder.nodeStatusText(node, formatRelativeTime = { "3h ago" })
+
+ text shouldBe "Offline · 3h ago"
+ }
+
+ @Test
+ fun `nodeStatusText - formatRelativeTime receives lastHeard in millis`() {
+ val lastHeardSecs = 100_000
+ var receivedMillis = 0L
+ val node = Node(num = 1, user = User(long_name = "Test"), isFavorite = true, lastHeard = lastHeardSecs)
+ CarScreenDataBuilder.nodeStatusText(node, formatRelativeTime = { millis ->
+ receivedMillis = millis
+ "ago"
+ })
+
+ receivedMillis shouldBe lastHeardSecs * 1000L
+ }
+
+ // ---- nodeDetailText ----
+
+ @Test
+ fun `nodeDetailText - shows short name and battery separated by bullet`() {
+ val node = Node(
+ num = 1,
+ user = User(long_name = "Alice", short_name = "ALIC"),
+ isFavorite = true,
+ deviceMetrics = DeviceMetrics(battery_level = 85),
+ )
+ val text = CarScreenDataBuilder.nodeDetailText(node)
+
+ text shouldContain "ALIC"
+ text shouldContain "85%"
+ text shouldContain "·"
+ }
+
+ @Test
+ fun `nodeDetailText - shows only short name when no battery data`() {
+ val node = Node(
+ num = 1,
+ user = User(long_name = "Alice", short_name = "ALIC"),
+ isFavorite = true,
+ )
+ val text = CarScreenDataBuilder.nodeDetailText(node)
+
+ text shouldBe "ALIC"
+ }
+
+ @Test
+ fun `nodeDetailText - returns empty string when no short name and no battery`() {
+ val node = Node(num = 1, user = User(long_name = "Alice", short_name = ""), isFavorite = true)
+ CarScreenDataBuilder.nodeDetailText(node).shouldBeEmpty()
+ }
+
+ @Test
+ fun `nodeDetailText - shows only battery when short name is blank`() {
+ val node = Node(
+ num = 1,
+ user = User(long_name = "Alice", short_name = ""),
+ isFavorite = true,
+ deviceMetrics = DeviceMetrics(battery_level = 72),
+ )
+ val text = CarScreenDataBuilder.nodeDetailText(node)
+
+ text shouldBe "72%"
+ text shouldNotContain "·"
+ }
+
+ // ---- contactPreviewText ----
+
+ @Test
+ fun `contactPreviewText - returns lastMessageText when present`() {
+ val contact = makeCarContact(lastMessageText = "Hello world")
+ CarScreenDataBuilder.contactPreviewText(contact) shouldBe "Hello world"
+ }
+
+ @Test
+ fun `contactPreviewText - returns No messages yet when lastMessageText is null`() {
+ val contact = makeCarContact(lastMessageText = null)
+ CarScreenDataBuilder.contactPreviewText(contact) shouldBe "No messages yet"
+ }
+
+ @Test
+ fun `contactPreviewText - returns No messages yet when lastMessageText is empty`() {
+ val contact = makeCarContact(lastMessageText = "")
+ CarScreenDataBuilder.contactPreviewText(contact) shouldBe "No messages yet"
+ }
+
+ // ---- contactSecondaryText ----
+
+ @Test
+ fun `contactSecondaryText - shows N unread when unreadCount is positive`() {
+ val contact = makeCarContact(unreadCount = 5)
+ CarScreenDataBuilder.contactSecondaryText(contact) shouldBe "5 unread"
+ }
+
+ @Test
+ fun `contactSecondaryText - calls formatShortDate when no unread but time is set`() {
+ val contact = makeCarContact(unreadCount = 0, lastMessageTime = 999_999L)
+ val text = CarScreenDataBuilder.contactSecondaryText(contact, formatShortDate = { "Jan 1" })
+
+ text shouldBe "Jan 1"
+ }
+
+ @Test
+ fun `contactSecondaryText - formatShortDate receives the exact lastMessageTime`() {
+ val timestamp = 123_456_789L
+ val contact = makeCarContact(unreadCount = 0, lastMessageTime = timestamp)
+ var received = 0L
+ CarScreenDataBuilder.contactSecondaryText(contact, formatShortDate = { millis ->
+ received = millis
+ "date"
+ })
+
+ received shouldBe timestamp
+ }
+
+ @Test
+ fun `contactSecondaryText - returns empty string when no unread and no lastMessageTime`() {
+ val contact = makeCarContact(unreadCount = 0, lastMessageTime = null)
+ CarScreenDataBuilder.contactSecondaryText(contact).shouldBeEmpty()
+ }
+
+ @Test
+ fun `contactSecondaryText - unread takes precedence over lastMessageTime`() {
+ val contact = makeCarContact(unreadCount = 3, lastMessageTime = 500L)
+ CarScreenDataBuilder.contactSecondaryText(contact, formatShortDate = { "should not appear" }) shouldBe "3 unread"
+ }
+
+ // ---- helpers ----
+
+ /** Returns a node guaranteed to be online (lastHeard == now). */
+ private fun onlineNode(hopsAway: Int): Node {
+ val nowSecs = (System.currentTimeMillis() / 1000).toInt()
+ return Node(
+ num = 1,
+ user = User(long_name = "Test"),
+ isFavorite = true,
+ lastHeard = nowSecs,
+ hopsAway = hopsAway,
+ )
+ }
+
+ private fun makeChannelPacket(ch: Int): DataPacket =
+ DataPacket(bytes = null, dataType = 1, time = 1000L, channel = ch).apply {
+ to = DataPacket.ID_BROADCAST
+ from = DataPacket.ID_LOCAL
+ }
+
+ private fun makeDmPacket(from: String, to: String, time: Long): DataPacket =
+ DataPacket(bytes = null, dataType = 1, time = time, channel = 0).apply {
+ this.from = from
+ this.to = to
+ }
+
+ private fun makeCarContact(
+ contactKey: String = "!test",
+ displayName: String = "Test",
+ unreadCount: Int = 0,
+ isBroadcast: Boolean = false,
+ channelIndex: Int = 0,
+ lastMessageTime: Long? = null,
+ lastMessageText: String? = null,
+ ) = CarContact(
+ contactKey = contactKey,
+ displayName = displayName,
+ unreadCount = unreadCount,
+ isBroadcast = isBroadcast,
+ channelIndex = channelIndex,
+ lastMessageTime = lastMessageTime,
+ lastMessageText = lastMessageText,
+ )
+}