diff --git a/app/build.gradle.kts b/app/build.gradle.kts index d239d0530..74e926098 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -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) diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro index de2b3144c..b1fbcdace 100644 --- a/app/proguard-rules.pro +++ b/app/proguard-rules.pro @@ -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 { (); } +-keep class org.meshtastic.feature.auto.MeshtasticCarSession { (); } + # ---- Networking (transitive references from Ktor on Android) ---------------- -dontwarn org.conscrypt.** diff --git a/app/src/test/kotlin/org/meshtastic/app/service/Fakes.kt b/app/src/test/kotlin/org/meshtastic/app/service/Fakes.kt index 37c19f477..05562bb9d 100644 --- a/app/src/test/kotlin/org/meshtastic/app/service/Fakes.kt +++ b/app/src/test/kotlin/org/meshtastic/app/service/Fakes.kt @@ -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) {} diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshDataHandlerImpl.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshDataHandlerImpl.kt index 384f722d8..8b0ee1529 100644 --- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshDataHandlerImpl.kt +++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshDataHandlerImpl.kt @@ -357,7 +357,7 @@ class MeshDataHandlerImpl( read = fromLocal || isFiltered, filtered = isFiltered, ) - if (!isFiltered) { + if (!isFiltered && !fromLocal) { handlePacketNotification(dataPacket, contactKey, updateNotification) } } diff --git a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/MeshServiceNotifications.kt b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/MeshServiceNotifications.kt index a68157943..b73b44ab0 100644 --- a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/MeshServiceNotifications.kt +++ b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/MeshServiceNotifications.kt @@ -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) diff --git a/core/service/src/androidHostTest/kotlin/org/meshtastic/core/service/MarkAsReadReceiverTest.kt b/core/service/src/androidHostTest/kotlin/org/meshtastic/core/service/MarkAsReadReceiverTest.kt new file mode 100644 index 000000000..b5e76ed33 --- /dev/null +++ b/core/service/src/androidHostTest/kotlin/org/meshtastic/core/service/MarkAsReadReceiverTest.kt @@ -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 . + */ +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 { 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) + } +} diff --git a/core/service/src/androidHostTest/kotlin/org/meshtastic/core/service/MeshServiceNotificationsImplTest.kt b/core/service/src/androidHostTest/kotlin/org/meshtastic/core/service/MeshServiceNotificationsImplTest.kt index a4a3b0fe3..de3f4e4b6 100644 --- a/core/service/src/androidHostTest/kotlin/org/meshtastic/core/service/MeshServiceNotificationsImplTest.kt +++ b/core/service/src/androidHostTest/kotlin/org/meshtastic/core/service/MeshServiceNotificationsImplTest.kt @@ -59,6 +59,7 @@ class MeshServiceNotificationsImplTest { context = context, packetRepository = lazy { error("Not used in this test") }, nodeRepository = lazy { error("Not used in this test") }, + shortcutManager = lazy { error("Not used in this test") }, ) notifications.initChannels() diff --git a/core/service/src/androidHostTest/kotlin/org/meshtastic/core/service/ReactionReceiverTest.kt b/core/service/src/androidHostTest/kotlin/org/meshtastic/core/service/ReactionReceiverTest.kt new file mode 100644 index 000000000..30260ee25 --- /dev/null +++ b/core/service/src/androidHostTest/kotlin/org/meshtastic/core/service/ReactionReceiverTest.kt @@ -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 . + */ +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 } + single { 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(MockMode.autofill) + everySuspend { throwingRepo.onServiceAction(any()) } calls { throw IllegalStateException("boom") } + stopKoin() + val dispatcher = UnconfinedTestDispatcher() + startKoin { + modules( + module { + single { throwingRepo } + single { 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) + } +} diff --git a/core/service/src/androidHostTest/kotlin/org/meshtastic/core/service/ReplyReceiverTest.kt b/core/service/src/androidHostTest/kotlin/org/meshtastic/core/service/ReplyReceiverTest.kt new file mode 100644 index 000000000..2cd651541 --- /dev/null +++ b/core/service/src/androidHostTest/kotlin/org/meshtastic/core/service/ReplyReceiverTest.kt @@ -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 . + */ +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() + + @Before + fun setUp() { + context = ApplicationProvider.getApplicationContext() + radioController = RecordingRadioController(callLog) + notifications = RecordingNotifications(callLog) + val dispatcher = UnconfinedTestDispatcher() + startKoin { + modules( + module { + single { radioController } + single { 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) : FakeRadioController() { + override suspend fun sendMessage(packet: org.meshtastic.core.model.DataPacket) { + callLog.add("send") + super.sendMessage(packet) + } +} + +internal class RecordingNotifications(private val callLog: MutableList) : + org.meshtastic.core.testing.FakeMeshServiceNotifications() { + val appendCalls = mutableListOf>() + val markReadCalls = mutableListOf() + + 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) + } +} diff --git a/core/service/src/androidMain/kotlin/org/meshtastic/core/service/ConversationShortcutManager.kt b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/ConversationShortcutManager.kt new file mode 100644 index 000000000..d6393a4e8 --- /dev/null +++ b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/ConversationShortcutManager.kt @@ -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 . + */ +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, channels: List>) { + 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) +} diff --git a/core/service/src/androidMain/kotlin/org/meshtastic/core/service/MarkAsReadReceiver.kt b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/MarkAsReadReceiver.kt index 36c26c879..8eb3af994 100644 --- a/core/service/src/androidMain/kotlin/org/meshtastic/core/service/MarkAsReadReceiver.kt +++ b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/MarkAsReadReceiver.kt @@ -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() } diff --git a/core/service/src/androidMain/kotlin/org/meshtastic/core/service/MeshService.kt b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/MeshService.kt index 5869ce94f..155d87839 100644 --- a/core/service/src/androidMain/kotlin/org/meshtastic/core/service/MeshService.kt +++ b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/MeshService.kt @@ -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() diff --git a/core/service/src/androidMain/kotlin/org/meshtastic/core/service/MeshServiceNotificationsImpl.kt b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/MeshServiceNotificationsImpl.kt index 211e3b9c4..0e390ed0a 100644 --- a/core/service/src/androidMain/kotlin/org/meshtastic/core/service/MeshServiceNotificationsImpl.kt +++ b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/MeshServiceNotificationsImpl.kt @@ -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, private val nodeRepository: Lazy, + private val shortcutManager: Lazy, ) : MeshServiceNotifications { private val notificationManager = context.getSystemService()!! @@ -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, 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 diff --git a/core/service/src/androidMain/kotlin/org/meshtastic/core/service/PersonIconFactory.kt b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/PersonIconFactory.kt new file mode 100644 index 000000000..84a015d20 --- /dev/null +++ b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/PersonIconFactory.kt @@ -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 . + */ +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) + } +} diff --git a/core/service/src/androidMain/kotlin/org/meshtastic/core/service/ReactionReceiver.kt b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/ReactionReceiver.kt index f4db74403..304dff076 100644 --- a/core/service/src/androidMain/kotlin/org/meshtastic/core/service/ReactionReceiver.kt +++ b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/ReactionReceiver.kt @@ -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 { diff --git a/core/service/src/androidMain/kotlin/org/meshtastic/core/service/ReplyReceiver.kt b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/ReplyReceiver.kt index d7a943783..b00c3c255 100644 --- a/core/service/src/androidMain/kotlin/org/meshtastic/core/service/ReplyReceiver.kt +++ b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/ReplyReceiver.kt @@ -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() } diff --git a/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeMeshServiceNotifications.kt b/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeMeshServiceNotifications.kt index 4f0a4b153..fef69fdc7 100644 --- a/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeMeshServiceNotifications.kt +++ b/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeMeshServiceNotifications.kt @@ -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) {} diff --git a/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeRadioController.kt b/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeRadioController.kt index d23a7f1ec..9d2e490f5 100644 --- a/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeRadioController.kt +++ b/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeRadioController.kt @@ -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 { diff --git a/desktop/src/main/kotlin/org/meshtastic/desktop/notification/DesktopMeshServiceNotifications.kt b/desktop/src/main/kotlin/org/meshtastic/desktop/notification/DesktopMeshServiceNotifications.kt index 4cda00251..f2ad6ca3e 100644 --- a/desktop/src/main/kotlin/org/meshtastic/desktop/notification/DesktopMeshServiceNotifications.kt +++ b/desktop/src/main/kotlin/org/meshtastic/desktop/notification/DesktopMeshServiceNotifications.kt @@ -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) } diff --git a/feature/auto/build.gradle.kts b/feature/auto/build.gradle.kts new file mode 100644 index 000000000..c9da00795 --- /dev/null +++ b/feature/auto/build.gradle.kts @@ -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 . + */ + +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) +} diff --git a/feature/auto/src/main/AndroidManifest.xml b/feature/auto/src/main/AndroidManifest.xml new file mode 100644 index 000000000..e9fbb05ec --- /dev/null +++ b/feature/auto/src/main/AndroidManifest.xml @@ -0,0 +1,41 @@ + + + + + + + + + + + + + + + + + + diff --git a/feature/auto/src/main/kotlin/org/meshtastic/feature/auto/CarContact.kt b/feature/auto/src/main/kotlin/org/meshtastic/feature/auto/CarContact.kt new file mode 100644 index 000000000..7c9413c4f --- /dev/null +++ b/feature/auto/src/main/kotlin/org/meshtastic/feature/auto/CarContact.kt @@ -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 . + */ +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?, +) diff --git a/feature/auto/src/main/kotlin/org/meshtastic/feature/auto/CarScreenDataBuilder.kt b/feature/auto/src/main/kotlin/org/meshtastic/feature/auto/CarScreenDataBuilder.kt new file mode 100644 index 000000000..49e701ff0 --- /dev/null +++ b/feature/auto/src/main/kotlin/org/meshtastic/feature/auto/CarScreenDataBuilder.kt @@ -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 . + */ +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 `"^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 = + (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, + myId: String?, + channelSet: ChannelSet, + resolveUser: (String) -> User, + channelLabel: (Int) -> String = { "Channel $it" }, + unknownLabel: String = "Unknown", + ): List { + 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): List = + 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 ยท