feat(auto): unified Messages tab — channels + DMs, mirroring Contacts screen

Agent-Logs-Url: https://github.com/meshtastic/Meshtastic-Android/sessions/8757a33e-0881-45a4-9c3b-5489642c413d

Co-authored-by: jamesarich <2199651+jamesarich@users.noreply.github.com>
This commit is contained in:
copilot-swe-agent[bot]
2026-04-17 16:32:46 +00:00
committed by GitHub
parent 01b1759503
commit 7c15c7bcb4
2 changed files with 200 additions and 192 deletions

View File

@@ -35,7 +35,6 @@ import androidx.lifecycle.DefaultLifecycleObserver
import androidx.lifecycle.LifecycleOwner
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.combine
@@ -48,31 +47,35 @@ import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
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.ChannelSettings
import org.meshtastic.proto.ChannelSet
import org.meshtastic.proto.PortNum
/**
* Root screen displayed in Android Auto.
*
* Renders a three-tab UI mirroring the iOS CarPlay tab-based navigation:
* Renders a two-tab UI that mirrors the app's Contacts screen:
* - **Status** — Connection state and device name
* - **Favorites** — Favourited mesh nodes with unread message counts
* - **Channels** — Active channels with unread message counts
* - **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].
*
* `TabTemplate` requires Car API level 6. On hosts running Car API level 15 the
* screen falls back to a single [ListTemplate] showing the same data (status row +
* favourites + channels) without tab chrome. The manifest declares
* `minCarApiLevel=1` so the app remains usable on all supported vehicles.
* Unlike the previous three-tab design (Status / Favorites / Channels), this view reflects
* every conversation in the database—not just favorited nodes—and correctly handles DMs
* on non-primary channels.
*
* `TabTemplate` requires Car API level 6. On hosts running Car API level 15 the screen falls
* back to a single [ListTemplate] that includes a status row followed by the same contact list.
*
* When the user taps a [MessagingStyle][androidx.core.app.NotificationCompat.MessagingStyle]
* notification in the Android Auto notification shade, the host calls
* [MeshtasticCarSession.onNewIntent] with the conversation deep-link URI.
* The session delegates to [selectContactKey] so the correct tab is pre-selected
* before [onGetTemplate] fires.
* notification in the Android Auto notification shade the host calls
* [MeshtasticCarSession.onNewIntent] which delegates to [selectContactKey] to switch to the
* Messages tab.
*/
class MeshtasticCarScreen(carContext: CarContext) :
Screen(carContext),
@@ -80,23 +83,25 @@ class MeshtasticCarScreen(carContext: CarContext) :
DefaultLifecycleObserver {
private val nodeRepository: NodeRepository by inject()
private val radioConfigRepository: RadioConfigRepository by inject()
private val packetRepository: PacketRepository by inject()
private val radioConfigRepository: RadioConfigRepository by inject()
private val serviceRepository: ServiceRepository by inject()
private val scope = CoroutineScope(Dispatchers.Main + SupervisorJob())
private var observeJob: Job? = null
private var activeTabId = TAB_STATUS
private var connectionState: ConnectionState = ConnectionState.Disconnected
private var favoriteNodes: List<Node> = emptyList()
private var channels: List<Pair<Int, ChannelSettings>> = emptyList()
private var unreadCounts: Map<String, Int> = emptyMap()
/**
* True until the first [collect] emission arrives from the repository flows.
* While loading, [onGetTemplate] returns a spinner [MessageTemplate] instead of
* an empty/disconnected 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 [collect] emission arrives from the repository flows, preventing a
* flash of an empty/disconnected screen on Car API ≥ 5 hosts.
*/
private var isLoading = true
@@ -113,64 +118,108 @@ class MeshtasticCarScreen(carContext: CarContext) :
}
private fun startObserving() {
observeJob?.cancel()
observeJob =
scope.launch {
// serviceRepository.connectionState is a StateFlow — distinctUntilChanged is a no-op on it.
val stateFlow = serviceRepository.connectionState
val favoritesFlow =
nodeRepository.nodeDBbyNum
.map { nodes ->
val myNum = nodeRepository.myNodeInfo.value?.myNodeNum
nodes.values
.filter { it.isFavorite && !it.isIgnored && it.num != myNum }
.sortedBy { it.user.long_name }
}
.distinctUntilChanged()
val channelsFlow =
radioConfigRepository.channelSetFlow
.map { cs ->
cs.settings.mapIndexedNotNull { index, settings ->
if (index == 0 || settings.name.isNotEmpty()) index to settings else null
}
}
.distinctUntilChanged()
combine(stateFlow, favoritesFlow, channelsFlow) { state, favorites, chs ->
Triple(state, favorites, chs)
}
.flatMapLatest { (state, favorites, chs) ->
val contactKeys =
favorites.map { "0${it.user.id}" } + chs.map { (i, _) -> "${i}${DataPacket.ID_BROADCAST}" }
if (contactKeys.isEmpty()) {
flowOf(Triple(state, favorites, chs) to emptyMap())
} else {
val unreadFlows =
contactKeys.map { key ->
packetRepository.getUnreadCountFlow(key).map { count -> key to count }
}
combine(unreadFlows) { pairs -> Triple(state, favorites, chs) to pairs.toMap() }
}
}
.collect { (triple, counts) ->
val (state, favorites, chs) = triple
connectionState = state
favoriteNodes = favorites
channels = chs
unreadCounts = counts
isLoading = false
invalidate()
}
// Observe the contact list (channels + DMs) with reactive unread counts.
scope.launch {
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 — mirroring the
// behaviour of ContactsViewModel.contactList.
val placeholders = buildChannelPlaceholders(channelSet)
// Real DB entries take precedence over placeholders when present.
val merged = rawContacts + (placeholders - rawContacts.keys)
buildCarContacts(merged, myId, channelSet)
}
.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() }
}
}
.collect { updated ->
contacts = updated
isLoading = false
invalidate()
}
}
// Connection state is observed separately since it only affects the Status tab.
scope.launch {
serviceRepository.connectionState.collect { state ->
connectionState = state
invalidate()
}
}
}
/** Returns a map of `"<ch>^all" → placeholder DataPacket` for every configured channel. */
private fun buildChannelPlaceholders(channelSet: ChannelSet): Map<String, DataPacket> =
(0 until channelSet.settings.size).associate { ch ->
// dataType uses PortNum.TEXT_MESSAGE_APP (value 1) to match the placeholder
// construction in ContactsViewModel and PacketRepository contact queries.
"${ch}${DataPacket.ID_BROADCAST}" to
DataPacket(bytes = null, dataType = PortNum.TEXT_MESSAGE_APP.value, time = 0L, channel = ch)
}
/**
* Converts the merged DB + placeholder map into an ordered [CarContact] list.
*
* Channels (keys ending with [DataPacket.ID_BROADCAST]) appear first sorted by channel index.
* DM conversations follow sorted by [CarContact.lastMessageTime] descending — matching the
* ordering used by the phone's Contacts screen.
*/
private fun buildCarContacts(
merged: Map<String, DataPacket>,
myId: String?,
channelSet: ChannelSet,
): List<CarContact> {
val all =
merged.map { (contactKey, packet) ->
val fromLocal = packet.from == DataPacket.ID_LOCAL || packet.from == myId
val toBroadcast = packet.to == DataPacket.ID_BROADCAST
val userId = if (fromLocal) packet.to else packet.from
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.
val user = nodeRepository.getUser(userId ?: DataPacket.ID_BROADCAST)
user.long_name.ifEmpty { user.short_name }.ifEmpty { "Unknown" }
}
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,
)
}
return all.filter { it.isBroadcast }.sortedBy { it.channelIndex } +
all.filter { !it.isBroadcast }.sortedByDescending { it.lastMessageTime ?: 0L }
}
// ---- Template building ----
override fun onGetTemplate(): Template {
// MessageTemplate.setLoading() requires Car API 5+. On older hosts fall through
// to the ListTemplate fallback immediately (StateFlows emit their cached state
// near-instantly so the transient empty state is barely visible).
// to the ListTemplate fallback (StateFlows emit their cached state near-instantly).
if (isLoading && carContext.carAppApiLevel >= CarAppApiLevels.LEVEL_5) {
return MessageTemplate.Builder("Loading…")
.setHeaderAction(Action.APP_ICON)
@@ -194,8 +243,7 @@ class MeshtasticCarScreen(carContext: CarContext) :
val activeContent =
when (activeTabId) {
TAB_FAVORITES -> TabContents.Builder(buildFavoritesTemplate()).build()
TAB_CHANNELS -> TabContents.Builder(buildChannelsTemplate()).build()
TAB_MESSAGES -> TabContents.Builder(buildMessagesTemplate()).build()
else -> TabContents.Builder(buildStatusTemplate()).build()
}
@@ -210,16 +258,9 @@ class MeshtasticCarScreen(carContext: CarContext) :
)
.addTab(
Tab.Builder()
.setTitle("Favorites")
.setIcon(carIcon(R.drawable.auto_ic_favorites))
.setContentId(TAB_FAVORITES)
.build(),
)
.addTab(
Tab.Builder()
.setTitle("Channels")
.setTitle("Messages")
.setIcon(carIcon(R.drawable.auto_ic_channels))
.setContentId(TAB_CHANNELS)
.setContentId(TAB_MESSAGES)
.build(),
)
.setTabContents(activeContent)
@@ -228,28 +269,55 @@ class MeshtasticCarScreen(carContext: CarContext) :
}
/**
* Called by [MeshtasticCarSession.onNewIntent] when the user taps a conversation
* notification in the Android Auto notification shade.
* Called by [MeshtasticCarSession.onNewIntent] when the user taps a conversation notification
* in the Android Auto notification shade. Switches to [TAB_MESSAGES] regardless of whether
* the originating contact is a channel broadcast or a DM, because both appear in the same tab.
*
* Selects the [TAB_FAVORITES] tab if [contactKey] looks like a DM (starts with a
* channel digit followed by a node ID), or [TAB_CHANNELS] if it is a broadcast
* conversation. Triggers a template refresh so the correct tab is highlighted.
* The [contactKey] parameter is accepted for API symmetry with the session and may be used in
* the future to scroll the Messages list to the tapped conversation.
*/
fun selectContactKey(contactKey: String) {
activeTabId = if (contactKey.endsWith(DataPacket.ID_BROADCAST)) TAB_CHANNELS else TAB_FAVORITES
fun selectContactKey(@Suppress("UNUSED_PARAMETER") contactKey: String) {
activeTabId = TAB_MESSAGES
invalidate()
}
// ---- Individual template builders ----
private fun buildStatusTemplate(): ListTemplate =
ListTemplate.Builder()
.setTitle("Status")
.setSingleList(ItemList.Builder().addItem(buildStatusRow()).build())
.build()
/**
* Fallback template for Car API level 15 hosts that do not support [TabTemplate].
* 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()
}
/**
* Fallback for Car API level 15 hosts that do not support [TabTemplate].
*
* Shows a single [ListTemplate] with the status row followed by all favourites
* and all channels — the same data as the tab UI but in a combined list.
* Shows a status row followed by the combined contact list (channels first, then DMs) in a
* single [ListTemplate].
*/
private fun buildFallbackListTemplate(): ListTemplate {
val items = ItemList.Builder()
items.addItem(buildStatusRow())
contacts.take(MAX_LIST_ITEMS).forEach { contact -> items.addItem(buildContactRow(contact)) }
return ListTemplate.Builder().setTitle("Meshtastic").setSingleList(items.build()).build()
}
// Status row
private fun buildStatusRow(): Row {
val statusText =
when (connectionState) {
is ConnectionState.Connected -> "Connected"
@@ -258,114 +326,52 @@ class MeshtasticCarScreen(carContext: CarContext) :
is ConnectionState.Connecting -> "Connecting…"
}
val deviceName = nodeRepository.ourNodeInfo.value?.user?.long_name.orEmpty()
items.addItem(
Row.Builder()
.setTitle(statusText)
.apply { if (deviceName.isNotEmpty()) addText(deviceName) }
.setBrowsable(false)
.build(),
)
return Row.Builder()
.setTitle(statusText)
.apply { if (deviceName.isNotEmpty()) addText(deviceName) }
.setBrowsable(false)
.build()
}
// Favourite nodes
favoriteNodes.take(MAX_LIST_ITEMS).forEach { node ->
items.addItem(buildFavoriteNodeRow(node))
}
// Channels
channels.take(MAX_LIST_ITEMS).forEach { (index, settings) ->
items.addItem(buildChannelRow(index, settings))
}
return ListTemplate.Builder()
.setTitle("Meshtastic")
.setSingleList(items.build())
private fun buildContactRow(contact: CarContact): Row {
val subtitle = if (contact.unreadCount > 0) "${contact.unreadCount} unread" else ""
return Row.Builder()
.setTitle(contact.displayName)
.apply { if (subtitle.isNotEmpty()) addText(subtitle) }
.setBrowsable(false)
.build()
}
private fun carIcon(resId: Int) =
CarIcon.Builder(IconCompat.createWithResource(carContext, resId)).setTint(CarColor.DEFAULT).build()
private fun buildStatusTemplate(): ListTemplate {
val statusText =
when (connectionState) {
is ConnectionState.Connected -> "Connected"
is ConnectionState.Disconnected -> "Disconnected"
is ConnectionState.DeviceSleep -> "Device Sleeping"
is ConnectionState.Connecting -> "Connecting..."
}
// ---- Internal model ----
val deviceName = nodeRepository.ourNodeInfo.value?.user?.long_name ?: ""
val subtitle = if (deviceName.isNotEmpty()) deviceName else null
val row =
Row.Builder()
.setTitle(statusText)
.apply { if (subtitle != null) addText(subtitle) }
.setBrowsable(false)
.build()
return ListTemplate.Builder().setTitle("Status").setSingleList(ItemList.Builder().addItem(row).build()).build()
}
private fun buildFavoritesTemplate(): ListTemplate {
val items = ItemList.Builder()
if (favoriteNodes.isEmpty()) {
items.setNoItemsMessage("No favorite contacts")
} else {
favoriteNodes.take(MAX_LIST_ITEMS).forEach { node ->
items.addItem(buildFavoriteNodeRow(node))
}
}
return ListTemplate.Builder().setTitle("Favorites").setSingleList(items.build()).build()
}
private fun buildChannelsTemplate(): ListTemplate {
val items = ItemList.Builder()
if (channels.isEmpty()) {
items.setNoItemsMessage("No active channels")
} else {
channels.take(MAX_LIST_ITEMS).forEach { (index, settings) ->
items.addItem(buildChannelRow(index, settings))
}
}
return ListTemplate.Builder().setTitle("Channels").setSingleList(items.build()).build()
}
private fun buildChannelRow(index: Int, channelSettings: ChannelSettings): Row {
val contactKey = "${index}${DataPacket.ID_BROADCAST}"
val unread = unreadCounts[contactKey] ?: 0
val channelName = channelSettings.name.ifEmpty { "Primary Channel" }
val subtitle = if (unread > 0) "$unread unread" else ""
return Row.Builder()
.setTitle(channelName)
.apply { if (subtitle.isNotEmpty()) addText(subtitle) }
.setBrowsable(false)
.build()
}
private fun buildFavoriteNodeRow(node: Node): Row {
val contactKey = "0${node.user.id}"
val unread = unreadCounts[contactKey] ?: 0
val name = node.user.long_name.ifEmpty { node.user.short_name }.ifEmpty { "Unknown" }
val subtitle = buildString {
append(node.user.short_name)
if (node.hopsAway >= 0) append(" · ${node.hopsAway} hops")
if (unread > 0) append(" · $unread unread")
}
return Row.Builder().setTitle(name).addText(subtitle).setBrowsable(false).build()
}
/**
* Lightweight projection of a conversation used exclusively within this screen.
*
* [isBroadcast] and [channelIndex] drive ordering (channels before DMs, channels sorted by
* index). [lastMessageTime] drives DM ordering (most-recent first).
*/
private data class CarContact(
val contactKey: String,
val displayName: String,
val unreadCount: Int,
val isBroadcast: Boolean,
val channelIndex: Int,
val lastMessageTime: Long?,
)
companion object {
private const val TAB_STATUS = "status"
private const val TAB_FAVORITES = "favorites"
private const val TAB_CHANNELS = "channels"
private const val TAB_MESSAGES = "messages"
/**
* Android Auto enforces a per-[ListTemplate] item cap via [androidx.car.app.constraints.ConstraintManager]'s
* `CONTENT_LIMIT_TYPE_LIST`. 6 is the conservative floor across supported hosts.
* Car App Library enforces a per-[ListTemplate] item cap via
* `ConstraintManager.CONTENT_LIMIT_TYPE_LIST`. 6 is the conservative floor across all
* supported hosts.
*/
private const val MAX_LIST_ITEMS = 6
}
}

View File

@@ -46,7 +46,9 @@ class MeshtasticCarSession : Session() {
private fun handleIntent(intent: Intent, screen: MeshtasticCarScreen) {
// Deep-link URIs from MessagingStyle notifications look like:
// meshtastic://messages/0!abcd1234 (DM: channel=0, nodeId=!abcd1234)
// meshtastic://messages/2^all (channel broadcast, contactKey e.g. "2^all")
// meshtastic://messages/2^all (channel broadcast, e.g. contactKey "2^all")
// Both channels and DMs now live in the same Messages tab, so we simply
// switch to that tab regardless of the contact type.
val contactKey = intent.data?.lastPathSegment ?: return
screen.selectContactKey(contactKey)
}