feat(auto): add Android Auto communications app with notification and Car App Library support

Agent-Logs-Url: https://github.com/meshtastic/Meshtastic-Android/sessions/571f7a66-0c36-43f2-890e-c8ed87ec7164

Co-authored-by: garthvh <1795163+garthvh@users.noreply.github.com>
This commit is contained in:
copilot-swe-agent[bot]
2026-04-17 03:11:21 +00:00
committed by GitHub
parent a6a889430b
commit c018ca6066
10 changed files with 567 additions and 0 deletions

View File

@@ -270,6 +270,7 @@ 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,6 +153,25 @@
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

@@ -0,0 +1,54 @@
/*
* 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 android.content.pm.ApplicationInfo
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 {
return if (applicationInfo.flags and ApplicationInfo.FLAG_DEBUGGABLE != 0) {
HostValidator.ALLOW_ALL_HOSTS_VALIDATOR
} else {
HostValidator.ALLOW_ALL_HOSTS_VALIDATOR
}
}
override fun onCreateSession(sessionInfo: SessionInfo): Session {
return MeshtasticCarSession()
}
@Suppress("DEPRECATION", "OVERRIDE_DEPRECATION")
override fun onCreateSession(): Session {
return MeshtasticCarSession()
}
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
}
}

View File

@@ -0,0 +1,241 @@
/*
* 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.CarIcon
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 (val state = 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) {
val contactKey = "0${node.user.id}"
val unread = unreadCounts[contactKey] ?: 0
val name = node.user.long_name.ifEmpty { node.user.short_name }
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.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()
}
}

View File

@@ -0,0 +1,31 @@
/*
* 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

@@ -0,0 +1,22 @@
<?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>

View File

@@ -0,0 +1,190 @@
/*
* 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.core.service
import android.content.Context
import android.content.Intent
import android.graphics.Canvas
import android.graphics.Paint
import androidx.core.app.Person
import androidx.core.content.LocusIdCompat
import androidx.core.content.pm.ShortcutInfoCompat
import androidx.core.content.pm.ShortcutManagerCompat
import androidx.core.graphics.createBitmap
import androidx.core.graphics.drawable.IconCompat
import androidx.core.net.toUri
import co.touchlab.kermit.Logger
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
import org.koin.core.annotation.Single
import org.meshtastic.core.di.CoroutineDispatchers
import org.meshtastic.core.model.Node
import org.meshtastic.core.navigation.DEEP_LINK_BASE_URI
import org.meshtastic.core.repository.NodeRepository
import org.meshtastic.core.repository.RadioConfigRepository
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.
*/
@Single
class ConversationShortcutManager(
private val context: Context,
private val nodeRepository: NodeRepository,
private val radioConfigRepository: RadioConfigRepository,
private val dispatchers: CoroutineDispatchers,
) {
private var observeJob: Job? = null
/**
* 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()
val channelsFlow = radioConfigRepository.channelSetFlow
.map { cs -> cs.settings.filter { it.name.isNotEmpty() || cs.settings.indexOf(it) == 0 } }
.distinctUntilChanged()
combine(favoritesFlow, channelsFlow) { favorites, channels ->
favorites to channels
}.collect { (favorites, channels) ->
publishShortcuts(favorites, channels)
}
}
}
/** Stops the observation coroutine. Call from [MeshService.onDestroy]. */
fun stopObserving() {
observeJob?.cancel()
observeJob = null
}
private fun publishShortcuts(favorites: List<Node>, channels: List<ChannelSettings>) {
val myNodeNum = nodeRepository.myNodeInfo.value?.myNodeNum
val shortcuts = mutableListOf<ShortcutInfoCompat>()
// Favorite node shortcuts (direct message conversations)
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 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()
shortcuts.add(shortcut)
}
// Channel shortcuts (broadcast conversations)
for ((index, channelSettings) in channels.withIndex()) {
val contactKey = "${index}${org.meshtastic.core.model.DataPacket.ID_BROADCAST}"
val channelName = channelSettings.name.ifEmpty { "Primary Channel" }
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()
shortcuts.add(shortcut)
}
try {
ShortcutManagerCompat.removeAllDynamicShortcuts(context)
ShortcutManagerCompat.addDynamicShortcuts(context, shortcuts)
Logger.d { "Published ${shortcuts.size} conversation shortcuts (${favorites.size} favorites, ${channels.size} channels)" }
} catch (e: Exception) {
Logger.e(e) { "Failed to publish conversation shortcuts" }
}
}
private fun createPersonIcon(name: String, backgroundColor: Int, foregroundColor: Int): IconCompat {
val size = ICON_SIZE
val bitmap = createBitmap(size, size)
val canvas = Canvas(bitmap)
val paint = Paint(Paint.ANTI_ALIAS_FLAG)
paint.color = backgroundColor
canvas.drawCircle(size / 2f, size / 2f, size / 2f, paint)
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 xPos = canvas.width / 2f
val yPos = (canvas.height / 2f - (paint.descent() + paint.ascent()) / 2f)
canvas.drawText(initial, xPos, yPos, paint)
return IconCompat.createWithBitmap(bitmap)
}
companion object {
private const val ICON_SIZE = 128
private const val TEXT_SIZE_RATIO = 0.5f
}
}

View File

@@ -76,6 +76,8 @@ class MeshService : Service() {
private val notifications: MeshServiceNotifications by inject()
private val shortcutManager: ConversationShortcutManager by inject()
/** Android-typed accessor for the foreground service notification. */
private val androidNotifications: MeshServiceNotificationsImpl
get() = notifications as MeshServiceNotificationsImpl
@@ -118,6 +120,7 @@ class MeshService : Service() {
try {
orchestrator.start()
shortcutManager.startObserving(serviceScope)
isServiceInitialized = true
} catch (e: IllegalStateException) {
// Koin throws IllegalStateException when the DI graph is not yet initialized.
@@ -209,6 +212,7 @@ class MeshService : Service() {
Logger.i { "Destroying mesh service" }
ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_REMOVE)
if (isServiceInitialized) {
shortcutManager.stopObserving()
orchestrator.stop()
}
serviceJob.cancel()

View File

@@ -32,6 +32,7 @@ import android.media.RingtoneManager
import androidx.core.app.NotificationCompat
import androidx.core.app.Person
import androidx.core.app.RemoteInput
import androidx.core.content.LocusIdCompat
import androidx.core.content.getSystemService
import androidx.core.graphics.createBitmap
import androidx.core.graphics.drawable.IconCompat
@@ -622,6 +623,8 @@ class MeshServiceNotificationsImpl(
.setAutoCancel(true)
.setStyle(style)
.setGroup(GROUP_KEY_MESSAGES)
.setShortcutId(contactKey)
.setLocusId(LocusIdCompat(contactKey))
.setVisibility(NotificationCompat.VISIBILITY_PRIVATE)
.setWhen(lastMessage.receivedTime)
.setShowWhen(true)

View File

@@ -8,6 +8,7 @@ accompanist = "0.37.3"
# androidx
datastore = "1.2.1"
car-app = "1.7.0"
glance = "1.2.0-rc01"
lifecycle = "2.10.0"
jetbrains-lifecycle = "2.11.0-alpha03"
@@ -92,6 +93,7 @@ androidx-camera-view = { module = "androidx.camera:camera-view", version.ref = "
androidx-camera-compose = { module = "androidx.camera:camera-compose", version.ref = "camerax" }
androidx-camera-viewfinder-compose = { module = "androidx.camera.viewfinder:viewfinder-compose", version = "1.6.0" }
androidx-core-ktx = { module = "androidx.core:core-ktx", version = "1.18.0" }
androidx-car-app = { module = "androidx.car.app:app", version.ref = "car-app" }
androidx-core-location-altitude = { module = "androidx.core:core-location-altitude", version = "1.0.0-rc01" }
androidx-core-splashscreen = { module = "androidx.core:core-splashscreen", version = "1.2.0" }
androidx-datastore = { module = "androidx.datastore:datastore", version.ref = "datastore" }