diff --git a/app/detekt-baseline.xml b/app/detekt-baseline.xml
index e62c0a1dd..86f3334e6 100644
--- a/app/detekt-baseline.xml
+++ b/app/detekt-baseline.xml
@@ -119,6 +119,8 @@
LongMethod:StoreForwardConfigItemList.kt$@Composable fun StoreForwardConfigScreen(navController: NavController, viewModel: RadioConfigViewModel = hiltViewModel())
LongMethod:TelemetryConfigItemList.kt$@Composable fun TelemetryConfigScreen(navController: NavController, viewModel: RadioConfigViewModel = hiltViewModel())
LongMethod:UserConfigItemList.kt$@Composable fun UserConfigScreen(navController: NavController, viewModel: RadioConfigViewModel = hiltViewModel())
+ LongParameterList:MessageList.kt$( nodes: List<Node>, ourNode: Node?, isConnected: Boolean, modifier: Modifier = Modifier, listState: LazyListState = rememberLazyListState(), messages: List<Message>, selectedIds: MutableState<Set<Long>>, onUnreadChanged: (Long) -> Unit, onSendReaction: (String, Int) -> Unit, onNodeMenuAction: (NodeMenuAction) -> Unit, onDeleteMessages: (List<Long>) -> Unit, onSendMessage: (messageText: String, contactKey: String) -> Unit, contactKey: String, onReply: (Message?) -> Unit, )
+ LongParameterList:MessageViewModel.kt$MessageViewModel$( private val nodeRepository: NodeRepository, radioConfigRepository: RadioConfigRepository, quickChatActionRepository: QuickChatActionRepository, private val serviceRepository: ServiceRepository, private val packetRepository: PacketRepository, private val uiPrefs: UiPrefs, private val meshServiceNotifications: MeshServiceNotifications, )
MagicNumber:BluetoothInterface.kt$BluetoothInterface$1000
MagicNumber:BluetoothInterface.kt$BluetoothInterface$500
MagicNumber:BluetoothInterface.kt$BluetoothInterface$512
@@ -386,6 +388,7 @@
TooManyFunctions:BluetoothInterface.kt$BluetoothInterface : IRadioInterfaceLogging
TooManyFunctions:MeshService.kt$MeshService : ServiceLogging
TooManyFunctions:MeshService.kt$MeshService$<no name provided> : Stub
+ TooManyFunctions:MessageViewModel.kt$MessageViewModel : ViewModel
TooManyFunctions:NodeDetail.kt$com.geeksville.mesh.ui.node.NodeDetail.kt
TooManyFunctions:NodesViewModel.kt$NodesViewModel : ViewModel
TooManyFunctions:PacketRepository.kt$PacketRepository
@@ -400,7 +403,6 @@
ViewModelForwarding:Main.kt$MainAppBar( viewModel = uIViewModel, navController = navController, onAction = { action -> when (action) { is NodeMenuAction.MoreDetails -> { navController.navigate( NodesRoutes.NodeDetailGraph(action.node.num), { launchSingleTop = true restoreState = true }, ) } is NodeMenuAction.Share -> sharedContact = action.node else -> {} } }, )
ViewModelForwarding:Main.kt$ScannedQrCodeDialog(uIViewModel, newChannelSet)
ViewModelForwarding:Main.kt$VersionChecks(uIViewModel)
- ViewModelForwarding:Message.kt$MessageList( modifier = Modifier.fillMaxSize(), listState = listState, messages = messages, selectedIds = selectedMessageIds, onUnreadChanged = { messageId -> onEvent(MessageScreenEvent.ClearUnreadCount(messageId)) }, onSendReaction = { emoji, id -> onEvent(MessageScreenEvent.SendReaction(emoji, id)) }, viewModel = viewModel, contactKey = contactKey, onReply = { message -> replyingToPacketId = message?.packetId }, onNodeMenuAction = { action -> onEvent(MessageScreenEvent.HandleNodeMenuAction(action)) }, )
ViewModelInjection:DebugSearch.kt$viewModel
WildcardImport:UsbRepository.kt$import kotlinx.coroutines.flow.*
Wrapping:Message.kt${ event -> when (event) { is MessageScreenEvent.SendMessage -> { viewModel.sendMessage(event.text, contactKey, event.replyingToPacketId) if (event.replyingToPacketId != null) replyingToPacketId = null messageInputState.clearText() } is MessageScreenEvent.SendReaction -> viewModel.sendReaction(event.emoji, event.messageId, contactKey) is MessageScreenEvent.DeleteMessages -> { viewModel.deleteMessages(event.ids) selectedMessageIds.value = emptySet() showDeleteDialog = false } is MessageScreenEvent.ClearUnreadCount -> viewModel.clearUnreadCount(contactKey, event.lastReadMessageId) is MessageScreenEvent.HandleNodeMenuAction -> { when (val action = event.action) { is NodeMenuAction.DirectMessage -> { val hasPKC = ourNode?.hasPKC == true && action.node.hasPKC val targetChannel = if (hasPKC) { DataPacket.PKC_CHANNEL_INDEX } else { action.node.channel } navigateToMessages("$targetChannel${action.node.user.id}") } is NodeMenuAction.MoreDetails -> navigateToNodeDetails(action.node.num) is NodeMenuAction.Share -> sharedContact = action.node else -> viewModel.handleNodeMenuAction(action) } } is MessageScreenEvent.SetTitle -> viewModel.setTitle(event.title) is MessageScreenEvent.NavigateToMessages -> navigateToMessages(event.contactKey) is MessageScreenEvent.NavigateToNodeDetails -> navigateToNodeDetails(event.nodeNum) MessageScreenEvent.NavigateBack -> onNavigateBack() is MessageScreenEvent.CopyToClipboard -> { clipboardManager.nativeClipboard.setPrimaryClip(ClipData.newPlainText(event.text, event.text)) selectedMessageIds.value = emptySet() } } }
diff --git a/app/src/main/java/com/geeksville/mesh/model/UIState.kt b/app/src/main/java/com/geeksville/mesh/model/UIState.kt
index b01713eb9..86a3dad2a 100644
--- a/app/src/main/java/com/geeksville/mesh/model/UIState.kt
+++ b/app/src/main/java/com/geeksville/mesh/model/UIState.kt
@@ -65,20 +65,17 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.filterNotNull
-import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.mapNotNull
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.shareIn
import kotlinx.coroutines.flow.stateIn
-import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import org.meshtastic.core.database.entity.MyNodeEntity
import org.meshtastic.core.database.entity.Packet
import org.meshtastic.core.database.entity.QuickChatAction
import org.meshtastic.core.database.entity.asDeviceVersion
-import org.meshtastic.core.database.model.Message
import org.meshtastic.core.database.model.Node
import org.meshtastic.core.datastore.UiPreferencesDataSource
import org.meshtastic.core.model.DataPacket
@@ -281,27 +278,6 @@ constructor(
.getAllActions()
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
- private val _showQuickChat = MutableStateFlow(uiPrefs.showQuickChat)
- val showQuickChat: StateFlow = _showQuickChat
-
- fun toggleShowQuickChat() = toggle(_showQuickChat) { uiPrefs.showQuickChat = it }
-
- private fun toggle(state: MutableStateFlow, onChanged: (newValue: Boolean) -> Unit) {
- (!state.value).let { toggled ->
- state.update { toggled }
- onChanged(toggled)
- }
- }
-
- val nodeList: StateFlow> =
- nodeDB
- .getNodes()
- .stateIn(
- scope = viewModelScope,
- started = SharingStarted.WhileSubscribed(5_000),
- initialValue = emptyList(),
- )
-
// hardware info about our local device (can be null)
val myNodeInfo: StateFlow
get() = nodeDB.myNodeInfo
@@ -418,22 +394,6 @@ constructor(
initialValue = emptyList(),
)
- fun getMessagesFrom(contactKey: String): StateFlow> {
- contactKeyForMessages.value = contactKey
- return messagesForContactKey
- }
-
- private val contactKeyForMessages: MutableStateFlow = MutableStateFlow(null)
- private val messagesForContactKey: StateFlow> =
- contactKeyForMessages
- .filterNotNull()
- .flatMapLatest { contactKey -> packetRepository.getMessagesFrom(contactKey, ::getNode) }
- .stateIn(
- scope = viewModelScope,
- started = SharingStarted.WhileSubscribed(5_000),
- initialValue = emptyList(),
- )
-
fun generatePacketId(): Int? {
return try {
meshService?.packetId
@@ -443,23 +403,6 @@ constructor(
}
}
- 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 node = nodeDB.getNode(dest)
- if (!node.isFavorite) {
- favoriteNode(nodeDB.getNode(dest))
- }
- }
- val p = DataPacket(dest, channel ?: 0, str, replyId)
- sendDataPacket(p)
- }
-
fun sendWaypoint(wpt: MeshProtos.Waypoint, contactKey: String = "0${DataPacket.ID_BROADCAST}") {
// contactKey: unique contact key filter (channel)+(nodeId)
val channel = contactKey[0].digitToIntOrNull()
@@ -477,9 +420,6 @@ constructor(
}
}
- fun sendReaction(emoji: String, replyId: Int, contactKey: String) =
- viewModelScope.launch { serviceRepository.onServiceAction(ServiceAction.Reaction(emoji, replyId, contactKey)) }
-
private val _sharedContactRequested: MutableStateFlow = MutableStateFlow(null)
val sharedContactRequested: StateFlow
get() = _sharedContactRequested.asStateFlow()
@@ -533,17 +473,8 @@ constructor(
fun deleteContacts(contacts: List) =
viewModelScope.launch(Dispatchers.IO) { packetRepository.deleteContacts(contacts) }
- fun deleteMessages(uuidList: List) =
- viewModelScope.launch(Dispatchers.IO) { packetRepository.deleteMessages(uuidList) }
-
fun deleteWaypoint(id: Int) = viewModelScope.launch(Dispatchers.IO) { packetRepository.deleteWaypoint(id) }
- fun clearUnreadCount(contact: String, timestamp: Long) = viewModelScope.launch(Dispatchers.IO) {
- packetRepository.clearUnreadCount(contact, timestamp)
- val unreadCount = packetRepository.getUnreadCount(contact)
- if (unreadCount == 0) meshServiceNotifications.cancelMessageNotification(contact)
- }
-
// Connection state to our radio device
val connectionState
get() = serviceRepository.connectionState
diff --git a/app/src/main/java/com/geeksville/mesh/navigation/ContactsNavigation.kt b/app/src/main/java/com/geeksville/mesh/navigation/ContactsNavigation.kt
index 023d96dfe..d6224fd00 100644
--- a/app/src/main/java/com/geeksville/mesh/navigation/ContactsNavigation.kt
+++ b/app/src/main/java/com/geeksville/mesh/navigation/ContactsNavigation.kt
@@ -65,7 +65,6 @@ fun NavGraphBuilder.contactsGraph(navController: NavHostController, uiViewModel:
MessageScreen(
contactKey = args.contactKey,
message = args.message,
- viewModel = uiViewModel,
navigateToMessages = { navController.navigate(ContactsRoutes.Messages(it)) },
navigateToNodeDetails = { navController.navigate(NodesRoutes.NodeDetailGraph(it)) },
navigateToQuickChatOptions = { navController.navigate(ContactsRoutes.QuickChat) },
diff --git a/app/src/main/java/com/geeksville/mesh/ui/message/Message.kt b/app/src/main/java/com/geeksville/mesh/ui/message/Message.kt
index 1eb7be061..3affaee13 100644
--- a/app/src/main/java/com/geeksville/mesh/ui/message/Message.kt
+++ b/app/src/main/java/com/geeksville/mesh/ui/message/Message.kt
@@ -95,7 +95,6 @@ import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.geeksville.mesh.AppOnlyProtos
-import com.geeksville.mesh.model.UIViewModel
import com.geeksville.mesh.model.getChannel
import com.geeksville.mesh.ui.common.components.SecurityIcon
import com.geeksville.mesh.ui.node.components.NodeKeyStatusIcon
@@ -120,7 +119,7 @@ private const val ROUNDED_CORNER_PERCENT = 100
*
* @param contactKey A unique key identifying the contact or channel.
* @param message An optional message to pre-fill in the input field.
- * @param viewModel The [UIViewModel] instance for handling business logic and state.
+ * @param viewModel The [MessageViewModel] instance for handling business logic and state.
* @param navigateToMessages Callback to navigate to a different message thread.
* @param navigateToNodeDetails Callback to navigate to a node's detail screen.
* @param onNavigateBack Callback to navigate back from this screen.
@@ -130,7 +129,7 @@ private const val ROUNDED_CORNER_PERCENT = 100
internal fun MessageScreen(
contactKey: String,
message: String,
- viewModel: UIViewModel = hiltViewModel(),
+ viewModel: MessageViewModel = hiltViewModel(),
navigateToMessages: (String) -> Unit,
navigateToNodeDetails: (Int) -> Unit,
navigateToQuickChatOptions: () -> Unit,
@@ -139,9 +138,9 @@ internal fun MessageScreen(
val coroutineScope = rememberCoroutineScope()
val clipboardManager = LocalClipboard.current
- // State from ViewModel
+ val nodes by viewModel.nodeList.collectAsStateWithLifecycle()
val ourNode by viewModel.ourNodeInfo.collectAsStateWithLifecycle()
- val isConnected by viewModel.isConnectedStateFlow.collectAsStateWithLifecycle(initialValue = false)
+ val connectionState by viewModel.connectionState.collectAsStateWithLifecycle()
val channels by viewModel.channels.collectAsStateWithLifecycle()
val quickChatActions by viewModel.quickChatActions.collectAsStateWithLifecycle(initialValue = emptyList())
val messages by viewModel.getMessagesFrom(contactKey).collectAsStateWithLifecycle(initialValue = emptyList())
@@ -296,13 +295,17 @@ internal fun MessageScreen(
Column(Modifier.padding(paddingValues)) {
Box(modifier = Modifier.weight(1f)) {
MessageList(
+ nodes = nodes,
+ ourNode = ourNode,
+ isConnected = connectionState.isConnected(),
modifier = Modifier.fillMaxSize(),
listState = listState,
messages = messages,
selectedIds = selectedMessageIds,
onUnreadChanged = { messageId -> onEvent(MessageScreenEvent.ClearUnreadCount(messageId)) },
onSendReaction = { emoji, id -> onEvent(MessageScreenEvent.SendReaction(emoji, id)) },
- viewModel = viewModel,
+ onDeleteMessages = { viewModel.deleteMessages(it) },
+ onSendMessage = { text, contactKey -> viewModel.sendMessage(text, contactKey) },
contactKey = contactKey,
onReply = { message -> replyingToPacketId = message?.packetId },
onNodeMenuAction = { action -> onEvent(MessageScreenEvent.HandleNodeMenuAction(action)) },
@@ -314,7 +317,7 @@ internal fun MessageScreen(
}
AnimatedVisibility(visible = showQuickChat) {
QuickChatRow(
- enabled = isConnected,
+ enabled = connectionState.isConnected(),
actions = quickChatActions,
onClick = { action ->
handleQuickChatAction(
@@ -337,7 +340,7 @@ internal fun MessageScreen(
ourNode = ourNode,
)
MessageInput(
- isEnabled = isConnected,
+ isEnabled = connectionState.isConnected(),
textFieldState = messageInputState,
onSendMessage = {
val messageText = messageInputState.text.toString().trim()
diff --git a/app/src/main/java/com/geeksville/mesh/ui/message/MessageList.kt b/app/src/main/java/com/geeksville/mesh/ui/message/MessageList.kt
index 1dd81329f..65414a369 100644
--- a/app/src/main/java/com/geeksville/mesh/ui/message/MessageList.kt
+++ b/app/src/main/java/com/geeksville/mesh/ui/message/MessageList.kt
@@ -46,8 +46,6 @@ import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
-import androidx.lifecycle.compose.collectAsStateWithLifecycle
-import com.geeksville.mesh.model.UIViewModel
import com.geeksville.mesh.ui.message.components.MessageItem
import com.geeksville.mesh.ui.message.components.ReactionDialog
import com.geeksville.mesh.ui.node.components.NodeMenuAction
@@ -57,6 +55,7 @@ import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.launch
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.R
@@ -106,6 +105,9 @@ fun DeliveryInfo(
@Suppress("LongMethod")
@Composable
internal fun MessageList(
+ nodes: List,
+ ourNode: Node?,
+ isConnected: Boolean,
modifier: Modifier = Modifier,
listState: LazyListState = rememberLazyListState(),
messages: List,
@@ -113,7 +115,8 @@ internal fun MessageList(
onUnreadChanged: (Long) -> Unit,
onSendReaction: (String, Int) -> Unit,
onNodeMenuAction: (NodeMenuAction) -> Unit,
- viewModel: UIViewModel,
+ onDeleteMessages: (List) -> Unit,
+ onSendMessage: (messageText: String, contactKey: String) -> Unit,
contactKey: String,
onReply: (Message?) -> Unit,
) {
@@ -131,9 +134,9 @@ internal fun MessageList(
text = text,
onConfirm = {
val deleteList: List = listOf(msg.uuid)
- viewModel.deleteMessages(deleteList)
+ onDeleteMessages(deleteList)
showStatusDialog = null
- viewModel.sendMessage(msg.text, contactKey)
+ onSendMessage(msg.text, contactKey)
},
onDismiss = { showStatusDialog = null },
resendOption = msg.status?.equals(MessageStatus.ERROR) ?: false,
@@ -152,9 +155,6 @@ internal fun MessageList(
value += uuid
}
- val nodes by viewModel.nodeList.collectAsStateWithLifecycle()
- val ourNode by viewModel.ourNodeInfo.collectAsStateWithLifecycle()
- val isConnected by viewModel.isConnectedStateFlow.collectAsStateWithLifecycle(false)
val coroutineScope = rememberCoroutineScope()
LazyColumn(modifier = modifier.fillMaxSize(), state = listState, reverseLayout = true) {
items(messages, key = { it.uuid }) { msg ->
@@ -165,7 +165,7 @@ internal fun MessageList(
MessageItem(
modifier = Modifier.animateItem(),
node = node,
- ourNode = ourNode!!,
+ ourNode = ourNode,
message = msg,
selected = selected,
onClick = { if (inSelectionMode) selectedIds.toggle(msg.uuid) },
diff --git a/app/src/main/java/com/geeksville/mesh/ui/message/MessageViewModel.kt b/app/src/main/java/com/geeksville/mesh/ui/message/MessageViewModel.kt
new file mode 100644
index 000000000..eac3456b7
--- /dev/null
+++ b/app/src/main/java/com/geeksville/mesh/ui/message/MessageViewModel.kt
@@ -0,0 +1,238 @@
+/*
+ * Copyright (c) 2025 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 com.geeksville.mesh.ui.message
+
+import android.os.RemoteException
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import com.geeksville.mesh.channelSet
+import com.geeksville.mesh.database.NodeRepository
+import com.geeksville.mesh.database.PacketRepository
+import com.geeksville.mesh.database.QuickChatActionRepository
+import com.geeksville.mesh.repository.datastore.RadioConfigRepository
+import com.geeksville.mesh.service.MeshServiceNotifications
+import com.geeksville.mesh.service.ServiceAction
+import com.geeksville.mesh.service.ServiceRepository
+import com.geeksville.mesh.ui.node.components.NodeMenuAction
+import dagger.hilt.android.lifecycle.HiltViewModel
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.SharingStarted
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.filterNotNull
+import kotlinx.coroutines.flow.flatMapLatest
+import kotlinx.coroutines.flow.stateIn
+import kotlinx.coroutines.flow.update
+import kotlinx.coroutines.launch
+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.Position
+import org.meshtastic.core.prefs.ui.UiPrefs
+import timber.log.Timber
+import javax.inject.Inject
+
+@HiltViewModel
+class MessageViewModel
+@Inject
+constructor(
+ private val nodeRepository: NodeRepository,
+ radioConfigRepository: RadioConfigRepository,
+ quickChatActionRepository: QuickChatActionRepository,
+ private val serviceRepository: ServiceRepository,
+ private val packetRepository: PacketRepository,
+ private val uiPrefs: UiPrefs,
+ private val meshServiceNotifications: MeshServiceNotifications,
+) : ViewModel() {
+ private val _title = MutableStateFlow("")
+ val title: StateFlow = _title.asStateFlow()
+
+ val ourNodeInfo = nodeRepository.ourNodeInfo
+
+ val connectionState = serviceRepository.connectionState
+
+ val nodeList: StateFlow> =
+ nodeRepository
+ .getNodes()
+ .stateIn(
+ scope = viewModelScope,
+ started = SharingStarted.WhileSubscribed(5_000),
+ initialValue = emptyList(),
+ )
+
+ val channels =
+ radioConfigRepository.channelSetFlow.stateIn(
+ viewModelScope,
+ SharingStarted.WhileSubscribed(5_000),
+ channelSet {},
+ )
+
+ private val _showQuickChat = MutableStateFlow(uiPrefs.showQuickChat)
+ val showQuickChat: StateFlow = _showQuickChat
+
+ val quickChatActions =
+ quickChatActionRepository
+ .getAllActions()
+ .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
+
+ private val contactKeyForMessages: MutableStateFlow = MutableStateFlow(null)
+ private val messagesForContactKey: StateFlow> =
+ contactKeyForMessages
+ .filterNotNull()
+ .flatMapLatest { contactKey -> packetRepository.getMessagesFrom(contactKey, ::getNode) }
+ .stateIn(
+ scope = viewModelScope,
+ started = SharingStarted.WhileSubscribed(5_000),
+ initialValue = emptyList(),
+ )
+
+ // TODO this should be moved to a repository class
+ private val _lastTraceRouteTime = MutableStateFlow(null)
+ val lastTraceRouteTime: StateFlow = _lastTraceRouteTime.asStateFlow()
+
+ fun setTitle(title: String) {
+ viewModelScope.launch { _title.value = title }
+ }
+
+ fun getMessagesFrom(contactKey: String): StateFlow> {
+ contactKeyForMessages.value = contactKey
+ return messagesForContactKey
+ }
+
+ fun toggleShowQuickChat() = toggle(_showQuickChat) { uiPrefs.showQuickChat = it }
+
+ private fun toggle(state: MutableStateFlow, onChanged: (newValue: Boolean) -> Unit) {
+ (!state.value).let { toggled ->
+ state.update { toggled }
+ onChanged(toggled)
+ }
+ }
+
+ fun getNode(userId: String?) = nodeRepository.getNode(userId ?: DataPacket.ID_BROADCAST)
+
+ fun getUser(userId: String?) = nodeRepository.getUser(userId ?: DataPacket.ID_BROADCAST)
+
+ 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 node = nodeRepository.getNode(dest)
+ if (!node.isFavorite) {
+ favoriteNode(nodeRepository.getNode(dest))
+ }
+ }
+ val p = DataPacket(dest, channel ?: 0, str, replyId)
+ sendDataPacket(p)
+ }
+
+ fun sendReaction(emoji: String, replyId: Int, contactKey: String) =
+ viewModelScope.launch { serviceRepository.onServiceAction(ServiceAction.Reaction(emoji, replyId, contactKey)) }
+
+ fun deleteMessages(uuidList: List) =
+ viewModelScope.launch(Dispatchers.IO) { packetRepository.deleteMessages(uuidList) }
+
+ fun clearUnreadCount(contact: String, timestamp: Long) = viewModelScope.launch(Dispatchers.IO) {
+ packetRepository.clearUnreadCount(contact, timestamp)
+ val unreadCount = packetRepository.getUnreadCount(contact)
+ if (unreadCount == 0) meshServiceNotifications.cancelMessageNotification(contact)
+ }
+
+ fun handleNodeMenuAction(action: NodeMenuAction) {
+ when (action) {
+ is NodeMenuAction.Remove -> removeNode(action.node.num)
+ is NodeMenuAction.Ignore -> ignoreNode(action.node)
+ is NodeMenuAction.Favorite -> favoriteNode(action.node)
+ is NodeMenuAction.RequestUserInfo -> requestUserInfo(action.node.num)
+ is NodeMenuAction.RequestPosition -> requestPosition(action.node.num)
+ is NodeMenuAction.TraceRoute -> {
+ requestTraceroute(action.node.num)
+ _lastTraceRouteTime.value = System.currentTimeMillis()
+ }
+
+ else -> {}
+ }
+ }
+
+ private fun favoriteNode(node: Node) = viewModelScope.launch {
+ try {
+ serviceRepository.onServiceAction(ServiceAction.Favorite(node))
+ } catch (ex: RemoteException) {
+ Timber.e(ex, "Favorite node error")
+ }
+ }
+
+ private fun sendDataPacket(p: DataPacket) {
+ try {
+ serviceRepository.meshService?.send(p)
+ } catch (ex: RemoteException) {
+ Timber.e("Send DataPacket error: ${ex.message}")
+ }
+ }
+
+ private fun removeNode(nodeNum: Int) = viewModelScope.launch(Dispatchers.IO) {
+ Timber.i("Removing node '$nodeNum'")
+ try {
+ val packetId = serviceRepository.meshService?.packetId ?: return@launch
+ serviceRepository.meshService?.removeByNodenum(packetId, nodeNum)
+ nodeRepository.deleteNode(nodeNum)
+ } catch (ex: RemoteException) {
+ Timber.e("Remove node error: ${ex.message}")
+ }
+ }
+
+ private fun ignoreNode(node: Node) = viewModelScope.launch {
+ try {
+ serviceRepository.onServiceAction(ServiceAction.Ignore(node))
+ } catch (ex: RemoteException) {
+ Timber.e(ex, "Ignore node error:")
+ }
+ }
+
+ private fun requestUserInfo(destNum: Int) {
+ Timber.i("Requesting UserInfo for '$destNum'")
+ try {
+ serviceRepository.meshService?.requestUserInfo(destNum)
+ } catch (ex: RemoteException) {
+ Timber.e("Request NodeInfo error: ${ex.message}")
+ }
+ }
+
+ fun requestPosition(destNum: Int, position: Position = Position(0.0, 0.0, 0)) {
+ Timber.i("Requesting position for '$destNum'")
+ try {
+ serviceRepository.meshService?.requestPosition(destNum, position)
+ } catch (ex: RemoteException) {
+ Timber.e("Request position error: ${ex.message}")
+ }
+ }
+
+ fun requestTraceroute(destNum: Int) {
+ Timber.i("Requesting traceroute for '$destNum'")
+ try {
+ val packetId = serviceRepository.meshService?.packetId ?: return
+ serviceRepository.meshService?.requestTraceroute(packetId, destNum)
+ } catch (ex: RemoteException) {
+ Timber.e("Request traceroute error: ${ex.message}")
+ }
+ }
+}