mirror of
https://github.com/meshtastic/Meshtastic-Android.git
synced 2026-05-12 00:28:20 -04:00
fix(auto): ConversationItem API, manifest guards, and detekt compliance
- Raise minCarApiLevel to 7 (required for MESSAGING category) - Add uses-feature android.hardware.type.automotive required=false guard - Migrate Messages tab from plain Rows to ConversationItem (API 7+) - Inject CoroutineDispatchers, SendMessageUseCase, MeshServiceNotifications - Add durable callbackScope for reply/mark-as-read operations - Populate CarContact with lastMessage fields for ConversationItem - Remove dead Car API <6 fallback code and unused string resource - Fix hosts_allowlist rename (car-app 1.7.0), screenManager access - Fix test compilation: DeviceMetrics import, map matchers, junit5 - Fix detekt: suppress TooManyFunctions, LongMethod, MagicNumber, TooGenericExceptionCaught where justified Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
@@ -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)
|
||||
}
|
||||
|
||||
@@ -17,6 +17,16 @@
|
||||
-->
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
<!--
|
||||
This app is an Android Auto (phone-projection) messaging app — it is NOT designed
|
||||
to run standalone on Android Automotive OS (AAOS). Declaring the automotive hardware
|
||||
feature as not required prevents the APK from being offered on AAOS devices.
|
||||
(Play Console device-targeting should also exclude AAOS form factors.)
|
||||
-->
|
||||
<uses-feature
|
||||
android:name="android.hardware.type.automotive"
|
||||
android:required="false" />
|
||||
|
||||
<!-- Android Auto Car App Service for browsable messaging UI -->
|
||||
<application>
|
||||
<service
|
||||
@@ -29,13 +39,13 @@
|
||||
</intent-filter>
|
||||
</service>
|
||||
|
||||
<!-- Car API level 1 is sufficient for MessagingStyle notification projection and
|
||||
ListTemplate. The browsable TabTemplate UI requires Car API 6; the screen
|
||||
detects the host's level at runtime and falls back to a ListTemplate on
|
||||
older hosts so the app remains usable on all vehicles. -->
|
||||
<!-- Car API 7 is the minimum for ConversationItem, which is required for the
|
||||
MESSAGING category's templated experience. Hosts running older API levels
|
||||
still receive the MessagingStyle notification-based experience; only the
|
||||
browsable template UI requires API 7. -->
|
||||
<meta-data
|
||||
android:name="androidx.car.app.minCarApiLevel"
|
||||
android:value="1" />
|
||||
android:value="7" />
|
||||
</application>
|
||||
|
||||
</manifest>
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -27,20 +27,18 @@ import org.meshtastic.proto.PortNum
|
||||
import org.meshtastic.proto.User
|
||||
|
||||
/**
|
||||
* Pure-function helpers that convert domain models into [CarContact] and display strings for
|
||||
* [MeshtasticCarScreen].
|
||||
* Pure-function helpers that convert domain models into [CarContact] and display strings for [MeshtasticCarScreen].
|
||||
*
|
||||
* All methods are free of Car App Library dependencies, making them straightforwardly testable as
|
||||
* plain JVM unit tests without Robolectric.
|
||||
* All methods are free of Car App Library dependencies, making them straightforwardly testable as plain JVM unit tests
|
||||
* without Robolectric.
|
||||
*/
|
||||
internal object CarScreenDataBuilder {
|
||||
|
||||
/**
|
||||
* Returns a map of `"<ch>^all" → placeholder DataPacket` for every configured channel.
|
||||
*
|
||||
* Channel placeholders ensure every configured channel is always visible in the Messages
|
||||
* tab — even before any messages have been sent or received — mirroring the behaviour of
|
||||
* `ContactsViewModel.contactList`.
|
||||
* Channel placeholders ensure every configured channel is always visible in the Messages tab — even before any
|
||||
* messages have been sent or received — mirroring the behaviour of `ContactsViewModel.contactList`.
|
||||
*/
|
||||
fun buildChannelPlaceholders(channelSet: ChannelSet): Map<String, DataPacket> =
|
||||
(0 until channelSet.settings.size).associate { ch ->
|
||||
@@ -51,16 +49,16 @@ internal object CarScreenDataBuilder {
|
||||
/**
|
||||
* Converts the merged DB + placeholder map into an ordered [CarContact] list.
|
||||
*
|
||||
* Channels (keys ending with [DataPacket.ID_BROADCAST]) appear first sorted by channel index.
|
||||
* DM conversations follow sorted by [CarContact.lastMessageTime] descending — matching the
|
||||
* ordering used by the phone's Contacts screen.
|
||||
* Channels (keys ending with [DataPacket.ID_BROADCAST]) appear first sorted by channel index. DM conversations
|
||||
* follow sorted by [CarContact.lastMessageTime] descending — matching the ordering used by the phone's Contacts
|
||||
* screen.
|
||||
*
|
||||
* @param resolveUser Returns the [User] for a given node-ID string. The caller is responsible
|
||||
* for providing a null-safe fallback (typically [NodeRepository.getUser]).
|
||||
* @param channelLabel Produces the display name for a channel given its index.
|
||||
* Defaults to `"Channel N"`; callers can supply a localised string.
|
||||
* @param unknownLabel Fallback display name when neither long name nor short name is available.
|
||||
* Defaults to `"Unknown"`; callers can supply a localised string.
|
||||
* @param resolveUser Returns the [User] for a given node-ID string. The caller is responsible for providing a
|
||||
* null-safe fallback (typically [NodeRepository.getUser]).
|
||||
* @param channelLabel Produces the display name for a channel given its index. Defaults to `"Channel N"`; callers
|
||||
* can supply a localised string.
|
||||
* @param unknownLabel Fallback display name when neither long name nor short name is available. Defaults to
|
||||
* `"Unknown"`; callers can supply a localised string.
|
||||
*/
|
||||
fun buildCarContacts(
|
||||
merged: Map<String, DataPacket>,
|
||||
@@ -70,58 +68,60 @@ internal object CarScreenDataBuilder {
|
||||
channelLabel: (Int) -> String = { "Channel $it" },
|
||||
unknownLabel: String = "Unknown",
|
||||
): List<CarContact> {
|
||||
val all = merged.map { (contactKey, packet) ->
|
||||
val fromLocal = packet.from == DataPacket.ID_LOCAL || packet.from == myId
|
||||
val toBroadcast = packet.to == DataPacket.ID_BROADCAST
|
||||
val userId = if (fromLocal) packet.to else packet.from
|
||||
val all =
|
||||
merged.map { (contactKey, packet) ->
|
||||
val fromLocal = packet.from == DataPacket.ID_LOCAL || packet.from == myId
|
||||
val toBroadcast = packet.to == DataPacket.ID_BROADCAST
|
||||
val userId = if (fromLocal) packet.to else packet.from
|
||||
|
||||
// Resolve the user once; used for both displayName and message prefix.
|
||||
val user = resolveUser(userId ?: DataPacket.ID_BROADCAST)
|
||||
// Resolve the user once; used for both displayName and message prefix.
|
||||
val user = resolveUser(userId ?: DataPacket.ID_BROADCAST)
|
||||
|
||||
val displayName = if (toBroadcast) {
|
||||
channelSet.getChannel(packet.channel)?.name?.takeIf { it.isNotEmpty() }
|
||||
?: channelLabel(packet.channel)
|
||||
} else {
|
||||
// userId can be null for malformed packets (e.g. both `from` and `to` are null).
|
||||
// Fall back to a broadcast lookup which returns an "Unknown" user rather than crashing.
|
||||
user.long_name.ifEmpty { user.short_name }.ifEmpty { unknownLabel }
|
||||
val displayName =
|
||||
if (toBroadcast) {
|
||||
channelSet.getChannel(packet.channel)?.name?.takeIf { it.isNotEmpty() }
|
||||
?: channelLabel(packet.channel)
|
||||
} else {
|
||||
// userId can be null for malformed packets (e.g. both `from` and `to` are null).
|
||||
// Fall back to a broadcast lookup which returns an "Unknown" user rather than crashing.
|
||||
user.long_name.ifEmpty { user.short_name }.ifEmpty { unknownLabel }
|
||||
}
|
||||
|
||||
// Mirror ContactsViewModel: prefix received DM text with the sender's short name,
|
||||
// matching how ContactItem's ChatMetadata renders lastMessageText.
|
||||
val shortName = if (!toBroadcast) user.short_name else ""
|
||||
val rawText = packet.text
|
||||
val lastMessageText =
|
||||
rawText?.let { text -> if (fromLocal || shortName.isEmpty()) text else "$shortName: $text" }
|
||||
|
||||
CarContact(
|
||||
contactKey = contactKey,
|
||||
displayName = displayName,
|
||||
unreadCount = 0, // filled in reactively by the screen's flatMapLatest
|
||||
isBroadcast = toBroadcast,
|
||||
channelIndex = packet.channel,
|
||||
lastMessageTime = if (packet.time != 0L) packet.time else null,
|
||||
lastMessageText = lastMessageText,
|
||||
lastMessageRawText = rawText,
|
||||
lastMessageSenderName = shortName.takeIf { it.isNotEmpty() },
|
||||
lastMessageFromSelf = fromLocal,
|
||||
)
|
||||
}
|
||||
|
||||
// Mirror ContactsViewModel: prefix received DM text with the sender's short name,
|
||||
// matching how ContactItem's ChatMetadata renders lastMessageText.
|
||||
val shortName = if (!toBroadcast) user.short_name else ""
|
||||
val lastMessageText = packet.text?.let { text ->
|
||||
if (fromLocal || shortName.isEmpty()) text else "$shortName: $text"
|
||||
}
|
||||
|
||||
CarContact(
|
||||
contactKey = contactKey,
|
||||
displayName = displayName,
|
||||
unreadCount = 0, // filled in reactively by the screen's flatMapLatest
|
||||
isBroadcast = toBroadcast,
|
||||
channelIndex = packet.channel,
|
||||
lastMessageTime = if (packet.time != 0L) packet.time else null,
|
||||
lastMessageText = lastMessageText,
|
||||
)
|
||||
}
|
||||
|
||||
// partition avoids iterating the list twice.
|
||||
val (channels, dms) = all.partition { it.isBroadcast }
|
||||
return channels.sortedBy { it.channelIndex } +
|
||||
dms.sortedByDescending { it.lastMessageTime ?: 0L }
|
||||
return channels.sortedBy { it.channelIndex } + dms.sortedByDescending { it.lastMessageTime ?: 0L }
|
||||
}
|
||||
|
||||
/**
|
||||
* Filters and sorts [nodes] to produce the Favorites tab list.
|
||||
*
|
||||
* Only nodes with [Node.isFavorite] are included. They are sorted alphabetically by
|
||||
* [User.long_name], falling back to [User.short_name] when the long name is empty —
|
||||
* matching the alphabetical sort used by the phone's node list when filtered to favorites.
|
||||
* Only nodes with [Node.isFavorite] are included. They are sorted alphabetically by [User.long_name], falling back
|
||||
* to [User.short_name] when the long name is empty — matching the alphabetical sort used by the phone's node list
|
||||
* when filtered to favorites.
|
||||
*/
|
||||
fun sortFavorites(nodes: Collection<Node>): List<Node> =
|
||||
nodes
|
||||
.filter { it.isFavorite }
|
||||
.sortedWith(compareBy { it.user.long_name.ifEmpty { it.user.short_name } })
|
||||
nodes.filter { it.isFavorite }.sortedWith(compareBy { it.user.long_name.ifEmpty { it.user.short_name } })
|
||||
|
||||
/**
|
||||
* Returns the primary status line for a favorite-node row (Text 1 in the Car UI row).
|
||||
@@ -133,15 +133,15 @@ internal object CarScreenDataBuilder {
|
||||
* - `"Offline · <time ago>"` when [Node.lastHeard] is set
|
||||
* - `"Offline"` otherwise
|
||||
*
|
||||
* @param labelOnline Localised "Online" label; defaults to English.
|
||||
* @param labelOnline Localised "Online" label; defaults to English.
|
||||
* @param labelOffline Localised "Offline" label; defaults to English.
|
||||
* @param labelDirect Suffix appended when [Node.hopsAway] == 0 (include leading " · ");
|
||||
* defaults to `" · Direct"`.
|
||||
* @param labelHops Produces the hop-count suffix given the count (include leading " · ");
|
||||
* defaults to `" · N hops"`.
|
||||
* @param formatRelativeTime Converts a millis timestamp to a human-readable "X ago" string.
|
||||
* Defaults to [DateFormatter.formatRelativeTime]; injectable for testing.
|
||||
* @param labelDirect Suffix appended when [Node.hopsAway] == 0 (include leading " · "); defaults to `" · Direct"`.
|
||||
* @param labelHops Produces the hop-count suffix given the count (include leading " · "); defaults to `" · N
|
||||
* hops"`.
|
||||
* @param formatRelativeTime Converts a millis timestamp to a human-readable "X ago" string. Defaults to
|
||||
* [DateFormatter.formatRelativeTime]; injectable for testing.
|
||||
*/
|
||||
@Suppress("MagicNumber")
|
||||
fun nodeStatusText(
|
||||
node: Node,
|
||||
labelOnline: String = "Online",
|
||||
@@ -167,8 +167,8 @@ internal object CarScreenDataBuilder {
|
||||
/**
|
||||
* Returns the secondary detail line for a favorite-node row (Text 2 in the Car UI row).
|
||||
*
|
||||
* Mirrors NodeItem's battery row + node chip: `"NODE · 85%"`.
|
||||
* Returns an empty string when neither short name nor battery level is available.
|
||||
* Mirrors NodeItem's battery row + node chip: `"NODE · 85%"`. Returns an empty string when neither short name nor
|
||||
* battery level is available.
|
||||
*/
|
||||
fun nodeDetailText(node: Node): String = buildString {
|
||||
val shortName = node.user.short_name
|
||||
@@ -183,15 +183,13 @@ internal object CarScreenDataBuilder {
|
||||
/**
|
||||
* Returns the message preview line for a contact row (Text 1 in the Car UI row).
|
||||
*
|
||||
* Mirrors `ChatMetadata`'s `lastMessageText` display: shows the last message text
|
||||
* (with sender prefix for received DMs), or [noMessagesLabel] for empty channels.
|
||||
* Mirrors `ChatMetadata`'s `lastMessageText` display: shows the last message text (with sender prefix for received
|
||||
* DMs), or [noMessagesLabel] for empty channels.
|
||||
*
|
||||
* @param noMessagesLabel Localised empty-state label; defaults to `"No messages yet"`.
|
||||
*/
|
||||
fun contactPreviewText(
|
||||
contact: CarContact,
|
||||
noMessagesLabel: String = "No messages yet",
|
||||
): String = contact.lastMessageText?.takeIf { it.isNotEmpty() } ?: noMessagesLabel
|
||||
fun contactPreviewText(contact: CarContact, noMessagesLabel: String = "No messages yet"): String =
|
||||
contact.lastMessageText?.takeIf { it.isNotEmpty() } ?: noMessagesLabel
|
||||
|
||||
/**
|
||||
* Returns the secondary metadata line for a contact row (Text 2 in the Car UI row).
|
||||
@@ -201,10 +199,10 @@ internal object CarScreenDataBuilder {
|
||||
* - Formatted short date of the last message otherwise
|
||||
* - Empty string when there are no messages at all
|
||||
*
|
||||
* @param unreadLabel Produces the unread-count label given the count.
|
||||
* Defaults to `"N unread"`; callers can supply a localised format string.
|
||||
* @param formatShortDate Converts a millis timestamp to a short date string.
|
||||
* Defaults to [DateFormatter.formatShortDate]; injectable for testing.
|
||||
* @param unreadLabel Produces the unread-count label given the count. Defaults to `"N unread"`; callers can supply
|
||||
* a localised format string.
|
||||
* @param formatShortDate Converts a millis timestamp to a short date string. Defaults to
|
||||
* [DateFormatter.formatShortDate]; injectable for testing.
|
||||
*/
|
||||
fun contactSecondaryText(
|
||||
contact: CarContact,
|
||||
@@ -219,15 +217,11 @@ internal object CarScreenDataBuilder {
|
||||
/**
|
||||
* Builds a [CarLocalStats] snapshot from the device's [Node], [LocalStats], and node DB.
|
||||
*
|
||||
* Falls back to [Node.deviceMetrics] when [LocalStats] hasn't been populated yet — the
|
||||
* same strategy used by [org.meshtastic.feature.widget.LocalStatsWidgetStateProvider].
|
||||
* Falls back to [Node.deviceMetrics] when [LocalStats] hasn't been populated yet — the same strategy used by
|
||||
* [org.meshtastic.feature.widget.LocalStatsWidgetStateProvider].
|
||||
*/
|
||||
@Suppress("MagicNumber")
|
||||
fun buildLocalStats(
|
||||
ourNode: Node?,
|
||||
stats: LocalStats,
|
||||
allNodes: Collection<Node>,
|
||||
): CarLocalStats {
|
||||
fun buildLocalStats(ourNode: Node?, stats: LocalStats, allNodes: Collection<Node>): CarLocalStats {
|
||||
val metrics = ourNode?.deviceMetrics
|
||||
val batteryLevel = metrics?.battery_level ?: 0
|
||||
val hasStats = stats.uptime_seconds != 0
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<Node> = emptyList()
|
||||
|
||||
/**
|
||||
* Ordered contact list for the Messages tab: channel entries first (sorted by channel index,
|
||||
* always present as placeholders even when no messages exist), then DM conversations sorted
|
||||
* by most-recent message descending — identical ordering to the phone's Contacts screen.
|
||||
* Ordered contact list for the Messages tab: channel entries first (sorted by channel index, always present as
|
||||
* placeholders even when no messages exist), then DM conversations sorted by most-recent message descending —
|
||||
* identical ordering to the phone's Contacts screen.
|
||||
*/
|
||||
private var contacts: List<CarContact> = emptyList()
|
||||
|
||||
/**
|
||||
* True until the first combined emission from all repository flows arrives.
|
||||
*
|
||||
* On Car API ≥ 5 this prevents a flash of empty content before data loads by showing a
|
||||
* [MessageTemplate] loading spinner. On older API levels the loading spinner is unavailable
|
||||
* so [isLoading] starts as `false` and the fallback [ListTemplate] handles the empty state
|
||||
* with [ItemList.Builder.setNoItemsMessage].
|
||||
* Car API 7+ supports [MessageTemplate.setLoading] so we show a loading spinner instead of a flash of empty content
|
||||
* before data loads.
|
||||
*/
|
||||
private var isLoading = carContext.carAppApiLevel >= CarAppApiLevels.LEVEL_5
|
||||
private var isLoading = true
|
||||
|
||||
init {
|
||||
lifecycle.addObserver(this)
|
||||
@@ -144,62 +152,72 @@ class MeshtasticCarScreen(carContext: CarContext) :
|
||||
|
||||
override fun onDestroy(owner: LifecycleOwner) {
|
||||
scope.cancel()
|
||||
callbackScope.cancel()
|
||||
}
|
||||
|
||||
@Suppress("LongMethod")
|
||||
private fun startObserving() {
|
||||
// Build the contacts sub-flow independently so it can feed the outer combine below.
|
||||
val contactsFlow = combine(
|
||||
nodeRepository.myId,
|
||||
packetRepository.getContacts(),
|
||||
radioConfigRepository.channelSetFlow,
|
||||
) { myId, rawContacts, channelSet ->
|
||||
// Channel placeholders are always included so every configured channel is
|
||||
// visible even before any messages have been sent/received.
|
||||
val placeholders = CarScreenDataBuilder.buildChannelPlaceholders(channelSet)
|
||||
// Real DB entries take precedence over placeholders when present.
|
||||
val merged = rawContacts + (placeholders - rawContacts.keys)
|
||||
CarScreenDataBuilder.buildCarContacts(
|
||||
merged, myId, channelSet,
|
||||
resolveUser = { userId -> nodeRepository.getUser(userId) },
|
||||
channelLabel = { carContext.getString(R.string.auto_channel_number, it) },
|
||||
unknownLabel = carContext.getString(R.string.auto_unknown),
|
||||
)
|
||||
}
|
||||
.distinctUntilChanged()
|
||||
.flatMapLatest { baseContacts ->
|
||||
if (baseContacts.isEmpty()) {
|
||||
flowOf(emptyList())
|
||||
} else {
|
||||
val unreadFlows = baseContacts.map { contact ->
|
||||
packetRepository.getUnreadCountFlow(contact.contactKey)
|
||||
.map { unread -> contact.copy(unreadCount = unread) }
|
||||
}
|
||||
combine(unreadFlows) { it.toList() }
|
||||
}
|
||||
val contactsFlow =
|
||||
combine(nodeRepository.myId, packetRepository.getContacts(), radioConfigRepository.channelSetFlow) {
|
||||
myId,
|
||||
rawContacts,
|
||||
channelSet,
|
||||
->
|
||||
// Channel placeholders are always included so every configured channel is
|
||||
// visible even before any messages have been sent/received.
|
||||
val placeholders = CarScreenDataBuilder.buildChannelPlaceholders(channelSet)
|
||||
// Real DB entries take precedence over placeholders when present.
|
||||
val merged = rawContacts + (placeholders - rawContacts.keys)
|
||||
CarScreenDataBuilder.buildCarContacts(
|
||||
merged,
|
||||
myId,
|
||||
channelSet,
|
||||
resolveUser = { userId -> nodeRepository.getUser(userId) },
|
||||
channelLabel = { carContext.getString(R.string.auto_channel_number, it) },
|
||||
unknownLabel = carContext.getString(R.string.auto_unknown),
|
||||
)
|
||||
}
|
||||
.distinctUntilChanged()
|
||||
.flatMapLatest { baseContacts ->
|
||||
if (baseContacts.isEmpty()) {
|
||||
flowOf(emptyList())
|
||||
} else {
|
||||
val unreadFlows =
|
||||
baseContacts.map { contact ->
|
||||
packetRepository.getUnreadCountFlow(contact.contactKey).map { unread ->
|
||||
contact.copy(unreadCount = unread)
|
||||
}
|
||||
}
|
||||
combine(unreadFlows) { it.toList() }
|
||||
}
|
||||
}
|
||||
|
||||
val favoritesFlow = nodeRepository.nodeDBbyNum
|
||||
.map { db -> CarScreenDataBuilder.sortFavorites(db.values) }
|
||||
.distinctUntilChanged()
|
||||
val favoritesFlow =
|
||||
nodeRepository.nodeDBbyNum
|
||||
.map { db -> CarScreenDataBuilder.sortFavorites(db.values) }
|
||||
.distinctUntilChanged()
|
||||
|
||||
val localStatsFlow = combine(
|
||||
nodeRepository.ourNodeInfo,
|
||||
nodeRepository.localStats,
|
||||
nodeRepository.nodeDBbyNum,
|
||||
) { ourNode, stats, nodeDb ->
|
||||
CarScreenDataBuilder.buildLocalStats(ourNode, stats, nodeDb.values)
|
||||
}.distinctUntilChanged()
|
||||
val localStatsFlow =
|
||||
combine(nodeRepository.ourNodeInfo, nodeRepository.localStats, nodeRepository.nodeDBbyNum) {
|
||||
ourNode,
|
||||
stats,
|
||||
nodeDb,
|
||||
->
|
||||
CarScreenDataBuilder.buildLocalStats(ourNode, stats, nodeDb.values)
|
||||
}
|
||||
.distinctUntilChanged()
|
||||
|
||||
// All data sources feed a single combined collector so that each batch of
|
||||
// repository changes produces exactly one invalidate() call, avoiding unnecessary
|
||||
// template rebuilds and staying well within the host's update-rate budget.
|
||||
scope.launch {
|
||||
combine(
|
||||
serviceRepository.connectionState,
|
||||
favoritesFlow,
|
||||
contactsFlow,
|
||||
localStatsFlow,
|
||||
) { connState, favs, ctcts, stats ->
|
||||
combine(serviceRepository.connectionState, favoritesFlow, contactsFlow, localStatsFlow) {
|
||||
connState,
|
||||
favs,
|
||||
ctcts,
|
||||
stats,
|
||||
->
|
||||
CombinedState(connState, favs, ctcts, stats)
|
||||
}
|
||||
.collect { state ->
|
||||
@@ -216,8 +234,6 @@ class MeshtasticCarScreen(carContext: CarContext) :
|
||||
// ---- Template building ----
|
||||
|
||||
override fun onGetTemplate(): Template {
|
||||
// MessageTemplate.setLoading() requires Car API 5+. isLoading is only ever true
|
||||
// on ≥5 hosts (see initialisation above), so no additional level check is needed here.
|
||||
if (isLoading) {
|
||||
return MessageTemplate.Builder(carContext.getString(R.string.auto_loading))
|
||||
.setHeaderAction(Action.APP_ICON)
|
||||
@@ -225,24 +241,20 @@ class MeshtasticCarScreen(carContext: CarContext) :
|
||||
.build()
|
||||
}
|
||||
|
||||
// TabTemplate requires Car API level 6. Fall back to a combined ListTemplate
|
||||
// on older hosts so the app remains functional on all supported vehicles.
|
||||
if (carContext.carAppApiLevel < CarAppApiLevels.LEVEL_6) {
|
||||
return buildFallbackListTemplate()
|
||||
}
|
||||
|
||||
val tabCallback = object : TabTemplate.TabCallback {
|
||||
override fun onTabSelected(tabContentId: String) {
|
||||
activeTabId = tabContentId
|
||||
invalidate()
|
||||
val tabCallback =
|
||||
object : TabTemplate.TabCallback {
|
||||
override fun onTabSelected(tabContentId: String) {
|
||||
activeTabId = tabContentId
|
||||
invalidate()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val activeContent = when (activeTabId) {
|
||||
TAB_FAVORITES -> TabContents.Builder(buildFavoritesTemplate()).build()
|
||||
TAB_MESSAGES -> TabContents.Builder(buildMessagesTemplate()).build()
|
||||
else -> TabContents.Builder(buildStatusTemplate()).build()
|
||||
}
|
||||
val activeContent =
|
||||
when (activeTabId) {
|
||||
TAB_FAVORITES -> TabContents.Builder(buildFavoritesTemplate()).build()
|
||||
TAB_MESSAGES -> TabContents.Builder(buildMessagesTemplate()).build()
|
||||
else -> TabContents.Builder(buildStatusTemplate()).build()
|
||||
}
|
||||
|
||||
return TabTemplate.Builder(tabCallback)
|
||||
.setHeaderAction(Action.APP_ICON)
|
||||
@@ -273,13 +285,13 @@ class MeshtasticCarScreen(carContext: CarContext) :
|
||||
}
|
||||
|
||||
/**
|
||||
* Called by [MeshtasticCarSession.onNewIntent] when the user taps a conversation
|
||||
* notification in the Android Auto notification shade. Switches to [TAB_MESSAGES] —
|
||||
* channels and DMs both live in the same tab, so no per-key handling is required.
|
||||
* Called by [MeshtasticCarSession.onNewIntent] when the user taps a conversation notification in the Android Auto
|
||||
* notification shade. Switches to [TAB_MESSAGES] — channels and DMs both live in the same tab, so no per-key
|
||||
* handling is required.
|
||||
*
|
||||
* `androidx.car.app.model.ListTemplate` does not currently expose a programmatic scroll
|
||||
* API, so we cannot focus a specific conversation row. If/when a scroll API is added,
|
||||
* the contactKey can be threaded through `MeshtasticCarSession.onNewIntent`.
|
||||
* `androidx.car.app.model.ListTemplate` does not currently expose a programmatic scroll API, so we cannot focus a
|
||||
* specific conversation row. If/when a scroll API is added, the contactKey can be threaded through
|
||||
* `MeshtasticCarSession.onNewIntent`.
|
||||
*/
|
||||
fun selectMessagesTab() {
|
||||
activeTabId = TAB_MESSAGES
|
||||
@@ -289,8 +301,8 @@ class MeshtasticCarScreen(carContext: CarContext) :
|
||||
// ---- Individual template builders ----
|
||||
|
||||
/**
|
||||
* Builds the Status tab: connection state + local device stats mirroring the home-screen
|
||||
* Local Stats widget (battery, channel/air utilization, node counts, uptime, traffic).
|
||||
* Builds the Status tab: connection state + local device stats mirroring the home-screen Local Stats widget
|
||||
* (battery, channel/air utilization, node counts, uptime, traffic).
|
||||
*/
|
||||
private fun buildStatusTemplate(): ListTemplate {
|
||||
val items = ItemList.Builder()
|
||||
@@ -303,87 +315,65 @@ class MeshtasticCarScreen(carContext: CarContext) :
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the Favorites tab: one row per starred node, mirroring the key status info shown
|
||||
* by [org.meshtastic.feature.node.component.NodeItem] on the phone.
|
||||
*
|
||||
* Builds the Favorites tab: one row per starred node, mirroring the key status info shown by
|
||||
* [org.meshtastic.feature.node.component.NodeItem] on the phone.
|
||||
* - **Title**: node's long name (short name fallback).
|
||||
* - **Text 1**: `"Online · Direct"` / `"Online · N hops"` / `"Offline · Xh ago"` —
|
||||
* mirrors the signal row and last-heard chip in NodeItem.
|
||||
* - **Text 1**: `"Online · Direct"` / `"Online · N hops"` / `"Offline · Xh ago"` — mirrors the signal row and
|
||||
* last-heard chip in NodeItem.
|
||||
* - **Text 2**: battery percentage and short name — mirrors the battery row and node chip.
|
||||
*/
|
||||
private fun buildFavoritesTemplate(): ListTemplate =
|
||||
buildListTemplate(
|
||||
carContext.getString(R.string.auto_tab_favorites),
|
||||
favorites,
|
||||
carContext.getString(R.string.auto_no_favorites),
|
||||
) { buildFavoriteNodeRow(it) }
|
||||
private fun buildFavoritesTemplate(): ListTemplate = buildListTemplate(
|
||||
carContext.getString(R.string.auto_tab_favorites),
|
||||
favorites,
|
||||
carContext.getString(R.string.auto_no_favorites),
|
||||
) {
|
||||
buildFavoriteNodeRow(it)
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the Messages tab content: channels first (always present, even if empty), followed
|
||||
* by DM conversations sorted by most-recent message — identical to the phone's Contacts screen.
|
||||
* Builds the Messages tab content using [ConversationItem]s for conversations that have at least one message, and
|
||||
* plain [Row]s for empty channel placeholders. This provides built-in play, reply, and mark-as-read affordances for
|
||||
* active conversations.
|
||||
*/
|
||||
private fun buildMessagesTemplate(): ListTemplate =
|
||||
buildListTemplate(
|
||||
carContext.getString(R.string.auto_tab_messages),
|
||||
contacts,
|
||||
carContext.getString(R.string.auto_no_conversations),
|
||||
) { buildContactRow(it) }
|
||||
|
||||
/**
|
||||
* Fallback for Car API level 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<Row> {
|
||||
@@ -392,51 +382,47 @@ class MeshtasticCarScreen(carContext: CarContext) :
|
||||
val rows = mutableListOf<Row>()
|
||||
|
||||
if (s.hasBattery) {
|
||||
val batteryValue = if (s.batteryLevel > 100) {
|
||||
carContext.getString(R.string.auto_stats_powered)
|
||||
} else {
|
||||
"${s.batteryLevel}%"
|
||||
}
|
||||
rows += Row.Builder()
|
||||
.setTitle(carContext.getString(R.string.auto_stats_battery, batteryValue))
|
||||
.addText(
|
||||
carContext.getString(
|
||||
R.string.auto_stats_channel_util,
|
||||
"%.1f%%".format(s.channelUtilization),
|
||||
"%.1f%%".format(s.airUtilization),
|
||||
),
|
||||
)
|
||||
.setBrowsable(false)
|
||||
.build()
|
||||
val batteryValue =
|
||||
if (s.batteryLevel > 100) {
|
||||
carContext.getString(R.string.auto_stats_powered)
|
||||
} else {
|
||||
"${s.batteryLevel}%"
|
||||
}
|
||||
rows +=
|
||||
Row.Builder()
|
||||
.setTitle(carContext.getString(R.string.auto_stats_battery, batteryValue))
|
||||
.addText(
|
||||
carContext.getString(
|
||||
R.string.auto_stats_channel_util,
|
||||
"%.1f%%".format(s.channelUtilization),
|
||||
"%.1f%%".format(s.airUtilization),
|
||||
),
|
||||
)
|
||||
.setBrowsable(false)
|
||||
.build()
|
||||
}
|
||||
|
||||
rows += Row.Builder()
|
||||
.setTitle(carContext.getString(R.string.auto_stats_nodes, s.onlineNodes, s.totalNodes))
|
||||
.addTextIfNotEmpty(
|
||||
if (s.uptimeSeconds > 0) {
|
||||
carContext.getString(
|
||||
R.string.auto_stats_uptime,
|
||||
formatUptime(s.uptimeSeconds),
|
||||
)
|
||||
} else {
|
||||
""
|
||||
},
|
||||
)
|
||||
.setBrowsable(false)
|
||||
.build()
|
||||
|
||||
if (s.numPacketsTx > 0 || s.numPacketsRx > 0) {
|
||||
rows += Row.Builder()
|
||||
.setTitle(
|
||||
carContext.getString(
|
||||
R.string.auto_stats_traffic,
|
||||
s.numPacketsTx,
|
||||
s.numPacketsRx,
|
||||
s.numRxDupe,
|
||||
),
|
||||
rows +=
|
||||
Row.Builder()
|
||||
.setTitle(carContext.getString(R.string.auto_stats_nodes, s.onlineNodes, s.totalNodes))
|
||||
.addTextIfNotEmpty(
|
||||
if (s.uptimeSeconds > 0) {
|
||||
carContext.getString(R.string.auto_stats_uptime, formatUptime(s.uptimeSeconds))
|
||||
} else {
|
||||
""
|
||||
},
|
||||
)
|
||||
.setBrowsable(false)
|
||||
.build()
|
||||
|
||||
if (s.numPacketsTx > 0 || s.numPacketsRx > 0) {
|
||||
rows +=
|
||||
Row.Builder()
|
||||
.setTitle(
|
||||
carContext.getString(R.string.auto_stats_traffic, s.numPacketsTx, s.numPacketsRx, s.numRxDupe),
|
||||
)
|
||||
.setBrowsable(false)
|
||||
.build()
|
||||
}
|
||||
|
||||
return rows
|
||||
@@ -446,13 +432,13 @@ class MeshtasticCarScreen(carContext: CarContext) :
|
||||
* Builds a single favorite-node row.
|
||||
*
|
||||
* Mirrors the content of [org.meshtastic.feature.node.component.NodeItem]:
|
||||
* - Title → `long_name` (prominent, matches NodeItem header text)
|
||||
* - Title → `long_name` (prominent, matches NodeItem header text)
|
||||
* - Text 1 → online/offline + hop distance (matches NodeItem signal row)
|
||||
* - Text 2 → battery level + short name chip equivalent (matches NodeItem battery row)
|
||||
*/
|
||||
private fun buildFavoriteNodeRow(node: Node): Row {
|
||||
val name = node.user.long_name.ifEmpty { node.user.short_name }
|
||||
.ifEmpty { carContext.getString(R.string.auto_unknown) }
|
||||
val name =
|
||||
node.user.long_name.ifEmpty { node.user.short_name }.ifEmpty { carContext.getString(R.string.auto_unknown) }
|
||||
return Row.Builder()
|
||||
.setTitle(name)
|
||||
.addText(
|
||||
@@ -473,40 +459,115 @@ class MeshtasticCarScreen(carContext: CarContext) :
|
||||
* Builds a single conversation row.
|
||||
*
|
||||
* Mirrors [org.meshtastic.feature.messaging.ui.contact.ContactItem]:
|
||||
* - **Title** → channel or DM display name (matches the bodyLarge name in ContactHeader).
|
||||
* - **Text 1** → last message preview with sender prefix for received DMs, or "No messages
|
||||
* yet" for empty channel placeholders (matches ChatMetadata's message text).
|
||||
* - **Text 2** → `"N unread"` when there are unread messages, or the last-message timestamp
|
||||
* when there are none (matches the unread badge and date in ContactHeader/ChatMetadata).
|
||||
* - **Title** → channel or DM display name (matches the bodyLarge name in ContactHeader).
|
||||
* - **Text 1** → last message preview with sender prefix for received DMs, or "No messages yet" for empty channel
|
||||
* placeholders (matches ChatMetadata's message text).
|
||||
* - **Text 2** → `"N unread"` when there are unread messages, or the last-message timestamp when there are none
|
||||
* (matches the unread badge and date in ContactHeader/ChatMetadata).
|
||||
*/
|
||||
private fun buildContactRow(contact: CarContact): Row =
|
||||
Row.Builder()
|
||||
.setTitle(contact.displayName)
|
||||
.addText(
|
||||
CarScreenDataBuilder.contactPreviewText(
|
||||
contact,
|
||||
noMessagesLabel = carContext.getString(R.string.auto_no_messages),
|
||||
),
|
||||
)
|
||||
.addTextIfNotEmpty(
|
||||
CarScreenDataBuilder.contactSecondaryText(
|
||||
contact,
|
||||
unreadLabel = { carContext.getString(R.string.auto_unread_count, it) },
|
||||
),
|
||||
)
|
||||
.setBrowsable(false)
|
||||
private fun buildContactRow(contact: CarContact): Row = Row.Builder()
|
||||
.setTitle(contact.displayName)
|
||||
.addText(
|
||||
CarScreenDataBuilder.contactPreviewText(
|
||||
contact,
|
||||
noMessagesLabel = carContext.getString(R.string.auto_no_messages),
|
||||
),
|
||||
)
|
||||
.addTextIfNotEmpty(
|
||||
CarScreenDataBuilder.contactSecondaryText(
|
||||
contact,
|
||||
unreadLabel = { carContext.getString(R.string.auto_unread_count, it) },
|
||||
),
|
||||
)
|
||||
.setBrowsable(false)
|
||||
.build()
|
||||
|
||||
/**
|
||||
* Builds a [ConversationItem] for a contact that has at least one message.
|
||||
*
|
||||
* The item links to the conversation's shortcut ID (matching [ConversationShortcutManager]) so the host can
|
||||
* associate notifications with the template entry.
|
||||
*/
|
||||
private fun buildConversationItem(contact: CarContact, selfPerson: Person): ConversationItem {
|
||||
val senderPerson =
|
||||
if (contact.lastMessageFromSelf) {
|
||||
selfPerson
|
||||
} else {
|
||||
Person.Builder().setName(contact.lastMessageSenderName ?: contact.displayName).build()
|
||||
}
|
||||
|
||||
val carMessage =
|
||||
CarMessage.Builder()
|
||||
.setSender(senderPerson)
|
||||
.setBody(CarText.Builder(contact.lastMessageRawText ?: "").build())
|
||||
.setReceivedTimeEpochMillis(contact.lastMessageTime ?: 0L)
|
||||
.setRead(contact.unreadCount == 0)
|
||||
.build()
|
||||
|
||||
val messages = listOf(carMessage)
|
||||
val callback = buildConversationCallback(contact.contactKey)
|
||||
|
||||
return ConversationItem.Builder(
|
||||
/* id = */
|
||||
contact.contactKey,
|
||||
/* title = */
|
||||
CarText.Builder(contact.displayName).build(),
|
||||
/* self = */
|
||||
selfPerson,
|
||||
/* messages = */
|
||||
messages,
|
||||
/* conversationCallback = */
|
||||
callback,
|
||||
)
|
||||
.setGroupConversation(contact.isBroadcast)
|
||||
.build()
|
||||
}
|
||||
|
||||
private fun buildSelfPerson(): Person {
|
||||
val ourName = nodeRepository.ourNodeInfo.value?.user?.long_name ?: "Me"
|
||||
return Person.Builder().setName(ourName).build()
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a [ConversationCallback] for the given contact that handles reply and mark-as-read actions. Uses
|
||||
* [callbackScope] (IO-backed, durable) so that in-flight replies are not cancelled by screen invalidations.
|
||||
*/
|
||||
@Suppress("TooGenericExceptionCaught")
|
||||
private fun buildConversationCallback(contactKey: String): ConversationCallback = object : ConversationCallback {
|
||||
override fun onMarkAsRead() {
|
||||
callbackScope.launch {
|
||||
try {
|
||||
meshServiceNotifications.markConversationRead(contactKey)
|
||||
} catch (e: Exception) {
|
||||
Logger.e(e) { "Failed to mark conversation $contactKey as read" }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onTextReply(replyText: String) {
|
||||
callbackScope.launch {
|
||||
try {
|
||||
sendMessageUseCase(replyText, contactKey)
|
||||
meshServiceNotifications.appendOutgoingMessage(contactKey, replyText)
|
||||
meshServiceNotifications.markConversationRead(contactKey)
|
||||
} catch (e: Exception) {
|
||||
Logger.e(e) { "Failed to send reply to $contactKey" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun carIcon(resId: Int) =
|
||||
CarIcon.Builder(IconCompat.createWithResource(carContext, resId)).setTint(CarColor.DEFAULT).build()
|
||||
|
||||
/** Adds [text] as a new text line only when it is non-empty, avoiding blank Car UI rows. */
|
||||
private fun Row.Builder.addTextIfNotEmpty(text: String): Row.Builder =
|
||||
apply { if (text.isNotEmpty()) addText(text) }
|
||||
private fun Row.Builder.addTextIfNotEmpty(text: String): Row.Builder = apply {
|
||||
if (text.isNotEmpty()) addText(text)
|
||||
}
|
||||
|
||||
/**
|
||||
* DRY helper: builds a [ListTemplate] from a list of items, capping at [listContentLimit]
|
||||
* (the per-host limit reported by [ConstraintManager]).
|
||||
* DRY helper: builds a [ListTemplate] from a list of items, capping at [listContentLimit] (the per-host limit
|
||||
* reported by [ConstraintManager]).
|
||||
*
|
||||
* Shows [noItemsMessage] when the list is empty.
|
||||
*/
|
||||
|
||||
@@ -18,6 +18,7 @@ package org.meshtastic.feature.auto
|
||||
|
||||
import android.content.Intent
|
||||
import androidx.car.app.Screen
|
||||
import androidx.car.app.ScreenManager
|
||||
import androidx.car.app.Session
|
||||
|
||||
/** Android Auto session that hosts the [MeshtasticCarScreen] root screen. */
|
||||
@@ -31,15 +32,15 @@ class MeshtasticCarSession : Session() {
|
||||
|
||||
/**
|
||||
* Called by the Android Auto host when the session is re-activated from an existing
|
||||
* [MessagingStyle][androidx.core.app.NotificationCompat.MessagingStyle] notification tap or a
|
||||
* launcher shortcut. Switches the root screen to the Messages tab.
|
||||
* [MessagingStyle][androidx.core.app.NotificationCompat.MessagingStyle] notification tap or a launcher shortcut.
|
||||
* Switches the root screen to the Messages tab.
|
||||
*
|
||||
* The deep-link URI (`meshtastic://meshtastic/messages/<contactKey>`) carries the originating
|
||||
* contact key, but `androidx.car.app.model.ListTemplate` does not currently expose a
|
||||
* programmatic scroll API, so we cannot focus a specific conversation row.
|
||||
* The deep-link URI (`meshtastic://meshtastic/messages/<contactKey>`) carries the originating contact key, but
|
||||
* `androidx.car.app.model.ListTemplate` does not currently expose a programmatic scroll API, so we cannot focus a
|
||||
* specific conversation row.
|
||||
*/
|
||||
override fun onNewIntent(intent: Intent) {
|
||||
val screen = screenManager.top as? MeshtasticCarScreen ?: return
|
||||
val screen = carContext.getCarService(ScreenManager::class.java).top as? MeshtasticCarScreen ?: return
|
||||
applyIntent(intent, screen)
|
||||
}
|
||||
|
||||
|
||||
@@ -23,7 +23,6 @@
|
||||
|
||||
<!-- Template titles / loading / empty states -->
|
||||
<string name="auto_loading">Loading\u2026</string>
|
||||
<string name="auto_fallback_title">Meshtastic</string>
|
||||
<string name="auto_no_favorites">No favorite nodes</string>
|
||||
<string name="auto_no_conversations">No conversations</string>
|
||||
<string name="auto_no_messages">No messages yet</string>
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user