diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 52ffd9593..74e926098 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -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) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 758a266e0..f7d2ce900 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -153,25 +153,6 @@ android:name="google_analytics_default_allow_analytics_storage" android:value="false" /> - - - - - - - - - - - - - - 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) diff --git a/feature/auto/build.gradle.kts b/feature/auto/build.gradle.kts new file mode 100644 index 000000000..721e844be --- /dev/null +++ b/feature/auto/build.gradle.kts @@ -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 . + */ + +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) +} diff --git a/feature/auto/src/main/AndroidManifest.xml b/feature/auto/src/main/AndroidManifest.xml new file mode 100644 index 000000000..a6c4ed78f --- /dev/null +++ b/feature/auto/src/main/AndroidManifest.xml @@ -0,0 +1,41 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/kotlin/org/meshtastic/app/auto/MeshtasticCarAppService.kt b/feature/auto/src/main/kotlin/org/meshtastic/feature/auto/MeshtasticCarAppService.kt similarity index 64% rename from app/src/main/kotlin/org/meshtastic/app/auto/MeshtasticCarAppService.kt rename to feature/auto/src/main/kotlin/org/meshtastic/feature/auto/MeshtasticCarAppService.kt index 43f9c97ba..b6bd73793 100644 --- a/app/src/main/kotlin/org/meshtastic/app/auto/MeshtasticCarAppService.kt +++ b/feature/auto/src/main/kotlin/org/meshtastic/feature/auto/MeshtasticCarAppService.kt @@ -14,8 +14,9 @@ * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ -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() } diff --git a/app/src/main/kotlin/org/meshtastic/app/auto/MeshtasticCarScreen.kt b/feature/auto/src/main/kotlin/org/meshtastic/feature/auto/MeshtasticCarScreen.kt similarity index 57% rename from app/src/main/kotlin/org/meshtastic/app/auto/MeshtasticCarScreen.kt rename to feature/auto/src/main/kotlin/org/meshtastic/feature/auto/MeshtasticCarScreen.kt index 7096cd0c2..c90344a60 100644 --- a/app/src/main/kotlin/org/meshtastic/app/auto/MeshtasticCarScreen.kt +++ b/feature/auto/src/main/kotlin/org/meshtastic/feature/auto/MeshtasticCarScreen.kt @@ -14,7 +14,7 @@ * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ -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() - 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 } } - diff --git a/app/src/main/kotlin/org/meshtastic/app/auto/MeshtasticCarSession.kt b/feature/auto/src/main/kotlin/org/meshtastic/feature/auto/MeshtasticCarSession.kt similarity index 78% rename from app/src/main/kotlin/org/meshtastic/app/auto/MeshtasticCarSession.kt rename to feature/auto/src/main/kotlin/org/meshtastic/feature/auto/MeshtasticCarSession.kt index 4a405cafd..9fd816bbf 100644 --- a/app/src/main/kotlin/org/meshtastic/app/auto/MeshtasticCarSession.kt +++ b/feature/auto/src/main/kotlin/org/meshtastic/feature/auto/MeshtasticCarSession.kt @@ -14,18 +14,14 @@ * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ -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) } diff --git a/app/src/main/res/xml/automotive_app_desc.xml b/feature/auto/src/main/res/xml/auto_app_desc.xml similarity index 100% rename from app/src/main/res/xml/automotive_app_desc.xml rename to feature/auto/src/main/res/xml/auto_app_desc.xml diff --git a/settings.gradle.kts b/settings.gradle.kts index f9664baaa..dc24932b7 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -46,6 +46,7 @@ include( ":feature:settings", ":feature:firmware", ":feature:wifi-provision", + ":feature:auto", ":feature:widget", ":desktop", )