fix(car): wire notifications & emergency, fix TabTemplate crash, pin car-app to stable (#5997)

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
James Rich
2026-06-28 18:14:12 -05:00
committed by GitHub
parent a23b557544
commit 5cf433dd26
12 changed files with 312 additions and 223 deletions

View File

@@ -456,6 +456,13 @@ class MeshNotificationManagerImpl(
it.id != SUMMARY_ID && it.notification.group == GROUP_KEY_MESSAGES
}
// No conversations left — drop the summary too, otherwise it lingers in Android Auto after the
// last message notification is cancelled (e.g. on reply / mark-as-read).
if (activeNotifications.isEmpty()) {
notificationManager.cancel(SUMMARY_ID)
return
}
val ourNode = nodeRepository.value.ourNodeInfo.value
val meName = ourNode?.user?.long_name ?: getString(Res.string.you)
val me =
@@ -517,7 +524,11 @@ class MeshNotificationManagerImpl(
notificationManager.notify(clientNotification.toString().hashCode(), notification)
}
override fun cancelMessageNotification(contactKey: String) = notificationManager.cancel(contactKey.hashCode())
override fun cancelMessageNotification(contactKey: String) {
notificationManager.cancel(contactKey.hashCode())
// Rebuild (or clear) the group summary so it doesn't keep showing the dismissed conversation in Android Auto.
showGroupSummary()
}
override fun cancelLowBatteryNotification(node: Node) = notificationManager.cancel(node.num)
@@ -794,6 +805,9 @@ class MeshNotificationManagerImpl(
return NotificationCompat.Action.Builder(android.R.drawable.ic_menu_send, replyLabel, replyPendingIntent)
.addRemoteInput(remoteInput)
// Required for Android Auto to drive reply hands-free without opening any UI.
.setSemanticAction(NotificationCompat.Action.SEMANTIC_ACTION_REPLY)
.setShowsUserInterface(false)
.build()
}
@@ -812,7 +826,11 @@ class MeshNotificationManagerImpl(
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)
// Required for Android Auto to mark a conversation read hands-free without opening any UI.
.setSemanticAction(NotificationCompat.Action.SEMANTIC_ACTION_MARK_AS_READ)
.setShowsUserInterface(false)
.build()
}
private fun createReactionAction(

View File

@@ -20,15 +20,18 @@ import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import androidx.core.app.RemoteInput
import co.touchlab.kermit.Logger
import kotlinx.coroutines.CoroutineScope
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.model.ContactKey
import org.meshtastic.core.model.DataPacket
import org.meshtastic.core.repository.MeshNotificationManager
import org.meshtastic.core.repository.PacketRepository
import org.meshtastic.core.repository.RadioController
/**
@@ -45,31 +48,46 @@ class ReplyReceiver :
private val meshServiceNotifications: MeshNotificationManager by inject()
private val packetRepository: PacketRepository by inject()
private val dispatchers: CoroutineDispatchers by inject()
private val scope by lazy { CoroutineScope(dispatchers.io + SupervisorJob()) }
companion object {
private const val TAG = "ReplyReceiver"
const val REPLY_ACTION = "org.meshtastic.app.REPLY_ACTION"
const val CONTACT_KEY = "contactKey"
const val KEY_TEXT_REPLY = "key_text_reply"
}
@Suppress("TooGenericExceptionCaught") // a reply must never crash the receiver, whatever the radio throws
override fun onReceive(context: Context, intent: Intent) {
val remoteInput = RemoteInput.getResultsFromIntent(intent)
if (remoteInput == null) {
Logger.w(tag = TAG) { "reply received but RemoteInput was null" }
return
}
if (remoteInput != null) {
val contactKey = intent.getStringExtra(CONTACT_KEY).orEmpty()
val message = remoteInput.getCharSequence(KEY_TEXT_REPLY)?.toString().orEmpty()
val contactKey = intent.getStringExtra(CONTACT_KEY).orEmpty()
val message = remoteInput.getCharSequence(KEY_TEXT_REPLY)?.toString().orEmpty()
Logger.i(tag = TAG) { "reply for contactKey=$contactKey len=${message.length}" }
val pendingResult = goAsync()
scope.launch {
try {
sendMessage(message, contactKey)
meshServiceNotifications.cancelMessageNotification(contactKey)
} finally {
pendingResult.finish()
}
val pendingResult = goAsync()
scope.launch {
try {
// Send first so the reply isn't lost; then dismiss. The cancel can't break the send.
sendMessage(message, contactKey)
// Replying implies the conversation has been read — mark it so, like the mark-as-read action.
// Android Auto keys notification dismissal off read state, not just cancel().
packetRepository.clearUnreadCount(contactKey, nowMillis)
Logger.i(tag = TAG) { "reply sent + marked read" }
} catch (e: Exception) {
Logger.e(tag = TAG, throwable = e) { "reply send failed" }
} finally {
runCatching { meshServiceNotifications.cancelMessageNotification(contactKey) }
.onFailure { Logger.e(tag = TAG, throwable = it) { "cancel notification failed" } }
pendingResult.finish()
}
}
}

49
feature/car/TESTING.md Normal file
View File

@@ -0,0 +1,49 @@
# Testing the Car (Android Auto) feature
Two ways to verify `feature:car`. Use the unit tests for correctness; use the DHU only when you
want to eyeball the real in-car experience end to end.
## 1. Automated — `SessionController` tests (no hardware, runs in CI)
```bash
./gradlew :feature:car:testGoogleDebugUnitTest
```
`CarScreensTest` drives the real `HomeScreen` through the androidx.car.app testing context, pushing
state via the `CarStateCoordinator` test seam (`setStateForTest`), and asserts the right template
renders per connection state plus the `ALERT_APP` → emergency flow. This is what catches regressions
(e.g. it caught the `TabTemplate.setActiveTabContentId` crash the 1.7.0 pin introduced). Robolectric
is pinned to `@Config(sdk = [36])` because no Robolectric SDK-37 sandbox exists yet — harmless, the
templates are SDK-agnostic.
## 2. Visual — Desktop Head Unit (DHU)
### Platform matters
DHU decodes an H.264 video stream from the phone. **macOS Apple Silicon (`2.1-mac-arm64`) fails at
this step** — it connects and reports `Has video focus: true` but renders no frames. Use an
**x86_64 Linux (or Windows) host**; `linux-arm64` DHU does not exist, and an emulated x86 VM has no
hardware decode (same failure). A VM is fine **only on an x86_64 host with real virtualization**.
### Phone prep (one time)
1. Settings → About → tap *Build number* ×7 → enable **USB debugging** and **Wireless debugging**.
2. Open Android Auto settings (on a Pixel: `adb shell am start -n com.google.android.projection.gearhead/.companion.settings.DefaultSettingsActivity`),
tap *Version* ~10× to unlock **Developer settings**, then enable **Start head unit server**.
### On the x86_64 box
```bash
sdkmanager "extras;google;auto" # installs the linux-x86_64 DHU
# Connect to the phone over wifi (no re-plugging; works even if the phone lives on another machine):
adb pair <phone-ip>:<pair-port> # one-time; code shown under Wireless debugging
adb connect <phone-ip>:<debug-port>
adb forward tcp:5277 tcp:5277
cd "$ANDROID_SDK_ROOT/extras/google/auto" && ./desktop-head-unit
```
DHU commands: type `help` in its shell; `focus video`, `tap <x> <y>`, `screenshot <file>`.
### Caveat: our app has no car-launcher tile
`feature:car` is a **MESSAGING**-category app. It does not appear as an icon on the Android Auto
home — it surfaces when a **message notification** arrives. To see the `ConversationItem` UI in the
DHU you must have a **paired Meshtastic radio sending a text**; the notification (read-aloud + reply)
is the entry point. Without a radio you'll see the AA home, not our screens. (For the same reason,
the AAOS emulator is not a fit either: messaging isn't an AAOS distribution category, and the app is
projection-only — no `CarAppActivity`.)

View File

@@ -19,6 +19,7 @@ plugins {
alias(libs.plugins.meshtastic.android.library)
alias(libs.plugins.meshtastic.android.library.flavors)
id("meshtastic.koin")
id("dev.mokkery")
}
android {
@@ -30,6 +31,9 @@ android {
minSdk = 23
consumerProguardFiles("proguard-rules.pro")
}
// Robolectric provides the Android context that androidx.car.app TestCarContext/ScreenController need.
testOptions { unitTests { isIncludeAndroidResources = true } }
}
dependencies {
@@ -52,6 +56,7 @@ dependencies {
testImplementation(libs.androidx.car.app.testing)
testImplementation(libs.koin.test)
testImplementation(libs.robolectric)
testImplementation(kotlin("test-junit"))
testRuntimeOnly(libs.junit.vintage.engine)
}

View File

@@ -12,12 +12,14 @@
</intent-filter>
</service>
<receiver
android:name="org.meshtastic.feature.car.service.CarReplyReceiver"
android:exported="false" />
<meta-data
android:name="androidx.car.app.minCarApiLevel"
android:value="7" />
<!-- Required for Android Auto to surface MessagingStyle notifications and recognize the
template app. Without this the automotive_app_desc declarations never reach the host. -->
<meta-data
android:name="com.google.android.gms.car.application"
android:resource="@xml/automotive_app_desc" />
</application>
</manifest>

View File

@@ -188,6 +188,7 @@ class HomeScreen(
addTab(messagingTab)
addTab(nodesTab)
addTab(statusTab)
setActiveTabContentId(selectedTabId)
setTabContents(getTabContents())
}
.build()

View File

@@ -1,121 +0,0 @@
/*
* 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.car.service
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.os.Build
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import androidx.core.app.Person
import androidx.core.app.RemoteInput
import androidx.core.content.LocusIdCompat
import org.koin.core.annotation.Single
import org.meshtastic.feature.car.R
@Single
class CarNotificationManager(private val context: Context, private val shortcutManager: ConversationShortcutManager) {
init {
createNotificationChannel()
}
private fun createNotificationChannel() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val channel =
NotificationChannel(CHANNEL_ID, "Mesh Messages", NotificationManager.IMPORTANCE_HIGH).apply {
description = "Messages from Meshtastic mesh network"
}
val manager = context.getSystemService(NotificationManager::class.java)
manager.createNotificationChannel(channel)
}
}
fun postMessagingNotification(conversationId: String, senderName: String, messages: List<Pair<String, Long>>) {
shortcutManager.ensureConversationShortcut(conversationId, senderName)
val person = Person.Builder().setName(senderName).build()
val messagingStyle = NotificationCompat.MessagingStyle(Person.Builder().setName("Me").build())
messagingStyle.setConversationTitle(senderName)
messages.forEach { (text, timestamp) -> messagingStyle.addMessage(text, timestamp, person) }
val replyAction = buildReplyAction(conversationId)
val markReadAction = buildMarkReadAction(conversationId)
val notification =
NotificationCompat.Builder(context, CHANNEL_ID)
.setSmallIcon(R.drawable.ic_car_meshtastic)
.setStyle(messagingStyle)
.addAction(replyAction)
.addAction(markReadAction)
.setCategory(NotificationCompat.CATEGORY_MESSAGE)
.setShortcutId(conversationId)
.setLocusId(LocusIdCompat(conversationId))
.build()
NotificationManagerCompat.from(context).notify(conversationId.hashCode(), notification)
}
private fun buildReplyAction(conversationId: String): NotificationCompat.Action {
val remoteInput = RemoteInput.Builder(KEY_TEXT_REPLY).setLabel("Reply").build()
val replyIntent =
PendingIntent.getBroadcast(
context,
conversationId.hashCode(),
Intent(context, CarReplyReceiver::class.java)
.setAction(ACTION_REPLY)
.putExtra(EXTRA_CONVERSATION_ID, conversationId),
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_MUTABLE,
)
return NotificationCompat.Action.Builder(android.R.drawable.ic_menu_send, "Reply", replyIntent)
.addRemoteInput(remoteInput)
.setSemanticAction(NotificationCompat.Action.SEMANTIC_ACTION_REPLY)
.setShowsUserInterface(false)
.build()
}
private fun buildMarkReadAction(conversationId: String): NotificationCompat.Action {
val markReadIntent =
PendingIntent.getBroadcast(
context,
conversationId.hashCode() + 1,
Intent(context, CarReplyReceiver::class.java)
.setAction(ACTION_MARK_READ)
.putExtra(EXTRA_CONVERSATION_ID, conversationId),
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)
return NotificationCompat.Action.Builder(android.R.drawable.ic_menu_view, "Mark as Read", markReadIntent)
.setSemanticAction(NotificationCompat.Action.SEMANTIC_ACTION_MARK_AS_READ)
.setShowsUserInterface(false)
.build()
}
companion object {
const val CHANNEL_ID = "meshtastic_car_messages"
const val KEY_TEXT_REPLY = "key_text_reply"
const val ACTION_REPLY = "org.meshtastic.feature.car.REPLY"
const val ACTION_MARK_READ = "org.meshtastic.feature.car.MARK_READ"
const val EXTRA_CONVERSATION_ID = "conversation_id"
}
}

View File

@@ -1,79 +0,0 @@
/*
* 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.car.service
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import androidx.core.app.RemoteInput
import co.touchlab.kermit.Logger
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
import org.meshtastic.core.repository.PacketRepository
import org.meshtastic.core.repository.usecase.SendMessageUseCase
/**
* Handles inline reply and mark-read actions from car messaging notifications. Uses [goAsync] to keep the receiver
* alive while the coroutine completes, preventing premature process kill.
*/
class CarReplyReceiver :
BroadcastReceiver(),
KoinComponent {
private val sendMessageUseCase: SendMessageUseCase by inject()
private val packetRepository: PacketRepository by inject()
override fun onReceive(context: Context, intent: Intent) {
val pendingResult = goAsync()
val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
scope.launch {
try {
when (intent.action) {
CarNotificationManager.ACTION_REPLY -> handleReply(intent)
CarNotificationManager.ACTION_MARK_READ -> handleMarkRead(intent)
}
} finally {
pendingResult.finish()
}
}
}
private suspend fun handleReply(intent: Intent) {
val conversationId = intent.getStringExtra(CarNotificationManager.EXTRA_CONVERSATION_ID) ?: return
val remoteInput = RemoteInput.getResultsFromIntent(intent)
val replyText = remoteInput?.getCharSequence(CarNotificationManager.KEY_TEXT_REPLY)?.toString() ?: return
Logger.d(tag = TAG) { "Reply to conversation: $conversationId (${replyText.length} chars)" }
runCatching { sendMessageUseCase(replyText, conversationId) }
.onFailure { error -> Logger.e(tag = TAG, throwable = error) { "Failed to send reply" } }
}
private suspend fun handleMarkRead(intent: Intent) {
val conversationId = intent.getStringExtra(CarNotificationManager.EXTRA_CONVERSATION_ID) ?: return
Logger.d(tag = TAG) { "Mark read: $conversationId" }
runCatching { packetRepository.clearUnreadCount(conversationId, System.currentTimeMillis()) }
.onFailure { error -> Logger.e(tag = TAG, throwable = error) { "Failed to mark as read" } }
}
companion object {
private const val TAG = "CarReplyReceiver"
}
}

View File

@@ -16,6 +16,7 @@
*/
package org.meshtastic.feature.car.service
import androidx.annotation.VisibleForTesting
import co.touchlab.kermit.Logger
import kotlinx.coroutines.CoroutineExceptionHandler
import kotlinx.coroutines.CoroutineScope
@@ -27,10 +28,14 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChangedBy
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.mapNotNull
import kotlinx.coroutines.launch
import org.koin.core.annotation.Factory
import org.meshtastic.core.model.Channel
import org.meshtastic.core.model.ConnectionState
import org.meshtastic.core.model.DataPacket
import org.meshtastic.core.repository.NodeRepository
import org.meshtastic.core.repository.PacketRepository
import org.meshtastic.core.repository.RadioConfigRepository
@@ -40,11 +45,13 @@ import org.meshtastic.feature.car.model.CarLocalStats
import org.meshtastic.feature.car.model.CarSessionState
import org.meshtastic.feature.car.model.ChannelUi
import org.meshtastic.feature.car.model.ConversationUi
import org.meshtastic.feature.car.model.EmergencyAlert
import org.meshtastic.feature.car.model.MessagingUiState
import org.meshtastic.feature.car.model.NodeDashboardUiState
import org.meshtastic.feature.car.model.TopologyHeader
import org.meshtastic.feature.car.util.CarScreenDataBuilder
import org.meshtastic.feature.car.util.MessageFilter
import org.meshtastic.proto.PortNum
/** Snapshot of a message for car display (avoids leaking domain models to UI). */
data class MessageSnapshot(
@@ -106,6 +113,46 @@ class CarStateCoordinator(
private val _localStatsState = MutableStateFlow(CarLocalStats())
val localStatsState: StateFlow<CarLocalStats> = _localStatsState.asStateFlow()
/** Test seam: push presentation state directly without driving the repositories (see CarScreensTest). */
@VisibleForTesting
internal fun setStateForTest(
session: CarSessionState = _sessionState.value,
messaging: MessagingUiState = _messagingState.value,
nodes: NodeDashboardUiState = _nodeDashboardState.value,
stats: CarLocalStats = _localStatsState.value,
) {
_sessionState.value = session
_messagingState.value = messaging
_nodeDashboardState.value = nodes
_localStatsState.value = stats
}
/**
* Emits an [EmergencyAlert] for each new incoming ALERT_APP packet. Sourced from the contacts flow (last packet per
* contact), deduped by packet id so the same alert isn't re-raised. ponytail: an alert immediately superseded by a
* newer message in the same contact within one emission can be missed — acceptable for the in-car overlay; the core
* alert notification still fires regardless.
*/
val emergencyAlerts: kotlinx.coroutines.flow.Flow<EmergencyAlert> =
packetRepository
.getContacts()
.mapNotNull { contacts ->
contacts.values.filter { it.dataType == PortNum.ALERT_APP.value }.maxByOrNull { it.time }
}
.distinctUntilChangedBy { it.id }
.map { it.toEmergencyAlert() }
private fun DataPacket.toEmergencyAlert(): EmergencyAlert {
val entry = nodeRepository.nodeDBbyNum.value.entries.find { it.value.user.id == from }
return EmergencyAlert(
nodeNum = entry?.key ?: 0,
nodeName = entry?.value?.user?.long_name ?: from ?: "Unknown",
message = alert ?: "",
timestamp = time,
isActive = true,
)
}
private val selectedChannel = MutableStateFlow(0)
fun start() {
@@ -186,7 +233,7 @@ class CarStateCoordinator(
) { nodeMap, onlineCount, localConfig ->
val nodes = CarScreenDataBuilder.sortNodes(nodeMap.values, localConfig.lora?.modem_preset)
val totalCount = nodeMap.size
val meshName = nodeRepository.myNodeInfo.value?.firmwareVersion
val meshName = nodeRepository.ourNodeInfo.value?.user?.long_name
_nodeDashboardState.value =
NodeDashboardUiState(

View File

@@ -26,7 +26,6 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.emptyFlow
import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
import org.meshtastic.feature.car.alerts.EmergencyHandler
@@ -47,8 +46,7 @@ class MeshtasticCarSession :
crashlyticsCarTagger.setCarSession(true)
stateCoordinator.start()
conversationShortcutManager.startObserving(sessionScope)
// Emergency flow wired to emptyFlow() until emergency packet detection is implemented
emergencyHandler.startCollecting(emptyFlow())
emergencyHandler.startCollecting(stateCoordinator.emergencyAlerts)
lifecycle.addObserver(
object : DefaultLifecycleObserver {

View File

@@ -0,0 +1,152 @@
/*
* 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.car
import androidx.car.app.model.PaneTemplate
import androidx.car.app.model.TabTemplate
import androidx.car.app.testing.TestCarContext
import dev.mokkery.MockMode
import dev.mokkery.answering.returns
import dev.mokkery.every
import dev.mokkery.mock
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.runBlocking
import okio.ByteString.Companion.encodeUtf8
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
import org.meshtastic.core.model.ConnectionState
import org.meshtastic.core.model.DataPacket
import org.meshtastic.core.model.Node
import org.meshtastic.core.repository.NodeRepository
import org.meshtastic.core.repository.PacketRepository
import org.meshtastic.core.repository.RadioConfigRepository
import org.meshtastic.core.repository.ServiceRepository
import org.meshtastic.core.repository.usecase.SendMessageUseCase
import org.meshtastic.feature.car.alerts.EmergencyHandler
import org.meshtastic.feature.car.model.CarSessionState
import org.meshtastic.feature.car.model.ChannelUi
import org.meshtastic.feature.car.model.MessagingUiState
import org.meshtastic.feature.car.screens.HomeScreen
import org.meshtastic.feature.car.service.CarStateCoordinator
import org.meshtastic.feature.car.util.MessageFilter
import org.meshtastic.proto.PortNum
import org.robolectric.RobolectricTestRunner
import org.robolectric.RuntimeEnvironment
import org.robolectric.annotation.Config
/**
* Drives the real [HomeScreen] (via the androidx.car.app testing context) with state pushed through the
* [CarStateCoordinator] test seam, asserting the correct template renders per connection state, and verifies the
* emergency flow turns an ALERT_APP packet into an [org.meshtastic.feature.car.model.EmergencyAlert].
*/
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [36]) // Robolectric has no SDK 37 jar yet; the car templates are SDK-agnostic.
class CarScreensTest {
private val packetRepo = mock<PacketRepository>(MockMode.autofill)
private val nodeRepo = mock<NodeRepository>(MockMode.autofill)
private fun coordinator(): CarStateCoordinator {
every { packetRepo.getContacts() } returns flowOf(emptyMap())
every { nodeRepo.nodeDBbyNum } returns MutableStateFlow(emptyMap<Int, Node>())
return CarStateCoordinator(
nodeRepository = nodeRepo,
packetRepository = packetRepo,
serviceRepository = mock<ServiceRepository>(MockMode.autofill),
radioConfigRepository = mock<RadioConfigRepository>(MockMode.autofill),
sendMessageUseCase = mock<SendMessageUseCase>(MockMode.autofill),
messageFilter = MessageFilter(),
)
}
private fun homeScreen(coord: CarStateCoordinator): HomeScreen {
val ctx = TestCarContext.createCarContext(RuntimeEnvironment.getApplication())
return HomeScreen(ctx, coord, EmergencyHandler())
}
private fun session(state: ConnectionState) = CarSessionState(
connectionStatus = state,
onlineNodeCount = 0,
lastMessageTime = null,
activeEmergencies = emptyList(),
meshName = "Test Mesh",
)
@Test
fun `disconnected renders a pane template`() {
val coord = coordinator().apply { setStateForTest(session = session(ConnectionState.Disconnected)) }
assertTrue(homeScreen(coord).onGetTemplate() is PaneTemplate)
}
@Test
fun `connected with no channels renders the onboarding pane`() {
val coord =
coordinator().apply {
setStateForTest(
session = session(ConnectionState.Connected),
messaging = MessagingUiState(emptyList(), 0, emptyList(), null),
)
}
assertTrue(homeScreen(coord).onGetTemplate() is PaneTemplate)
}
@Test
fun `connected with channels renders the tab template`() {
val coord =
coordinator().apply {
setStateForTest(
session = session(ConnectionState.Connected),
messaging = MessagingUiState(listOf(ChannelUi(0, "LongFast", 0)), 0, emptyList(), null),
)
}
assertTrue(homeScreen(coord).onGetTemplate() is TabTemplate)
}
@Test
fun `ALERT_APP packet becomes an emergency alert`() = runBlocking {
every { nodeRepo.nodeDBbyNum } returns MutableStateFlow(emptyMap<Int, Node>())
val alert =
DataPacket(
to = null,
bytes = "HELP".encodeUtf8(),
dataType = PortNum.ALERT_APP.value,
from = "!abcd1234",
time = 100L,
id = 7,
)
every { packetRepo.getContacts() } returns flowOf(mapOf("k" to alert))
val coord =
CarStateCoordinator(
nodeRepository = nodeRepo,
packetRepository = packetRepo,
serviceRepository = mock<ServiceRepository>(MockMode.autofill),
radioConfigRepository = mock<RadioConfigRepository>(MockMode.autofill),
sendMessageUseCase = mock<SendMessageUseCase>(MockMode.autofill),
messageFilter = MessageFilter(),
)
val emergency = coord.emergencyAlerts.first()
assertEquals("HELP", emergency.message)
assertEquals("!abcd1234", emergency.nodeName)
assertTrue(emergency.isActive)
}
}

View File

@@ -4,7 +4,7 @@ xmlutil = "0.91.3"
# Android
agp = "9.2.1"
appcompat = "1.7.1"
car-app = "1.9.0-alpha01"
car-app = "1.7.0"
appfunctions = "1.0.0-alpha09"
# androidx
@@ -121,7 +121,6 @@ androidx-camera-compose = { module = "androidx.camera:camera-compose", version.r
androidx-camera-viewfinder-compose = { module = "androidx.camera.viewfinder:viewfinder-compose", version = "1.6.1" }
androidx-car-app = { module = "androidx.car.app:app", version.ref = "car-app" }
androidx-car-app-projected = { module = "androidx.car.app:app-projected", version.ref = "car-app" }
androidx-car-app-automotive = { module = "androidx.car.app:app-automotive", version.ref = "car-app" }
androidx-car-app-testing = { module = "androidx.car.app:app-testing", version.ref = "car-app" }
androidx-core-ktx = { module = "androidx.core:core-ktx", version = "1.19.0" }
androidx-core-location-altitude = { module = "androidx.core:core-location-altitude", version = "1.0.0" }