diff --git a/feature/auto/src/main/kotlin/org/meshtastic/feature/auto/MeshtasticCarScreen.kt b/feature/auto/src/main/kotlin/org/meshtastic/feature/auto/MeshtasticCarScreen.kt index c80b9b18a..d9b87aa56 100644 --- a/feature/auto/src/main/kotlin/org/meshtastic/feature/auto/MeshtasticCarScreen.kt +++ b/feature/auto/src/main/kotlin/org/meshtastic/feature/auto/MeshtasticCarScreen.kt @@ -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 1–5 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 1–5 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 = emptyList() - private var channels: List> = emptyList() - private var unreadCounts: Map = 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 = 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 `"^all" → placeholder DataPacket` for every configured channel. */ + private fun buildChannelPlaceholders(channelSet: ChannelSet): Map = + (0 until channelSet.settings.size).associate { ch -> + // dataType uses PortNum.TEXT_MESSAGE_APP (value 1) to match the placeholder + // construction in ContactsViewModel and PacketRepository contact queries. + "${ch}${DataPacket.ID_BROADCAST}" to + DataPacket(bytes = null, dataType = PortNum.TEXT_MESSAGE_APP.value, time = 0L, channel = ch) + } + + /** + * Converts the merged DB + placeholder map into an ordered [CarContact] list. + * + * Channels (keys ending with [DataPacket.ID_BROADCAST]) appear first sorted by channel index. + * DM conversations follow sorted by [CarContact.lastMessageTime] descending — matching the + * ordering used by the phone's Contacts screen. + */ + private fun buildCarContacts( + merged: Map, + myId: String?, + channelSet: ChannelSet, + ): List { + val all = + merged.map { (contactKey, packet) -> + val fromLocal = packet.from == DataPacket.ID_LOCAL || packet.from == myId + val toBroadcast = packet.to == DataPacket.ID_BROADCAST + val userId = if (fromLocal) packet.to else packet.from + + 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 1–5 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 1–5 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 } } + diff --git a/feature/auto/src/main/kotlin/org/meshtastic/feature/auto/MeshtasticCarSession.kt b/feature/auto/src/main/kotlin/org/meshtastic/feature/auto/MeshtasticCarSession.kt index bcb0fb7d5..13b3c5d3b 100644 --- a/feature/auto/src/main/kotlin/org/meshtastic/feature/auto/MeshtasticCarSession.kt +++ b/feature/auto/src/main/kotlin/org/meshtastic/feature/auto/MeshtasticCarSession.kt @@ -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) }