refactor(ui): compose resources, domain layer (#4628)

Signed-off-by: James Rich <2199651+jamesarich@users.noreply.github.com>
This commit is contained in:
James Rich 2026-02-22 21:39:50 -06:00 committed by GitHub
parent 96adc70401
commit 2676a51647
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
322 changed files with 3031 additions and 2790 deletions

View file

@ -32,7 +32,7 @@ graph TB
:feature:messaging -.-> :core:prefs
:feature:messaging -.-> :core:proto
:feature:messaging -.-> :core:service
:feature:messaging -.-> :core:strings
:feature:messaging -.-> :core:resources
:feature:messaging -.-> :core:ui
classDef android-application fill:#CAFFBF,stroke:#000,stroke-width:2px,color:#000;

View file

@ -48,7 +48,7 @@ dependencies {
implementation(projects.core.prefs)
implementation(projects.core.proto)
implementation(projects.core.service)
implementation(projects.core.strings)
implementation(projects.core.resources)
implementation(projects.core.ui)
implementation(libs.androidx.compose.material.iconsExtended)
@ -64,4 +64,6 @@ dependencies {
androidTestImplementation(libs.androidx.test.ext.junit)
testImplementation(libs.junit)
testImplementation(libs.mockk)
testImplementation(libs.kotlinx.coroutines.test)
}

View file

@ -29,10 +29,10 @@ import androidx.compose.ui.unit.dp
import org.jetbrains.compose.resources.StringResource
import org.jetbrains.compose.resources.pluralStringResource
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.strings.Res
import org.meshtastic.core.strings.close
import org.meshtastic.core.strings.relays
import org.meshtastic.core.strings.resend
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.close
import org.meshtastic.core.resources.relays
import org.meshtastic.core.resources.resend
import org.meshtastic.core.ui.component.MeshtasticDialog
@Suppress("UnusedParameter")

View file

@ -105,32 +105,32 @@ import org.meshtastic.core.database.model.Message
import org.meshtastic.core.database.model.Node
import org.meshtastic.core.model.DataPacket
import org.meshtastic.core.model.util.getChannel
import org.meshtastic.core.strings.Res
import org.meshtastic.core.strings.alert_bell_text
import org.meshtastic.core.strings.cancel_reply
import org.meshtastic.core.strings.clear_selection
import org.meshtastic.core.strings.copy
import org.meshtastic.core.strings.delete
import org.meshtastic.core.strings.delete_messages
import org.meshtastic.core.strings.delete_messages_title
import org.meshtastic.core.strings.filter_disable_for_contact
import org.meshtastic.core.strings.filter_enable_for_contact
import org.meshtastic.core.strings.filter_hide_count
import org.meshtastic.core.strings.filter_show_count
import org.meshtastic.core.strings.message_input_label
import org.meshtastic.core.strings.navigate_back
import org.meshtastic.core.strings.overflow_menu
import org.meshtastic.core.strings.quick_chat
import org.meshtastic.core.strings.quick_chat_hide
import org.meshtastic.core.strings.quick_chat_show
import org.meshtastic.core.strings.reply
import org.meshtastic.core.strings.replying_to
import org.meshtastic.core.strings.scroll_to_bottom
import org.meshtastic.core.strings.select_all
import org.meshtastic.core.strings.send
import org.meshtastic.core.strings.type_a_message
import org.meshtastic.core.strings.unknown
import org.meshtastic.core.strings.unknown_channel
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.alert_bell_text
import org.meshtastic.core.resources.cancel_reply
import org.meshtastic.core.resources.clear_selection
import org.meshtastic.core.resources.copy
import org.meshtastic.core.resources.delete
import org.meshtastic.core.resources.delete_messages
import org.meshtastic.core.resources.delete_messages_title
import org.meshtastic.core.resources.filter_disable_for_contact
import org.meshtastic.core.resources.filter_enable_for_contact
import org.meshtastic.core.resources.filter_hide_count
import org.meshtastic.core.resources.filter_show_count
import org.meshtastic.core.resources.message_input_label
import org.meshtastic.core.resources.navigate_back
import org.meshtastic.core.resources.overflow_menu
import org.meshtastic.core.resources.quick_chat
import org.meshtastic.core.resources.quick_chat_hide
import org.meshtastic.core.resources.quick_chat_show
import org.meshtastic.core.resources.reply
import org.meshtastic.core.resources.replying_to
import org.meshtastic.core.resources.scroll_to_bottom
import org.meshtastic.core.resources.select_all
import org.meshtastic.core.resources.send
import org.meshtastic.core.resources.type_a_message
import org.meshtastic.core.resources.unknown
import org.meshtastic.core.resources.unknown_channel
import org.meshtastic.core.ui.component.MeshtasticTextDialog
import org.meshtastic.core.ui.component.NodeKeyStatusIcon
import org.meshtastic.core.ui.component.SecurityIcon
@ -305,11 +305,7 @@ fun MessageScreen(
val originalMessage by
remember(replyingToPacketId, pagedMessages.itemCount) {
derivedStateOf {
replyingToPacketId?.let { id ->
(0 until pagedMessages.itemCount).firstNotNullOfOrNull { index ->
pagedMessages[index]?.takeIf { it.packetId == id }
}
}
replyingToPacketId?.let { id -> pagedMessages.itemSnapshotList.firstOrNull { it?.packetId == id } }
}
}

View file

@ -18,13 +18,13 @@ package org.meshtastic.feature.messaging
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyItemScope
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material3.CircularProgressIndicator
@ -53,6 +53,7 @@ import androidx.lifecycle.Lifecycle
import androidx.lifecycle.compose.LocalLifecycleOwner
import androidx.paging.LoadState
import androidx.paging.compose.LazyPagingItems
import androidx.paging.compose.itemContentType
import androidx.paging.compose.itemKey
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.FlowPreview
@ -65,8 +66,8 @@ import org.meshtastic.core.database.entity.Reaction
import org.meshtastic.core.database.model.Message
import org.meshtastic.core.database.model.Node
import org.meshtastic.core.model.MessageStatus
import org.meshtastic.core.strings.Res
import org.meshtastic.core.strings.new_messages_below
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.new_messages_below
import org.meshtastic.feature.messaging.component.MessageItem
import org.meshtastic.feature.messaging.component.ReactionDialog
@ -192,13 +193,13 @@ private fun MessageListPagedContent(
modifier: Modifier = Modifier,
quickEmojis: List<String>,
) {
// Calculate unread divider position
// Calculate unread divider position using snapshot to avoid side-effects and improve performance
// Optimized: Use full snapshot index to correctly match LazyColumn index range
val unreadDividerIndex by
remember(state.messages.itemCount, state.firstUnreadMessageUuid) {
derivedStateOf {
state.firstUnreadMessageUuid?.let { uuid ->
(0 until state.messages.itemCount).firstOrNull { index -> state.messages[index]?.uuid == uuid }
}
val uuid = state.firstUnreadMessageUuid ?: return@derivedStateOf null
state.messages.itemSnapshotList.indexOfFirst { it?.uuid == uuid }.takeIf { it != -1 }
}
}
@ -212,7 +213,11 @@ private fun MessageListPagedContent(
reverseLayout = true,
contentPadding = PaddingValues(bottom = 24.dp),
) {
items(count = state.messages.itemCount, key = state.messages.itemKey { it.uuid }) { index ->
items(
count = state.messages.itemCount,
key = state.messages.itemKey { it.uuid },
contentType = state.messages.itemContentType { "message" },
) { index ->
val message = state.messages[index]
val visuallyPrevMessage = if (index < state.messages.itemCount - 1) state.messages[index + 1] else null
val visuallyNextMessage = if (index > 0) state.messages[index - 1] else null
@ -234,27 +239,49 @@ private fun MessageListPagedContent(
}
if (message != null) {
renderPagedChatMessageRow(
message = message,
state = state,
nodeMap = nodeMap,
handlers = handlers,
inSelectionMode = inSelectionMode,
coroutineScope = coroutineScope,
haptics = haptics,
listState = listState,
onShowStatusDialog = onShowStatusDialog,
onShowReactions = onShowReactions,
enableAnimations = enableAnimations,
showUserName = !hasSamePrev,
hasSamePrev = hasSamePrev,
hasSameNext = hasSameNext,
quickEmojis = quickEmojis,
)
val isFirstUnread = state.hasUnreadMessages && unreadDividerIndex == index
val itemModifier = if (enableAnimations) Modifier.animateItem() else Modifier
// Show unread divider after the first unread message
if (state.hasUnreadMessages && unreadDividerIndex == index) {
UnreadMessagesDivider(modifier = if (enableAnimations) Modifier.animateItem() else Modifier)
if (isFirstUnread) {
// Wrap in Column to prevent overlapping of divider and message item
// Apply animation to the container Column once
Column(modifier = itemModifier) {
UnreadMessagesDivider()
RenderPagedChatMessageRow(
message = message,
state = state,
nodeMap = nodeMap,
handlers = handlers,
inSelectionMode = inSelectionMode,
coroutineScope = coroutineScope,
haptics = haptics,
listState = listState,
onShowStatusDialog = onShowStatusDialog,
onShowReactions = onShowReactions,
showUserName = !hasSamePrev,
hasSamePrev = hasSamePrev,
hasSameNext = hasSameNext,
quickEmojis = quickEmojis,
)
}
} else {
RenderPagedChatMessageRow(
message = message,
state = state,
nodeMap = nodeMap,
handlers = handlers,
inSelectionMode = inSelectionMode,
coroutineScope = coroutineScope,
haptics = haptics,
listState = listState,
onShowStatusDialog = onShowStatusDialog,
onShowReactions = onShowReactions,
modifier = itemModifier,
showUserName = !hasSamePrev,
hasSamePrev = hasSamePrev,
hasSameNext = hasSameNext,
quickEmojis = quickEmojis,
)
}
}
}
@ -263,7 +290,7 @@ private fun MessageListPagedContent(
state.messages.apply {
when {
loadState.append is LoadState.Loading -> {
item(key = "append_loading") {
item(key = "append_loading", contentType = "loading") {
Box(
modifier = Modifier.fillMaxWidth().padding(16.dp),
contentAlignment = Alignment.Center,
@ -280,7 +307,7 @@ private fun MessageListPagedContent(
@Suppress("LongParameterList")
@Composable
private fun LazyItemScope.renderPagedChatMessageRow(
private fun RenderPagedChatMessageRow(
message: Message,
state: MessageListPagedState,
nodeMap: Map<Int, Node>,
@ -291,7 +318,7 @@ private fun LazyItemScope.renderPagedChatMessageRow(
listState: LazyListState,
onShowStatusDialog: (Message) -> Unit,
onShowReactions: (List<Reaction>) -> Unit,
enableAnimations: Boolean,
modifier: Modifier = Modifier,
showUserName: Boolean,
hasSamePrev: Boolean,
hasSameNext: Boolean,
@ -305,7 +332,7 @@ private fun LazyItemScope.renderPagedChatMessageRow(
val node = nodeMap[message.node.num] ?: message.node
MessageItem(
modifier = if (enableAnimations) Modifier.animateItem() else Modifier,
modifier = modifier,
node = node,
ourNode = ourNode,
message = message,
@ -341,12 +368,10 @@ private fun LazyItemScope.renderPagedChatMessageRow(
onNavigateToOriginalMessage = {
coroutineScope.launch {
// Note: With pagination, we can't guarantee the original message is loaded
// This is a limitation of pagination - we would need to implement
// a search/jump feature to load and scroll to specific messages
// Optimized: Use snapshot to find index to avoid side-effects during search
val targetIndex =
(0 until state.messages.itemCount).firstOrNull { index ->
state.messages[index]?.packetId == message.replyId
}
state.messages.itemSnapshotList.indexOfFirst { it?.packetId == message.replyId }.takeIf { it != -1 }
if (targetIndex != null) {
listState.animateScrollToItem(index = targetIndex)
}
@ -408,19 +433,23 @@ private fun AutoScrollToBottomPaged(
}
private fun findFirstVisibleUnreadMessage(messages: LazyPagingItems<Message>, visibleIndex: Int): Message? {
val snapshot = messages.itemSnapshotList
if (visibleIndex >= snapshot.size) return null
val firstVisibleUnreadIndex =
(visibleIndex until messages.itemCount).firstOrNull { i ->
val msg = messages[i]
(visibleIndex until snapshot.size).firstOrNull { i ->
val msg = snapshot[i]
msg != null && !msg.read && !msg.fromLocal
}
return firstVisibleUnreadIndex?.let { messages[it] }
return firstVisibleUnreadIndex?.let { snapshot[it] }
}
private fun findLastUnreadMessageIndex(messages: LazyPagingItems<Message>): Int? =
(0 until messages.itemCount).lastOrNull { i ->
val msg = messages[i]
private fun findLastUnreadMessageIndex(messages: LazyPagingItems<Message>): Int? {
val snapshot = messages.itemSnapshotList
return (0 until snapshot.size).lastOrNull { i ->
val msg = snapshot[i]
msg != null && !msg.read && !msg.fromLocal
}
}
@OptIn(FlowPreview::class)
@Composable
@ -503,7 +532,7 @@ internal fun UnreadMessagesDivider(modifier: Modifier = Modifier) {
}
@Composable
internal fun MessageStatusDialog(
private fun MessageStatusDialog(
message: Message,
nodes: List<Node>,
ourNode: Node?,

View file

@ -16,13 +16,11 @@
*/
package org.meshtastic.feature.messaging
import android.os.RemoteException
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import androidx.paging.PagingData
import androidx.paging.cachedIn
import co.touchlab.kermit.Logger
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow
@ -41,7 +39,6 @@ import org.meshtastic.core.data.repository.RadioConfigRepository
import org.meshtastic.core.database.entity.ContactSettings
import org.meshtastic.core.database.model.Message
import org.meshtastic.core.database.model.Node
import org.meshtastic.core.model.Capabilities
import org.meshtastic.core.model.DataPacket
import org.meshtastic.core.prefs.emoji.CustomEmojiPrefs
import org.meshtastic.core.prefs.homoglyph.HomoglyphPrefs
@ -50,9 +47,8 @@ import org.meshtastic.core.service.MeshServiceNotifications
import org.meshtastic.core.service.ServiceAction
import org.meshtastic.core.service.ServiceRepository
import org.meshtastic.core.ui.viewmodel.stateInWhileSubscribed
import org.meshtastic.feature.messaging.domain.usecase.SendMessageUseCase
import org.meshtastic.proto.ChannelSet
import org.meshtastic.proto.Config.DeviceConfig.Role
import org.meshtastic.proto.SharedContact
import javax.inject.Inject
@Suppress("LongParameterList", "TooManyFunctions")
@ -70,6 +66,7 @@ constructor(
private val customEmojiPrefs: CustomEmojiPrefs,
private val homoglyphEncodingPrefs: HomoglyphPrefs,
private val meshServiceNotifications: MeshServiceNotifications,
private val sendMessageUseCase: SendMessageUseCase,
) : ViewModel() {
private val _title = MutableStateFlow("")
val title: StateFlow<String> = _title.asStateFlow()
@ -194,46 +191,8 @@ constructor(
* broadcasting on channel 0.
* @param replyId The ID of the message this is a reply to, if any.
*/
@Suppress("NestedBlockDepth")
fun sendMessage(str: String, contactKey: String = "0${DataPacket.ID_BROADCAST}", replyId: Int? = null) {
// contactKey: unique contact key filter (channel)+(nodeId)
val channel = contactKey[0].digitToIntOrNull()
val dest = if (channel != null) contactKey.substring(1) else contactKey
// if the destination is a node, we need to ensure it's a
// favorite so it does not get removed from the on-device node database.
if (channel == null) { // no channel specified, so we assume it's a direct message
val fwVersion = ourNodeInfo.value?.metadata?.firmware_version
val destNode = nodeRepository.getNode(dest)
val isClientBase = ourNodeInfo.value?.user?.role == Role.CLIENT_BASE
val capabilities = Capabilities(fwVersion)
if (capabilities.canSendVerifiedContacts) {
sendSharedContact(destNode)
} else {
if (!destNode.isFavorite && !isClientBase) {
favoriteNode(destNode)
}
}
}
// Applying homoglyph encoding to the transmitted string if user has activated the feature
// In most cases the value in "str" parameter will already contain the correct
// transformed string from the text input. This call here added to make sure that
// the feature is effective across all possible message paths (quick-chat, reply, etc.)
val dataPacketText: String =
if (homoglyphEncodingPrefs.homoglyphEncodingEnabled) {
HomoglyphCharacterStringTransformer.optimizeUtf8StringWithHomoglyphs(str)
} else {
str
}
val p =
DataPacket(dest, channel ?: 0, dataPacketText, replyId).apply {
from = ourNodeInfo.value?.user?.id ?: DataPacket.ID_LOCAL
}
sendDataPacket(p)
viewModelScope.launch { sendMessageUseCase.invoke(str, contactKey, replyId) }
}
fun sendReaction(emoji: String, replyId: Int, contactKey: String) =
@ -253,30 +212,4 @@ constructor(
val unreadCount = packetRepository.getUnreadCount(contact)
if (unreadCount == 0) meshServiceNotifications.cancelMessageNotification(contact)
}
private fun favoriteNode(node: Node) = viewModelScope.launch {
try {
serviceRepository.onServiceAction(ServiceAction.Favorite(node))
} catch (ex: RemoteException) {
Logger.e(ex) { "Favorite node error" }
}
}
private fun sendSharedContact(node: Node) = viewModelScope.launch {
try {
val contact =
SharedContact(node_num = node.num, user = node.user, manually_verified = node.manuallyVerified)
serviceRepository.onServiceAction(ServiceAction.SendContact(contact = contact))
} catch (ex: RemoteException) {
Logger.e(ex) { "Send shared contact error" }
}
}
private fun sendDataPacket(p: DataPacket) {
try {
serviceRepository.meshService?.send(p)
} catch (ex: RemoteException) {
Logger.e { "Send DataPacket error: ${ex.message}" }
}
}
}

View file

@ -65,18 +65,18 @@ import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.database.entity.QuickChatAction
import org.meshtastic.core.strings.Res
import org.meshtastic.core.strings.add
import org.meshtastic.core.strings.cancel
import org.meshtastic.core.strings.delete
import org.meshtastic.core.strings.message
import org.meshtastic.core.strings.name
import org.meshtastic.core.strings.quick_chat
import org.meshtastic.core.strings.quick_chat_append
import org.meshtastic.core.strings.quick_chat_edit
import org.meshtastic.core.strings.quick_chat_instant
import org.meshtastic.core.strings.quick_chat_new
import org.meshtastic.core.strings.save
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.add
import org.meshtastic.core.resources.cancel
import org.meshtastic.core.resources.delete
import org.meshtastic.core.resources.message
import org.meshtastic.core.resources.name
import org.meshtastic.core.resources.quick_chat
import org.meshtastic.core.resources.quick_chat_append
import org.meshtastic.core.resources.quick_chat_edit
import org.meshtastic.core.resources.quick_chat_instant
import org.meshtastic.core.resources.quick_chat_new
import org.meshtastic.core.resources.save
import org.meshtastic.core.ui.component.MainAppBar
import org.meshtastic.core.ui.component.MeshtasticDialog
import org.meshtastic.core.ui.component.dragContainer

View file

@ -42,10 +42,10 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.model.MessageStatus
import org.meshtastic.core.strings.Res
import org.meshtastic.core.strings.message_delivery_status
import org.meshtastic.core.strings.react
import org.meshtastic.core.strings.reply
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.message_delivery_status
import org.meshtastic.core.resources.react
import org.meshtastic.core.resources.reply
import org.meshtastic.core.ui.emoji.EmojiPickerDialog
@Composable

View file

@ -47,12 +47,12 @@ import androidx.compose.ui.unit.sp
import org.jetbrains.compose.resources.StringResource
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.model.MessageStatus
import org.meshtastic.core.strings.Res
import org.meshtastic.core.strings.copy
import org.meshtastic.core.strings.delete
import org.meshtastic.core.strings.message_delivery_status
import org.meshtastic.core.strings.reply
import org.meshtastic.core.strings.select
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.copy
import org.meshtastic.core.resources.delete
import org.meshtastic.core.resources.message_delivery_status
import org.meshtastic.core.resources.reply
import org.meshtastic.core.resources.select
@Composable
fun MessageActionsContent(

View file

@ -66,11 +66,11 @@ import org.meshtastic.core.database.entity.Reaction
import org.meshtastic.core.database.model.Message
import org.meshtastic.core.database.model.Node
import org.meshtastic.core.model.MessageStatus
import org.meshtastic.core.strings.Res
import org.meshtastic.core.strings.filter_message_label
import org.meshtastic.core.strings.message_delivery_status
import org.meshtastic.core.strings.reply
import org.meshtastic.core.strings.sample_message
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.filter_message_label
import org.meshtastic.core.resources.message_delivery_status
import org.meshtastic.core.resources.reply
import org.meshtastic.core.resources.sample_message
import org.meshtastic.core.ui.component.AutoLinkText
import org.meshtastic.core.ui.component.NodeChip
import org.meshtastic.core.ui.component.Rssi

View file

@ -64,14 +64,14 @@ import org.meshtastic.core.database.model.getStringResFrom
import org.meshtastic.core.model.DataPacket
import org.meshtastic.core.model.MessageStatus
import org.meshtastic.core.model.util.getShortDateTime
import org.meshtastic.core.strings.Res
import org.meshtastic.core.strings.delivery_confirmed
import org.meshtastic.core.strings.error
import org.meshtastic.core.strings.message_delivery_status
import org.meshtastic.core.strings.message_status_enroute
import org.meshtastic.core.strings.message_status_queued
import org.meshtastic.core.strings.react
import org.meshtastic.core.strings.you
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.delivery_confirmed
import org.meshtastic.core.resources.error
import org.meshtastic.core.resources.message_delivery_status
import org.meshtastic.core.resources.message_status_enroute
import org.meshtastic.core.resources.message_status_queued
import org.meshtastic.core.resources.react
import org.meshtastic.core.resources.you
import org.meshtastic.core.ui.component.BottomSheetDialog
import org.meshtastic.core.ui.component.Rssi
import org.meshtastic.core.ui.component.Snr

View file

@ -0,0 +1,103 @@
/*
* Copyright (c) 2025-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.messaging.domain.usecase
import co.touchlab.kermit.Logger
import org.meshtastic.core.data.repository.NodeRepository
import org.meshtastic.core.database.model.Node
import org.meshtastic.core.model.Capabilities
import org.meshtastic.core.model.DataPacket
import org.meshtastic.core.prefs.homoglyph.HomoglyphPrefs
import org.meshtastic.core.service.ServiceAction
import org.meshtastic.core.service.ServiceRepository
import org.meshtastic.feature.messaging.HomoglyphCharacterStringTransformer
import org.meshtastic.proto.Config
import org.meshtastic.proto.SharedContact
import javax.inject.Inject
@Suppress("TooGenericExceptionCaught")
class SendMessageUseCase
@Inject
constructor(
private val nodeRepository: NodeRepository,
private val serviceRepository: ServiceRepository,
private val homoglyphEncodingPrefs: HomoglyphPrefs,
) {
@Suppress("NestedBlockDepth", "LongMethod", "CyclomaticComplexMethod")
suspend operator fun invoke(
text: String,
contactKey: String = "0${DataPacket.ID_BROADCAST}",
replyId: Int? = null,
) {
val channel = contactKey[0].digitToIntOrNull()
val dest = if (channel != null) contactKey.substring(1) else contactKey
val ourNode = nodeRepository.ourNodeInfo.value
val fromId = ourNode?.user?.id ?: DataPacket.ID_LOCAL
// logic for direct messages
if (channel == null) {
val destNode = nodeRepository.getNode(dest)
val fwVersion = ourNode?.metadata?.firmware_version
val isClientBase = ourNode?.user?.role == Config.DeviceConfig.Role.CLIENT_BASE
val capabilities = Capabilities(fwVersion)
if (capabilities.canSendVerifiedContacts) {
sendSharedContact(destNode)
} else {
if (!destNode.isFavorite && !isClientBase) {
favoriteNode(destNode)
}
}
}
// Apply homoglyph encoding
val finalMessageText =
if (homoglyphEncodingPrefs.homoglyphEncodingEnabled) {
HomoglyphCharacterStringTransformer.optimizeUtf8StringWithHomoglyphs(text)
} else {
text
}
val packet = DataPacket(dest, channel ?: 0, finalMessageText, replyId).apply { from = fromId }
try {
serviceRepository.meshService?.send(packet)
} catch (ex: Exception) {
Logger.e(ex) { "Failed to send data packet" }
}
}
private suspend fun favoriteNode(node: Node) {
try {
serviceRepository.onServiceAction(ServiceAction.Favorite(node))
} catch (ex: Exception) {
Logger.e(ex) { "Favorite node error" }
}
}
private suspend fun sendSharedContact(node: Node) {
try {
val contact =
SharedContact(node_num = node.num, user = node.user, manually_verified = node.manuallyVerified)
serviceRepository.onServiceAction(ServiceAction.SendContact(contact = contact))
} catch (ex: Exception) {
Logger.e(ex) { "Send shared contact error" }
}
}
}

View file

@ -0,0 +1,146 @@
/*
* Copyright (c) 2025-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.messaging.domain.usecase
import io.mockk.coVerify
import io.mockk.every
import io.mockk.mockk
import io.mockk.mockkConstructor
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.test.runTest
import org.junit.Before
import org.junit.Test
import org.meshtastic.core.data.repository.NodeRepository
import org.meshtastic.core.database.model.Node
import org.meshtastic.core.model.Capabilities
import org.meshtastic.core.model.DataPacket
import org.meshtastic.core.prefs.homoglyph.HomoglyphPrefs
import org.meshtastic.core.service.ServiceAction
import org.meshtastic.core.service.ServiceRepository
import org.meshtastic.proto.Config
import org.meshtastic.proto.DeviceMetadata
class SendMessageUseCaseTest {
private lateinit var nodeRepository: NodeRepository
private lateinit var serviceRepository: ServiceRepository
private lateinit var homoglyphEncodingPrefs: HomoglyphPrefs
private lateinit var useCase: SendMessageUseCase
@Before
fun setUp() {
nodeRepository = mockk(relaxed = true)
serviceRepository = mockk(relaxed = true)
homoglyphEncodingPrefs = mockk(relaxed = true)
useCase =
SendMessageUseCase(
nodeRepository = nodeRepository,
serviceRepository = serviceRepository,
homoglyphEncodingPrefs = homoglyphEncodingPrefs,
)
mockkConstructor(Capabilities::class)
}
@Test
fun `invoke with broadcast message simply sends data packet`() = runTest {
// Arrange
val ourNode = mockk<Node>(relaxed = true)
every { ourNode.user.id } returns "!1234"
every { nodeRepository.ourNodeInfo } returns MutableStateFlow(ourNode)
every { homoglyphEncodingPrefs.homoglyphEncodingEnabled } returns false
// Act
useCase("Hello broadcast", "0${DataPacket.ID_BROADCAST}", null)
// Assert
coVerify(exactly = 0) { serviceRepository.onServiceAction(any()) }
coVerify(exactly = 1) { serviceRepository.meshService?.send(any()) }
}
@Test
fun `invoke with direct message to older firmware triggers favoriteNode`() = runTest {
// Arrange
val ourNode = mockk<Node>(relaxed = true)
val metadata = mockk<DeviceMetadata>(relaxed = true)
every { ourNode.user.id } returns "!local"
every { ourNode.user.role } returns Config.DeviceConfig.Role.CLIENT
every { ourNode.metadata } returns metadata
every { metadata.firmware_version } returns "2.0.0" // Older firmware
every { nodeRepository.ourNodeInfo } returns MutableStateFlow(ourNode)
val destNode = mockk<Node>(relaxed = true)
every { destNode.isFavorite } returns false
every { nodeRepository.getNode("!dest") } returns destNode
every { homoglyphEncodingPrefs.homoglyphEncodingEnabled } returns false
every { anyConstructed<Capabilities>().canSendVerifiedContacts } returns false
// Act
useCase("Direct message", "!dest", null)
// Assert
coVerify(exactly = 1) { serviceRepository.onServiceAction(match { it is ServiceAction.Favorite }) }
coVerify(exactly = 1) { serviceRepository.meshService?.send(any()) }
}
@Test
fun `invoke with direct message to new firmware triggers sendSharedContact`() = runTest {
// Arrange
val ourNode = mockk<Node>(relaxed = true)
val metadata = mockk<DeviceMetadata>(relaxed = true)
every { ourNode.user.id } returns "!local"
every { ourNode.user.role } returns Config.DeviceConfig.Role.CLIENT
every { ourNode.metadata } returns metadata
every { metadata.firmware_version } returns "2.7.12" // Newer firmware
every { nodeRepository.ourNodeInfo } returns MutableStateFlow(ourNode)
val destNode = mockk<Node>(relaxed = true)
every { nodeRepository.getNode("!dest") } returns destNode
every { homoglyphEncodingPrefs.homoglyphEncodingEnabled } returns false
every { anyConstructed<Capabilities>().canSendVerifiedContacts } returns true
// Act
useCase("Direct message", "!dest", null)
// Assert
coVerify(exactly = 1) { serviceRepository.onServiceAction(match { it is ServiceAction.SendContact }) }
coVerify(exactly = 1) { serviceRepository.meshService?.send(any()) }
}
@Test
fun `invoke with homoglyph enabled transforms text`() = runTest {
// Arrange
val ourNode = mockk<Node>(relaxed = true)
every { nodeRepository.ourNodeInfo } returns MutableStateFlow(ourNode)
every { homoglyphEncodingPrefs.homoglyphEncodingEnabled } returns true
// Let's use a cyrillic character 'A' (U+0410) that will be mapped to Latin 'A'
val originalText = "\u0410pple"
// Act
useCase(originalText, "0${DataPacket.ID_BROADCAST}", null)
// Assert
// We verify that send was called with the transformed text (Latin 'A'pple)
coVerify(exactly = 1) { serviceRepository.meshService?.send(match { it.text?.contains("Apple") == true }) }
}
}