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

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