feat(auto): replace ListTemplate with TabTemplate for iOS CarPlay parity

Agent-Logs-Url: https://github.com/meshtastic/Meshtastic-Android/sessions/67580c49-612a-450b-8452-9c88875df1c3

Co-authored-by: garthvh <1795163+garthvh@users.noreply.github.com>
This commit is contained in:
copilot-swe-agent[bot]
2026-04-17 14:44:18 +00:00
committed by GitHub
parent eb3a27a3d3
commit dac4880e0f
5 changed files with 180 additions and 50 deletions

View File

@@ -33,9 +33,10 @@
</intent-filter>
</service>
<!-- TabTemplate requires Car API level 2 (androidx.car.app 1.2.0+) -->
<meta-data
android:name="androidx.car.app.minCarApiLevel"
android:value="1" />
android:value="2" />
</application>
</manifest>

View File

@@ -19,11 +19,14 @@ package org.meshtastic.feature.auto
import androidx.car.app.CarContext
import androidx.car.app.Screen
import androidx.car.app.model.Action
import androidx.car.app.model.CarIcon
import androidx.car.app.model.ItemList
import androidx.car.app.model.ListTemplate
import androidx.car.app.model.Row
import androidx.car.app.model.SectionedItemList
import androidx.car.app.model.Tab
import androidx.car.app.model.TabTemplate
import androidx.car.app.model.Template
import androidx.core.graphics.drawable.IconCompat
import androidx.lifecycle.DefaultLifecycleObserver
import androidx.lifecycle.LifecycleOwner
import kotlinx.coroutines.CoroutineScope
@@ -51,10 +54,12 @@ import org.meshtastic.proto.ChannelSettings
/**
* Root screen displayed in Android Auto.
*
* Shows three sections mirroring the iOS CarPlay implementation:
* 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
*
* Requires Car API level 2+ (androidx.car.app:app 1.2.0+) for [TabTemplate] support.
*/
class MeshtasticCarScreen(carContext: CarContext) :
Screen(carContext),
@@ -69,6 +74,7 @@ class MeshtasticCarScreen(carContext: CarContext) :
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()
@@ -141,24 +147,48 @@ class MeshtasticCarScreen(carContext: CarContext) :
}
override fun onGetTemplate(): Template {
val listBuilder = ListTemplate.Builder()
listBuilder.addSectionedList(SectionedItemList.create(buildStatusSection(), "Status"))
val favoritesSection = buildFavoritesSection()
if (favoritesSection.items.isNotEmpty()) {
listBuilder.addSectionedList(SectionedItemList.create(favoritesSection, "Favorites"))
val tabCallback = TabTemplate.TabCallback { tabContentId ->
activeTabId = tabContentId
invalidate()
}
val channelsSection = buildChannelsSection()
if (channelsSection.items.isNotEmpty()) {
listBuilder.addSectionedList(SectionedItemList.create(channelsSection, "Channels"))
val activeContent = when (activeTabId) {
TAB_FAVORITES -> TabTemplate.TabContents.Builder(buildFavoritesTemplate()).build()
TAB_CHANNELS -> TabTemplate.TabContents.Builder(buildChannelsTemplate()).build()
else -> TabTemplate.TabContents.Builder(buildStatusTemplate()).build()
}
return listBuilder.setTitle("Meshtastic").setHeaderAction(Action.APP_ICON).build()
return TabTemplate.Builder(tabCallback)
.setHeaderAction(Action.APP_ICON)
.addTab(
Tab.Builder()
.setTitle("Status")
.setIcon(carIcon(R.drawable.auto_ic_status))
.setContentId(TAB_STATUS)
.build(),
)
.addTab(
Tab.Builder()
.setTitle("Favorites")
.setIcon(carIcon(R.drawable.auto_ic_favorites))
.setContentId(TAB_FAVORITES)
.build(),
)
.addTab(
Tab.Builder()
.setTitle("Channels")
.setIcon(carIcon(R.drawable.auto_ic_channels))
.setContentId(TAB_CHANNELS)
.build(),
)
.setTabContents(activeContent)
.setActiveTabContentId(activeTabId)
.build()
}
private fun buildStatusSection(): ItemList {
private fun carIcon(resId: Int) = CarIcon.Builder(IconCompat.createWithResource(carContext, resId)).build()
private fun buildStatusTemplate(): ListTemplate {
val statusText =
when (connectionState) {
is ConnectionState.Connected -> "Connected"
@@ -177,55 +207,70 @@ class MeshtasticCarScreen(carContext: CarContext) :
.setBrowsable(false)
.build()
return ItemList.Builder().addItem(row).build()
return ListTemplate.Builder()
.setTitle("Status")
.setSingleList(ItemList.Builder().addItem(row).build())
.build()
}
private fun buildFavoritesSection(): ItemList {
val builder = ItemList.Builder()
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")
private fun buildFavoritesTemplate(): ListTemplate {
val items = ItemList.Builder()
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())
}
val row = Row.Builder().setTitle(name).addText(subtitle).setBrowsable(false).build()
builder.addItem(row)
}
return builder.build()
return ListTemplate.Builder()
.setTitle("Favorites")
.setSingleList(items.build())
.build()
}
private fun buildChannelsSection(): ItemList {
val builder = ItemList.Builder()
private fun buildChannelsTemplate(): ListTemplate {
val items = ItemList.Builder()
if (channels.isEmpty()) {
items.setNoItemsMessage("No active channels")
} else {
for ((index, channelSettings) in channels.take(MAX_LIST_ITEMS)) {
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 ""
for ((index, channelSettings) in channels.take(MAX_LIST_ITEMS)) {
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 ""
val row =
Row.Builder()
.setTitle(channelName)
.apply { if (subtitle.isNotEmpty()) addText(subtitle) }
.setBrowsable(false)
.build()
builder.addItem(row)
val row =
Row.Builder()
.setTitle(channelName)
.apply { if (subtitle.isNotEmpty()) addText(subtitle) }
.setBrowsable(false)
.build()
items.addItem(row)
}
}
return builder.build()
return ListTemplate.Builder()
.setTitle("Channels")
.setSingleList(items.build())
.build()
}
companion object {
private const val TAB_STATUS = "status"
private const val TAB_FAVORITES = "favorites"
private const val TAB_CHANNELS = "channels"
/**
* Android Auto enforces a maximum item count per [ListTemplate] section. Car API level 1 supports up to 6 items
* per section.
* Android Auto enforces a maximum item count per [ListTemplate]. Car API level 2 supports up to 6 items.
*/
private const val MAX_LIST_ITEMS = 6
}

View File

@@ -0,0 +1,28 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
~ Copyright (c) 2026 Meshtastic LLC
~
~ This program is free software: you can redistribute it and/or modify
~ it under the terms of the GNU General Public License as published by
~ the Free Software Foundation, either version 3 of the License, or
~ (at your option) any later version.
~
~ This program is distributed in the hope that it will be useful,
~ but WITHOUT ANY WARRANTY; without even the implied warranty of
~ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
~ GNU General Public License for more details.
~
~ You should have received a copy of the GNU General Public License
~ along with this program. If not, see <https://www.gnu.org/licenses/>.
-->
<!-- Material Design "chat" icon — used for the Channels tab in Android Auto -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#FFFFFF"
android:pathData="M20,2L4,2c-1.1,0 -2,0.9 -2,2v18l4,-4h14c1.1,0 2,-0.9 2,-2L22,4c0,-1.1 -0.9,-2 -2,-2z" />
</vector>

View File

@@ -0,0 +1,28 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
~ Copyright (c) 2026 Meshtastic LLC
~
~ This program is free software: you can redistribute it and/or modify
~ it under the terms of the GNU General Public License as published by
~ the Free Software Foundation, either version 3 of the License, or
~ (at your option) any later version.
~
~ This program is distributed in the hope that it will be useful,
~ but WITHOUT ANY WARRANTY; without even the implied warranty of
~ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
~ GNU General Public License for more details.
~
~ You should have received a copy of the GNU General Public License
~ along with this program. If not, see <https://www.gnu.org/licenses/>.
-->
<!-- Material Design "star" icon — used for the Favorites tab in Android Auto -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#FFFFFF"
android:pathData="M12,17.27L18.18,21l-1.64,-7.03L22,9.24l-7.19,-0.61L12,2 9.19,8.63 2,9.24l5.46,4.73L5.82,21z" />
</vector>

View File

@@ -0,0 +1,28 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
~ Copyright (c) 2026 Meshtastic LLC
~
~ This program is free software: you can redistribute it and/or modify
~ it under the terms of the GNU General Public License as published by
~ the Free Software Foundation, either version 3 of the License, or
~ (at your option) any later version.
~
~ This program is distributed in the hope that it will be useful,
~ but WITHOUT ANY WARRANTY; without even the implied warranty of
~ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
~ GNU General Public License for more details.
~
~ You should have received a copy of the GNU General Public License
~ along with this program. If not, see <https://www.gnu.org/licenses/>.
-->
<!-- Material Design "wifi" icon — used for the Status tab in Android Auto -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#FFFFFF"
android:pathData="M1,9l2,2c4.97,-4.97 13.03,-4.97 18,0l2,-2C16.93,2.93 7.08,2.93 1,9zM9,17l3,3 3,-3c-1.65,-1.66 -4.34,-1.66 -6,0zM5,13l2,2c2.76,-2.76 7.24,-2.76 10,0l2,-2C15.14,9.14 8.87,9.14 5,13z" />
</vector>