From 5cf433dd26b10d5047defaa3eafff8f189176f7f Mon Sep 17 00:00:00 2001 From: James Rich <2199651+jamesarich@users.noreply.github.com> Date: Sun, 28 Jun 2026 18:14:12 -0500 Subject: [PATCH] fix(car): wire notifications & emergency, fix TabTemplate crash, pin car-app to stable (#5997) Co-authored-by: Claude Opus 4.8 --- .../service/MeshNotificationManagerImpl.kt | 22 ++- .../meshtastic/core/service/ReplyReceiver.kt | 40 +++-- feature/car/TESTING.md | 49 ++++++ feature/car/build.gradle.kts | 5 + feature/car/src/main/AndroidManifest.xml | 10 +- .../feature/car/screens/HomeScreen.kt | 1 + .../car/service/CarNotificationManager.kt | 121 -------------- .../feature/car/service/CarReplyReceiver.kt | 79 --------- .../car/service/CarStateCoordinator.kt | 49 +++++- .../car/service/MeshtasticCarSession.kt | 4 +- .../meshtastic/feature/car/CarScreensTest.kt | 152 ++++++++++++++++++ gradle/libs.versions.toml | 3 +- 12 files changed, 312 insertions(+), 223 deletions(-) create mode 100644 feature/car/TESTING.md delete mode 100644 feature/car/src/main/kotlin/org/meshtastic/feature/car/service/CarNotificationManager.kt delete mode 100644 feature/car/src/main/kotlin/org/meshtastic/feature/car/service/CarReplyReceiver.kt create mode 100644 feature/car/src/test/kotlin/org/meshtastic/feature/car/CarScreensTest.kt diff --git a/core/service/src/androidMain/kotlin/org/meshtastic/core/service/MeshNotificationManagerImpl.kt b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/MeshNotificationManagerImpl.kt index 35aa69594..d9b0d0c99 100644 --- a/core/service/src/androidMain/kotlin/org/meshtastic/core/service/MeshNotificationManagerImpl.kt +++ b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/MeshNotificationManagerImpl.kt @@ -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( diff --git a/core/service/src/androidMain/kotlin/org/meshtastic/core/service/ReplyReceiver.kt b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/ReplyReceiver.kt index 6e295ade6..c03e568d3 100644 --- a/core/service/src/androidMain/kotlin/org/meshtastic/core/service/ReplyReceiver.kt +++ b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/ReplyReceiver.kt @@ -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() } } } diff --git a/feature/car/TESTING.md b/feature/car/TESTING.md new file mode 100644 index 000000000..45f97a883 --- /dev/null +++ b/feature/car/TESTING.md @@ -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 : # one-time; code shown under Wireless debugging +adb connect : +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 `, `screenshot `. + +### 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`.) diff --git a/feature/car/build.gradle.kts b/feature/car/build.gradle.kts index 43b532bf7..93592f82f 100644 --- a/feature/car/build.gradle.kts +++ b/feature/car/build.gradle.kts @@ -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) } diff --git a/feature/car/src/main/AndroidManifest.xml b/feature/car/src/main/AndroidManifest.xml index c0706eac9..34bbec51c 100644 --- a/feature/car/src/main/AndroidManifest.xml +++ b/feature/car/src/main/AndroidManifest.xml @@ -12,12 +12,14 @@ - - + + + diff --git a/feature/car/src/main/kotlin/org/meshtastic/feature/car/screens/HomeScreen.kt b/feature/car/src/main/kotlin/org/meshtastic/feature/car/screens/HomeScreen.kt index bec906010..6e72e6022 100644 --- a/feature/car/src/main/kotlin/org/meshtastic/feature/car/screens/HomeScreen.kt +++ b/feature/car/src/main/kotlin/org/meshtastic/feature/car/screens/HomeScreen.kt @@ -188,6 +188,7 @@ class HomeScreen( addTab(messagingTab) addTab(nodesTab) addTab(statusTab) + setActiveTabContentId(selectedTabId) setTabContents(getTabContents()) } .build() diff --git a/feature/car/src/main/kotlin/org/meshtastic/feature/car/service/CarNotificationManager.kt b/feature/car/src/main/kotlin/org/meshtastic/feature/car/service/CarNotificationManager.kt deleted file mode 100644 index 216e95ceb..000000000 --- a/feature/car/src/main/kotlin/org/meshtastic/feature/car/service/CarNotificationManager.kt +++ /dev/null @@ -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 . - */ -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>) { - 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" - } -} diff --git a/feature/car/src/main/kotlin/org/meshtastic/feature/car/service/CarReplyReceiver.kt b/feature/car/src/main/kotlin/org/meshtastic/feature/car/service/CarReplyReceiver.kt deleted file mode 100644 index 8ddc890bc..000000000 --- a/feature/car/src/main/kotlin/org/meshtastic/feature/car/service/CarReplyReceiver.kt +++ /dev/null @@ -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 . - */ -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" - } -} diff --git a/feature/car/src/main/kotlin/org/meshtastic/feature/car/service/CarStateCoordinator.kt b/feature/car/src/main/kotlin/org/meshtastic/feature/car/service/CarStateCoordinator.kt index ec2833bff..68f725bfa 100644 --- a/feature/car/src/main/kotlin/org/meshtastic/feature/car/service/CarStateCoordinator.kt +++ b/feature/car/src/main/kotlin/org/meshtastic/feature/car/service/CarStateCoordinator.kt @@ -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 = _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 = + 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( diff --git a/feature/car/src/main/kotlin/org/meshtastic/feature/car/service/MeshtasticCarSession.kt b/feature/car/src/main/kotlin/org/meshtastic/feature/car/service/MeshtasticCarSession.kt index 29798dedc..225d4a42b 100644 --- a/feature/car/src/main/kotlin/org/meshtastic/feature/car/service/MeshtasticCarSession.kt +++ b/feature/car/src/main/kotlin/org/meshtastic/feature/car/service/MeshtasticCarSession.kt @@ -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 { diff --git a/feature/car/src/test/kotlin/org/meshtastic/feature/car/CarScreensTest.kt b/feature/car/src/test/kotlin/org/meshtastic/feature/car/CarScreensTest.kt new file mode 100644 index 000000000..6c8a982eb --- /dev/null +++ b/feature/car/src/test/kotlin/org/meshtastic/feature/car/CarScreensTest.kt @@ -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 . + */ +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(MockMode.autofill) + private val nodeRepo = mock(MockMode.autofill) + + private fun coordinator(): CarStateCoordinator { + every { packetRepo.getContacts() } returns flowOf(emptyMap()) + every { nodeRepo.nodeDBbyNum } returns MutableStateFlow(emptyMap()) + return CarStateCoordinator( + nodeRepository = nodeRepo, + packetRepository = packetRepo, + serviceRepository = mock(MockMode.autofill), + radioConfigRepository = mock(MockMode.autofill), + sendMessageUseCase = mock(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()) + 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(MockMode.autofill), + radioConfigRepository = mock(MockMode.autofill), + sendMessageUseCase = mock(MockMode.autofill), + messageFilter = MessageFilter(), + ) + + val emergency = coord.emergencyAlerts.first() + + assertEquals("HELP", emergency.message) + assertEquals("!abcd1234", emergency.nodeName) + assertTrue(emergency.isActive) + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index dfd4a2397..32fc45e3c 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -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" }