mirror of
https://github.com/meshtastic/Meshtastic-Android.git
synced 2026-06-29 07:55:37 -04:00
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:
@@ -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(
|
||||
|
||||
@@ -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
49
feature/car/TESTING.md
Normal 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`.)
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -188,6 +188,7 @@ class HomeScreen(
|
||||
addTab(messagingTab)
|
||||
addTab(nodesTab)
|
||||
addTab(statusTab)
|
||||
setActiveTabContentId(selectedTabId)
|
||||
setTabContents(getTabContents())
|
||||
}
|
||||
.build()
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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" }
|
||||
|
||||
Reference in New Issue
Block a user