feat(car): complete data wiring — conversations, TTS, onboarding, disconnected state

- 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>
This commit is contained in:
James Rich
2026-05-21 18:38:35 -05:00
parent b0be0aa675
commit 2d23a03890
5 changed files with 151 additions and 15 deletions

View File

@@ -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<MessageUi>,
private val messagesProvider: () -> List<MessageSnapshot>,
private val onVoiceReply: () -> Unit,
private val onQuickReply: (String) -> Unit,
private val onReadAloud: () -> Unit,

View File

@@ -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"

View File

@@ -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<List<MessageSnapshot>> = 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<String, List<MessageSnapshot>>()
fun cacheMessages(contactKey: String, messages: List<MessageSnapshot>) {
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

View File

@@ -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)
}

View File

@@ -4,6 +4,7 @@
<string name="car_tab_messages">Messages</string>
<string name="car_tab_nodes">Nodes</string>
<string name="car_disconnected">Disconnected</string>
<string name="car_reconnecting">Radio connection lost. Will reconnect automatically.</string>
<string name="car_connecting">Connecting…</string>
<string name="car_no_channels">No channels configured</string>
<string name="car_no_nodes">No nodes heard</string>