mirror of
https://github.com/meshtastic/Meshtastic-Android.git
synced 2026-05-12 08:42:01 -04: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:
committed by
GitHub
parent
a6a889430b
commit
c018ca6066
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
22
app/src/main/res/xml/automotive_app_desc.xml
Normal file
22
app/src/main/res/xml/automotive_app_desc.xml
Normal 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>
|
||||
@@ -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)
|
||||
|
||||
@@ -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" }
|
||||
|
||||
Reference in New Issue
Block a user