mirror of
https://github.com/meshtastic/Meshtastic-Android.git
synced 2026-04-20 22:23:37 +00:00
refactor(ui): compose resources, domain layer (#4628)
Signed-off-by: James Rich <2199651+jamesarich@users.noreply.github.com>
This commit is contained in:
parent
96adc70401
commit
2676a51647
322 changed files with 3031 additions and 2790 deletions
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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 } }
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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?,
|
||||
|
|
|
|||
|
|
@ -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}" }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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" }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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 }) }
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue