From d6cd58120143bb9076ed78a9fdd8549e575771ec Mon Sep 17 00:00:00 2001 From: James Rich Date: Thu, 21 May 2026 17:14:43 -0500 Subject: [PATCH] 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> --- .../feature/car/alerts/EmergencyHandler.kt | 103 ++++++++++++++ .../car/alerts/EmergencySessionWiring.kt | 37 +++++ .../feature/car/screens/ConversationScreen.kt | 91 ++++++++++++ .../car/screens/EmergencySpotlightBuilder.kt | 47 +++++++ .../feature/car/screens/MessagingScreen.kt | 89 ++++++++++++ .../feature/car/service/BatchMessageLoader.kt | 56 ++++++++ .../car/service/CarNotificationManager.kt | 129 ++++++++++++++++++ .../feature/car/util/FuzzyNodeNameResolver.kt | 72 ++++++++++ .../feature/car/util/MessageFilter.kt | 56 ++++++++ 9 files changed, 680 insertions(+) create mode 100644 feature/car/src/main/kotlin/org/meshtastic/feature/car/alerts/EmergencyHandler.kt create mode 100644 feature/car/src/main/kotlin/org/meshtastic/feature/car/alerts/EmergencySessionWiring.kt create mode 100644 feature/car/src/main/kotlin/org/meshtastic/feature/car/screens/ConversationScreen.kt create mode 100644 feature/car/src/main/kotlin/org/meshtastic/feature/car/screens/EmergencySpotlightBuilder.kt create mode 100644 feature/car/src/main/kotlin/org/meshtastic/feature/car/screens/MessagingScreen.kt create mode 100644 feature/car/src/main/kotlin/org/meshtastic/feature/car/service/BatchMessageLoader.kt create mode 100644 feature/car/src/main/kotlin/org/meshtastic/feature/car/service/CarNotificationManager.kt create mode 100644 feature/car/src/main/kotlin/org/meshtastic/feature/car/util/FuzzyNodeNameResolver.kt create mode 100644 feature/car/src/main/kotlin/org/meshtastic/feature/car/util/MessageFilter.kt diff --git a/feature/car/src/main/kotlin/org/meshtastic/feature/car/alerts/EmergencyHandler.kt b/feature/car/src/main/kotlin/org/meshtastic/feature/car/alerts/EmergencyHandler.kt new file mode 100644 index 000000000..68671347d --- /dev/null +++ b/feature/car/src/main/kotlin/org/meshtastic/feature/car/alerts/EmergencyHandler.kt @@ -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 . + */ + +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>(emptyList()) + val activeAlerts: StateFlow> = _activeAlerts.asStateFlow() + + private var toneGenerator: ToneGenerator? = null + + fun startCollecting(emergencyFlow: Flow) { + 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 + } +} diff --git a/feature/car/src/main/kotlin/org/meshtastic/feature/car/alerts/EmergencySessionWiring.kt b/feature/car/src/main/kotlin/org/meshtastic/feature/car/alerts/EmergencySessionWiring.kt new file mode 100644 index 000000000..9ab4cfede --- /dev/null +++ b/feature/car/src/main/kotlin/org/meshtastic/feature/car/alerts/EmergencySessionWiring.kt @@ -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 . + */ + +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) { + emergencyHandler.startCollecting(emergencyFlow) + } + + fun detach() { + emergencyHandler.stopCollecting() + } +} 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 new file mode 100644 index 000000000..28bec4d8b --- /dev/null +++ b/feature/car/src/main/kotlin/org/meshtastic/feature/car/screens/ConversationScreen.kt @@ -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 . + */ + +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, + 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 + } +} diff --git a/feature/car/src/main/kotlin/org/meshtastic/feature/car/screens/EmergencySpotlightBuilder.kt b/feature/car/src/main/kotlin/org/meshtastic/feature/car/screens/EmergencySpotlightBuilder.kt new file mode 100644 index 000000000..40e35124b --- /dev/null +++ b/feature/car/src/main/kotlin/org/meshtastic/feature/car/screens/EmergencySpotlightBuilder.kt @@ -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 . + */ + +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, + 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() + } +} diff --git a/feature/car/src/main/kotlin/org/meshtastic/feature/car/screens/MessagingScreen.kt b/feature/car/src/main/kotlin/org/meshtastic/feature/car/screens/MessagingScreen.kt new file mode 100644 index 000000000..2ef9ea463 --- /dev/null +++ b/feature/car/src/main/kotlin/org/meshtastic/feature/car/screens/MessagingScreen.kt @@ -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 . + */ + +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 + } +} diff --git a/feature/car/src/main/kotlin/org/meshtastic/feature/car/service/BatchMessageLoader.kt b/feature/car/src/main/kotlin/org/meshtastic/feature/car/service/BatchMessageLoader.kt new file mode 100644 index 000000000..3efa62c47 --- /dev/null +++ b/feature/car/src/main/kotlin/org/meshtastic/feature/car/service/BatchMessageLoader.kt @@ -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 . + */ + +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, + 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, + ): 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 + } +} diff --git a/feature/car/src/main/kotlin/org/meshtastic/feature/car/service/CarNotificationManager.kt b/feature/car/src/main/kotlin/org/meshtastic/feature/car/service/CarNotificationManager.kt new file mode 100644 index 000000000..1e39f2d9b --- /dev/null +++ b/feature/car/src/main/kotlin/org/meshtastic/feature/car/service/CarNotificationManager.kt @@ -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 . + */ + +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>, + ) { + 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" + } +} diff --git a/feature/car/src/main/kotlin/org/meshtastic/feature/car/util/FuzzyNodeNameResolver.kt b/feature/car/src/main/kotlin/org/meshtastic/feature/car/util/FuzzyNodeNameResolver.kt new file mode 100644 index 000000000..2277bf585 --- /dev/null +++ b/feature/car/src/main/kotlin/org/meshtastic/feature/car/util/FuzzyNodeNameResolver.kt @@ -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 . + */ + +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>): 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 + } +} diff --git a/feature/car/src/main/kotlin/org/meshtastic/feature/car/util/MessageFilter.kt b/feature/car/src/main/kotlin/org/meshtastic/feature/car/util/MessageFilter.kt new file mode 100644 index 000000000..b0eedf419 --- /dev/null +++ b/feature/car/src/main/kotlin/org/meshtastic/feature/car/util/MessageFilter.kt @@ -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 . + */ + +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]+") + } +}