mirror of
https://github.com/meshtastic/Meshtastic-Android.git
synced 2026-04-20 22:23:37 +00:00
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:
parent
41b99fd079
commit
0df6d70317
10 changed files with 259 additions and 194 deletions
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue