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

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

@ -1,37 +0,0 @@
/*
* 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/>.
*/
package org.meshtastic.app.auto
import androidx.car.app.CarAppService
import androidx.car.app.Session
import androidx.car.app.SessionInfo
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.
*/
class MeshtasticCarAppService : CarAppService() {
override fun createHostValidator(): HostValidator =
HostValidator.ALLOW_ALL_HOSTS_VALIDATOR
override fun onCreateSession(sessionInfo: SessionInfo): Session =
MeshtasticCarSession()
}

View file

@ -1,249 +0,0 @@
/*
* 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/>.
*/
package org.meshtastic.app.auto
import androidx.car.app.CarContext
import androidx.car.app.Screen
import androidx.car.app.model.Action
import androidx.car.app.model.ItemList
import androidx.car.app.model.ListTemplate
import androidx.car.app.model.Row
import androidx.car.app.model.SectionedItemList
import androidx.car.app.model.Template
import androidx.lifecycle.DefaultLifecycleObserver
import androidx.lifecycle.LifecycleOwner
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
import org.meshtastic.core.model.ConnectionState
import org.meshtastic.core.model.DataPacket
import org.meshtastic.core.model.Node
import org.meshtastic.core.repository.NodeRepository
import org.meshtastic.core.repository.PacketRepository
import org.meshtastic.core.repository.RadioConfigRepository
import org.meshtastic.core.repository.ServiceRepository
import org.meshtastic.proto.ChannelSettings
/**
* Root screen displayed in Android Auto.
*
* Shows three sections mirroring the iOS CarPlay implementation:
* - **Status**: Connection state and active device name
* - **Favorites**: Favorited mesh nodes with unread message counts
* - **Channels**: Active channels with unread message counts
*/
class MeshtasticCarScreen(carContext: CarContext) :
Screen(carContext),
KoinComponent,
DefaultLifecycleObserver {
private val nodeRepository: NodeRepository by inject()
private val radioConfigRepository: RadioConfigRepository by inject()
private val packetRepository: PacketRepository by inject()
private val serviceRepository: ServiceRepository by inject()
private val scope = CoroutineScope(Dispatchers.Main + SupervisorJob())
private var observeJob: Job? = null
private var connectionState: ConnectionState = ConnectionState.Disconnected
private var favoriteNodes: List<Node> = emptyList()
private var channels: List<ChannelSettings> = emptyList()
private var unreadCounts: Map<String, Int> = emptyMap()
init {
lifecycle.addObserver(this)
}
override fun onCreate(owner: LifecycleOwner) {
startObserving()
}
override fun onDestroy(owner: LifecycleOwner) {
scope.cancel()
}
private fun startObserving() {
observeJob?.cancel()
observeJob = scope.launch {
val stateFlow = serviceRepository.connectionState
.distinctUntilChanged()
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)
}.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",
),
)
// Favorites section
val favoritesSection = buildFavoritesSection()
if (favoritesSection.items.isNotEmpty()) {
listBuilder.addSectionedList(
SectionedItemList.create(
favoritesSection,
"Favorites",
),
)
}
// Channels section
val channelsSection = buildChannelsSection()
if (channelsSection.items.isNotEmpty()) {
listBuilder.addSectionedList(
SectionedItemList.create(
channelsSection,
"Channels",
),
)
}
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 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()
return ItemList.Builder()
.addItem(row)
.build()
}
private fun buildFavoritesSection(): ItemList {
val builder = ItemList.Builder()
for (node in favoriteNodes.take(MAX_LIST_ITEMS)) {
val contactKey = "0${node.user.id}"
val unread = unreadCounts[contactKey] ?: 0
val name = node.user.long_name.ifEmpty { node.user.short_name }.ifEmpty { "Unknown" }
val subtitle = buildString {
append(node.user.short_name)
if (node.hopsAway >= 0) append(" · ${node.hopsAway} hops")
if (unread > 0) append(" · $unread unread")
}
val row = Row.Builder()
.setTitle(name)
.addText(subtitle)
.setBrowsable(false)
.build()
builder.addItem(row)
}
return builder.build()
}
private fun buildChannelsSection(): ItemList {
val builder = ItemList.Builder()
for ((index, channelSettings) in channels.take(MAX_LIST_ITEMS).withIndex()) {
val contactKey = "${index}${DataPacket.ID_BROADCAST}"
val unread = unreadCounts[contactKey] ?: 0
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()
builder.addItem(row)
}
return builder.build()
}
companion object {
/**
* 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

@ -1,31 +0,0 @@
/*
* 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/>.
*/
package org.meshtastic.app.auto
import android.content.Intent
import androidx.car.app.Screen
import androidx.car.app.Session
/**
* Android Auto session that hosts the [MeshtasticCarScreen] root screen.
*/
class MeshtasticCarSession : Session() {
override fun onCreateScreen(intent: Intent): Screen {
return MeshtasticCarScreen(carContext)
}
}

View file

@ -1,22 +0,0 @@
<?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/>.
-->
<automotiveApp>
<uses name="notification" />
<uses name="template" />
</automotiveApp>