refactor(auto): extract Android Auto into feature:auto module

- Move MeshtasticCarAppService, CarSession, CarScreen from app/auto
  to the new :feature:auto module (meshtastic.android.library)
- Move automotive_app_desc.xml → auto_app_desc.xml (respects resourcePrefix)
- Move manifest entries (service + meta-data) into feature module so they
  merge into app rather than living in the app manifest directly
- Fix HostValidator: use ApplicationInfo.FLAG_DEBUGGABLE instead of
  BuildConfig.DEBUG (library modules don't ship their own BuildConfig)
- Fix stale unread counts: replace point-in-time getUnreadCount() with
  flatMapLatest + per-conversation getUnreadCountFlow() so the car screen
  invalidates on new messages, not just topology changes
- Fix ConversationShortcutManager: replace removeAllDynamicShortcuts +
  addDynamicShortcuts with pushDynamicShortcut per conversation to
  preserve usage/ranking history; remove stale shortcuts individually;
  respect getMaxShortcutCountPerActivity() limit
- Fix SHORTCUT_CATEGORY_CONVERSATION: constant lives on ShortcutInfo,
  not ShortcutManagerCompat
- Remove androidx.car.app dependency from :app (now owned by :feature:auto)
- Add :feature:auto to settings.gradle.kts and app dependencies

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
James Rich 2026-04-17 07:34:16 -05:00
parent 41b99fd079
commit 0df6d70317
10 changed files with 259 additions and 194 deletions

View file

@ -235,6 +235,7 @@ dependencies {
implementation(projects.feature.settings)
implementation(projects.feature.firmware)
implementation(projects.feature.wifiProvision)
implementation(projects.feature.auto)
implementation(projects.feature.widget)
implementation(libs.jetbrains.compose.material3.adaptive)
@ -270,7 +271,6 @@ dependencies {
implementation(libs.accompanist.permissions)
implementation(libs.kermit)
implementation(libs.kotlinx.datetime)
implementation(libs.androidx.car.app)
debugImplementation(libs.androidx.compose.ui.test.manifest)
debugImplementation(libs.androidx.glance.preview)

View file

@ -153,25 +153,6 @@
android:name="google_analytics_default_allow_analytics_storage"
android:value="false" />
<!-- Android Auto: declare as a messaging/communications app -->
<meta-data
android:name="com.google.android.gms.car.application"
android:resource="@xml/automotive_app_desc" />
<!-- Android Auto Car App Service for browsable messaging UI -->
<service
android:name="org.meshtastic.app.auto.MeshtasticCarAppService"
android:exported="true">
<intent-filter>
<action android:name="androidx.car.app.CarAppService" />
<category android:name="androidx.car.app.category.MESSAGING" />
</intent-filter>
</service>
<meta-data
android:name="androidx.car.app.minCarApiLevel"
android:value="1" />
<!-- This is the public API for doing mesh radio operations from android apps -->
<service
android:name="org.meshtastic.core.service.MeshService"

View file

@ -18,6 +18,7 @@ package org.meshtastic.core.service
import android.content.Context
import android.content.Intent
import android.content.pm.ShortcutInfo
import android.graphics.Canvas
import android.graphics.Paint
import androidx.core.app.Person
@ -46,9 +47,9 @@ import org.meshtastic.proto.ChannelSettings
/**
* Publishes dynamic shortcuts for favorited nodes and active channels.
*
* These shortcuts enable Android Auto (and the launcher) to surface Meshtastic conversations
* as share targets and messaging destinations. Each shortcut is linked to a conversation
* via [LocusIdCompat] so that notifications and the car messaging UI can associate them.
* These shortcuts enable Android Auto (and the launcher) to surface Meshtastic conversations as share targets and
* messaging destinations. Each shortcut is linked to a conversation via [LocusIdCompat] so that notifications and the
* car messaging UI can associate them.
*/
@Single
class ConversationShortcutManager(
@ -61,33 +62,30 @@ class ConversationShortcutManager(
private var observeJob: Job? = null
/**
* Starts observing favorite nodes and active channels, publishing shortcuts whenever
* the data changes. Call from [MeshService.onCreate].
* Starts observing favorite nodes and active channels, publishing shortcuts whenever the data changes. Call from
* [MeshService.onCreate].
*/
fun startObserving(scope: CoroutineScope) {
observeJob?.cancel()
observeJob = scope.launch(dispatchers.io) {
val favoritesFlow = nodeRepository.nodeDBbyNum
.map { nodes ->
nodes.values.filter { it.isFavorite && !it.isIgnored }
.sortedBy { it.user.long_name }
}
.distinctUntilChanged()
observeJob =
scope.launch(dispatchers.io) {
val favoritesFlow =
nodeRepository.nodeDBbyNum
.map { nodes ->
nodes.values.filter { it.isFavorite && !it.isIgnored }.sortedBy { it.user.long_name }
}
.distinctUntilChanged()
val channelsFlow = radioConfigRepository.channelSetFlow
.map { cs ->
cs.settings.filterIndexed { index, settings ->
settings.name.isNotEmpty() || index == 0
}
}
.distinctUntilChanged()
val channelsFlow =
radioConfigRepository.channelSetFlow
.map { cs ->
cs.settings.filterIndexed { index, settings -> settings.name.isNotEmpty() || index == 0 }
}
.distinctUntilChanged()
combine(favoritesFlow, channelsFlow) { favorites, channels ->
favorites to channels
}.collect { (favorites, channels) ->
publishShortcuts(favorites, channels)
combine(favoritesFlow, channelsFlow) { favorites, channels -> favorites to channels }
.collect { (favorites, channels) -> publishShortcuts(favorites, channels) }
}
}
}
/** Stops the observation coroutine. Call from [MeshService.onDestroy]. */
@ -104,25 +102,27 @@ class ConversationShortcutManager(
for (node in favorites) {
if (node.num == myNodeNum) continue
val contactKey = "0${node.user.id}"
val person = Person.Builder()
.setName(node.user.long_name)
.setKey(node.user.id)
.setIcon(createPersonIcon(node.user.short_name, node.colors.second, node.colors.first))
.build()
val person =
Person.Builder()
.setName(node.user.long_name)
.setKey(node.user.id)
.setIcon(createPersonIcon(node.user.short_name, node.colors.second, node.colors.first))
.build()
val shortcut = ShortcutInfoCompat.Builder(context, contactKey)
.setShortLabel(node.user.long_name.ifEmpty { node.user.short_name })
.setLongLabel(node.user.long_name.ifEmpty { node.user.short_name })
.setLocusId(LocusIdCompat(contactKey))
.setPerson(person)
.setLongLived(true)
.setCategories(setOf(ShortcutManagerCompat.SHORTCUT_CATEGORY_CONVERSATION))
.setIntent(
Intent(Intent.ACTION_VIEW, "$DEEP_LINK_BASE_URI/messages/$contactKey".toUri()).apply {
setPackage(context.packageName)
},
)
.build()
val shortcut =
ShortcutInfoCompat.Builder(context, contactKey)
.setShortLabel(node.user.long_name.ifEmpty { node.user.short_name })
.setLongLabel(node.user.long_name.ifEmpty { node.user.short_name })
.setLocusId(LocusIdCompat(contactKey))
.setPerson(person)
.setLongLived(true)
.setCategories(setOf(ShortcutInfo.SHORTCUT_CATEGORY_CONVERSATION))
.setIntent(
Intent(Intent.ACTION_VIEW, "$DEEP_LINK_BASE_URI/messages/$contactKey".toUri()).apply {
setPackage(context.packageName)
},
)
.build()
shortcuts.add(shortcut)
}
@ -131,32 +131,44 @@ class ConversationShortcutManager(
for ((index, channelSettings) in channels.withIndex()) {
val contactKey = "${index}${DataPacket.ID_BROADCAST}"
val channelName = channelSettings.name.ifEmpty { "Primary Channel" }
val person = Person.Builder()
.setName(channelName)
.setKey("channel-$index")
.build()
val person = Person.Builder().setName(channelName).setKey("channel-$index").build()
val shortcut = ShortcutInfoCompat.Builder(context, contactKey)
.setShortLabel(channelName)
.setLongLabel(channelName)
.setLocusId(LocusIdCompat(contactKey))
.setPerson(person)
.setLongLived(true)
.setCategories(setOf(ShortcutManagerCompat.SHORTCUT_CATEGORY_CONVERSATION))
.setIntent(
Intent(Intent.ACTION_VIEW, "$DEEP_LINK_BASE_URI/messages/$contactKey".toUri()).apply {
setPackage(context.packageName)
},
)
.build()
val shortcut =
ShortcutInfoCompat.Builder(context, contactKey)
.setShortLabel(channelName)
.setLongLabel(channelName)
.setLocusId(LocusIdCompat(contactKey))
.setPerson(person)
.setLongLived(true)
.setCategories(setOf(ShortcutInfo.SHORTCUT_CATEGORY_CONVERSATION))
.setIntent(
Intent(Intent.ACTION_VIEW, "$DEEP_LINK_BASE_URI/messages/$contactKey".toUri()).apply {
setPackage(context.packageName)
},
)
.build()
shortcuts.add(shortcut)
}
try {
ShortcutManagerCompat.removeAllDynamicShortcuts(context)
ShortcutManagerCompat.addDynamicShortcuts(context, shortcuts)
Logger.d { "Published ${shortcuts.size} conversation shortcuts (${favorites.size} favorites, ${channels.size} channels)" }
val limit = ShortcutManagerCompat.getMaxShortcutCountPerActivity(context)
// Remove shortcuts for conversations that are no longer in favorites/channels,
// so stale entries don't clutter the share sheet.
val currentKeys = shortcuts.map { it.id }.toSet()
val stale = ShortcutManagerCompat.getDynamicShortcuts(context).map { it.id }.filter { it !in currentKeys }
if (stale.isNotEmpty()) {
ShortcutManagerCompat.removeDynamicShortcuts(context, stale)
}
// Push each shortcut individually to preserve usage/ranking history.
// pushDynamicShortcut upserts without wiping other shortcuts.
for (shortcut in shortcuts.take(limit)) {
ShortcutManagerCompat.pushDynamicShortcut(context, shortcut)
}
val published = shortcuts.size.coerceAtMost(limit)
Logger.d {
"Published $published conversation shortcuts (${favorites.size} favorites, ${channels.size} channels)"
}
} catch (e: Exception) {
Logger.e(e) { "Failed to publish conversation shortcuts" }
}
@ -174,12 +186,13 @@ class ConversationShortcutManager(
paint.color = foregroundColor
paint.textSize = size * TEXT_SIZE_RATIO
paint.textAlign = Paint.Align.CENTER
val initial = if (name.isNotEmpty()) {
val codePoint = name.codePointAt(0)
String(Character.toChars(codePoint)).uppercase()
} else {
"?"
}
val initial =
if (name.isNotEmpty()) {
val codePoint = name.codePointAt(0)
String(Character.toChars(codePoint)).uppercase()
} else {
"?"
}
val xPos = canvas.width / 2f
val yPos = (canvas.height / 2f - (paint.descent() + paint.ascent()) / 2f)
canvas.drawText(initial, xPos, yPos, paint)

View file

@ -0,0 +1,41 @@
/*
* 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/>.
*/
plugins {
alias(libs.plugins.meshtastic.android.library)
alias(libs.plugins.meshtastic.koin)
}
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 {
implementation(projects.core.common)
implementation(projects.core.model)
implementation(projects.core.proto)
implementation(projects.core.repository)
implementation(libs.androidx.car.app)
implementation(libs.kermit)
implementation(libs.koin.annotations)
}

View file

@ -0,0 +1,41 @@
<?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/>.
-->
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- Android Auto: declare as a messaging/communications app -->
<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">
<intent-filter>
<action android:name="androidx.car.app.CarAppService" />
<category android:name="androidx.car.app.category.MESSAGING" />
</intent-filter>
</service>
<meta-data
android:name="androidx.car.app.minCarApiLevel"
android:value="1" />
</application>
</manifest>

View file

@ -14,8 +14,9 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.app.auto
package org.meshtastic.feature.auto
import android.content.pm.ApplicationInfo
import androidx.car.app.CarAppService
import androidx.car.app.Session
import androidx.car.app.SessionInfo
@ -24,14 +25,21 @@ import androidx.car.app.validation.HostValidator
/**
* Entry point for the Meshtastic Android Auto experience.
*
* Registers with the Android Auto host to provide a browsable list of
* favorite contacts and active channels for messaging.
* Registers with the Android Auto host to provide a browsable list of favorite contacts and active channels for
* messaging.
*/
class MeshtasticCarAppService : CarAppService() {
override fun createHostValidator(): HostValidator =
HostValidator.ALLOW_ALL_HOSTS_VALIDATOR
override fun createHostValidator(): HostValidator {
val isDebug = applicationContext.applicationInfo.flags and ApplicationInfo.FLAG_DEBUGGABLE != 0
return if (isDebug) {
HostValidator.ALLOW_ALL_HOSTS_VALIDATOR
} else {
HostValidator.Builder(applicationContext)
.addAllowedHosts(androidx.car.app.R.array.hosts_allowlist_sample)
.build()
}
}
override fun onCreateSession(sessionInfo: SessionInfo): Session =
MeshtasticCarSession()
override fun onCreateSession(sessionInfo: SessionInfo): Session = MeshtasticCarSession()
}

View file

@ -14,7 +14,7 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.app.auto
package org.meshtastic.feature.auto
import androidx.car.app.CarContext
import androidx.car.app.Screen
@ -33,6 +33,8 @@ import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
import org.koin.core.component.KoinComponent
@ -86,110 +88,97 @@ class MeshtasticCarScreen(carContext: CarContext) :
private fun startObserving() {
observeJob?.cancel()
observeJob = scope.launch {
val stateFlow = serviceRepository.connectionState
.distinctUntilChanged()
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 }
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.filterIndexed { index, settings -> index == 0 || settings.name.isNotEmpty() }
}
.distinctUntilChanged()
combine(stateFlow, favoritesFlow, channelsFlow) { state, favorites, chs ->
Triple(state, favorites, chs)
}
.distinctUntilChanged()
.flatMapLatest { (state, favorites, chs) ->
// Build per-conversation unread flows so the car screen invalidates
// on new messages, not just on topology/channel changes.
val contactKeys =
favorites.map { "0${it.user.id}" } +
chs.mapIndexed { i, _ -> "${i}${DataPacket.ID_BROADCAST}" }
val channelsFlow = radioConfigRepository.channelSetFlow
.map { cs ->
cs.settings.filterIndexed { index, settings ->
index == 0 || settings.name.isNotEmpty()
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
invalidate()
}
}
.distinctUntilChanged()
combine(stateFlow, favoritesFlow, channelsFlow) { state, favorites, chs ->
Triple(state, favorites, chs)
}.collect { (state, favorites, chs) ->
connectionState = state
favoriteNodes = favorites
channels = chs
// Collect unread counts for all conversations
val counts = mutableMapOf<String, Int>()
for (node in favorites) {
val key = "0${node.user.id}"
counts[key] = packetRepository.getUnreadCount(key)
}
for ((index, _) in chs.withIndex()) {
val key = "${index}${DataPacket.ID_BROADCAST}"
counts[key] = packetRepository.getUnreadCount(key)
}
unreadCounts = counts
invalidate()
}
}
}
override fun onGetTemplate(): Template {
val listBuilder = ListTemplate.Builder()
// Status section
listBuilder.addSectionedList(
SectionedItemList.create(
buildStatusSection(),
"Status",
),
)
listBuilder.addSectionedList(SectionedItemList.create(buildStatusSection(), "Status"))
// Favorites section
val favoritesSection = buildFavoritesSection()
if (favoritesSection.items.isNotEmpty()) {
listBuilder.addSectionedList(
SectionedItemList.create(
favoritesSection,
"Favorites",
),
)
listBuilder.addSectionedList(SectionedItemList.create(favoritesSection, "Favorites"))
}
// Channels section
val channelsSection = buildChannelsSection()
if (channelsSection.items.isNotEmpty()) {
listBuilder.addSectionedList(
SectionedItemList.create(
channelsSection,
"Channels",
),
)
listBuilder.addSectionedList(SectionedItemList.create(channelsSection, "Channels"))
}
return listBuilder
.setTitle("Meshtastic")
.setHeaderAction(Action.APP_ICON)
.build()
return listBuilder.setTitle("Meshtastic").setHeaderAction(Action.APP_ICON).build()
}
private fun buildStatusSection(): ItemList {
val statusText = when (connectionState) {
is ConnectionState.Connected -> "Connected"
is ConnectionState.Disconnected -> "Disconnected"
is ConnectionState.DeviceSleep -> "Device Sleeping"
is ConnectionState.Connecting -> "Connecting..."
}
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 ?: ""
val subtitle = if (deviceName.isNotEmpty()) deviceName else null
val row = Row.Builder()
.setTitle(statusText)
.apply { if (subtitle != null) addText(subtitle) }
.setBrowsable(false)
.build()
val row =
Row.Builder()
.setTitle(statusText)
.apply { if (subtitle != null) addText(subtitle) }
.setBrowsable(false)
.build()
return ItemList.Builder()
.addItem(row)
.build()
return ItemList.Builder().addItem(row).build()
}
private fun buildFavoritesSection(): ItemList {
@ -205,12 +194,7 @@ class MeshtasticCarScreen(carContext: CarContext) :
if (unread > 0) append(" · $unread unread")
}
val row = Row.Builder()
.setTitle(name)
.addText(subtitle)
.setBrowsable(false)
.build()
val row = Row.Builder().setTitle(name).addText(subtitle).setBrowsable(false).build()
builder.addItem(row)
}
@ -226,11 +210,12 @@ class MeshtasticCarScreen(carContext: CarContext) :
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()
val row =
Row.Builder()
.setTitle(channelName)
.apply { if (subtitle.isNotEmpty()) addText(subtitle) }
.setBrowsable(false)
.build()
builder.addItem(row)
}
@ -240,10 +225,9 @@ class MeshtasticCarScreen(carContext: CarContext) :
companion object {
/**
* 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] section. Car API level 1 supports up to 6 items
* per section.
*/
private const val MAX_LIST_ITEMS = 6
}
}

View file

@ -14,18 +14,14 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.app.auto
package org.meshtastic.feature.auto
import android.content.Intent
import androidx.car.app.Screen
import androidx.car.app.Session
/**
* Android Auto session that hosts the [MeshtasticCarScreen] root screen.
*/
/** Android Auto session that hosts the [MeshtasticCarScreen] root screen. */
class MeshtasticCarSession : Session() {
override fun onCreateScreen(intent: Intent): Screen {
return MeshtasticCarScreen(carContext)
}
override fun onCreateScreen(intent: Intent): Screen = MeshtasticCarScreen(carContext)
}

View file

@ -46,6 +46,7 @@ include(
":feature:settings",
":feature:firmware",
":feature:wifi-provision",
":feature:auto",
":feature:widget",
":desktop",
)