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
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 567 additions and 0 deletions

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)