From 2d23a0389071d5935d91f4e73c05468dcf232b91 Mon Sep 17 00:00:00 2001 From: James Rich Date: Thu, 21 May 2026 18:38:35 -0500 Subject: [PATCH] =?UTF-8?q?feat(car):=20complete=20data=20wiring=20?= =?UTF-8?q?=E2=80=94=20conversations,=20TTS,=20onboarding,=20disconnected?= =?UTF-8?q?=20state?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Wire real message loading via PacketRepository with send support - Add TTS read-aloud with message caching (limit 3 messages) - Add onboarding screen when no channels configured - Add disconnected state handling with reconnection notice - Wire EmergencyHandler with placeholder flow for future detection - Suppress TooManyFunctions for coordinator (legitimate orchestrator) - Remove unused messagesCache from HomeScreen (moved to coordinator) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../feature/car/screens/ConversationScreen.kt | 5 +- .../feature/car/screens/HomeScreen.kt | 81 ++++++++++++++++--- .../car/service/CarStateCoordinator.kt | 71 ++++++++++++++++ .../car/service/MeshtasticCarSession.kt | 8 ++ feature/car/src/main/res/values/strings.xml | 1 + 5 files changed, 151 insertions(+), 15 deletions(-) diff --git a/feature/car/src/main/kotlin/org/meshtastic/feature/car/screens/ConversationScreen.kt b/feature/car/src/main/kotlin/org/meshtastic/feature/car/screens/ConversationScreen.kt index 8f9f5c596..cc5a9fc8f 100644 --- a/feature/car/src/main/kotlin/org/meshtastic/feature/car/screens/ConversationScreen.kt +++ b/feature/car/src/main/kotlin/org/meshtastic/feature/car/screens/ConversationScreen.kt @@ -26,13 +26,12 @@ import androidx.car.app.model.ListTemplate import androidx.car.app.model.Row import androidx.car.app.model.Template import org.meshtastic.feature.car.R - -data class MessageUi(val id: Int, val senderName: String, val text: String, val timestamp: Long, val isFromMe: Boolean) +import org.meshtastic.feature.car.service.MessageSnapshot class ConversationScreen( carContext: CarContext, private val conversationName: String, - private val messagesProvider: () -> List, + private val messagesProvider: () -> List, private val onVoiceReply: () -> Unit, private val onQuickReply: (String) -> Unit, private val onReadAloud: () -> Unit, 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 8a4c7c618..511d0a1e6 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 @@ -19,8 +19,11 @@ package org.meshtastic.feature.car.screens import androidx.car.app.CarContext import androidx.car.app.Screen import androidx.car.app.model.Action +import androidx.car.app.model.Header import androidx.car.app.model.ItemList import androidx.car.app.model.ListTemplate +import androidx.car.app.model.Pane +import androidx.car.app.model.PaneTemplate import androidx.car.app.model.Row import androidx.car.app.model.Tab import androidx.car.app.model.TabContents @@ -32,8 +35,10 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.launch import org.meshtastic.feature.car.R +import org.meshtastic.feature.car.model.ConnectionStatus import org.meshtastic.feature.car.model.NodeUi import org.meshtastic.feature.car.model.SignalQuality import org.meshtastic.feature.car.service.CarStateCoordinator @@ -60,9 +65,19 @@ class HomeScreen(carContext: CarContext, private val stateCoordinator: CarStateC private fun observeState() { scope.launch { stateCoordinator.messagingState.collect { invalidate() } } scope.launch { stateCoordinator.nodeDashboardState.collect { invalidate() } } + scope.launch { stateCoordinator.sessionState.collect { invalidate() } } } + @Suppress("ReturnCount") override fun onGetTemplate(): Template { + val connectionStatus = stateCoordinator.sessionState.value.connectionStatus + if (connectionStatus == ConnectionStatus.DISCONNECTED) { + return buildDisconnectedTemplate() + } + val messaging = stateCoordinator.messagingState.value + if (messaging.channels.isEmpty()) { + return buildOnboardingTemplate() + } val messagingTab = Tab.Builder() .setContentId(TAB_ID_MESSAGES) @@ -112,18 +127,7 @@ class HomeScreen(carContext: CarContext, private val stateCoordinator: CarStateC .setTitle(conversation.displayName) .addText(conversation.lastMessage) .setBrowsable(true) - .setOnClickListener { - screenManager.push( - ConversationScreen( - carContext = carContext, - conversationName = conversation.displayName, - messagesProvider = { emptyList() }, - onVoiceReply = {}, - onQuickReply = {}, - onReadAloud = {}, - ), - ) - } + .setOnClickListener { openConversation(conversation.contactKey, conversation.displayName) } .build(), ) } @@ -132,6 +136,23 @@ class HomeScreen(carContext: CarContext, private val stateCoordinator: CarStateC return ListTemplate.Builder().setSingleList(listBuilder.build()).build() } + private fun openConversation(contactKey: String, displayName: String) { + scope.launch { + val messages = stateCoordinator.getMessagesFlow(contactKey).firstOrNull() ?: emptyList() + stateCoordinator.cacheMessages(contactKey, messages) + screenManager.push( + ConversationScreen( + carContext = carContext, + conversationName = displayName, + messagesProvider = { messages }, + onVoiceReply = { /* Voice input requires CarContext intent — deferred to DHU testing */ }, + onQuickReply = { text -> stateCoordinator.sendMessage(contactKey, text) }, + onReadAloud = { stateCoordinator.readMessagesAloud(contactKey) }, + ), + ) + } + } + private fun buildNodeList(): Template { val state = stateCoordinator.nodeDashboardState.value val listBuilder = ItemList.Builder() @@ -172,6 +193,42 @@ class HomeScreen(carContext: CarContext, private val stateCoordinator: CarStateC return "$signal$battery$status" } + private fun buildDisconnectedTemplate(): Template = PaneTemplate.Builder( + Pane.Builder() + .addRow( + Row.Builder() + .setTitle(carContext.getString(R.string.car_disconnected)) + .addText(carContext.getString(R.string.car_reconnecting)) + .build(), + ) + .build(), + ) + .setHeader( + Header.Builder() + .setTitle(carContext.getString(R.string.car_app_name)) + .setStartHeaderAction(Action.APP_ICON) + .build(), + ) + .build() + + private fun buildOnboardingTemplate(): Template = PaneTemplate.Builder( + Pane.Builder() + .addRow( + Row.Builder() + .setTitle(carContext.getString(R.string.car_onboarding_title)) + .addText(carContext.getString(R.string.car_onboarding_text)) + .build(), + ) + .build(), + ) + .setHeader( + Header.Builder() + .setTitle(carContext.getString(R.string.car_app_name)) + .setStartHeaderAction(Action.APP_ICON) + .build(), + ) + .build() + companion object { private const val TAB_ID_MESSAGES = "messages" private const val TAB_ID_NODES = "nodes" 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 98ef7fad8..cbcd30d61 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 @@ -20,15 +20,20 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch +import okio.ByteString.Companion.toByteString 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.model.Node +import org.meshtastic.core.repository.CommandSender import org.meshtastic.core.repository.NodeRepository import org.meshtastic.core.repository.PacketRepository import org.meshtastic.core.repository.QuickChatActionRepository @@ -43,18 +48,31 @@ import org.meshtastic.feature.car.model.NodeDashboardUiState import org.meshtastic.feature.car.model.NodeUi import org.meshtastic.feature.car.model.SignalQuality import org.meshtastic.feature.car.model.TopologyHeader +import org.meshtastic.feature.car.util.CarTtsEngine + +/** Snapshot of a message for car display (avoids leaking domain models to UI). */ +data class MessageSnapshot( + val id: Int, + val senderName: String, + val text: String, + val timestamp: Long, + val isFromMe: Boolean, +) /** * Bridges repository data flows to car screen presentation state. Created per car session — destroyed when session * ends. */ @Factory +@Suppress("TooManyFunctions") class CarStateCoordinator( private val nodeRepository: NodeRepository, private val packetRepository: PacketRepository, private val serviceRepository: ServiceRepository, private val radioConfigRepository: RadioConfigRepository, private val quickChatActionRepository: QuickChatActionRepository, + private val commandSender: CommandSender, + private val ttsEngine: CarTtsEngine, ) { private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate) @@ -102,6 +120,56 @@ class CarStateCoordinator( _messagingState.value = _messagingState.value.copy(selectedChannelIndex = index) } + suspend fun getMessagesFlow(contactKey: String): Flow> = packetRepository + .getMessagesFrom( + contact = contactKey, + limit = MAX_MESSAGES_PER_CONVERSATION, + includeFiltered = false, + getNode = { nodeId -> resolveNode(nodeId) }, + ) + .map { messages -> + messages.map { msg -> + MessageSnapshot( + id = msg.packetId, + senderName = msg.node.user.long_name.ifEmpty { "Unknown" }, + text = msg.text, + timestamp = msg.receivedTime, + isFromMe = msg.fromLocal, + ) + } + } + + fun sendMessage(contactKey: String, text: String) { + val packet = + DataPacket( + to = contactKey, + bytes = text.encodeToByteArray().toByteString(), + dataType = DATA_TYPE_TEXT, + channel = selectedChannelIndex, + ) + commandSender.sendData(packet) + } + + fun readMessagesAloud(contactKey: String) { + val messages = messagesCache[contactKey] ?: return + messages.takeLast(READ_ALOUD_LIMIT).forEach { msg -> + if (!msg.isFromMe) { + ttsEngine.readAloud(msg.senderName, msg.text) + } + } + } + + private val messagesCache = mutableMapOf>() + + fun cacheMessages(contactKey: String, messages: List) { + messagesCache[contactKey] = messages + } + + private suspend fun resolveNode(nodeId: String?): Node { + val nodes = nodeRepository.nodeDBbyNum.value + return nodes.values.find { it.user.id == nodeId } ?: Node(num = 0) + } + fun destroy() { scope.cancel() } @@ -211,6 +279,9 @@ class CarStateCoordinator( companion object { private const val MAX_CONVERSATIONS = 10 + private const val MAX_MESSAGES_PER_CONVERSATION = 20 + private const val READ_ALOUD_LIMIT = 3 + private const val DATA_TYPE_TEXT = 1 private const val SECONDS_TO_MILLIS = 1000L private const val BATTERY_MAX_PERCENT = 100 private const val SNR_EXCELLENT = 10f 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 2140479d6..c02f90e1e 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 @@ -20,8 +20,11 @@ import android.content.Intent import android.content.res.Configuration import androidx.car.app.Screen import androidx.car.app.Session +import kotlinx.coroutines.flow.emptyFlow import org.koin.core.component.KoinComponent import org.koin.core.component.inject +import org.meshtastic.feature.car.alerts.EmergencyHandler +import org.meshtastic.feature.car.alerts.EmergencySessionWiring import org.meshtastic.feature.car.screens.HomeScreen import org.meshtastic.feature.car.util.CrashlyticsCarTagger @@ -31,10 +34,14 @@ class MeshtasticCarSession : private val crashlyticsCarTagger: CrashlyticsCarTagger by inject() private val stateCoordinator: CarStateCoordinator by inject() + private val emergencyHandler: EmergencyHandler by inject() + private val emergencyWiring = EmergencySessionWiring(emergencyHandler) override fun onCreateScreen(intent: Intent): Screen { crashlyticsCarTagger.setCarSession(true) stateCoordinator.start() + // Emergency flow wired to emptyFlow() until emergency packet detection is implemented + emergencyWiring.attach(emptyFlow()) return HomeScreen(carContext, stateCoordinator) } @@ -47,6 +54,7 @@ class MeshtasticCarSession : } fun destroy() { + emergencyWiring.detach() stateCoordinator.destroy() crashlyticsCarTagger.setCarSession(false) } diff --git a/feature/car/src/main/res/values/strings.xml b/feature/car/src/main/res/values/strings.xml index 070c46f9b..f6708db10 100644 --- a/feature/car/src/main/res/values/strings.xml +++ b/feature/car/src/main/res/values/strings.xml @@ -4,6 +4,7 @@ Messages Nodes Disconnected + Radio connection lost. Will reconnect automatically. Connecting… No channels configured No nodes heard