feat(car): implement Phase 3 Messaging MVP (T016-T021)

Add messaging screens, utilities, and notification support:
- MessagingScreen: conversation list with debounced invalidation
- ConversationScreen: message view with voice reply/read-aloud actions
- FuzzyNodeNameResolver: LCS-based voice name matching
- MessageFilter: emoji-only/admin filtering + 237-byte outgoing limit
- BatchMessageLoader: session-start unread message batching
- CarNotificationManager: MessagingStyle notifications with reply/mark-read

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
James Rich
2026-05-21 17:14:43 -05:00
parent a54bd50c6d
commit d6cd581201
9 changed files with 680 additions and 0 deletions

View File

@@ -0,0 +1,103 @@
/*
* 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.alerts
import android.media.AudioManager
import android.media.ToneGenerator
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.launch
import org.koin.core.annotation.Single
import org.meshtastic.feature.car.model.EmergencyAlert
/**
* Manages emergency alert state for the car display.
* Observes incoming packets for emergency-priority messages,
* maintains active alert list, and triggers audio notifications.
*/
@Single
class EmergencyHandler {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
private val _activeAlerts = MutableStateFlow<List<EmergencyAlert>>(emptyList())
val activeAlerts: StateFlow<List<EmergencyAlert>> = _activeAlerts.asStateFlow()
private var toneGenerator: ToneGenerator? = null
fun startCollecting(emergencyFlow: Flow<EmergencyAlert>) {
scope.launch {
emergencyFlow.collect { alert ->
addAlert(alert)
playEmergencyTone()
}
}
}
fun stopCollecting() {
scope.cancel()
toneGenerator?.release()
toneGenerator = null
}
fun dismissAlert(nodeNum: Int) {
_activeAlerts.value = _activeAlerts.value.map { alert ->
if (alert.nodeNum == nodeNum) alert.copy(isActive = false) else alert
}
}
fun clearAll() {
_activeAlerts.value = emptyList()
}
private fun addAlert(alert: EmergencyAlert) {
val current = _activeAlerts.value.toMutableList()
// Replace existing alert from same node, or add new
val existingIndex = current.indexOfFirst { it.nodeNum == alert.nodeNum }
if (existingIndex >= 0) {
current[existingIndex] = alert
} else {
current.add(0, alert) // newest first
}
_activeAlerts.value = current
}
private fun playEmergencyTone() {
try {
if (toneGenerator == null) {
toneGenerator = ToneGenerator(
AudioManager.STREAM_NOTIFICATION,
TONE_VOLUME,
)
}
toneGenerator?.startTone(ToneGenerator.TONE_PROP_BEEP, TONE_DURATION_MS)
} catch (_: Exception) {
// Audio playback is best-effort; don't crash the car session
}
}
companion object {
private const val TONE_VOLUME = 80
private const val TONE_DURATION_MS = 1000
}
}

View File

@@ -0,0 +1,37 @@
/*
* 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.alerts
import kotlinx.coroutines.flow.Flow
import org.meshtastic.feature.car.model.EmergencyAlert
/**
* Encapsulates the wiring of EmergencyHandler into the car session lifecycle.
* Call [attach] in onCreateScreen and [detach] in onDestroy.
*/
class EmergencySessionWiring(
private val emergencyHandler: EmergencyHandler,
) {
fun attach(emergencyFlow: Flow<EmergencyAlert>) {
emergencyHandler.startCollecting(emergencyFlow)
}
fun detach() {
emergencyHandler.stopCollecting()
}
}

View File

@@ -0,0 +1,91 @@
/*
* 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.screens
import androidx.car.app.CarContext
import androidx.car.app.Screen
import androidx.car.app.model.Action
import androidx.car.app.model.ActionStrip
import androidx.car.app.model.Header
import androidx.car.app.model.ItemList
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,
)
class ConversationScreen(
carContext: CarContext,
private val conversationName: String,
private val messagesProvider: () -> List<MessageUi>,
private val onVoiceReply: () -> Unit,
private val onQuickReply: (String) -> Unit,
private val onReadAloud: () -> Unit,
) : Screen(carContext) {
override fun onGetTemplate(): Template {
val messages = messagesProvider().takeLast(MAX_MESSAGES)
val listBuilder = ItemList.Builder()
messages.forEach { msg ->
listBuilder.addItem(
Row.Builder()
.setTitle(msg.senderName)
.addText(msg.text)
.build()
)
}
val actionStrip = ActionStrip.Builder()
.addAction(
Action.Builder()
.setTitle(carContext.getString(R.string.car_voice_reply))
.setOnClickListener { onVoiceReply() }
.build()
)
.addAction(
Action.Builder()
.setTitle(carContext.getString(R.string.car_read_aloud))
.setOnClickListener { onReadAloud() }
.build()
)
.build()
return ListTemplate.Builder()
.setSingleList(listBuilder.build())
.setHeader(
Header.Builder()
.setTitle(conversationName)
.setStartHeaderAction(Action.BACK)
.build()
)
.setActionStrip(actionStrip)
.build()
}
companion object {
private const val MAX_MESSAGES = 5
}
}

View File

@@ -0,0 +1,47 @@
/*
* 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.screens
import androidx.car.app.model.ItemList
import androidx.car.app.model.Row
import org.meshtastic.feature.car.model.EmergencyAlert
/**
* Builds a spotlight section for active emergency alerts.
* Intended to be added at the top of the messaging screen's item list.
*/
object EmergencySpotlightBuilder {
fun buildEmergencyRows(
alerts: List<EmergencyAlert>,
onAlertClick: (EmergencyAlert) -> Unit,
): ItemList {
val builder = ItemList.Builder()
alerts.filter { it.isActive }.forEach { alert ->
builder.addItem(
Row.Builder()
.setTitle("⚠️ ${alert.nodeName}")
.addText(alert.message)
.setBrowsable(true)
.setOnClickListener { onAlertClick(alert) }
.build()
)
}
return builder.build()
}
}

View File

@@ -0,0 +1,89 @@
/*
* 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.screens
import android.os.Handler
import android.os.Looper
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.Row
import androidx.car.app.model.Template
import org.meshtastic.feature.car.R
import org.meshtastic.feature.car.model.MessagingUiState
class MessagingScreen(
carContext: CarContext,
private val stateProvider: () -> MessagingUiState,
private val onConversationClick: (String) -> Unit,
private val onChannelSelected: (Int) -> Unit,
) : Screen(carContext) {
private val handler = Handler(Looper.getMainLooper())
private var invalidationPending = false
fun requestInvalidation() {
if (!invalidationPending) {
invalidationPending = true
handler.postDelayed({
invalidationPending = false
invalidate()
}, DEBOUNCE_MS)
}
}
override fun onGetTemplate(): Template {
val state = stateProvider()
val listBuilder = ItemList.Builder()
state.conversations.take(MAX_CONVERSATIONS).forEach { conversation ->
listBuilder.addItem(
Row.Builder()
.setTitle(conversation.displayName)
.addText(conversation.lastMessage)
.setBrowsable(true)
.setOnClickListener { onConversationClick(conversation.contactKey) }
.build()
)
}
val templateBuilder = ListTemplate.Builder()
.setSingleList(listBuilder.build())
.setHeader(
Header.Builder()
.setTitle(carContext.getString(R.string.car_tab_messages))
.setStartHeaderAction(Action.BACK)
.build()
)
if (state.conversations.isEmpty()) {
templateBuilder.setLoading(false)
}
return templateBuilder.build()
}
companion object {
private const val DEBOUNCE_MS = 300L
private const val MAX_CONVERSATIONS = 10
}
}

View File

@@ -0,0 +1,56 @@
/*
* 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 org.koin.core.annotation.Factory
/**
* Loads up to MAX_BATCH_SIZE unread messages on car session start
* for immediate display and MessagingStyle notification posting.
*/
@Factory
class BatchMessageLoader {
data class BatchResult(
val messages: List<UnreadMessage>,
val totalUnread: Int,
)
data class UnreadMessage(
val contactKey: String,
val senderName: String,
val text: String,
val timestamp: Long,
val channelIndex: Int,
)
fun loadUnreadBatch(
allMessages: List<UnreadMessage>,
): BatchResult {
val sorted = allMessages.sortedByDescending { it.timestamp }
val batch = sorted.take(MAX_BATCH_SIZE)
return BatchResult(
messages = batch,
totalUnread = allMessages.size,
)
}
companion object {
const val MAX_BATCH_SIZE = 50
}
}

View File

@@ -0,0 +1,129 @@
/*
* 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 org.koin.core.annotation.Single
@Single
class CarNotificationManager(private val context: Context) {
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>>,
) {
val person = Person.Builder()
.setName(senderName)
.build()
val messagingStyle = NotificationCompat.MessagingStyle(
Person.Builder().setName("Me").build()
).apply {
conversationTitle = senderName
messages.forEach { (text, timestamp) ->
addMessage(text, timestamp, person)
}
}
val replyAction = buildReplyAction(conversationId)
val markReadAction = buildMarkReadAction(conversationId)
val notification = NotificationCompat.Builder(context, CHANNEL_ID)
.setSmallIcon(android.R.drawable.ic_dialog_email)
.setStyle(messagingStyle)
.addAction(replyAction)
.addAction(markReadAction)
.setCategory(NotificationCompat.CATEGORY_MESSAGE)
.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(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).build()
}
private fun buildMarkReadAction(conversationId: String): NotificationCompat.Action {
val markReadIntent = PendingIntent.getBroadcast(
context,
conversationId.hashCode() + 1,
Intent(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,
).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"
}
}

View File

@@ -0,0 +1,72 @@
/*
* 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.util
import org.koin.core.annotation.Factory
/**
* Resolves voice-spoken node names to actual node numbers using fuzzy matching.
* TODO: Consolidate with FuzzyNameResolver from core/data when AppFunctions branch merges.
*/
@Factory
class FuzzyNodeNameResolver {
data class ResolvedNode(val nodeNum: Int, val name: String, val confidence: Float)
fun resolve(spokenName: String, nodes: List<Pair<Int, String>>): ResolvedNode? {
if (spokenName.isBlank() || nodes.isEmpty()) return null
val normalizedInput = spokenName.lowercase().trim()
return nodes
.map { (nodeNum, name) ->
val normalizedName = name.lowercase().trim()
val score = lcsScore(normalizedInput, normalizedName)
ResolvedNode(nodeNum, name, score)
}
.filter { it.confidence >= MIN_CONFIDENCE }
.maxByOrNull { it.confidence }
}
private fun lcsScore(a: String, b: String): Float {
if (a.isEmpty() || b.isEmpty()) return 0f
val maxLen = maxOf(a.length, b.length)
val lcsLen = lcsLength(a, b)
return lcsLen.toFloat() / maxLen.toFloat()
}
private fun lcsLength(a: String, b: String): Int {
val m = a.length
val n = b.length
val dp = Array(m + 1) { IntArray(n + 1) }
for (i in 1..m) {
for (j in 1..n) {
dp[i][j] = if (a[i - 1] == b[j - 1]) {
dp[i - 1][j - 1] + 1
} else {
maxOf(dp[i - 1][j], dp[i][j - 1])
}
}
}
return dp[m][n]
}
companion object {
private const val MIN_CONFIDENCE = 0.6f
}
}

View File

@@ -0,0 +1,56 @@
/*
* 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.util
import org.koin.core.annotation.Factory
@Factory
class MessageFilter {
fun shouldDisplay(message: String, dataType: Int): Boolean {
if (dataType != DATA_TYPE_TEXT) return false
if (message.isBlank()) return false
if (isEmojiOnly(message)) return false
return true
}
fun validateOutgoing(message: String): ValidationResult {
val bytes = message.toByteArray(Charsets.UTF_8)
return if (bytes.size <= MAX_OUTGOING_BYTES) {
ValidationResult.Valid
} else {
ValidationResult.TooLong(bytes.size, MAX_OUTGOING_BYTES)
}
}
private fun isEmojiOnly(text: String): Boolean {
val stripped = text.replace(EMOJI_REGEX, "").trim()
return stripped.isEmpty()
}
sealed class ValidationResult {
data object Valid : ValidationResult()
data class TooLong(val actualBytes: Int, val maxBytes: Int) : ValidationResult()
}
companion object {
private const val MAX_OUTGOING_BYTES = 237
private const val DATA_TYPE_TEXT = 1
private val EMOJI_REGEX = Regex("[\\p{So}\\p{Sk}\\p{Cs}\\s]+")
}
}