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