mirror of
https://github.com/meshtastic/Meshtastic-Android.git
synced 2026-04-20 22:23:37 +00:00
Merge 7a21d9c7d9 into 68a414b75b
This commit is contained in:
commit
fad92e8f8c
35 changed files with 2383 additions and 69 deletions
|
|
@ -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)
|
||||
|
|
|
|||
8
app/proguard-rules.pro
vendored
8
app/proguard-rules.pro
vendored
|
|
@ -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.**
|
||||
|
|
|
|||
|
|
@ -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) {}
|
||||
|
|
|
|||
|
|
@ -357,7 +357,7 @@ class MeshDataHandlerImpl(
|
|||
read = fromLocal || isFiltered,
|
||||
filtered = isFiltered,
|
||||
)
|
||||
if (!isFiltered) {
|
||||
if (!isFiltered && !fromLocal) {
|
||||
handlePacketNotification(dataPacket, contactKey, updateNotification)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
41
feature/auto/build.gradle.kts
Normal file
41
feature/auto/build.gradle.kts
Normal 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)
|
||||
}
|
||||
41
feature/auto/src/main/AndroidManifest.xml
Normal file
41
feature/auto/src/main/AndroidManifest.xml
Normal 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>
|
||||
|
|
@ -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?,
|
||||
)
|
||||
|
|
@ -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 -> ""
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
|
|
@ -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 1–5 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 1–5 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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
28
feature/auto/src/main/res/drawable/auto_ic_channels.xml
Normal file
28
feature/auto/src/main/res/drawable/auto_ic_channels.xml
Normal 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>
|
||||
28
feature/auto/src/main/res/drawable/auto_ic_favorites.xml
Normal file
28
feature/auto/src/main/res/drawable/auto_ic_favorites.xml
Normal 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>
|
||||
28
feature/auto/src/main/res/drawable/auto_ic_status.xml
Normal file
28
feature/auto/src/main/res/drawable/auto_ic_status.xml
Normal 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>
|
||||
22
feature/auto/src/main/res/values/colors.xml
Normal file
22
feature/auto/src/main/res/values/colors.xml
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
~ Copyright (c) 2026 Meshtastic LLC
|
||||
~
|
||||
~ This program is free software: you can redistribute it and/or modify
|
||||
~ it under the terms of the GNU General Public License as published by
|
||||
~ the Free Software Foundation, either version 3 of the License, or
|
||||
~ (at your option) any later version.
|
||||
~
|
||||
~ This program is distributed in the hope that it will be useful,
|
||||
~ but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
~ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
~ GNU General Public License for more details.
|
||||
~
|
||||
~ You should have received a copy of the GNU General Public License
|
||||
~ along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
-->
|
||||
<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>
|
||||
53
feature/auto/src/main/res/values/strings.xml
Normal file
53
feature/auto/src/main/res/values/strings.xml
Normal 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>
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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" }
|
||||
|
|
|
|||
|
|
@ -46,6 +46,7 @@ include(
|
|||
":feature:settings",
|
||||
":feature:firmware",
|
||||
":feature:wifi-provision",
|
||||
":feature:auto",
|
||||
":feature:widget",
|
||||
":desktop",
|
||||
)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue