mirror of
https://github.com/meshtastic/Meshtastic-Android.git
synced 2026-05-24 23:01:22 -04:00
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:
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user