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