From 01b17595035251203372cba17db4737805fd4190 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Fri, 17 Apr 2026 16:13:09 +0000
Subject: [PATCH] =?UTF-8?q?feat(auto):=20spec-compliance=20=E2=80=94=20min?=
=?UTF-8?q?CarApiLevel=3D1,=20runtime=20API=20fallback,=20onNewIntent,=20l?=
=?UTF-8?q?oading=20state?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Agent-Logs-Url: https://github.com/meshtastic/Meshtastic-Android/sessions/50f9540a-3ba0-4e05-8e06-83cc8c4c93aa
Co-authored-by: jamesarich <2199651+jamesarich@users.noreply.github.com>
---
feature/auto/src/main/AndroidManifest.xml | 8 +-
.../feature/auto/MeshtasticCarScreen.kt | 124 +++++++++++++++---
.../feature/auto/MeshtasticCarSession.kt | 28 +++-
3 files changed, 141 insertions(+), 19 deletions(-)
diff --git a/feature/auto/src/main/AndroidManifest.xml b/feature/auto/src/main/AndroidManifest.xml
index bda5f1114..8cf24a494 100644
--- a/feature/auto/src/main/AndroidManifest.xml
+++ b/feature/auto/src/main/AndroidManifest.xml
@@ -33,11 +33,13 @@
-
+
+ android:value="1" />
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 812255f18..c80b9b18a 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
@@ -16,6 +16,7 @@
*/
package org.meshtastic.feature.auto
+import androidx.car.app.CarAppApiLevels
import androidx.car.app.CarContext
import androidx.car.app.Screen
import androidx.car.app.model.Action
@@ -23,6 +24,7 @@ import androidx.car.app.model.CarColor
import androidx.car.app.model.CarIcon
import androidx.car.app.model.ItemList
import androidx.car.app.model.ListTemplate
+import androidx.car.app.model.MessageTemplate
import androidx.car.app.model.Row
import androidx.car.app.model.Tab
import androidx.car.app.model.TabContents
@@ -56,12 +58,21 @@ import org.meshtastic.proto.ChannelSettings
/**
* Root screen displayed in Android Auto.
*
- * Shows three tabs mirroring the iOS CarPlay tab-based navigation:
- * - **Status**: Connection state and active device name
- * - **Favorites**: Favorited mesh nodes with unread message counts
- * - **Channels**: Active channels with unread message counts
+ * Renders a three-tab UI mirroring the iOS CarPlay tab-based navigation:
+ * - **Status** — Connection state and device name
+ * - **Favorites** — Favourited mesh nodes with unread message counts
+ * - **Channels** — Active channels with unread message counts
*
- * Requires Car API level 2+ (androidx.car.app:app 1.2.0+) for [TabTemplate] support.
+ * `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.
+ *
+ * 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.
*/
class MeshtasticCarScreen(carContext: CarContext) :
Screen(carContext),
@@ -82,6 +93,13 @@ class MeshtasticCarScreen(carContext: CarContext) :
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.
+ */
+ private var isLoading = true
+
init {
lifecycle.addObserver(this)
}
@@ -143,12 +161,29 @@ class MeshtasticCarScreen(carContext: CarContext) :
favoriteNodes = favorites
channels = chs
unreadCounts = counts
+ isLoading = false
invalidate()
}
}
}
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).
+ if (isLoading && carContext.carAppApiLevel >= CarAppApiLevels.LEVEL_5) {
+ return MessageTemplate.Builder("Loading…")
+ .setHeaderAction(Action.APP_ICON)
+ .setLoading(true)
+ .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) {
@@ -192,6 +227,61 @@ class MeshtasticCarScreen(carContext: CarContext) :
.build()
}
+ /**
+ * Called by [MeshtasticCarSession.onNewIntent] when the user taps a conversation
+ * notification in the Android Auto notification shade.
+ *
+ * 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.
+ */
+ fun selectContactKey(contactKey: String) {
+ activeTabId = if (contactKey.endsWith(DataPacket.ID_BROADCAST)) TAB_CHANNELS else TAB_FAVORITES
+ invalidate()
+ }
+
+ /**
+ * Fallback template 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.
+ */
+ private fun buildFallbackListTemplate(): ListTemplate {
+ val items = ItemList.Builder()
+
+ // Status row
+ val statusText =
+ when (connectionState) {
+ is ConnectionState.Connected -> "Connected"
+ is ConnectionState.Disconnected -> "Disconnected"
+ is ConnectionState.DeviceSleep -> "Device Sleeping"
+ is ConnectionState.Connecting -> "Connecting…"
+ }
+ val deviceName = nodeRepository.ourNodeInfo.value?.user?.long_name.orEmpty()
+ items.addItem(
+ 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())
+ .build()
+ }
+
private fun carIcon(resId: Int) =
CarIcon.Builder(IconCompat.createWithResource(carContext, resId)).setTint(CarColor.DEFAULT).build()
@@ -222,16 +312,8 @@ class MeshtasticCarScreen(carContext: CarContext) :
if (favoriteNodes.isEmpty()) {
items.setNoItemsMessage("No favorite contacts")
} else {
- for (node in favoriteNodes.take(MAX_LIST_ITEMS)) {
- 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")
- }
- items.addItem(Row.Builder().setTitle(name).addText(subtitle).setBrowsable(false).build())
+ favoriteNodes.take(MAX_LIST_ITEMS).forEach { node ->
+ items.addItem(buildFavoriteNodeRow(node))
}
}
@@ -263,6 +345,18 @@ class MeshtasticCarScreen(carContext: CarContext) :
.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()
+ }
+
companion object {
private const val TAB_STATUS = "status"
private const val TAB_FAVORITES = "favorites"
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 9fd816bbf..bcb0fb7d5 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
@@ -23,5 +23,31 @@ import androidx.car.app.Session
/** Android Auto session that hosts the [MeshtasticCarScreen] root screen. */
class MeshtasticCarSession : Session() {
- override fun onCreateScreen(intent: Intent): Screen = MeshtasticCarScreen(carContext)
+ override fun onCreateScreen(intent: Intent): Screen {
+ val screen = MeshtasticCarScreen(carContext)
+ handleIntent(intent, screen)
+ return screen
+ }
+
+ /**
+ * 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.
+ *
+ * Parses the conversation [contactKey] from the deep-link URI
+ * (`meshtastic://messages/`) and delegates to
+ * [MeshtasticCarScreen.selectContactKey] so the correct tab is pre-selected.
+ */
+ override fun onNewIntent(intent: Intent) {
+ val screen = screenManager.top as? MeshtasticCarScreen ?: return
+ handleIntent(intent, screen)
+ }
+
+ 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")
+ val contactKey = intent.data?.lastPathSegment ?: return
+ screen.selectContactKey(contactKey)
+ }
}