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:
copilot-swe-agent[bot]
2026-04-17 18:29:52 +00:00
committed by GitHub
parent d70c3b66dc
commit 67e300da96
11 changed files with 238 additions and 107 deletions

View File

@@ -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(

View File

@@ -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 {

View File

@@ -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" />

View File

@@ -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 -> ""
}

View File

@@ -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()
}
}

View File

@@ -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 15 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
}
}

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View 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>

View 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>