mirror of
https://github.com/meshtastic/Meshtastic-Android.git
synced 2026-05-12 08:42:01 -04:00
fix(auto): apply Android Auto best-practices audit fixes
Agent-Logs-Url: https://github.com/meshtastic/Meshtastic-Android/sessions/8d768315-9c58-4b16-8912-d0b4f97c3681 Co-authored-by: jamesarich <2199651+jamesarich@users.noreply.github.com>
This commit is contained in:
committed by
GitHub
parent
d70c3b66dc
commit
67e300da96
@@ -920,7 +920,10 @@ class MeshServiceNotificationsImpl(
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
|
||||
)
|
||||
|
||||
return NotificationCompat.Action.Builder(android.R.drawable.ic_menu_add, label, pendingIntent).build()
|
||||
return NotificationCompat.Action.Builder(android.R.drawable.ic_menu_add, label, pendingIntent)
|
||||
.setSemanticAction(NotificationCompat.Action.SEMANTIC_ACTION_NONE)
|
||||
.setShowsUserInterface(false)
|
||||
.build()
|
||||
}
|
||||
|
||||
private fun commonBuilder(
|
||||
|
||||
@@ -23,10 +23,6 @@ plugins {
|
||||
android {
|
||||
namespace = "org.meshtastic.feature.auto"
|
||||
resourcePrefix = "auto_"
|
||||
|
||||
// Car App Library requires API 23+; bump above the app's default minSdk
|
||||
// so we can use conversation shortcuts and LocusId APIs cleanly.
|
||||
defaultConfig { minSdk = 23 }
|
||||
}
|
||||
|
||||
dependencies {
|
||||
|
||||
@@ -17,16 +17,12 @@
|
||||
-->
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
<!-- Android Auto: declare as a messaging/communications app -->
|
||||
<!-- Android Auto Car App Service for browsable messaging UI -->
|
||||
<application>
|
||||
<meta-data
|
||||
android:name="com.google.android.gms.car.application"
|
||||
android:resource="@xml/auto_app_desc" />
|
||||
|
||||
<!-- Android Auto Car App Service for browsable messaging UI -->
|
||||
<service
|
||||
android:name="org.meshtastic.feature.auto.MeshtasticCarAppService"
|
||||
android:exported="true">
|
||||
android:exported="true"
|
||||
android:permission="androidx.car.app.CarAppService">
|
||||
<intent-filter>
|
||||
<action android:name="androidx.car.app.CarAppService" />
|
||||
<category android:name="androidx.car.app.category.MESSAGING" />
|
||||
|
||||
@@ -55,12 +55,18 @@ internal object CarScreenDataBuilder {
|
||||
*
|
||||
* @param resolveUser Returns the [User] for a given node-ID string. The caller is responsible
|
||||
* for providing a null-safe fallback (typically [NodeRepository.getUser]).
|
||||
* @param channelLabel Produces the display name for a channel given its index.
|
||||
* Defaults to `"Channel N"`; callers can supply a localised string.
|
||||
* @param unknownLabel Fallback display name when neither long name nor short name is available.
|
||||
* Defaults to `"Unknown"`; callers can supply a localised string.
|
||||
*/
|
||||
fun buildCarContacts(
|
||||
merged: Map<String, DataPacket>,
|
||||
myId: String?,
|
||||
channelSet: ChannelSet,
|
||||
resolveUser: (String) -> User,
|
||||
channelLabel: (Int) -> String = { "Channel $it" },
|
||||
unknownLabel: String = "Unknown",
|
||||
): List<CarContact> {
|
||||
val all = merged.map { (contactKey, packet) ->
|
||||
val fromLocal = packet.from == DataPacket.ID_LOCAL || packet.from == myId
|
||||
@@ -72,11 +78,11 @@ internal object CarScreenDataBuilder {
|
||||
|
||||
val displayName = if (toBroadcast) {
|
||||
channelSet.getChannel(packet.channel)?.name?.takeIf { it.isNotEmpty() }
|
||||
?: "Channel ${packet.channel}"
|
||||
?: channelLabel(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.
|
||||
user.long_name.ifEmpty { user.short_name }.ifEmpty { "Unknown" }
|
||||
user.long_name.ifEmpty { user.short_name }.ifEmpty { unknownLabel }
|
||||
}
|
||||
|
||||
// Mirror ContactsViewModel: prefix received DM text with the sender's short name,
|
||||
@@ -125,21 +131,31 @@ internal object CarScreenDataBuilder {
|
||||
* - `"Offline · <time ago>"` when [Node.lastHeard] is set
|
||||
* - `"Offline"` otherwise
|
||||
*
|
||||
* @param labelOnline Localised "Online" label; defaults to English.
|
||||
* @param labelOffline Localised "Offline" label; defaults to English.
|
||||
* @param labelDirect Suffix appended when [Node.hopsAway] == 0 (include leading " · ");
|
||||
* defaults to `" · Direct"`.
|
||||
* @param labelHops Produces the hop-count suffix given the count (include leading " · ");
|
||||
* defaults to `" · N hops"`.
|
||||
* @param formatRelativeTime Converts a millis timestamp to a human-readable "X ago" string.
|
||||
* Defaults to [DateFormatter.formatRelativeTime]; injectable for testing.
|
||||
*/
|
||||
fun nodeStatusText(
|
||||
node: Node,
|
||||
labelOnline: String = "Online",
|
||||
labelOffline: String = "Offline",
|
||||
labelDirect: String = " · Direct",
|
||||
labelHops: (Int) -> String = { " · $it hops" },
|
||||
formatRelativeTime: (Long) -> String = DateFormatter::formatRelativeTime,
|
||||
): String = buildString {
|
||||
if (node.isOnline) {
|
||||
append("Online")
|
||||
append(labelOnline)
|
||||
when {
|
||||
node.hopsAway == 0 -> append(" · Direct")
|
||||
node.hopsAway > 0 -> append(" · ${node.hopsAway} hops")
|
||||
node.hopsAway == 0 -> append(labelDirect)
|
||||
node.hopsAway > 0 -> append(labelHops(node.hopsAway))
|
||||
}
|
||||
} else {
|
||||
append("Offline")
|
||||
append(labelOffline)
|
||||
if (node.lastHeard > 0) {
|
||||
append(" · ${formatRelativeTime(node.lastHeard * 1000L)}")
|
||||
}
|
||||
@@ -166,27 +182,34 @@ internal object CarScreenDataBuilder {
|
||||
* Returns the message preview line for a contact row (Text 1 in the Car UI row).
|
||||
*
|
||||
* Mirrors `ChatMetadata`'s `lastMessageText` display: shows the last message text
|
||||
* (with sender prefix for received DMs), or `"No messages yet"` for empty channels.
|
||||
* (with sender prefix for received DMs), or [noMessagesLabel] for empty channels.
|
||||
*
|
||||
* @param noMessagesLabel Localised empty-state label; defaults to `"No messages yet"`.
|
||||
*/
|
||||
fun contactPreviewText(contact: CarContact): String =
|
||||
contact.lastMessageText?.takeIf { it.isNotEmpty() } ?: "No messages yet"
|
||||
fun contactPreviewText(
|
||||
contact: CarContact,
|
||||
noMessagesLabel: String = "No messages yet",
|
||||
): String = contact.lastMessageText?.takeIf { it.isNotEmpty() } ?: noMessagesLabel
|
||||
|
||||
/**
|
||||
* Returns the secondary metadata line for a contact row (Text 2 in the Car UI row).
|
||||
*
|
||||
* Mirrors ContactItem's unread badge + date header:
|
||||
* - `"N unread"` when there are unread messages
|
||||
* - [unreadLabel] result when there are unread messages
|
||||
* - Formatted short date of the last message otherwise
|
||||
* - Empty string when there are no messages at all
|
||||
*
|
||||
* @param unreadLabel Produces the unread-count label given the count.
|
||||
* Defaults to `"N unread"`; callers can supply a localised format string.
|
||||
* @param formatShortDate Converts a millis timestamp to a short date string.
|
||||
* Defaults to [DateFormatter.formatShortDate]; injectable for testing.
|
||||
*/
|
||||
fun contactSecondaryText(
|
||||
contact: CarContact,
|
||||
unreadLabel: (Int) -> String = { "$it unread" },
|
||||
formatShortDate: (Long) -> String = DateFormatter::formatShortDate,
|
||||
): String = when {
|
||||
contact.unreadCount > 0 -> "${contact.unreadCount} unread"
|
||||
contact.unreadCount > 0 -> unreadLabel(contact.unreadCount)
|
||||
contact.lastMessageTime != null -> formatShortDate(contact.lastMessageTime)
|
||||
else -> ""
|
||||
}
|
||||
|
||||
@@ -36,7 +36,7 @@ class MeshtasticCarAppService : CarAppService() {
|
||||
HostValidator.ALLOW_ALL_HOSTS_VALIDATOR
|
||||
} else {
|
||||
HostValidator.Builder(applicationContext)
|
||||
.addAllowedHosts(androidx.car.app.R.array.hosts_allowlist_sample)
|
||||
.addAllowedHosts(androidx.car.app.R.array.hosts_allowlist)
|
||||
.build()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,6 +19,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.constraints.ConstraintManager
|
||||
import androidx.car.app.model.Action
|
||||
import androidx.car.app.model.CarColor
|
||||
import androidx.car.app.model.CarIcon
|
||||
@@ -88,6 +89,16 @@ class MeshtasticCarScreen(carContext: CarContext) :
|
||||
|
||||
private val scope = CoroutineScope(Dispatchers.Main + SupervisorJob())
|
||||
|
||||
/**
|
||||
* Per-host list item cap, retrieved once on first template render via
|
||||
* [ConstraintManager.getContentLimit]. Replaces a hardcoded constant so that
|
||||
* hosts that allow more than the minimum 5 items are fully utilised.
|
||||
*/
|
||||
private val listContentLimit: Int by lazy {
|
||||
carContext.getCarService(ConstraintManager::class.java)
|
||||
.getContentLimit(ConstraintManager.CONTENT_LIMIT_TYPE_LIST)
|
||||
}
|
||||
|
||||
private var activeTabId = TAB_STATUS
|
||||
private var connectionState: ConnectionState = ConnectionState.Disconnected
|
||||
|
||||
@@ -105,10 +116,14 @@ class MeshtasticCarScreen(carContext: CarContext) :
|
||||
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.
|
||||
* True until the first combined emission from all repository flows arrives.
|
||||
*
|
||||
* On Car API ≥ 5 this prevents a flash of empty content before data loads by showing a
|
||||
* [MessageTemplate] loading spinner. On older API levels the loading spinner is unavailable
|
||||
* so [isLoading] starts as `false` and the fallback [ListTemplate] handles the empty state
|
||||
* with [ItemList.Builder.setNoItemsMessage].
|
||||
*/
|
||||
private var isLoading = true
|
||||
private var isLoading = carContext.carAppApiLevel >= CarAppApiLevels.LEVEL_5
|
||||
|
||||
init {
|
||||
lifecycle.addObserver(this)
|
||||
@@ -123,68 +138,67 @@ class MeshtasticCarScreen(carContext: CarContext) :
|
||||
}
|
||||
|
||||
private fun startObserving() {
|
||||
// Observe the contact list (channels + DMs) with reactive unread counts.
|
||||
// Build the contacts sub-flow independently so it can feed the outer combine below.
|
||||
val contactsFlow = 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.
|
||||
val placeholders = CarScreenDataBuilder.buildChannelPlaceholders(channelSet)
|
||||
// Real DB entries take precedence over placeholders when present.
|
||||
val merged = rawContacts + (placeholders - rawContacts.keys)
|
||||
CarScreenDataBuilder.buildCarContacts(
|
||||
merged, myId, channelSet,
|
||||
resolveUser = { userId -> nodeRepository.getUser(userId) },
|
||||
channelLabel = { carContext.getString(R.string.auto_channel_number, it) },
|
||||
unknownLabel = carContext.getString(R.string.auto_unknown),
|
||||
)
|
||||
}
|
||||
.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() }
|
||||
}
|
||||
}
|
||||
|
||||
val favoritesFlow = nodeRepository.nodeDBbyNum
|
||||
.map { db -> CarScreenDataBuilder.sortFavorites(db.values) }
|
||||
.distinctUntilChanged()
|
||||
|
||||
// All three data sources feed a single combined collector so that each batch of
|
||||
// repository changes produces exactly one invalidate() call, avoiding unnecessary
|
||||
// template rebuilds and staying well within the host's update-rate budget.
|
||||
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.
|
||||
val placeholders = CarScreenDataBuilder.buildChannelPlaceholders(channelSet)
|
||||
// Real DB entries take precedence over placeholders when present.
|
||||
val merged = rawContacts + (placeholders - rawContacts.keys)
|
||||
CarScreenDataBuilder.buildCarContacts(merged, myId, channelSet) { userId ->
|
||||
nodeRepository.getUser(userId)
|
||||
}
|
||||
}
|
||||
.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
|
||||
serviceRepository.connectionState,
|
||||
favoritesFlow,
|
||||
contactsFlow,
|
||||
) { connState, favs, ctcts -> Triple(connState, favs, ctcts) }
|
||||
.collect { (connState, favs, ctcts) ->
|
||||
connectionState = connState
|
||||
favorites = favs
|
||||
contacts = ctcts
|
||||
isLoading = false
|
||||
invalidate()
|
||||
}
|
||||
}
|
||||
|
||||
// Connection state is observed separately since it only affects the Status tab.
|
||||
scope.launch {
|
||||
serviceRepository.connectionState.collect { state ->
|
||||
connectionState = state
|
||||
invalidate()
|
||||
}
|
||||
}
|
||||
|
||||
// Favorite nodes — filter to isFavorite only, sort alphabetically.
|
||||
scope.launch {
|
||||
nodeRepository.nodeDBbyNum
|
||||
.map { db -> CarScreenDataBuilder.sortFavorites(db.values) }
|
||||
.distinctUntilChanged()
|
||||
.collect { nodes ->
|
||||
favorites = nodes
|
||||
invalidate()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Template building ----
|
||||
|
||||
override fun onGetTemplate(): Template {
|
||||
// MessageTemplate.setLoading() requires Car API 5+. On older hosts fall through
|
||||
// to the ListTemplate fallback (StateFlows emit their cached state near-instantly).
|
||||
if (isLoading && carContext.carAppApiLevel >= CarAppApiLevels.LEVEL_5) {
|
||||
return MessageTemplate.Builder("Loading…")
|
||||
// MessageTemplate.setLoading() requires Car API 5+. isLoading is only ever true
|
||||
// on ≥5 hosts (see initialisation above), so no additional level check is needed here.
|
||||
if (isLoading) {
|
||||
return MessageTemplate.Builder(carContext.getString(R.string.auto_loading))
|
||||
.setHeaderAction(Action.APP_ICON)
|
||||
.setLoading(true)
|
||||
.build()
|
||||
@@ -213,21 +227,21 @@ class MeshtasticCarScreen(carContext: CarContext) :
|
||||
.setHeaderAction(Action.APP_ICON)
|
||||
.addTab(
|
||||
Tab.Builder()
|
||||
.setTitle("Status")
|
||||
.setTitle(carContext.getString(R.string.auto_tab_status))
|
||||
.setIcon(carIcon(R.drawable.auto_ic_status))
|
||||
.setContentId(TAB_STATUS)
|
||||
.build(),
|
||||
)
|
||||
.addTab(
|
||||
Tab.Builder()
|
||||
.setTitle("Favorites")
|
||||
.setTitle(carContext.getString(R.string.auto_tab_favorites))
|
||||
.setIcon(carIcon(R.drawable.auto_ic_favorites))
|
||||
.setContentId(TAB_FAVORITES)
|
||||
.build(),
|
||||
)
|
||||
.addTab(
|
||||
Tab.Builder()
|
||||
.setTitle("Messages")
|
||||
.setTitle(carContext.getString(R.string.auto_tab_messages))
|
||||
.setIcon(carIcon(R.drawable.auto_ic_channels))
|
||||
.setContentId(TAB_MESSAGES)
|
||||
.build(),
|
||||
@@ -254,7 +268,7 @@ class MeshtasticCarScreen(carContext: CarContext) :
|
||||
|
||||
private fun buildStatusTemplate(): ListTemplate =
|
||||
ListTemplate.Builder()
|
||||
.setTitle("Status")
|
||||
.setTitle(carContext.getString(R.string.auto_tab_status))
|
||||
.setSingleList(ItemList.Builder().addItem(buildStatusRow()).build())
|
||||
.build()
|
||||
|
||||
@@ -268,27 +282,35 @@ class MeshtasticCarScreen(carContext: CarContext) :
|
||||
* - **Text 2**: battery percentage and short name — mirrors the battery row and node chip.
|
||||
*/
|
||||
private fun buildFavoritesTemplate(): ListTemplate =
|
||||
buildListTemplate("Favorites", favorites, "No favorite nodes") { buildFavoriteNodeRow(it) }
|
||||
buildListTemplate(
|
||||
carContext.getString(R.string.auto_tab_favorites),
|
||||
favorites,
|
||||
carContext.getString(R.string.auto_no_favorites),
|
||||
) { buildFavoriteNodeRow(it) }
|
||||
|
||||
/**
|
||||
* 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 =
|
||||
buildListTemplate("Messages", contacts, "No conversations") { buildContactRow(it) }
|
||||
buildListTemplate(
|
||||
carContext.getString(R.string.auto_tab_messages),
|
||||
contacts,
|
||||
carContext.getString(R.string.auto_no_conversations),
|
||||
) { buildContactRow(it) }
|
||||
|
||||
/**
|
||||
* Fallback for Car API level 1–5 hosts that do not support [TabTemplate].
|
||||
*
|
||||
* Shows a status row, then favorite-node rows, then conversation rows, all capped at
|
||||
* [MAX_LIST_ITEMS] total — matching the three-tab content in a single list.
|
||||
* [listContentLimit] total — matching the three-tab content in a single list.
|
||||
*
|
||||
* The remaining slots after status are split evenly: half for favorites, half for messages.
|
||||
* This prevents a long favorites list from crowding out all conversation entries.
|
||||
*/
|
||||
private fun buildFallbackListTemplate(): ListTemplate {
|
||||
val items = ItemList.Builder()
|
||||
var remaining = MAX_LIST_ITEMS
|
||||
var remaining = listContentLimit
|
||||
items.addItem(buildStatusRow())
|
||||
remaining--
|
||||
// Give each section at most half the remaining space so neither dominates.
|
||||
@@ -300,15 +322,18 @@ class MeshtasticCarScreen(carContext: CarContext) :
|
||||
contacts.take(remaining).forEach { contact ->
|
||||
items.addItem(buildContactRow(contact))
|
||||
}
|
||||
return ListTemplate.Builder().setTitle("Meshtastic").setSingleList(items.build()).build()
|
||||
return ListTemplate.Builder()
|
||||
.setTitle(carContext.getString(R.string.auto_fallback_title))
|
||||
.setSingleList(items.build())
|
||||
.build()
|
||||
}
|
||||
|
||||
private fun buildStatusRow(): Row {
|
||||
val statusText = when (connectionState) {
|
||||
is ConnectionState.Connected -> "Connected"
|
||||
is ConnectionState.Disconnected -> "Disconnected"
|
||||
is ConnectionState.DeviceSleep -> "Device Sleeping"
|
||||
is ConnectionState.Connecting -> "Connecting…"
|
||||
is ConnectionState.Connected -> carContext.getString(R.string.auto_status_connected)
|
||||
is ConnectionState.Disconnected -> carContext.getString(R.string.auto_status_disconnected)
|
||||
is ConnectionState.DeviceSleep -> carContext.getString(R.string.auto_status_sleeping)
|
||||
is ConnectionState.Connecting -> carContext.getString(R.string.auto_status_connecting)
|
||||
}
|
||||
val deviceName = nodeRepository.ourNodeInfo.value?.user?.long_name.orEmpty()
|
||||
return Row.Builder()
|
||||
@@ -327,10 +352,19 @@ class MeshtasticCarScreen(carContext: CarContext) :
|
||||
* - Text 2 → battery level + short name chip equivalent (matches NodeItem battery row)
|
||||
*/
|
||||
private fun buildFavoriteNodeRow(node: Node): Row {
|
||||
val name = node.user.long_name.ifEmpty { node.user.short_name }.ifEmpty { "Unknown" }
|
||||
val name = node.user.long_name.ifEmpty { node.user.short_name }
|
||||
.ifEmpty { carContext.getString(R.string.auto_unknown) }
|
||||
return Row.Builder()
|
||||
.setTitle(name)
|
||||
.addText(CarScreenDataBuilder.nodeStatusText(node))
|
||||
.addText(
|
||||
CarScreenDataBuilder.nodeStatusText(
|
||||
node,
|
||||
labelOnline = carContext.getString(R.string.auto_node_online),
|
||||
labelOffline = carContext.getString(R.string.auto_node_offline),
|
||||
labelDirect = carContext.getString(R.string.auto_node_direct),
|
||||
labelHops = { carContext.getString(R.string.auto_node_hops, it) },
|
||||
),
|
||||
)
|
||||
.addTextIfNotEmpty(CarScreenDataBuilder.nodeDetailText(node))
|
||||
.setBrowsable(false)
|
||||
.build()
|
||||
@@ -349,8 +383,18 @@ class MeshtasticCarScreen(carContext: CarContext) :
|
||||
private fun buildContactRow(contact: CarContact): Row =
|
||||
Row.Builder()
|
||||
.setTitle(contact.displayName)
|
||||
.addText(CarScreenDataBuilder.contactPreviewText(contact))
|
||||
.addTextIfNotEmpty(CarScreenDataBuilder.contactSecondaryText(contact))
|
||||
.addText(
|
||||
CarScreenDataBuilder.contactPreviewText(
|
||||
contact,
|
||||
noMessagesLabel = carContext.getString(R.string.auto_no_messages),
|
||||
),
|
||||
)
|
||||
.addTextIfNotEmpty(
|
||||
CarScreenDataBuilder.contactSecondaryText(
|
||||
contact,
|
||||
unreadLabel = { carContext.getString(R.string.auto_unread_count, it) },
|
||||
),
|
||||
)
|
||||
.setBrowsable(false)
|
||||
.build()
|
||||
|
||||
@@ -362,7 +406,8 @@ class MeshtasticCarScreen(carContext: CarContext) :
|
||||
apply { if (text.isNotEmpty()) addText(text) }
|
||||
|
||||
/**
|
||||
* DRY helper: builds a [ListTemplate] from a list of items, capping at [MAX_LIST_ITEMS].
|
||||
* DRY helper: builds a [ListTemplate] from a list of items, capping at [listContentLimit]
|
||||
* (the per-host limit reported by [ConstraintManager]).
|
||||
*
|
||||
* Shows [noItemsMessage] when the list is empty.
|
||||
*/
|
||||
@@ -373,7 +418,7 @@ class MeshtasticCarScreen(carContext: CarContext) :
|
||||
buildRow: (T) -> Row,
|
||||
): ListTemplate {
|
||||
val listBuilder = ItemList.Builder()
|
||||
val capped = items.take(MAX_LIST_ITEMS)
|
||||
val capped = items.take(listContentLimit)
|
||||
if (capped.isEmpty()) {
|
||||
listBuilder.setNoItemsMessage(noItemsMessage)
|
||||
} else {
|
||||
@@ -386,12 +431,5 @@ class MeshtasticCarScreen(carContext: CarContext) :
|
||||
private const val TAB_STATUS = "status"
|
||||
private const val TAB_FAVORITES = "favorites"
|
||||
private const val TAB_MESSAGES = "messages"
|
||||
|
||||
/**
|
||||
* 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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,6 +23,6 @@
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:fillColor="#FFFFFF"
|
||||
android:fillColor="@color/auto_icon_color"
|
||||
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>
|
||||
|
||||
@@ -23,6 +23,6 @@
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:fillColor="#FFFFFF"
|
||||
android:fillColor="@color/auto_icon_color"
|
||||
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>
|
||||
|
||||
@@ -23,6 +23,6 @@
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:fillColor="#FFFFFF"
|
||||
android:fillColor="@color/auto_icon_color"
|
||||
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>
|
||||
|
||||
22
feature/auto/src/main/res/values/colors.xml
Normal file
22
feature/auto/src/main/res/values/colors.xml
Normal file
@@ -0,0 +1,22 @@
|
||||
<?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/>.
|
||||
-->
|
||||
<resources>
|
||||
<!-- Default fill color for Car tab icons. The CarIcon tint (CarColor.DEFAULT) overrides
|
||||
this at runtime; the named constant keeps the vector drawables maintainable. -->
|
||||
<color name="auto_icon_color">#FFFFFF</color>
|
||||
</resources>
|
||||
53
feature/auto/src/main/res/values/strings.xml
Normal file
53
feature/auto/src/main/res/values/strings.xml
Normal file
@@ -0,0 +1,53 @@
|
||||
<?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/>.
|
||||
-->
|
||||
<resources>
|
||||
<!-- Tab titles -->
|
||||
<string name="auto_tab_status">Status</string>
|
||||
<string name="auto_tab_favorites">Favorites</string>
|
||||
<string name="auto_tab_messages">Messages</string>
|
||||
|
||||
<!-- Template titles / loading / empty states -->
|
||||
<string name="auto_loading">Loading\u2026</string>
|
||||
<string name="auto_fallback_title">Meshtastic</string>
|
||||
<string name="auto_no_favorites">No favorite nodes</string>
|
||||
<string name="auto_no_conversations">No conversations</string>
|
||||
<string name="auto_no_messages">No messages yet</string>
|
||||
|
||||
<!-- Connection status -->
|
||||
<string name="auto_status_connected">Connected</string>
|
||||
<string name="auto_status_disconnected">Disconnected</string>
|
||||
<string name="auto_status_sleeping">Device Sleeping</string>
|
||||
<string name="auto_status_connecting">Connecting\u2026</string>
|
||||
|
||||
<!-- Node reachability (used in the Favorites tab rows) -->
|
||||
<string name="auto_node_online">Online</string>
|
||||
<string name="auto_node_offline">Offline</string>
|
||||
<!-- Leading space + bullet + space is intentional — appended directly after Online/Offline -->
|
||||
<string name="auto_node_direct"> · Direct</string>
|
||||
<!-- %d is replaced with the hop count at runtime -->
|
||||
<string name="auto_node_hops"> · %d hops</string>
|
||||
|
||||
<!-- Messages tab -->
|
||||
<!-- %d is replaced with the unread-message count at runtime -->
|
||||
<string name="auto_unread_count">%d unread</string>
|
||||
<!-- %d is replaced with the channel index at runtime -->
|
||||
<string name="auto_channel_number">Channel %d</string>
|
||||
|
||||
<!-- Generic fallback label when a node has no name -->
|
||||
<string name="auto_unknown">Unknown</string>
|
||||
</resources>
|
||||
Reference in New Issue
Block a user