mirror of
https://github.com/meshtastic/Meshtastic-Android.git
synced 2026-05-12 08:42:01 -04:00
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:
committed by
GitHub
parent
01b1759503
commit
7c15c7bcb4
@@ -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<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 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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user