This commit is contained in:
Copilot 2026-04-18 03:05:34 +00:00 committed by GitHub
commit fad92e8f8c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
35 changed files with 2383 additions and 69 deletions

View file

@ -235,6 +235,7 @@ dependencies {
implementation(projects.feature.settings)
implementation(projects.feature.firmware)
implementation(projects.feature.wifiProvision)
implementation(projects.feature.auto)
implementation(projects.feature.widget)
implementation(libs.jetbrains.compose.material3.adaptive)

View file

@ -34,6 +34,14 @@
# for auditing. Inspect this file after a release build to see what libraries inject.
-printconfiguration build/outputs/mapping/r8-merged-config.txt
# ---- Android Auto / Car App Library -----------------------------------------
# MeshtasticCarAppService and MeshtasticCarSession are instantiated by class name
# by the Android Auto host. Keep both classes (and their no-arg constructors) so
# release builds aren't broken by R8 tree-shaking.
-keep class org.meshtastic.feature.auto.MeshtasticCarAppService { <init>(); }
-keep class org.meshtastic.feature.auto.MeshtasticCarSession { <init>(); }
# ---- Networking (transitive references from Ktor on Android) ----------------
-dontwarn org.conscrypt.**

View file

@ -74,6 +74,10 @@ class FakeMeshServiceNotifications : MeshServiceNotifications {
override fun cancelMessageNotification(contactKey: String) {}
override suspend fun markConversationRead(contactKey: String) {}
override suspend fun appendOutgoingMessage(contactKey: String, text: String) {}
override fun cancelLowBatteryNotification(node: Node) {}
override fun clearClientNotification(notification: ClientNotification) {}

View file

@ -357,7 +357,7 @@ class MeshDataHandlerImpl(
read = fromLocal || isFiltered,
filtered = isFiltered,
)
if (!isFiltered) {
if (!isFiltered && !fromLocal) {
handlePacketNotification(dataPacket, contactKey, updateNotification)
}
}

View file

@ -67,6 +67,20 @@ interface MeshServiceNotifications {
fun cancelMessageNotification(contactKey: String)
/**
* Marks the conversation for [contactKey] as read: clears its unread count in the packet repository and cancels the
* posted message notification (and the group summary). Intended for use by notification action receivers (reply,
* mark-as-read, reaction) to keep behavior consistent.
*/
suspend fun markConversationRead(contactKey: String)
/**
* Appends an outgoing [text] message attributed to the local user to the currently posted conversation notification
* for [contactKey]. Used so that assistants such as Android Auto can briefly observe the reply in the
* MessagingStyle history before the notification is cancelled. No-op when there is nothing to update.
*/
suspend fun appendOutgoingMessage(contactKey: String, text: String)
fun cancelLowBatteryNotification(node: Node)
fun clearClientNotification(notification: ClientNotification)

View file

@ -0,0 +1,101 @@
/*
* 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 androidx.test.core.app.ApplicationProvider
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import org.junit.After
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.koin.core.context.startKoin
import org.koin.core.context.stopKoin
import org.koin.dsl.module
import org.meshtastic.core.di.CoroutineDispatchers
import org.meshtastic.core.repository.MeshServiceNotifications
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
import kotlin.test.assertEquals
@OptIn(ExperimentalCoroutinesApi::class)
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [34])
class MarkAsReadReceiverTest {
private lateinit var context: Context
private lateinit var notifications: RecordingNotifications
@Before
fun setUp() {
context = ApplicationProvider.getApplicationContext()
notifications = RecordingNotifications(mutableListOf())
val dispatcher = UnconfinedTestDispatcher()
startKoin {
modules(
module {
single<MeshServiceNotifications> { notifications }
single { CoroutineDispatchers(io = dispatcher, main = dispatcher, default = dispatcher) }
},
)
}
}
@After
fun tearDown() {
stopKoin()
}
@Test
fun `markAsRead action invokes markConversationRead`() {
val contactKey = "0!deadbeef"
val intent =
Intent(context, MarkAsReadReceiver::class.java).apply {
action = MarkAsReadReceiver.MARK_AS_READ_ACTION
putExtra(MarkAsReadReceiver.CONTACT_KEY, contactKey)
}
MarkAsReadReceiver().onReceive(context, intent)
assertEquals(listOf(contactKey), notifications.markReadCalls)
}
@Test
fun `missing contactKey does not invoke markConversationRead`() {
val intent =
Intent(context, MarkAsReadReceiver::class.java).apply { action = MarkAsReadReceiver.MARK_AS_READ_ACTION }
MarkAsReadReceiver().onReceive(context, intent)
assertEquals(emptyList(), notifications.markReadCalls)
}
@Test
fun `wrong action is ignored`() {
val intent =
Intent(context, MarkAsReadReceiver::class.java).apply {
action = "some.other.ACTION"
putExtra(MarkAsReadReceiver.CONTACT_KEY, "0!abcd")
}
MarkAsReadReceiver().onReceive(context, intent)
assertEquals(emptyList(), notifications.markReadCalls)
}
}

View file

@ -59,6 +59,7 @@ class MeshServiceNotificationsImplTest {
context = context,
packetRepository = lazy<PacketRepository> { error("Not used in this test") },
nodeRepository = lazy<NodeRepository> { error("Not used in this test") },
shortcutManager = lazy<ConversationShortcutManager> { error("Not used in this test") },
)
notifications.initChannels()

View file

@ -0,0 +1,157 @@
/*
* 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 androidx.test.core.app.ApplicationProvider
import dev.mokkery.MockMode
import dev.mokkery.answering.calls
import dev.mokkery.answering.returns
import dev.mokkery.everySuspend
import dev.mokkery.matcher.any
import dev.mokkery.mock
import dev.mokkery.verify.VerifyMode
import dev.mokkery.verifySuspend
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import org.junit.After
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.koin.core.context.startKoin
import org.koin.core.context.stopKoin
import org.koin.dsl.module
import org.meshtastic.core.di.CoroutineDispatchers
import org.meshtastic.core.model.service.ServiceAction
import org.meshtastic.core.repository.MeshServiceNotifications
import org.meshtastic.core.repository.ServiceRepository
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
import kotlin.test.assertEquals
@OptIn(ExperimentalCoroutinesApi::class)
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [34])
class ReactionReceiverTest {
private lateinit var context: Context
private lateinit var notifications: RecordingNotifications
private lateinit var serviceRepository: ServiceRepository
@Before
fun setUp() {
context = ApplicationProvider.getApplicationContext()
notifications = RecordingNotifications(mutableListOf())
serviceRepository = mock(MockMode.autofill)
val dispatcher = UnconfinedTestDispatcher()
startKoin {
modules(
module {
single<ServiceRepository> { serviceRepository }
single<MeshServiceNotifications> { notifications }
single { CoroutineDispatchers(io = dispatcher, main = dispatcher, default = dispatcher) }
},
)
}
}
@After
fun tearDown() {
stopKoin()
}
@Test
fun `reaction dispatches ServiceAction and marks conversation read`() {
val contactKey = "0!cafebabe"
val emoji = "👍"
val replyId = 42
everySuspend { serviceRepository.onServiceAction(any()) } returns Unit
val intent =
Intent(context, ReactionReceiver::class.java).apply {
action = ReactionReceiver.REACT_ACTION
putExtra(ReactionReceiver.EXTRA_CONTACT_KEY, contactKey)
putExtra(ReactionReceiver.EXTRA_EMOJI, emoji)
putExtra(ReactionReceiver.EXTRA_REPLY_ID, replyId)
}
ReactionReceiver().onReceive(context, intent)
verifySuspend(VerifyMode.exactly(1)) {
serviceRepository.onServiceAction(ServiceAction.Reaction(emoji, replyId, contactKey))
}
assertEquals(listOf(contactKey), notifications.markReadCalls)
}
@Test
fun `reaction does not markRead when ServiceAction dispatch throws`() {
val contactKey = "0!feedface"
val throwingRepo = mock<ServiceRepository>(MockMode.autofill)
everySuspend { throwingRepo.onServiceAction(any()) } calls { throw IllegalStateException("boom") }
stopKoin()
val dispatcher = UnconfinedTestDispatcher()
startKoin {
modules(
module {
single<ServiceRepository> { throwingRepo }
single<MeshServiceNotifications> { notifications }
single { CoroutineDispatchers(io = dispatcher, main = dispatcher, default = dispatcher) }
},
)
}
val intent =
Intent(context, ReactionReceiver::class.java).apply {
action = ReactionReceiver.REACT_ACTION
putExtra(ReactionReceiver.EXTRA_CONTACT_KEY, contactKey)
putExtra(ReactionReceiver.EXTRA_REACTION, "🎉")
putExtra(ReactionReceiver.EXTRA_PACKET_ID, 7)
}
ReactionReceiver().onReceive(context, intent)
assertEquals(emptyList(), notifications.markReadCalls)
}
@Test
fun `reaction without contactKey is dropped`() {
val intent =
Intent(context, ReactionReceiver::class.java).apply {
action = ReactionReceiver.REACT_ACTION
putExtra(ReactionReceiver.EXTRA_EMOJI, "👍")
}
ReactionReceiver().onReceive(context, intent)
assertEquals(emptyList(), notifications.markReadCalls)
}
@Test
fun `wrong action is ignored`() {
val intent =
Intent(context, ReactionReceiver::class.java).apply {
action = "other.ACTION"
putExtra(ReactionReceiver.EXTRA_CONTACT_KEY, "0!abcd")
putExtra(ReactionReceiver.EXTRA_EMOJI, "👍")
}
ReactionReceiver().onReceive(context, intent)
assertEquals(emptyList(), notifications.markReadCalls)
}
}

View file

@ -0,0 +1,125 @@
/*
* 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.os.Bundle
import androidx.core.app.RemoteInput
import androidx.test.core.app.ApplicationProvider
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import org.junit.After
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.koin.core.context.startKoin
import org.koin.core.context.stopKoin
import org.koin.dsl.module
import org.meshtastic.core.di.CoroutineDispatchers
import org.meshtastic.core.model.RadioController
import org.meshtastic.core.repository.MeshServiceNotifications
import org.meshtastic.core.testing.FakeRadioController
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
import kotlin.test.assertEquals
@OptIn(ExperimentalCoroutinesApi::class)
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [34])
class ReplyReceiverTest {
private lateinit var context: Context
private lateinit var radioController: FakeRadioController
private lateinit var notifications: RecordingNotifications
private val callLog = mutableListOf<String>()
@Before
fun setUp() {
context = ApplicationProvider.getApplicationContext()
radioController = RecordingRadioController(callLog)
notifications = RecordingNotifications(callLog)
val dispatcher = UnconfinedTestDispatcher()
startKoin {
modules(
module {
single<RadioController> { radioController }
single<MeshServiceNotifications> { notifications }
single { CoroutineDispatchers(io = dispatcher, main = dispatcher, default = dispatcher) }
},
)
}
}
@After
fun tearDown() {
stopKoin()
}
@Test
fun `reply sends DataPacket and marks conversation read in order`() {
val contactKey = "2!abcd1234"
val replyText = "hello world"
val intent =
Intent(context, ReplyReceiver::class.java).apply {
action = ReplyReceiver.REPLY_ACTION
putExtra(ReplyReceiver.CONTACT_KEY, contactKey)
}
val results = Bundle().apply { putCharSequence(ReplyReceiver.KEY_TEXT_REPLY, replyText) }
RemoteInput.addResultsToIntent(
arrayOf(RemoteInput.Builder(ReplyReceiver.KEY_TEXT_REPLY).build()),
intent,
results,
)
ReplyReceiver().onReceive(context, intent)
assertEquals(1, radioController.sentPackets.size)
val sent = radioController.sentPackets.first()
assertEquals("!abcd1234", sent.to)
assertEquals(2, sent.channel)
assertEquals(replyText, sent.text)
assertEquals(listOf(contactKey to replyText), notifications.appendCalls)
assertEquals(listOf(contactKey), notifications.markReadCalls)
assertEquals(listOf("send", "append", "markRead"), callLog)
}
}
private class RecordingRadioController(private val callLog: MutableList<String>) : FakeRadioController() {
override suspend fun sendMessage(packet: org.meshtastic.core.model.DataPacket) {
callLog.add("send")
super.sendMessage(packet)
}
}
internal class RecordingNotifications(private val callLog: MutableList<String>) :
org.meshtastic.core.testing.FakeMeshServiceNotifications() {
val appendCalls = mutableListOf<Pair<String, String>>()
val markReadCalls = mutableListOf<String>()
override suspend fun appendOutgoingMessage(contactKey: String, text: String) {
callLog.add("append")
appendCalls.add(contactKey to text)
}
override suspend fun markConversationRead(contactKey: String) {
callLog.add("markRead")
markReadCalls.add(contactKey)
}
}

View file

@ -0,0 +1,188 @@
/*
* 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.content.pm.ShortcutInfo
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.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.map
import kotlinx.coroutines.launch
import org.koin.core.annotation.Single
import org.meshtastic.core.di.CoroutineDispatchers
import org.meshtastic.core.model.DataPacket
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.mapIndexedNotNull { index, settings ->
if (index == 0 || settings.name.isNotEmpty()) index to settings else null
}
}
.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<Pair<Int, ChannelSettings>>) {
val myNodeNum = nodeRepository.myNodeInfo.value?.myNodeNum
val shortcuts =
favorites.filter { it.num != myNodeNum }.map { buildFavoriteShortcut(it) } +
channels.map { (index, settings) -> buildChannelShortcut(settings, index) }
try {
val limit = ShortcutManagerCompat.getMaxShortcutCountPerActivity(context)
val currentKeys = shortcuts.map { it.id }.toSet()
val stale = ShortcutManagerCompat.getDynamicShortcuts(context).map { it.id }.filter { it !in currentKeys }
if (stale.isNotEmpty()) ShortcutManagerCompat.removeDynamicShortcuts(context, stale)
for (shortcut in shortcuts.take(limit)) {
ShortcutManagerCompat.pushDynamicShortcut(context, shortcut)
}
Logger.d { "Published ${shortcuts.size.coerceAtMost(limit)} conversation shortcuts" }
} catch (e: IllegalArgumentException) {
Logger.e(e) { "Failed to publish conversation shortcuts" }
} catch (e: IllegalStateException) {
Logger.e(e) { "Failed to publish conversation shortcuts" }
}
}
private fun buildFavoriteShortcut(node: Node): ShortcutInfoCompat {
val contactKey = "0${node.user.id}"
val label = node.user.long_name.ifEmpty { node.user.short_name }
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()
return ShortcutInfoCompat.Builder(context, contactKey)
.setShortLabel(label)
.setLongLabel(label)
.setLocusId(LocusIdCompat(contactKey))
.setPerson(person)
.setLongLived(true)
.setCategories(setOf(ShortcutInfo.SHORTCUT_CATEGORY_CONVERSATION))
.setIntent(conversationIntent(contactKey))
.build()
}
private fun buildChannelShortcut(channelSettings: ChannelSettings, index: Int): ShortcutInfoCompat {
val contactKey = "${index}${DataPacket.ID_BROADCAST}"
val channelName = channelSettings.name.ifEmpty { "Primary Channel" }
val person = Person.Builder().setName(channelName).setKey("channel-$index").build()
return ShortcutInfoCompat.Builder(context, contactKey)
.setShortLabel(channelName)
.setLongLabel(channelName)
.setLocusId(LocusIdCompat(contactKey))
.setPerson(person)
.setLongLived(true)
.setCategories(setOf(ShortcutInfo.SHORTCUT_CATEGORY_CONVERSATION))
.setIntent(conversationIntent(contactKey))
.build()
}
private fun conversationIntent(contactKey: String): Intent =
Intent(Intent.ACTION_VIEW, "$DEEP_LINK_BASE_URI/messages/$contactKey".toUri()).apply {
setPackage(context.packageName)
}
/**
* Ensures a long-lived conversation shortcut exists for [contactKey]. Called on demand when a notification is about
* to reference a shortcut id that may not have been pre-published (e.g., an incoming DM on a non-primary channel,
* or from a non-favorite node). Android Auto requires a matching published shortcut to project the notification as
* a messaging HUN.
*/
fun ensureConversationShortcut(contactKey: String, person: Person, label: String) {
val alreadyPublished = ShortcutManagerCompat.getDynamicShortcuts(context).any { it.id == contactKey }
if (alreadyPublished) return
val shortcut =
ShortcutInfoCompat.Builder(context, contactKey)
.setShortLabel(label)
.setLongLabel(label)
.setLocusId(LocusIdCompat(contactKey))
.setPerson(person)
.setLongLived(true)
.setCategories(setOf(ShortcutInfo.SHORTCUT_CATEGORY_CONVERSATION))
.setIntent(conversationIntent(contactKey))
.build()
try {
ShortcutManagerCompat.pushDynamicShortcut(context, shortcut)
} catch (e: IllegalArgumentException) {
Logger.e(e) { "Failed to publish on-demand shortcut $contactKey" }
} catch (e: IllegalStateException) {
Logger.e(e) { "Failed to publish on-demand shortcut $contactKey" }
}
}
private fun createPersonIcon(name: String, backgroundColor: Int, foregroundColor: Int): IconCompat =
PersonIconFactory.create(name, backgroundColor, foregroundColor)
}

View file

@ -24,18 +24,14 @@ import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
import org.meshtastic.core.common.util.nowMillis
import org.meshtastic.core.di.CoroutineDispatchers
import org.meshtastic.core.repository.MeshServiceNotifications
import org.meshtastic.core.repository.PacketRepository
/** A [BroadcastReceiver] that handles "Mark as read" actions from notifications. */
class MarkAsReadReceiver :
BroadcastReceiver(),
KoinComponent {
private val packetRepository: PacketRepository by inject()
private val serviceNotifications: MeshServiceNotifications by inject()
private val dispatchers: CoroutineDispatchers by inject()
@ -54,8 +50,7 @@ class MarkAsReadReceiver :
scope.launch {
try {
packetRepository.clearUnreadCount(contactKey, nowMillis)
serviceNotifications.cancelMessageNotification(contactKey)
serviceNotifications.markConversationRead(contactKey)
} finally {
pendingResult.finish()
}

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

@ -24,16 +24,14 @@ import android.app.TaskStackBuilder
import android.content.ContentResolver.SCHEME_ANDROID_RESOURCE
import android.content.Context
import android.content.Intent
import android.graphics.Canvas
import android.graphics.Color
import android.graphics.Paint
import android.media.AudioAttributes
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
import androidx.core.net.toUri
import kotlinx.coroutines.flow.first
@ -109,6 +107,7 @@ class MeshServiceNotificationsImpl(
private val context: Context,
private val packetRepository: Lazy<PacketRepository>,
private val nodeRepository: Lazy<NodeRepository>,
private val shortcutManager: Lazy<ConversationShortcutManager>,
) : MeshServiceNotifications {
private val notificationManager = context.getSystemService<NotificationManager>()!!
@ -117,12 +116,9 @@ class MeshServiceNotificationsImpl(
const val MAX_BATTERY_LEVEL = 100
private val NOTIFICATION_LIGHT_COLOR = Color.BLUE
private const val MAX_HISTORY_MESSAGES = 10
private const val MIN_CONTEXT_MESSAGES = 3
private const val SNIPPET_LENGTH = 30
private const val GROUP_KEY_MESSAGES = "com.geeksville.mesh.GROUP_MESSAGES"
private const val SUMMARY_ID = 1
private const val PERSON_ICON_SIZE = 128
private const val PERSON_ICON_TEXT_SIZE_RATIO = 0.5f
private const val STATS_UPDATE_MINUTES = 15
private val STATS_UPDATE_INTERVAL = STATS_UPDATE_MINUTES.minutes
private const val BULLET = ""
@ -424,14 +420,8 @@ class MeshServiceNotificationsImpl(
.first()
val unread = history.filter { !it.read }
val displayHistory =
if (unread.size < MIN_CONTEXT_MESSAGES) {
history.take(MIN_CONTEXT_MESSAGES).reversed()
} else {
unread.take(MAX_HISTORY_MESSAGES).reversed()
}
if (displayHistory.isEmpty()) return
if (unread.isEmpty()) return
val displayHistory = unread.take(MAX_HISTORY_MESSAGES).reversed()
val notification =
createConversationNotification(
@ -445,20 +435,23 @@ class MeshServiceNotificationsImpl(
showGroupSummary()
}
private fun buildMePerson(): Person {
val ourNode = nodeRepository.value.ourNodeInfo.value
val meName = ourNode?.user?.long_name ?: getString(Res.string.you)
return Person.Builder()
.setName(meName)
.setKey(ourNode?.user?.id ?: DataPacket.ID_LOCAL)
.apply { ourNode?.let { setIcon(createPersonIcon(meName, it.colors.second, it.colors.first)) } }
.build()
}
private fun showGroupSummary() {
val activeNotifications =
notificationManager.activeNotifications.filter {
it.id != SUMMARY_ID && it.notification.group == GROUP_KEY_MESSAGES
}
val ourNode = nodeRepository.value.ourNodeInfo.value
val meName = ourNode?.user?.long_name ?: getString(Res.string.you)
val me =
Person.Builder()
.setName(meName)
.setKey(ourNode?.user?.id ?: DataPacket.ID_LOCAL)
.apply { ourNode?.let { setIcon(createPersonIcon(meName, it.colors.second, it.colors.first)) } }
.build()
val me = buildMePerson()
val messagingStyle =
NotificationCompat.MessagingStyle(me)
@ -512,7 +505,53 @@ class MeshServiceNotificationsImpl(
notificationManager.notify(clientNotification.toString().hashCode(), notification)
}
override fun cancelMessageNotification(contactKey: String) = notificationManager.cancel(contactKey.hashCode())
override fun cancelMessageNotification(contactKey: String) {
notificationManager.cancel(contactKey.hashCode())
// Always drop the group summary — reading notificationManager.activeNotifications right
// after cancel() races with NotificationManagerService, so we can't reliably count what's
// left. The next incoming message re-builds the summary via showGroupSummary().
notificationManager.cancel(SUMMARY_ID)
}
override suspend fun markConversationRead(contactKey: String) {
packetRepository.value.clearUnreadCount(contactKey, nowMillis)
cancelMessageNotification(contactKey)
}
override suspend fun appendOutgoingMessage(contactKey: String, text: String) {
if (text.isEmpty()) return
val ourNode = nodeRepository.value.ourNodeInfo.value
val history =
packetRepository.value
.getMessagesFrom(contactKey, includeFiltered = false) { nodeId ->
if (nodeId == DataPacket.ID_LOCAL) {
ourNode ?: nodeRepository.value.getNode(nodeId)
} else {
nodeRepository.value.getNode(nodeId ?: "")
}
}
.first()
// For the brief outgoing-reply confirmation we don't gate on unread state — the
// user just sent something and Android Auto needs to reflect that in the
// MessagingStyle notification regardless of whether other unread messages remain.
// We still cap the displayed history so the notification stays compact.
val displayHistory = history.take(MAX_HISTORY_MESSAGES).reversed()
val dest = if (contactKey.isNotEmpty()) contactKey.substring(1) else contactKey
val isBroadcast = dest == DataPacket.ID_BROADCAST
val notification =
createConversationNotification(
contactKey = contactKey,
isBroadcast = isBroadcast,
channelName = null,
history = displayHistory,
isSilent = true,
extraOutgoingMessage = text,
)
notificationManager.notify(contactKey.hashCode(), notification)
}
override fun cancelLowBatteryNotification(node: Node) = notificationManager.cancel(node.num)
@ -555,6 +594,7 @@ class MeshServiceNotificationsImpl(
channelName: String?,
history: List<Message>,
isSilent: Boolean = false,
extraOutgoingMessage: String? = null,
): Notification {
val type = if (isBroadcast) NotificationType.BroadcastMessage else NotificationType.DirectMessage
val builder = commonBuilder(type, createOpenMessageIntent(contactKey))
@ -563,14 +603,7 @@ class MeshServiceNotificationsImpl(
builder.setSilent(true)
}
val ourNode = nodeRepository.value.ourNodeInfo.value
val meName = ourNode?.user?.long_name ?: getString(Res.string.you)
val me =
Person.Builder()
.setName(meName)
.setKey(ourNode?.user?.id ?: DataPacket.ID_LOCAL)
.apply { ourNode?.let { setIcon(createPersonIcon(meName, it.colors.second, it.colors.first)) } }
.build()
val me = buildMePerson()
val style =
NotificationCompat.MessagingStyle(me)
@ -615,13 +648,20 @@ class MeshServiceNotificationsImpl(
)
}
}
if (!extraOutgoingMessage.isNullOrEmpty()) {
style.addMessage(extraOutgoingMessage, nowMillis, me)
}
val lastMessage = history.last()
ensureShortcutForNotification(contactKey, isBroadcast, channelName, lastMessage)
builder
.setCategory(Notification.CATEGORY_MESSAGE)
.setAutoCancel(true)
.setStyle(style)
.setGroup(GROUP_KEY_MESSAGES)
.setShortcutId(contactKey)
.setLocusId(LocusIdCompat(contactKey))
.setVisibility(NotificationCompat.VISIBILITY_PRIVATE)
.setWhen(lastMessage.receivedTime)
.setShowWhen(true)
@ -770,6 +810,43 @@ class MeshServiceNotificationsImpl(
}
}
private fun ensureShortcutForNotification(
contactKey: String,
isBroadcast: Boolean,
channelName: String?,
lastMessage: Message,
) {
val contactNode =
if (isBroadcast) {
null
} else {
// contactKey format: "${channel}${nodeId}"; the remote contact is the node keyed by nodeId,
// which is stable regardless of whether the latest message in history is incoming or outgoing.
val nodeId = contactKey.drop(1)
nodeRepository.value.getNode(nodeId)
}
val person =
if (isBroadcast) {
Person.Builder().setName(channelName ?: contactKey).setKey(contactKey).build()
} else {
val node = contactNode ?: lastMessage.node
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 label =
when {
isBroadcast -> channelName ?: contactKey
else -> {
val node = contactNode ?: lastMessage.node
node.user.long_name.ifEmpty { node.user.short_name }
}
}
shortcutManager.value.ensureConversationShortcut(contactKey, person, label)
}
private fun createReplyAction(contactKey: String): NotificationCompat.Action {
val replyLabel = getString(Res.string.reply)
val remoteInput = RemoteInput.Builder(KEY_TEXT_REPLY).setLabel(replyLabel).build()
@ -789,6 +866,9 @@ class MeshServiceNotificationsImpl(
return NotificationCompat.Action.Builder(android.R.drawable.ic_menu_send, replyLabel, replyPendingIntent)
.addRemoteInput(remoteInput)
.setSemanticAction(NotificationCompat.Action.SEMANTIC_ACTION_REPLY)
.setShowsUserInterface(false)
.setAllowGeneratedReplies(true)
.build()
}
@ -807,7 +887,10 @@ class MeshServiceNotificationsImpl(
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)
return NotificationCompat.Action.Builder(android.R.drawable.ic_menu_view, label, pendingIntent).build()
return NotificationCompat.Action.Builder(android.R.drawable.ic_menu_view, label, pendingIntent)
.setSemanticAction(NotificationCompat.Action.SEMANTIC_ACTION_MARK_AS_READ)
.setShowsUserInterface(false)
.build()
}
private fun createReactionAction(
@ -834,7 +917,10 @@ class MeshServiceNotificationsImpl(
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)
return NotificationCompat.Action.Builder(android.R.drawable.ic_menu_add, label, pendingIntent).build()
return NotificationCompat.Action.Builder(android.R.drawable.ic_menu_add, label, pendingIntent)
.setSemanticAction(NotificationCompat.Action.SEMANTIC_ACTION_NONE)
.setShowsUserInterface(false)
.build()
}
private fun commonBuilder(
@ -850,32 +936,8 @@ class MeshServiceNotificationsImpl(
.setContentIntent(contentIntent ?: openAppIntent)
}
private fun createPersonIcon(name: String, backgroundColor: Int, foregroundColor: Int): IconCompat {
val bitmap = createBitmap(PERSON_ICON_SIZE, PERSON_ICON_SIZE)
val canvas = Canvas(bitmap)
val paint = Paint(Paint.ANTI_ALIAS_FLAG)
// Draw background circle
paint.color = backgroundColor
canvas.drawCircle(PERSON_ICON_SIZE / 2f, PERSON_ICON_SIZE / 2f, PERSON_ICON_SIZE / 2f, paint)
// Draw initials
paint.color = foregroundColor
paint.textSize = PERSON_ICON_SIZE * PERSON_ICON_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)
}
private fun createPersonIcon(name: String, backgroundColor: Int, foregroundColor: Int): IconCompat =
PersonIconFactory.create(name, backgroundColor, foregroundColor)
// endregion

View file

@ -0,0 +1,62 @@
/*
* 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.graphics.Canvas
import android.graphics.Paint
import androidx.core.graphics.createBitmap
import androidx.core.graphics.drawable.IconCompat
/**
* Renders a circular avatar with a single uppercase initial used for [androidx.core.app.Person]
* icons in MessagingStyle notifications and for conversation shortcut avatars.
*
* Shared by [MeshServiceNotificationsImpl] and [ConversationShortcutManager] to keep the avatar
* appearance consistent across the notification shade and the launcher / Android Auto.
*/
internal object PersonIconFactory {
private const val ICON_SIZE = 128
private const val TEXT_SIZE_RATIO = 0.5f
fun create(name: String, backgroundColor: Int, foregroundColor: Int): IconCompat {
val bitmap = createBitmap(ICON_SIZE, ICON_SIZE)
val canvas = Canvas(bitmap)
val paint = Paint(Paint.ANTI_ALIAS_FLAG)
// Background circle.
paint.color = backgroundColor
canvas.drawCircle(ICON_SIZE / 2f, ICON_SIZE / 2f, ICON_SIZE / 2f, paint)
// Single uppercase initial centered on the circle.
paint.color = foregroundColor
paint.textSize = ICON_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)
}
}

View file

@ -27,6 +27,7 @@ import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
import org.meshtastic.core.di.CoroutineDispatchers
import org.meshtastic.core.model.service.ServiceAction
import org.meshtastic.core.repository.MeshServiceNotifications
import org.meshtastic.core.repository.ServiceRepository
/**
@ -41,6 +42,8 @@ class ReactionReceiver :
private val serviceRepository: ServiceRepository by inject()
private val meshServiceNotifications: MeshServiceNotifications by inject()
private val dispatchers: CoroutineDispatchers by inject()
private val scope by lazy { CoroutineScope(SupervisorJob() + dispatchers.io) }
@ -57,6 +60,7 @@ class ReactionReceiver :
scope.launch {
try {
serviceRepository.onServiceAction(ServiceAction.Reaction(reaction, replyId, contactKey))
meshServiceNotifications.markConversationRead(contactKey)
} catch (e: Exception) {
Logger.e(e) { "Error sending reaction" }
} finally {

View file

@ -65,7 +65,8 @@ class ReplyReceiver :
scope.launch {
try {
sendMessage(message, contactKey)
meshServiceNotifications.cancelMessageNotification(contactKey)
meshServiceNotifications.appendOutgoingMessage(contactKey, message)
meshServiceNotifications.markConversationRead(contactKey)
} finally {
pendingResult.finish()
}

View file

@ -24,7 +24,7 @@ import org.meshtastic.proto.Telemetry
/** A test double for [MeshServiceNotifications] that provides a no-op implementation. */
@Suppress("TooManyFunctions", "EmptyFunctionBlock")
class FakeMeshServiceNotifications : MeshServiceNotifications {
open class FakeMeshServiceNotifications : MeshServiceNotifications {
override fun clearNotifications() {}
override fun initChannels() {}
@ -67,6 +67,10 @@ class FakeMeshServiceNotifications : MeshServiceNotifications {
override fun cancelMessageNotification(contactKey: String) {}
override suspend fun markConversationRead(contactKey: String) {}
override suspend fun appendOutgoingMessage(contactKey: String, text: String) {}
override fun cancelLowBatteryNotification(node: Node) {}
override fun clearClientNotification(notification: ClientNotification) {}

View file

@ -31,7 +31,7 @@ import org.meshtastic.proto.User
* A test double for [RadioController] that provides a no-op implementation and tracks calls for assertions in tests.
*/
@Suppress("TooManyFunctions", "EmptyFunctionBlock")
class FakeRadioController :
open class FakeRadioController :
BaseFake(),
RadioController {

View file

@ -154,6 +154,14 @@ class DesktopMeshServiceNotifications(private val notificationManager: Notificat
notificationManager.cancel(contactKey.hashCode())
}
override suspend fun markConversationRead(contactKey: String) {
notificationManager.cancel(contactKey.hashCode())
}
override suspend fun appendOutgoingMessage(contactKey: String, text: String) {
// No-op: desktop tray notifications don't carry MessagingStyle history to augment.
}
override fun cancelLowBatteryNotification(node: Node) {
notificationManager.cancel(node.num)
}

View file

@ -0,0 +1,41 @@
/*
* 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/>.
*/
plugins { alias(libs.plugins.meshtastic.android.library) }
android {
namespace = "org.meshtastic.feature.auto"
resourcePrefix = "auto_"
}
dependencies {
implementation(projects.core.common)
implementation(projects.core.model)
implementation(projects.core.proto)
implementation(projects.core.repository)
implementation(libs.androidx.car.app)
implementation(libs.kermit)
// KoinComponent for service-locator-style injection inside Car App Library callbacks.
// No @Single/@Factory bindings live in this module, so the meshtastic.koin convention
// plugin (which adds koin-annotations + the KSP compiler) is not needed here.
implementation(libs.koin.core)
testImplementation(kotlin("test"))
testImplementation(libs.kotest.assertions)
testImplementation(libs.kotlinx.coroutines.test)
}

View file

@ -0,0 +1,41 @@
<?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/>.
-->
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- Android Auto Car App Service for browsable messaging UI -->
<application>
<service
android:name="org.meshtastic.feature.auto.MeshtasticCarAppService"
android:exported="true"
android:permission="androidx.car.app.CarAppService">
<intent-filter>
<action android:name="androidx.car.app.CarAppService" />
<category android:name="androidx.car.app.category.MESSAGING" />
</intent-filter>
</service>
<!-- Car API level 1 is sufficient for MessagingStyle notification projection and
ListTemplate. The browsable TabTemplate UI requires Car API 6; the screen
detects the host's level at runtime and falls back to a ListTemplate on
older hosts so the app remains usable on all vehicles. -->
<meta-data
android:name="androidx.car.app.minCarApiLevel"
android:value="1" />
</application>
</manifest>

View file

@ -0,0 +1,35 @@
/*
* 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.feature.auto
/**
* Lightweight projection of a conversation used exclusively within [MeshtasticCarScreen].
*
* [isBroadcast] and [channelIndex] drive ordering (channels before DMs, channels sorted by
* index). [lastMessageTime] drives DM ordering (most-recent first).
* [lastMessageText] mirrors `ContactsViewModel.contactList`'s `lastMessageText` received
* DMs are prefixed with the sender's short name, matching `ContactItem`'s ChatMetadata display.
*/
internal data class CarContact(
val contactKey: String,
val displayName: String,
val unreadCount: Int,
val isBroadcast: Boolean,
val channelIndex: Int,
val lastMessageTime: Long?,
val lastMessageText: String?,
)

View file

@ -0,0 +1,216 @@
/*
* 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.feature.auto
import org.meshtastic.core.common.util.DateFormatter
import org.meshtastic.core.model.DataPacket
import org.meshtastic.core.model.Node
import org.meshtastic.core.model.util.getChannel
import org.meshtastic.proto.ChannelSet
import org.meshtastic.proto.PortNum
import org.meshtastic.proto.User
/**
* Pure-function helpers that convert domain models into [CarContact] and display strings for
* [MeshtasticCarScreen].
*
* All methods are free of Car App Library dependencies, making them straightforwardly testable as
* plain JVM unit tests without Robolectric.
*/
internal object CarScreenDataBuilder {
/**
* Returns a map of `"<ch>^all" placeholder DataPacket` for every configured channel.
*
* Channel placeholders ensure every configured channel is always visible in the Messages
* tab even before any messages have been sent or received mirroring the behaviour of
* `ContactsViewModel.contactList`.
*/
fun buildChannelPlaceholders(channelSet: ChannelSet): Map<String, DataPacket> =
(0 until channelSet.settings.size).associate { ch ->
"${ch}${DataPacket.ID_BROADCAST}" to
DataPacket(bytes = null, dataType = PortNum.TEXT_MESSAGE_APP.value, time = 0L, channel = ch)
}
/**
* Converts the merged DB + placeholder map into an ordered [CarContact] list.
*
* Channels (keys ending with [DataPacket.ID_BROADCAST]) appear first sorted by channel index.
* DM conversations follow sorted by [CarContact.lastMessageTime] descending matching the
* ordering used by the phone's Contacts screen.
*
* @param resolveUser Returns the [User] for a given node-ID string. The caller is responsible
* for providing a null-safe fallback (typically [NodeRepository.getUser]).
* @param channelLabel Produces the display name for a channel given its index.
* Defaults to `"Channel N"`; callers can supply a localised string.
* @param unknownLabel Fallback display name when neither long name nor short name is available.
* Defaults to `"Unknown"`; callers can supply a localised string.
*/
fun buildCarContacts(
merged: Map<String, DataPacket>,
myId: String?,
channelSet: ChannelSet,
resolveUser: (String) -> User,
channelLabel: (Int) -> String = { "Channel $it" },
unknownLabel: String = "Unknown",
): List<CarContact> {
val all = merged.map { (contactKey, packet) ->
val fromLocal = packet.from == DataPacket.ID_LOCAL || packet.from == myId
val toBroadcast = packet.to == DataPacket.ID_BROADCAST
val userId = if (fromLocal) packet.to else packet.from
// Resolve the user once; used for both displayName and message prefix.
val user = resolveUser(userId ?: DataPacket.ID_BROADCAST)
val displayName = if (toBroadcast) {
channelSet.getChannel(packet.channel)?.name?.takeIf { it.isNotEmpty() }
?: channelLabel(packet.channel)
} else {
// userId can be null for malformed packets (e.g. both `from` and `to` are null).
// Fall back to a broadcast lookup which returns an "Unknown" user rather than crashing.
user.long_name.ifEmpty { user.short_name }.ifEmpty { unknownLabel }
}
// Mirror ContactsViewModel: prefix received DM text with the sender's short name,
// matching how ContactItem's ChatMetadata renders lastMessageText.
val shortName = if (!toBroadcast) user.short_name else ""
val lastMessageText = packet.text?.let { text ->
if (fromLocal || shortName.isEmpty()) text else "$shortName: $text"
}
CarContact(
contactKey = contactKey,
displayName = displayName,
unreadCount = 0, // filled in reactively by the screen's flatMapLatest
isBroadcast = toBroadcast,
channelIndex = packet.channel,
lastMessageTime = if (packet.time != 0L) packet.time else null,
lastMessageText = lastMessageText,
)
}
// partition avoids iterating the list twice.
val (channels, dms) = all.partition { it.isBroadcast }
return channels.sortedBy { it.channelIndex } +
dms.sortedByDescending { it.lastMessageTime ?: 0L }
}
/**
* Filters and sorts [nodes] to produce the Favorites tab list.
*
* Only nodes with [Node.isFavorite] are included. They are sorted alphabetically by
* [User.long_name], falling back to [User.short_name] when the long name is empty
* matching the alphabetical sort used by the phone's node list when filtered to favorites.
*/
fun sortFavorites(nodes: Collection<Node>): List<Node> =
nodes
.filter { it.isFavorite }
.sortedWith(compareBy { it.user.long_name.ifEmpty { it.user.short_name } })
/**
* Returns the primary status line for a favorite-node row (Text 1 in the Car UI row).
*
* Mirrors NodeItem's signal row:
* - `"Online · Direct"` when [Node.hopsAway] == 0
* - `"Online · N hops"` when [Node.hopsAway] > 0
* - `"Online"` when hop distance is unknown
* - `"Offline · <time ago>"` when [Node.lastHeard] is set
* - `"Offline"` otherwise
*
* @param labelOnline Localised "Online" label; defaults to English.
* @param labelOffline Localised "Offline" label; defaults to English.
* @param labelDirect Suffix appended when [Node.hopsAway] == 0 (include leading " · ");
* defaults to `" · Direct"`.
* @param labelHops Produces the hop-count suffix given the count (include leading " · ");
* defaults to `" · N hops"`.
* @param formatRelativeTime Converts a millis timestamp to a human-readable "X ago" string.
* Defaults to [DateFormatter.formatRelativeTime]; injectable for testing.
*/
fun nodeStatusText(
node: Node,
labelOnline: String = "Online",
labelOffline: String = "Offline",
labelDirect: String = " · Direct",
labelHops: (Int) -> String = { " · $it hops" },
formatRelativeTime: (Long) -> String = DateFormatter::formatRelativeTime,
): String = buildString {
if (node.isOnline) {
append(labelOnline)
when {
node.hopsAway == 0 -> append(labelDirect)
node.hopsAway > 0 -> append(labelHops(node.hopsAway))
}
} else {
append(labelOffline)
if (node.lastHeard > 0) {
append(" · ${formatRelativeTime(node.lastHeard * 1000L)}")
}
}
}
/**
* Returns the secondary detail line for a favorite-node row (Text 2 in the Car UI row).
*
* Mirrors NodeItem's battery row + node chip: `"NODE · 85%"`.
* Returns an empty string when neither short name nor battery level is available.
*/
fun nodeDetailText(node: Node): String = buildString {
val shortName = node.user.short_name
if (shortName.isNotEmpty()) append(shortName)
val battery = node.batteryStr
if (battery.isNotEmpty()) {
if (isNotEmpty()) append(" · ")
append(battery)
}
}
/**
* Returns the message preview line for a contact row (Text 1 in the Car UI row).
*
* Mirrors `ChatMetadata`'s `lastMessageText` display: shows the last message text
* (with sender prefix for received DMs), or [noMessagesLabel] for empty channels.
*
* @param noMessagesLabel Localised empty-state label; defaults to `"No messages yet"`.
*/
fun contactPreviewText(
contact: CarContact,
noMessagesLabel: String = "No messages yet",
): String = contact.lastMessageText?.takeIf { it.isNotEmpty() } ?: noMessagesLabel
/**
* Returns the secondary metadata line for a contact row (Text 2 in the Car UI row).
*
* Mirrors ContactItem's unread badge + date header:
* - [unreadLabel] result when there are unread messages
* - Formatted short date of the last message otherwise
* - Empty string when there are no messages at all
*
* @param unreadLabel Produces the unread-count label given the count.
* Defaults to `"N unread"`; callers can supply a localised format string.
* @param formatShortDate Converts a millis timestamp to a short date string.
* Defaults to [DateFormatter.formatShortDate]; injectable for testing.
*/
fun contactSecondaryText(
contact: CarContact,
unreadLabel: (Int) -> String = { "$it unread" },
formatShortDate: (Long) -> String = DateFormatter::formatShortDate,
): String = when {
contact.unreadCount > 0 -> unreadLabel(contact.unreadCount)
contact.lastMessageTime != null -> formatShortDate(contact.lastMessageTime)
else -> ""
}
}

View file

@ -0,0 +1,45 @@
/*
* 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.feature.auto
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 {
val isDebug = applicationContext.applicationInfo.flags and ApplicationInfo.FLAG_DEBUGGABLE != 0
return if (isDebug) {
HostValidator.ALLOW_ALL_HOSTS_VALIDATOR
} else {
HostValidator.Builder(applicationContext)
.addAllowedHosts(androidx.car.app.R.array.hosts_allowlist)
.build()
}
}
override fun onCreateSession(sessionInfo: SessionInfo): Session = MeshtasticCarSession()
}

View file

@ -0,0 +1,436 @@
/*
* 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.feature.auto
import androidx.car.app.CarAppApiLevels
import androidx.car.app.CarContext
import androidx.car.app.Screen
import androidx.car.app.constraints.ConstraintManager
import androidx.car.app.model.Action
import androidx.car.app.model.CarColor
import androidx.car.app.model.CarIcon
import androidx.car.app.model.ItemList
import androidx.car.app.model.ListTemplate
import androidx.car.app.model.MessageTemplate
import androidx.car.app.model.Row
import androidx.car.app.model.Tab
import androidx.car.app.model.TabContents
import androidx.car.app.model.TabTemplate
import androidx.car.app.model.Template
import androidx.core.graphics.drawable.IconCompat
import androidx.lifecycle.DefaultLifecycleObserver
import androidx.lifecycle.LifecycleOwner
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf
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.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
/**
* Root screen displayed in Android Auto.
*
* Renders a three-tab UI:
* - **Status** Connection state and device name.
* - **Favorites** All nodes the user has starred, with online/hop status shown as a subtitle.
* - **Messages** All conversations: active channels displayed first as permanent placeholders
* (always visible even when empty, sorted by channel index), followed by DM conversations
* sorted by most-recent message descending. This is the same ordering used by
* [org.meshtastic.feature.messaging.ui.contact.ContactsViewModel].
*
* Pure business-logic (contact ordering, row text, favourites sorting) is separated into
* [CarScreenDataBuilder], which is free of Car App Library dependencies and is unit-tested
* independently.
*
* `TabTemplate` requires Car API level 6. On hosts running Car API level 15 the screen falls
* back to a single [ListTemplate] that includes a status row, favorite-node rows, and the
* contact list.
*
* When the user taps a [MessagingStyle][androidx.core.app.NotificationCompat.MessagingStyle]
* notification in the Android Auto notification shade the host calls
* [MeshtasticCarSession.onNewIntent] which delegates to [selectMessagesTab] to switch to the
* Messages tab.
*/
class MeshtasticCarScreen(carContext: CarContext) :
Screen(carContext),
KoinComponent,
DefaultLifecycleObserver {
private val nodeRepository: NodeRepository by inject()
private val packetRepository: PacketRepository by inject()
private val radioConfigRepository: RadioConfigRepository by inject()
private val serviceRepository: ServiceRepository by inject()
private val scope = CoroutineScope(Dispatchers.Main + SupervisorJob())
/**
* Per-host list item cap, retrieved once on first template render via
* [ConstraintManager.getContentLimit]. Replaces a hardcoded constant so that
* hosts that allow more than the minimum 5 items are fully utilised.
*/
private val listContentLimit: Int by lazy {
carContext.getCarService(ConstraintManager::class.java)
.getContentLimit(ConstraintManager.CONTENT_LIMIT_TYPE_LIST)
}
private var activeTabId = TAB_STATUS
private var connectionState: ConnectionState = ConnectionState.Disconnected
/**
* Favorite nodes sorted alphabetically by long name. Updated reactively from
* [NodeRepository.nodeDBbyNum] whenever the user stars or un-stars a node.
*/
private var favorites: List<Node> = emptyList()
/**
* Ordered contact list for the Messages tab: channel entries first (sorted by channel index,
* always present as placeholders even when no messages exist), then DM conversations sorted
* by most-recent message descending identical ordering to the phone's Contacts screen.
*/
private var contacts: List<CarContact> = emptyList()
/**
* True until the first combined emission from all repository flows arrives.
*
* On Car API 5 this prevents a flash of empty content before data loads by showing a
* [MessageTemplate] loading spinner. On older API levels the loading spinner is unavailable
* so [isLoading] starts as `false` and the fallback [ListTemplate] handles the empty state
* with [ItemList.Builder.setNoItemsMessage].
*/
private var isLoading = carContext.carAppApiLevel >= CarAppApiLevels.LEVEL_5
init {
lifecycle.addObserver(this)
}
override fun onCreate(owner: LifecycleOwner) {
startObserving()
}
override fun onDestroy(owner: LifecycleOwner) {
scope.cancel()
}
private fun startObserving() {
// Build the contacts sub-flow independently so it can feed the outer combine below.
val contactsFlow = combine(
nodeRepository.myId,
packetRepository.getContacts(),
radioConfigRepository.channelSetFlow,
) { myId, rawContacts, channelSet ->
// Channel placeholders are always included so every configured channel is
// visible even before any messages have been sent/received.
val placeholders = CarScreenDataBuilder.buildChannelPlaceholders(channelSet)
// Real DB entries take precedence over placeholders when present.
val merged = rawContacts + (placeholders - rawContacts.keys)
CarScreenDataBuilder.buildCarContacts(
merged, myId, channelSet,
resolveUser = { userId -> nodeRepository.getUser(userId) },
channelLabel = { carContext.getString(R.string.auto_channel_number, it) },
unknownLabel = carContext.getString(R.string.auto_unknown),
)
}
.distinctUntilChanged()
.flatMapLatest { baseContacts ->
if (baseContacts.isEmpty()) {
flowOf(emptyList())
} else {
val unreadFlows = baseContacts.map { contact ->
packetRepository.getUnreadCountFlow(contact.contactKey)
.map { unread -> contact.copy(unreadCount = unread) }
}
combine(unreadFlows) { it.toList() }
}
}
val favoritesFlow = nodeRepository.nodeDBbyNum
.map { db -> CarScreenDataBuilder.sortFavorites(db.values) }
.distinctUntilChanged()
// All three data sources feed a single combined collector so that each batch of
// repository changes produces exactly one invalidate() call, avoiding unnecessary
// template rebuilds and staying well within the host's update-rate budget.
scope.launch {
combine(
serviceRepository.connectionState,
favoritesFlow,
contactsFlow,
) { connState, favs, ctcts -> Triple(connState, favs, ctcts) }
.collect { (connState, favs, ctcts) ->
connectionState = connState
favorites = favs
contacts = ctcts
isLoading = false
invalidate()
}
}
}
// ---- Template building ----
override fun onGetTemplate(): Template {
// MessageTemplate.setLoading() requires Car API 5+. isLoading is only ever true
// on ≥5 hosts (see initialisation above), so no additional level check is needed here.
if (isLoading) {
return MessageTemplate.Builder(carContext.getString(R.string.auto_loading))
.setHeaderAction(Action.APP_ICON)
.setLoading(true)
.build()
}
// TabTemplate requires Car API level 6. Fall back to a combined ListTemplate
// on older hosts so the app remains functional on all supported vehicles.
if (carContext.carAppApiLevel < CarAppApiLevels.LEVEL_6) {
return buildFallbackListTemplate()
}
val tabCallback = object : TabTemplate.TabCallback {
override fun onTabSelected(tabContentId: String) {
activeTabId = tabContentId
invalidate()
}
}
val activeContent = when (activeTabId) {
TAB_FAVORITES -> TabContents.Builder(buildFavoritesTemplate()).build()
TAB_MESSAGES -> TabContents.Builder(buildMessagesTemplate()).build()
else -> TabContents.Builder(buildStatusTemplate()).build()
}
return TabTemplate.Builder(tabCallback)
.setHeaderAction(Action.APP_ICON)
.addTab(
Tab.Builder()
.setTitle(carContext.getString(R.string.auto_tab_status))
.setIcon(carIcon(R.drawable.auto_ic_status))
.setContentId(TAB_STATUS)
.build(),
)
.addTab(
Tab.Builder()
.setTitle(carContext.getString(R.string.auto_tab_favorites))
.setIcon(carIcon(R.drawable.auto_ic_favorites))
.setContentId(TAB_FAVORITES)
.build(),
)
.addTab(
Tab.Builder()
.setTitle(carContext.getString(R.string.auto_tab_messages))
.setIcon(carIcon(R.drawable.auto_ic_channels))
.setContentId(TAB_MESSAGES)
.build(),
)
.setTabContents(activeContent)
.setActiveTabContentId(activeTabId)
.build()
}
/**
* Called by [MeshtasticCarSession.onNewIntent] when the user taps a conversation
* notification in the Android Auto notification shade. Switches to [TAB_MESSAGES]
* channels and DMs both live in the same tab, so no per-key handling is required.
*
* `androidx.car.app.model.ListTemplate` does not currently expose a programmatic scroll
* API, so we cannot focus a specific conversation row. If/when a scroll API is added,
* the contactKey can be threaded through `MeshtasticCarSession.onNewIntent`.
*/
fun selectMessagesTab() {
activeTabId = TAB_MESSAGES
invalidate()
}
// ---- Individual template builders ----
private fun buildStatusTemplate(): ListTemplate =
ListTemplate.Builder()
.setTitle(carContext.getString(R.string.auto_tab_status))
.setSingleList(ItemList.Builder().addItem(buildStatusRow()).build())
.build()
/**
* Builds the Favorites tab: one row per starred node, mirroring the key status info shown
* by [org.meshtastic.feature.node.component.NodeItem] on the phone.
*
* - **Title**: node's long name (short name fallback).
* - **Text 1**: `"Online · Direct"` / `"Online · N hops"` / `"Offline · Xh ago"`
* mirrors the signal row and last-heard chip in NodeItem.
* - **Text 2**: battery percentage and short name mirrors the battery row and node chip.
*/
private fun buildFavoritesTemplate(): ListTemplate =
buildListTemplate(
carContext.getString(R.string.auto_tab_favorites),
favorites,
carContext.getString(R.string.auto_no_favorites),
) { buildFavoriteNodeRow(it) }
/**
* Builds the Messages tab content: channels first (always present, even if empty), followed
* by DM conversations sorted by most-recent message identical to the phone's Contacts screen.
*/
private fun buildMessagesTemplate(): ListTemplate =
buildListTemplate(
carContext.getString(R.string.auto_tab_messages),
contacts,
carContext.getString(R.string.auto_no_conversations),
) { buildContactRow(it) }
/**
* Fallback for Car API level 15 hosts that do not support [TabTemplate].
*
* Shows a status row, then favorite-node rows, then conversation rows, all capped at
* [listContentLimit] total matching the three-tab content in a single list.
*
* The remaining slots after status are split evenly: half for favorites, half for messages.
* This prevents a long favorites list from crowding out all conversation entries.
*/
private fun buildFallbackListTemplate(): ListTemplate {
val items = ItemList.Builder()
var remaining = listContentLimit
items.addItem(buildStatusRow())
remaining--
// Give each section at most half the remaining space so neither dominates.
val halfRemaining = remaining / 2
favorites.take(halfRemaining).forEach { node ->
items.addItem(buildFavoriteNodeRow(node))
remaining--
}
contacts.take(remaining).forEach { contact ->
items.addItem(buildContactRow(contact))
}
return ListTemplate.Builder()
.setTitle(carContext.getString(R.string.auto_fallback_title))
.setSingleList(items.build())
.build()
}
private fun buildStatusRow(): Row {
val statusText = when (connectionState) {
is ConnectionState.Connected -> carContext.getString(R.string.auto_status_connected)
is ConnectionState.Disconnected -> carContext.getString(R.string.auto_status_disconnected)
is ConnectionState.DeviceSleep -> carContext.getString(R.string.auto_status_sleeping)
is ConnectionState.Connecting -> carContext.getString(R.string.auto_status_connecting)
}
val deviceName = nodeRepository.ourNodeInfo.value?.user?.long_name.orEmpty()
return Row.Builder()
.setTitle(statusText)
.addTextIfNotEmpty(deviceName)
.setBrowsable(false)
.build()
}
/**
* Builds a single favorite-node row.
*
* Mirrors the content of [org.meshtastic.feature.node.component.NodeItem]:
* - Title `long_name` (prominent, matches NodeItem header text)
* - Text 1 online/offline + hop distance (matches NodeItem signal row)
* - Text 2 battery level + short name chip equivalent (matches NodeItem battery row)
*/
private fun buildFavoriteNodeRow(node: Node): Row {
val name = node.user.long_name.ifEmpty { node.user.short_name }
.ifEmpty { carContext.getString(R.string.auto_unknown) }
return Row.Builder()
.setTitle(name)
.addText(
CarScreenDataBuilder.nodeStatusText(
node,
labelOnline = carContext.getString(R.string.auto_node_online),
labelOffline = carContext.getString(R.string.auto_node_offline),
labelDirect = carContext.getString(R.string.auto_node_direct),
labelHops = { carContext.getString(R.string.auto_node_hops, it) },
),
)
.addTextIfNotEmpty(CarScreenDataBuilder.nodeDetailText(node))
.setBrowsable(false)
.build()
}
/**
* Builds a single conversation row.
*
* Mirrors [org.meshtastic.feature.messaging.ui.contact.ContactItem]:
* - **Title** channel or DM display name (matches the bodyLarge name in ContactHeader).
* - **Text 1** last message preview with sender prefix for received DMs, or "No messages
* yet" for empty channel placeholders (matches ChatMetadata's message text).
* - **Text 2** `"N unread"` when there are unread messages, or the last-message timestamp
* when there are none (matches the unread badge and date in ContactHeader/ChatMetadata).
*/
private fun buildContactRow(contact: CarContact): Row =
Row.Builder()
.setTitle(contact.displayName)
.addText(
CarScreenDataBuilder.contactPreviewText(
contact,
noMessagesLabel = carContext.getString(R.string.auto_no_messages),
),
)
.addTextIfNotEmpty(
CarScreenDataBuilder.contactSecondaryText(
contact,
unreadLabel = { carContext.getString(R.string.auto_unread_count, it) },
),
)
.setBrowsable(false)
.build()
private fun carIcon(resId: Int) =
CarIcon.Builder(IconCompat.createWithResource(carContext, resId)).setTint(CarColor.DEFAULT).build()
/** Adds [text] as a new text line only when it is non-empty, avoiding blank Car UI rows. */
private fun Row.Builder.addTextIfNotEmpty(text: String): Row.Builder =
apply { if (text.isNotEmpty()) addText(text) }
/**
* DRY helper: builds a [ListTemplate] from a list of items, capping at [listContentLimit]
* (the per-host limit reported by [ConstraintManager]).
*
* Shows [noItemsMessage] when the list is empty.
*/
private fun <T> buildListTemplate(
title: String,
items: List<T>,
noItemsMessage: String,
buildRow: (T) -> Row,
): ListTemplate {
val listBuilder = ItemList.Builder()
val capped = items.take(listContentLimit)
if (capped.isEmpty()) {
listBuilder.setNoItemsMessage(noItemsMessage)
} else {
capped.forEach { listBuilder.addItem(buildRow(it)) }
}
return ListTemplate.Builder().setTitle(title).setSingleList(listBuilder.build()).build()
}
companion object {
private const val TAB_STATUS = "status"
private const val TAB_FAVORITES = "favorites"
private const val TAB_MESSAGES = "messages"
}
}

View file

@ -0,0 +1,49 @@
/*
* 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.feature.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 {
val screen = MeshtasticCarScreen(carContext)
applyIntent(intent, screen)
return screen
}
/**
* Called by the Android Auto host when the session is re-activated from an existing
* [MessagingStyle][androidx.core.app.NotificationCompat.MessagingStyle] notification tap or a
* launcher shortcut. Switches the root screen to the Messages tab.
*
* The deep-link URI (`meshtastic://meshtastic/messages/<contactKey>`) carries the originating
* contact key, but `androidx.car.app.model.ListTemplate` does not currently expose a
* programmatic scroll API, so we cannot focus a specific conversation row.
*/
override fun onNewIntent(intent: Intent) {
val screen = screenManager.top as? MeshtasticCarScreen ?: return
applyIntent(intent, screen)
}
private fun applyIntent(intent: Intent, screen: MeshtasticCarScreen) {
if (intent.data?.lastPathSegment != null) screen.selectMessagesTab()
}
}

View file

@ -0,0 +1,28 @@
<?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/>.
-->
<!-- Material Design "chat" icon — used for the Channels tab in Android Auto -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@color/auto_icon_color"
android:pathData="M20,2L4,2c-1.1,0 -2,0.9 -2,2v18l4,-4h14c1.1,0 2,-0.9 2,-2L22,4c0,-1.1 -0.9,-2 -2,-2z" />
</vector>

View file

@ -0,0 +1,28 @@
<?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/>.
-->
<!-- Material Design "star" icon — used for the Favorites tab in Android Auto -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@color/auto_icon_color"
android:pathData="M12,17.27L18.18,21l-1.64,-7.03L22,9.24l-7.19,-0.61L12,2 9.19,8.63 2,9.24l5.46,4.73L5.82,21z" />
</vector>

View file

@ -0,0 +1,28 @@
<?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/>.
-->
<!-- Material Design "wifi" icon — used for the Status tab in Android Auto -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@color/auto_icon_color"
android:pathData="M1,9l2,2c4.97,-4.97 13.03,-4.97 18,0l2,-2C16.93,2.93 7.08,2.93 1,9zM9,17l3,3 3,-3c-1.65,-1.66 -4.34,-1.66 -6,0zM5,13l2,2c2.76,-2.76 7.24,-2.76 10,0l2,-2C15.14,9.14 8.87,9.14 5,13z" />
</vector>

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/>.
-->
<resources>
<!-- Default fill color for Car tab icons. The CarIcon tint (CarColor.DEFAULT) overrides
this at runtime; the named constant keeps the vector drawables maintainable. -->
<color name="auto_icon_color">#FFFFFF</color>
</resources>

View file

@ -0,0 +1,53 @@
<?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/>.
-->
<resources>
<!-- Tab titles -->
<string name="auto_tab_status">Status</string>
<string name="auto_tab_favorites">Favorites</string>
<string name="auto_tab_messages">Messages</string>
<!-- Template titles / loading / empty states -->
<string name="auto_loading">Loading\u2026</string>
<string name="auto_fallback_title">Meshtastic</string>
<string name="auto_no_favorites">No favorite nodes</string>
<string name="auto_no_conversations">No conversations</string>
<string name="auto_no_messages">No messages yet</string>
<!-- Connection status -->
<string name="auto_status_connected">Connected</string>
<string name="auto_status_disconnected">Disconnected</string>
<string name="auto_status_sleeping">Device Sleeping</string>
<string name="auto_status_connecting">Connecting\u2026</string>
<!-- Node reachability (used in the Favorites tab rows) -->
<string name="auto_node_online">Online</string>
<string name="auto_node_offline">Offline</string>
<!-- Leading space + bullet + space is intentional — appended directly after Online/Offline -->
<string name="auto_node_direct"> · Direct</string>
<!-- %d is replaced with the hop count at runtime -->
<string name="auto_node_hops"> · %d hops</string>
<!-- Messages tab -->
<!-- %d is replaced with the unread-message count at runtime -->
<string name="auto_unread_count">%d unread</string>
<!-- %d is replaced with the channel index at runtime -->
<string name="auto_channel_number">Channel %d</string>
<!-- Generic fallback label when a node has no name -->
<string name="auto_unknown">Unknown</string>
</resources>

View file

@ -0,0 +1,538 @@
/*
* 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.feature.auto
import io.kotest.matchers.collections.shouldBeEmpty
import io.kotest.matchers.collections.shouldHaveSize
import io.kotest.matchers.shouldBe
import io.kotest.matchers.string.shouldBeEmpty
import io.kotest.matchers.string.shouldContain
import io.kotest.matchers.string.shouldNotContain
import org.meshtastic.core.model.DataPacket
import org.meshtastic.core.model.DeviceMetrics
import org.meshtastic.core.model.Node
import org.meshtastic.proto.ChannelSet
import org.meshtastic.proto.ChannelSettings
import org.meshtastic.proto.User
import kotlin.test.Test
/**
* Unit tests for [CarScreenDataBuilder].
*
* All tests are pure JVM no Android framework or Car App Library dependencies required.
* Time formatters are injected as lambdas returning fixed strings to keep assertions deterministic.
*/
class CarScreenDataBuilderTest {
// ---- buildChannelPlaceholders ----
@Test
fun `buildChannelPlaceholders - empty channelSet returns empty map`() {
val result = CarScreenDataBuilder.buildChannelPlaceholders(ChannelSet())
result.shouldBeEmpty()
}
@Test
fun `buildChannelPlaceholders - single channel produces correct contact key`() {
val channelSet = ChannelSet(settings = listOf(ChannelSettings(name = "LongFast")))
val result = CarScreenDataBuilder.buildChannelPlaceholders(channelSet)
result.keys shouldBe setOf("0${DataPacket.ID_BROADCAST}")
}
@Test
fun `buildChannelPlaceholders - three channels produce three distinct keys`() {
val channelSet = ChannelSet(
settings = listOf(
ChannelSettings(name = "Ch0"),
ChannelSettings(name = "Ch1"),
ChannelSettings(name = "Ch2"),
),
)
val result = CarScreenDataBuilder.buildChannelPlaceholders(channelSet)
result shouldHaveSize 3
result.keys shouldBe setOf("0^all", "1^all", "2^all")
}
@Test
fun `buildChannelPlaceholders - placeholder packets have zero time`() {
val channelSet = ChannelSet(settings = listOf(ChannelSettings(name = "Test")))
val packet = CarScreenDataBuilder.buildChannelPlaceholders(channelSet).values.first()
packet.time shouldBe 0L
packet.to shouldBe DataPacket.ID_BROADCAST
}
// ---- buildCarContacts - display names ----
@Test
fun `buildCarContacts - broadcast contact uses channel name from channelSet`() {
val channelSet = ChannelSet(settings = listOf(ChannelSettings(name = "LongFast")))
val packet = DataPacket(bytes = null, dataType = 1, time = 1000L, channel = 0).apply {
to = DataPacket.ID_BROADCAST
from = DataPacket.ID_LOCAL
}
val contacts = CarScreenDataBuilder.buildCarContacts(
merged = mapOf("0^all" to packet),
myId = "!aabbccdd",
channelSet = channelSet,
resolveUser = { User() },
)
contacts shouldHaveSize 1
contacts[0].displayName shouldBe "LongFast"
contacts[0].isBroadcast shouldBe true
}
@Test
fun `buildCarContacts - broadcast contact uses Channel N fallback when name is empty`() {
val channelSet = ChannelSet(settings = listOf(ChannelSettings(name = "")))
val packet = DataPacket(bytes = null, dataType = 1, time = 1000L, channel = 0).apply {
to = DataPacket.ID_BROADCAST
from = DataPacket.ID_LOCAL
}
val contacts = CarScreenDataBuilder.buildCarContacts(
merged = mapOf("0^all" to packet),
myId = null,
channelSet = channelSet,
resolveUser = { User() },
)
contacts[0].displayName shouldBe "Channel 0"
}
@Test
fun `buildCarContacts - DM contact uses sender long name`() {
val senderUser = User(id = "!sender", long_name = "Alice Tester", short_name = "ALIC")
val packet = DataPacket(bytes = null, dataType = 1, time = 2000L, channel = 0).apply {
to = "!localnode"
from = "!sender"
}
val contacts = CarScreenDataBuilder.buildCarContacts(
merged = mapOf("!sender" to packet),
myId = "!localnode",
channelSet = ChannelSet(),
resolveUser = { if (it == "!sender") senderUser else User() },
)
contacts[0].displayName shouldBe "Alice Tester"
contacts[0].isBroadcast shouldBe false
}
@Test
fun `buildCarContacts - DM contact falls back to short name when long name is blank`() {
val senderUser = User(id = "!sender", long_name = "", short_name = "ALIC")
val packet = DataPacket(bytes = null, dataType = 1, time = 2000L, channel = 0).apply {
to = "!localnode"
from = "!sender"
}
val contacts = CarScreenDataBuilder.buildCarContacts(
merged = mapOf("!sender" to packet),
myId = "!localnode",
channelSet = ChannelSet(),
resolveUser = { senderUser },
)
contacts[0].displayName shouldBe "ALIC"
}
// ---- buildCarContacts - lastMessageText ----
@Test
fun `buildCarContacts - received DM prefixes lastMessageText with sender short name`() {
val senderUser = User(id = "!sender", long_name = "Alice", short_name = "ALIC")
val packet = DataPacket(to = "!me", channel = 0, text = "Hello!")
packet.from = "!sender"
val contacts = CarScreenDataBuilder.buildCarContacts(
merged = mapOf("!sender" to packet),
myId = "!me",
channelSet = ChannelSet(),
resolveUser = { senderUser },
)
contacts[0].lastMessageText shouldBe "ALIC: Hello!"
}
@Test
fun `buildCarContacts - sent DM does not prefix lastMessageText`() {
val recipientUser = User(id = "!bob", long_name = "Bob", short_name = "BOB")
val packet = DataPacket(to = "!bob", channel = 0, text = "Hey Bob")
packet.from = "!me"
val contacts = CarScreenDataBuilder.buildCarContacts(
merged = mapOf("!bob" to packet),
myId = "!me",
channelSet = ChannelSet(),
resolveUser = { recipientUser },
)
// Sent message — no prefix
contacts[0].lastMessageText shouldBe "Hey Bob"
}
@Test
fun `buildCarContacts - null packet text yields null lastMessageText`() {
val packet = DataPacket(bytes = null, dataType = 1, time = 1000L, channel = 0).apply {
to = DataPacket.ID_BROADCAST
from = DataPacket.ID_LOCAL
}
val channelSet = ChannelSet(settings = listOf(ChannelSettings(name = "Ch0")))
val contacts = CarScreenDataBuilder.buildCarContacts(
merged = mapOf("0^all" to packet),
myId = null,
channelSet = channelSet,
resolveUser = { User() },
)
contacts[0].lastMessageText shouldBe null
}
// ---- buildCarContacts - ordering ----
@Test
fun `buildCarContacts - channel contacts appear before DM contacts`() {
val channelSet = ChannelSet(settings = listOf(ChannelSettings(name = "Ch0")))
val channelPacket = DataPacket(bytes = null, dataType = 1, time = 1000L, channel = 0).apply {
to = DataPacket.ID_BROADCAST
from = DataPacket.ID_LOCAL
}
val dmPacket = DataPacket(bytes = null, dataType = 1, time = 2000L, channel = 0).apply {
to = "!me"
from = "!alice"
}
val contacts = CarScreenDataBuilder.buildCarContacts(
merged = mapOf("!alice" to dmPacket, "0^all" to channelPacket),
myId = "!me",
channelSet = channelSet,
resolveUser = { User() },
)
contacts[0].isBroadcast shouldBe true
contacts[1].isBroadcast shouldBe false
}
@Test
fun `buildCarContacts - channels are sorted by channelIndex ascending`() {
val channelSet = ChannelSet(
settings = listOf(
ChannelSettings(name = "Ch0"),
ChannelSettings(name = "Ch1"),
ChannelSettings(name = "Ch2"),
),
)
// Insert in reverse order to verify sorting is applied
val packets = mapOf(
"2^all" to makeChannelPacket(ch = 2),
"0^all" to makeChannelPacket(ch = 0),
"1^all" to makeChannelPacket(ch = 1),
)
val contacts = CarScreenDataBuilder.buildCarContacts(
merged = packets,
myId = null,
channelSet = channelSet,
resolveUser = { User() },
)
contacts.map { it.channelIndex } shouldBe listOf(0, 1, 2)
}
@Test
fun `buildCarContacts - DMs are sorted by lastMessageTime descending`() {
val dmOld = makeDmPacket(from = "!alice", to = "!me", time = 1_000L)
val dmNew = makeDmPacket(from = "!bob", to = "!me", time = 3_000L)
val dmMid = makeDmPacket(from = "!carol", to = "!me", time = 2_000L)
val contacts = CarScreenDataBuilder.buildCarContacts(
merged = mapOf("!alice" to dmOld, "!carol" to dmMid, "!bob" to dmNew),
myId = "!me",
channelSet = ChannelSet(),
resolveUser = { userId ->
when (userId) {
"!alice" -> User(id = "!alice", long_name = "Alice")
"!bob" -> User(id = "!bob", long_name = "Bob")
"!carol" -> User(id = "!carol", long_name = "Carol")
else -> User()
}
},
)
contacts.map { it.displayName } shouldBe listOf("Bob", "Carol", "Alice")
}
// ---- sortFavorites ----
@Test
fun `sortFavorites - excludes non-favorite nodes`() {
val nodes = listOf(
Node(num = 1, user = User(long_name = "Alice"), isFavorite = false),
Node(num = 2, user = User(long_name = "Bob"), isFavorite = true),
)
val result = CarScreenDataBuilder.sortFavorites(nodes)
result shouldHaveSize 1
result[0].user.long_name shouldBe "Bob"
}
@Test
fun `sortFavorites - results are sorted alphabetically by long name`() {
val nodes = listOf(
Node(num = 3, user = User(long_name = "Charlie"), isFavorite = true),
Node(num = 1, user = User(long_name = "Alice"), isFavorite = true),
Node(num = 2, user = User(long_name = "Bob"), isFavorite = true),
)
val result = CarScreenDataBuilder.sortFavorites(nodes)
result.map { it.user.long_name } shouldBe listOf("Alice", "Bob", "Charlie")
}
@Test
fun `sortFavorites - falls back to short name when long name is empty`() {
val nodes = listOf(
Node(num = 2, user = User(long_name = "", short_name = "ZZZ"), isFavorite = true),
Node(num = 1, user = User(long_name = "", short_name = "AAA"), isFavorite = true),
)
val result = CarScreenDataBuilder.sortFavorites(nodes)
result[0].user.short_name shouldBe "AAA"
result[1].user.short_name shouldBe "ZZZ"
}
@Test
fun `sortFavorites - empty collection returns empty list`() {
CarScreenDataBuilder.sortFavorites(emptyList()).shouldBeEmpty()
}
// ---- nodeStatusText ----
@Test
fun `nodeStatusText - online node with hopsAway 0 shows Direct`() {
val node = onlineNode(hopsAway = 0)
val text = CarScreenDataBuilder.nodeStatusText(node)
text shouldContain "Online"
text shouldContain "Direct"
}
@Test
fun `nodeStatusText - online node with 2 hops shows hops count`() {
val node = onlineNode(hopsAway = 2)
val text = CarScreenDataBuilder.nodeStatusText(node)
text shouldContain "Online"
text shouldContain "2 hops"
}
@Test
fun `nodeStatusText - online node with unknown hops shows just Online`() {
val node = onlineNode(hopsAway = -1)
val text = CarScreenDataBuilder.nodeStatusText(node)
text shouldBe "Online"
}
@Test
fun `nodeStatusText - offline node with no lastHeard shows just Offline`() {
val node = Node(num = 1, user = User(long_name = "Test"), isFavorite = true, lastHeard = 0)
val text = CarScreenDataBuilder.nodeStatusText(node)
text shouldBe "Offline"
}
@Test
fun `nodeStatusText - offline node with lastHeard calls formatRelativeTime`() {
val node = Node(num = 1, user = User(long_name = "Test"), isFavorite = true, lastHeard = 12345)
val text = CarScreenDataBuilder.nodeStatusText(node, formatRelativeTime = { "3h ago" })
text shouldBe "Offline · 3h ago"
}
@Test
fun `nodeStatusText - formatRelativeTime receives lastHeard in millis`() {
val lastHeardSecs = 100_000
var receivedMillis = 0L
val node = Node(num = 1, user = User(long_name = "Test"), isFavorite = true, lastHeard = lastHeardSecs)
CarScreenDataBuilder.nodeStatusText(node, formatRelativeTime = { millis ->
receivedMillis = millis
"ago"
})
receivedMillis shouldBe lastHeardSecs * 1000L
}
// ---- nodeDetailText ----
@Test
fun `nodeDetailText - shows short name and battery separated by bullet`() {
val node = Node(
num = 1,
user = User(long_name = "Alice", short_name = "ALIC"),
isFavorite = true,
deviceMetrics = DeviceMetrics(battery_level = 85),
)
val text = CarScreenDataBuilder.nodeDetailText(node)
text shouldContain "ALIC"
text shouldContain "85%"
text shouldContain "·"
}
@Test
fun `nodeDetailText - shows only short name when no battery data`() {
val node = Node(
num = 1,
user = User(long_name = "Alice", short_name = "ALIC"),
isFavorite = true,
)
val text = CarScreenDataBuilder.nodeDetailText(node)
text shouldBe "ALIC"
}
@Test
fun `nodeDetailText - returns empty string when no short name and no battery`() {
val node = Node(num = 1, user = User(long_name = "Alice", short_name = ""), isFavorite = true)
CarScreenDataBuilder.nodeDetailText(node).shouldBeEmpty()
}
@Test
fun `nodeDetailText - shows only battery when short name is blank`() {
val node = Node(
num = 1,
user = User(long_name = "Alice", short_name = ""),
isFavorite = true,
deviceMetrics = DeviceMetrics(battery_level = 72),
)
val text = CarScreenDataBuilder.nodeDetailText(node)
text shouldBe "72%"
text shouldNotContain "·"
}
// ---- contactPreviewText ----
@Test
fun `contactPreviewText - returns lastMessageText when present`() {
val contact = makeCarContact(lastMessageText = "Hello world")
CarScreenDataBuilder.contactPreviewText(contact) shouldBe "Hello world"
}
@Test
fun `contactPreviewText - returns No messages yet when lastMessageText is null`() {
val contact = makeCarContact(lastMessageText = null)
CarScreenDataBuilder.contactPreviewText(contact) shouldBe "No messages yet"
}
@Test
fun `contactPreviewText - returns No messages yet when lastMessageText is empty`() {
val contact = makeCarContact(lastMessageText = "")
CarScreenDataBuilder.contactPreviewText(contact) shouldBe "No messages yet"
}
// ---- contactSecondaryText ----
@Test
fun `contactSecondaryText - shows N unread when unreadCount is positive`() {
val contact = makeCarContact(unreadCount = 5)
CarScreenDataBuilder.contactSecondaryText(contact) shouldBe "5 unread"
}
@Test
fun `contactSecondaryText - calls formatShortDate when no unread but time is set`() {
val contact = makeCarContact(unreadCount = 0, lastMessageTime = 999_999L)
val text = CarScreenDataBuilder.contactSecondaryText(contact, formatShortDate = { "Jan 1" })
text shouldBe "Jan 1"
}
@Test
fun `contactSecondaryText - formatShortDate receives the exact lastMessageTime`() {
val timestamp = 123_456_789L
val contact = makeCarContact(unreadCount = 0, lastMessageTime = timestamp)
var received = 0L
CarScreenDataBuilder.contactSecondaryText(contact, formatShortDate = { millis ->
received = millis
"date"
})
received shouldBe timestamp
}
@Test
fun `contactSecondaryText - returns empty string when no unread and no lastMessageTime`() {
val contact = makeCarContact(unreadCount = 0, lastMessageTime = null)
CarScreenDataBuilder.contactSecondaryText(contact).shouldBeEmpty()
}
@Test
fun `contactSecondaryText - unread takes precedence over lastMessageTime`() {
val contact = makeCarContact(unreadCount = 3, lastMessageTime = 500L)
CarScreenDataBuilder.contactSecondaryText(contact, formatShortDate = { "should not appear" }) shouldBe "3 unread"
}
// ---- helpers ----
/** Returns a node guaranteed to be online (lastHeard == now). */
private fun onlineNode(hopsAway: Int): Node {
val nowSecs = (System.currentTimeMillis() / 1000).toInt()
return Node(
num = 1,
user = User(long_name = "Test"),
isFavorite = true,
lastHeard = nowSecs,
hopsAway = hopsAway,
)
}
private fun makeChannelPacket(ch: Int): DataPacket =
DataPacket(bytes = null, dataType = 1, time = 1000L, channel = ch).apply {
to = DataPacket.ID_BROADCAST
from = DataPacket.ID_LOCAL
}
private fun makeDmPacket(from: String, to: String, time: Long): DataPacket =
DataPacket(bytes = null, dataType = 1, time = time, channel = 0).apply {
this.from = from
this.to = to
}
private fun makeCarContact(
contactKey: String = "!test",
displayName: String = "Test",
unreadCount: Int = 0,
isBroadcast: Boolean = false,
channelIndex: Int = 0,
lastMessageTime: Long? = null,
lastMessageText: String? = null,
) = CarContact(
contactKey = contactKey,
displayName = displayName,
unreadCount = unreadCount,
isBroadcast = isBroadcast,
channelIndex = channelIndex,
lastMessageTime = lastMessageTime,
lastMessageText = lastMessageText,
)
}

View file

@ -1 +1,13 @@
#This file is generated by updateDaemonJvm
toolchainUrl.FREE_BSD.AARCH64=https\://api.foojay.io/disco/v3.0/ids/4945f00643ec68e7c7a6b66f90124f89/redirect
toolchainUrl.FREE_BSD.X86_64=https\://api.foojay.io/disco/v3.0/ids/93aeea858331bd6bb00ba94759830234/redirect
toolchainUrl.LINUX.AARCH64=https\://api.foojay.io/disco/v3.0/ids/4945f00643ec68e7c7a6b66f90124f89/redirect
toolchainUrl.LINUX.X86_64=https\://api.foojay.io/disco/v3.0/ids/93aeea858331bd6bb00ba94759830234/redirect
toolchainUrl.MAC_OS.AARCH64=https\://api.foojay.io/disco/v3.0/ids/3426ffcaa54c3f62406beb1f1ab8b179/redirect
toolchainUrl.MAC_OS.X86_64=https\://api.foojay.io/disco/v3.0/ids/f636c800fdb3f9ae33f019dfa048ba72/redirect
toolchainUrl.UNIX.AARCH64=https\://api.foojay.io/disco/v3.0/ids/4945f00643ec68e7c7a6b66f90124f89/redirect
toolchainUrl.UNIX.X86_64=https\://api.foojay.io/disco/v3.0/ids/93aeea858331bd6bb00ba94759830234/redirect
toolchainUrl.WINDOWS.AARCH64=https\://api.foojay.io/disco/v3.0/ids/1e91f45234d88a64dafb961c93ddc75a/redirect
toolchainUrl.WINDOWS.X86_64=https\://api.foojay.io/disco/v3.0/ids/0ef34dd9312b12d61ba1b8e66126d140/redirect
toolchainVendor=ADOPTIUM
toolchainVersion=21

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"
@ -99,6 +100,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" }

View file

@ -46,6 +46,7 @@ include(
":feature:settings",
":feature:firmware",
":feature:wifi-provision",
":feature:auto",
":feature:widget",
":desktop",
)