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}") + } + } +}