Meshtastic-Android/app/src/main/java/com/geeksville/mesh/model/UIState.kt

831 lines
31 KiB
Kotlin
Raw Normal View History

/*
2025-01-02 06:50:26 -03:00
* 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 <https://www.gnu.org/licenses/>.
*/
2020-02-17 13:34:52 -08:00
package com.geeksville.mesh.model
import android.app.Application
2020-03-17 11:35:19 -07:00
import android.net.Uri
2020-02-18 12:22:45 -08:00
import android.os.RemoteException
2025-08-20 18:49:06 -04:00
import androidx.compose.material3.SnackbarDuration
2025-05-17 11:39:53 -05:00
import androidx.compose.material3.SnackbarHostState
2025-08-20 18:49:06 -04:00
import androidx.compose.material3.SnackbarResult
2022-04-22 17:22:06 -03:00
import androidx.lifecycle.LiveData
import androidx.lifecycle.ViewModel
2022-09-15 22:24:04 -03:00
import androidx.lifecycle.asLiveData
2020-09-23 22:47:45 -04:00
import androidx.lifecycle.viewModelScope
import com.geeksville.mesh.AdminProtos
import com.geeksville.mesh.AppOnlyProtos
import com.geeksville.mesh.ChannelProtos
import com.geeksville.mesh.ChannelProtos.ChannelSettings
2022-09-12 00:26:12 -03:00
import com.geeksville.mesh.ConfigProtos.Config
import com.geeksville.mesh.DataPacket
import com.geeksville.mesh.IMeshService
2022-09-12 00:26:12 -03:00
import com.geeksville.mesh.LocalOnlyProtos.LocalConfig
2022-11-22 22:01:37 -03:00
import com.geeksville.mesh.LocalOnlyProtos.LocalModuleConfig
import com.geeksville.mesh.MeshProtos
import com.geeksville.mesh.Position
import com.geeksville.mesh.R
2024-07-28 05:04:50 -05:00
import com.geeksville.mesh.android.Logging
import com.geeksville.mesh.android.prefs.UiPrefs
import com.geeksville.mesh.channel
import com.geeksville.mesh.channelSet
import com.geeksville.mesh.channelSettings
import com.geeksville.mesh.config
import com.geeksville.mesh.copy
2024-07-28 05:04:50 -05:00
import com.geeksville.mesh.database.MeshLogRepository
import com.geeksville.mesh.database.NodeRepository
2022-09-14 01:54:13 -03:00
import com.geeksville.mesh.database.PacketRepository
2024-07-28 05:04:50 -05:00
import com.geeksville.mesh.database.QuickChatActionRepository
import com.geeksville.mesh.database.entity.MyNodeEntity
import com.geeksville.mesh.database.entity.Packet
2024-07-28 05:04:50 -05:00
import com.geeksville.mesh.database.entity.QuickChatAction
import com.geeksville.mesh.database.entity.asDeviceVersion
import com.geeksville.mesh.repository.api.DeviceHardwareRepository
import com.geeksville.mesh.repository.api.FirmwareReleaseRepository
import com.geeksville.mesh.repository.datastore.RadioConfigRepository
import com.geeksville.mesh.repository.radio.MeshActivity
import com.geeksville.mesh.repository.radio.RadioInterfaceService
import com.geeksville.mesh.service.MeshServiceNotifications
import com.geeksville.mesh.service.ServiceAction
import com.geeksville.mesh.ui.node.components.NodeMenuAction
import com.geeksville.mesh.util.getShortDate
2025-08-30 13:00:51 +10:00
import com.geeksville.mesh.util.safeNumber
import dagger.hilt.android.lifecycle.HiltViewModel
2020-09-23 22:47:45 -04:00
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
2024-07-28 05:04:50 -05:00
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.filterNotNull
2022-02-03 18:15:06 -08:00
import kotlinx.coroutines.flow.first
2022-09-15 22:24:04 -03:00
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
2024-05-27 09:56:26 -03:00
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
2020-09-23 22:47:45 -04:00
import kotlinx.coroutines.launch
import javax.inject.Inject
// Given a human name, strip out the first letter of the first three words and return that as the
// initials for
// that user, ignoring emojis. If the original name is only one word, strip vowels from the original
// name and if the result is 3 or more characters, use the first three characters. If not, just take
// the first 3 characters of the original name.
fun getInitials(fullName: String): String {
val maxInitialLength = 4
val minWordCountForInitials = 2
val name = fullName.trim().withoutEmojis()
val words = name.split(Regex("\\s+")).filter { it.isNotEmpty() }
val initials =
when (words.size) {
in 0 until minWordCountForInitials -> {
val nameWithoutVowels =
if (name.isNotEmpty()) {
name.first() + name.drop(1).filterNot { c -> c.lowercase() in "aeiou" }
} else {
""
}
if (nameWithoutVowels.length >= maxInitialLength) nameWithoutVowels else name
}
2025-05-17 11:39:53 -05:00
else -> words.map { it.first() }.joinToString("")
}
return initials.take(maxInitialLength)
}
2020-03-15 16:30:12 -07:00
private fun String.withoutEmojis(): String = filterNot { char -> char.isSurrogate() }
/**
* Builds a [Channel] list from the difference between two [ChannelSettings] lists. Only changes are included in the
* resulting list.
*
* @param new The updated [ChannelSettings] list.
* @param old The current [ChannelSettings] list (required when disabling unused channels).
* @return A [Channel] list containing only the modified channels.
*/
internal fun getChannelList(new: List<ChannelSettings>, old: List<ChannelSettings>): List<ChannelProtos.Channel> =
buildList {
for (i in 0..maxOf(old.lastIndex, new.lastIndex)) {
if (old.getOrNull(i) != new.getOrNull(i)) {
add(
channel {
role =
when (i) {
0 -> ChannelProtos.Channel.Role.PRIMARY
in 1..new.lastIndex -> ChannelProtos.Channel.Role.SECONDARY
else -> ChannelProtos.Channel.Role.DISABLED
}
index = i
settings = new.getOrNull(i) ?: channelSettings {}
},
)
}
}
}
2025-05-17 11:39:53 -05:00
2024-05-27 09:56:26 -03:00
data class NodesUiState(
val sort: NodeSortOption = NodeSortOption.LAST_HEARD,
val filter: String = "",
val includeUnknown: Boolean = false,
val onlyOnline: Boolean = false,
val onlyDirect: Boolean = false,
2024-07-28 05:04:50 -05:00
val distanceUnits: Int = 0,
val tempInFahrenheit: Boolean = false,
val showDetails: Boolean = false,
val showIgnored: Boolean = false,
2024-05-27 09:56:26 -03:00
) {
companion object {
val Empty = NodesUiState()
}
}
data class Contact(
val contactKey: String,
val shortName: String,
val longName: String,
val lastMessageTime: String?,
val lastMessageText: String?,
val unreadCount: Int,
val messageCount: Int,
val isMuted: Boolean,
2025-05-20 16:05:40 -05:00
val isUnmessageable: Boolean,
val nodeColors: Pair<Int, Int>? = null,
)
@Suppress("LongParameterList", "LargeClass", "UnusedPrivateProperty")
@HiltViewModel
class UIViewModel
@Inject
constructor(
private val app: Application,
private val nodeDB: NodeRepository,
private val radioConfigRepository: RadioConfigRepository,
radioInterfaceService: RadioInterfaceService,
2022-09-13 22:49:38 -03:00
private val meshLogRepository: MeshLogRepository,
private val deviceHardwareRepository: DeviceHardwareRepository,
2022-09-14 01:54:13 -03:00
private val packetRepository: PacketRepository,
2022-08-11 16:43:26 +01:00
private val quickChatActionRepository: QuickChatActionRepository,
firmwareReleaseRepository: FirmwareReleaseRepository,
private val uiPrefs: UiPrefs,
private val meshServiceNotifications: MeshServiceNotifications,
) : ViewModel(),
Logging {
2020-09-23 22:47:45 -04:00
val theme: StateFlow<Int> = uiPrefs.themeFlow
2025-05-17 11:39:53 -05:00
private val _lastTraceRouteTime = MutableStateFlow<Long?>(null)
val lastTraceRouteTime: StateFlow<Long?> = _lastTraceRouteTime.asStateFlow()
val firmwareVersion = myNodeInfo.mapNotNull { nodeInfo -> nodeInfo?.firmwareVersion }
val firmwareEdition = meshLogRepository.getMyNodeInfo().map { nodeInfo -> nodeInfo?.firmwareEdition }
val deviceHardware: StateFlow<DeviceHardware?> =
ourNodeInfo
.mapNotNull { nodeInfo ->
2025-08-30 13:00:51 +10:00
nodeInfo?.user?.hwModel?.let { hwModel ->
deviceHardwareRepository.getDeviceHardwareByModel(hwModel.safeNumber()).getOrNull()
}
}
.stateIn(scope = viewModelScope, started = SharingStarted.WhileSubscribed(5_000), initialValue = null)
val clientNotification: StateFlow<MeshProtos.ClientNotification?> = radioConfigRepository.clientNotification
fun clearClientNotification(notification: MeshProtos.ClientNotification) {
radioConfigRepository.clearClientNotification()
meshServiceNotifications.clearClientNotification(notification)
}
/**
* Emits events for mesh network send/receive activity. This is a SharedFlow to ensure all events are delivered,
* even if they are the same.
*/
val meshActivity: SharedFlow<MeshActivity> =
radioInterfaceService.meshActivity.shareIn(viewModelScope, SharingStarted.Eagerly, 0)
2025-05-17 11:39:53 -05:00
data class AlertData(
val title: String,
val message: String? = null,
val html: String? = null,
val onConfirm: (() -> Unit)? = null,
val onDismiss: (() -> Unit)? = null,
val choices: Map<String, () -> Unit> = emptyMap(),
2025-05-17 11:39:53 -05:00
)
private val _currentAlert: MutableStateFlow<AlertData?> = MutableStateFlow(null)
val currentAlert = _currentAlert.asStateFlow()
fun showAlert(
title: String,
message: String? = null,
html: String? = null,
onConfirm: (() -> Unit)? = {},
dismissable: Boolean = true,
choices: Map<String, () -> Unit> = emptyMap(),
2025-05-17 11:39:53 -05:00
) {
_currentAlert.value =
AlertData(
title = title,
message = message,
html = html,
onConfirm = {
onConfirm?.invoke()
dismissAlert()
2025-05-17 11:39:53 -05:00
},
onDismiss = { if (dismissable) dismissAlert() },
choices = choices,
2025-05-17 11:39:53 -05:00
)
}
private fun dismissAlert() {
_currentAlert.value = null
}
private val _title = MutableStateFlow("")
val title: StateFlow<String> = _title.asStateFlow()
fun setTitle(title: String) {
viewModelScope.launch { _title.value = title }
}
2025-05-17 11:39:53 -05:00
val meshService: IMeshService?
get() = radioConfigRepository.meshService
2023-02-02 17:13:44 -03:00
private val localConfig = MutableStateFlow<LocalConfig>(LocalConfig.getDefaultInstance())
val config
get() = localConfig.value
2022-06-20 22:46:45 -03:00
private val _moduleConfig = MutableStateFlow<LocalModuleConfig>(LocalModuleConfig.getDefaultInstance())
2022-11-22 22:01:37 -03:00
val moduleConfig: StateFlow<LocalModuleConfig> = _moduleConfig
val module
get() = _moduleConfig.value
2022-11-22 22:01:37 -03:00
private val _channels = MutableStateFlow(channelSet {})
val channels: StateFlow<AppOnlyProtos.ChannelSet>
get() = _channels
2022-09-12 19:07:30 -03:00
2025-05-17 11:39:53 -05:00
val quickChatActions
get() =
quickChatActionRepository
.getAllActions()
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
2022-08-11 16:43:26 +01:00
2024-05-27 09:56:26 -03:00
private val nodeFilterText = MutableStateFlow("")
private val nodeSortOption =
MutableStateFlow(NodeSortOption.entries.getOrElse(uiPrefs.nodeSortOption) { NodeSortOption.VIA_FAVORITE })
private val includeUnknown = MutableStateFlow(uiPrefs.includeUnknown)
private val showDetails = MutableStateFlow(uiPrefs.showDetails)
private val onlyOnline = MutableStateFlow(uiPrefs.onlyOnline)
private val onlyDirect = MutableStateFlow(uiPrefs.onlyDirect)
private val _showIgnored = MutableStateFlow(uiPrefs.showIgnored)
val showIgnored: StateFlow<Boolean> = _showIgnored
private val _showQuickChat = MutableStateFlow(uiPrefs.showQuickChat)
val showQuickChat: StateFlow<Boolean> = _showQuickChat
fun toggleShowIgnored() = toggle(_showIgnored) { uiPrefs.showIgnored = it }
fun toggleShowQuickChat() = toggle(_showQuickChat) { uiPrefs.showQuickChat = it }
2024-05-27 09:56:26 -03:00
fun setSortOption(sort: NodeSortOption) {
nodeSortOption.value = sort
uiPrefs.nodeSortOption = sort.ordinal
2024-05-27 09:56:26 -03:00
}
fun toggleShowDetails() = toggle(showDetails) { uiPrefs.showDetails = it }
fun toggleIncludeUnknown() = toggle(includeUnknown) { uiPrefs.includeUnknown = it }
2024-07-28 05:04:50 -05:00
fun toggleOnlyOnline() = toggle(onlyOnline) { uiPrefs.onlyOnline = it }
fun toggleOnlyDirect() = toggle(onlyDirect) { uiPrefs.onlyDirect = it }
private fun toggle(state: MutableStateFlow<Boolean>, onChanged: (newValue: Boolean) -> Unit) {
(!state.value).let { toggled ->
state.update { toggled }
onChanged(toggled)
}
}
data class NodeFilterState(
val filterText: String,
val includeUnknown: Boolean,
val onlyOnline: Boolean,
val onlyDirect: Boolean,
val showIgnored: Boolean,
)
val nodeFilterStateFlow: Flow<NodeFilterState> =
combine(nodeFilterText, includeUnknown, onlyOnline, onlyDirect, showIgnored) {
filterText,
includeUnknown,
onlyOnline,
onlyDirect,
showIgnored,
->
NodeFilterState(filterText, includeUnknown, onlyOnline, onlyDirect, showIgnored)
}
2024-05-27 09:56:26 -03:00
val nodesUiState: StateFlow<NodesUiState> =
combine(nodeFilterStateFlow, nodeSortOption, showDetails, radioConfigRepository.deviceProfileFlow) {
filterFlow,
sort,
showDetails,
profile,
->
NodesUiState(
sort = sort,
filter = filterFlow.filterText,
includeUnknown = filterFlow.includeUnknown,
onlyOnline = filterFlow.onlyOnline,
onlyDirect = filterFlow.onlyDirect,
distanceUnits = profile.config.display.units.number,
tempInFahrenheit = profile.moduleConfig.telemetry.environmentDisplayFahrenheit,
showDetails = showDetails,
showIgnored = filterFlow.showIgnored,
)
}
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = NodesUiState.Empty,
)
val unfilteredNodeList: StateFlow<List<Node>> =
nodeDB
.getNodes()
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = emptyList(),
)
val nodeList: StateFlow<List<Node>> =
nodesUiState
.flatMapLatest { state ->
nodeDB
.getNodes(state.sort, state.filter, state.includeUnknown, state.onlyOnline, state.onlyDirect)
.map { list -> list.filter { it.isIgnored == state.showIgnored } }
}
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = emptyList(),
)
2024-05-27 09:56:26 -03:00
val onlineNodeCount =
nodeDB.onlineNodeCount.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = 0,
)
val totalNodeCount =
nodeDB.totalNodeCount.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = 0,
)
// hardware info about our local device (can be null)
val myNodeInfo: StateFlow<MyNodeEntity?>
get() = nodeDB.myNodeInfo
val ourNodeInfo: StateFlow<Node?>
get() = nodeDB.ourNodeInfo
fun getNode(userId: String?) = nodeDB.getNode(userId ?: DataPacket.ID_BROADCAST)
fun getUser(userId: String?) = nodeDB.getUser(userId ?: DataPacket.ID_BROADCAST)
2023-02-02 17:13:44 -03:00
2025-08-20 18:49:06 -04:00
val snackBarHostState = SnackbarHostState()
fun showSnackBar(text: Int) = showSnackBar(app.getString(text))
2025-08-20 18:49:06 -04:00
fun showSnackBar(
text: String,
actionLabel: String? = null,
withDismissAction: Boolean = false,
duration: SnackbarDuration = if (actionLabel == null) SnackbarDuration.Short else SnackbarDuration.Indefinite,
onActionPerformed: (() -> Unit) = {},
onDismissed: (() -> Unit) = {},
) = viewModelScope.launch {
snackBarHostState.showSnackbar(text, actionLabel, withDismissAction, duration).run {
when (this) {
SnackbarResult.ActionPerformed -> onActionPerformed()
SnackbarResult.Dismissed -> onDismissed()
}
}
}
2020-04-07 17:42:31 -07:00
init {
radioConfigRepository.errorMessage
.filterNotNull()
.onEach {
showAlert(
title = app.getString(R.string.client_notification),
message = it,
onConfirm = { radioConfigRepository.clearErrorMessage() },
dismissable = false,
)
}
.launchIn(viewModelScope)
radioConfigRepository.localConfigFlow.onEach { config -> localConfig.value = config }.launchIn(viewModelScope)
radioConfigRepository.moduleConfigFlow
.onEach { config -> _moduleConfig.value = config }
.launchIn(viewModelScope)
radioConfigRepository.channelSetFlow
.onEach { channelSet -> _channels.value = channelSet }
.launchIn(viewModelScope)
2020-04-07 17:42:31 -07:00
debug("ViewModel created")
}
val contactList =
combine(nodeDB.myNodeInfo, packetRepository.getContacts(), channels, packetRepository.getContactSettings()) {
myNodeInfo,
contacts,
channelSet,
settings,
->
val myNodeNum = myNodeInfo?.myNodeNum ?: return@combine emptyList()
// Add empty channel placeholders (always show Broadcast contacts, even when empty)
val placeholder =
(0 until channelSet.settingsCount).associate { ch ->
val contactKey = "$ch${DataPacket.ID_BROADCAST}"
val data = DataPacket(bytes = null, dataType = 1, time = 0L, channel = ch)
contactKey to Packet(0L, myNodeNum, 1, contactKey, 0L, true, data)
}
(contacts + (placeholder - contacts.keys)).values.map { packet ->
val data = packet.data
val contactKey = packet.contact_key
// Determine if this is my message (originated on this device)
val fromLocal = data.from == DataPacket.ID_LOCAL
val toBroadcast = data.to == DataPacket.ID_BROADCAST
// grab usernames from NodeInfo
val user = getUser(if (fromLocal) data.to else data.from)
val node = getNode(if (fromLocal) data.to else data.from)
val shortName = user.shortName
val longName =
if (toBroadcast) {
channelSet.getChannel(data.channel)?.name ?: app.getString(R.string.channel_name)
} else {
user.longName
}
Contact(
contactKey = contactKey,
shortName = if (toBroadcast) "${data.channel}" else shortName,
longName = longName,
lastMessageTime = getShortDate(data.time),
lastMessageText = if (fromLocal) data.text else "$shortName: ${data.text}",
unreadCount = packetRepository.getUnreadCount(contactKey),
messageCount = packetRepository.getMessageCount(contactKey),
isMuted = settings[contactKey]?.isMuted == true,
isUnmessageable = user.isUnmessagable,
nodeColors =
if (!toBroadcast) {
node.colors
} else {
null
},
)
}
}
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = emptyList(),
)
fun getMessagesFrom(contactKey: String): StateFlow<List<Message>> {
contactKeyForMessages.value = contactKey
return messagesForContactKey
}
private val contactKeyForMessages: MutableStateFlow<String?> = MutableStateFlow(null)
private val messagesForContactKey: StateFlow<List<Message>> =
contactKeyForMessages
.filterNotNull()
.flatMapLatest { contactKey -> packetRepository.getMessagesFrom(contactKey, ::getNode) }
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = emptyList(),
)
2022-09-15 22:24:04 -03:00
2023-02-01 12:16:44 -03:00
fun generatePacketId(): Int? {
return try {
meshService?.packetId
} catch (ex: RemoteException) {
errormsg("RemoteException: ${ex.message}")
return null
}
}
fun sendMessage(str: String, contactKey: String = "0${DataPacket.ID_BROADCAST}", replyId: Int? = null) {
2023-01-02 21:36:35 -03:00
// 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)
2023-02-01 12:16:44 -03:00
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()
val dest = if (channel != null) contactKey.substring(1) else contactKey
val p = DataPacket(dest, channel ?: 0, wpt)
2023-02-03 19:07:15 -03:00
if (wpt.id != 0) sendDataPacket(p)
2023-02-01 12:16:44 -03:00
}
private fun sendDataPacket(p: DataPacket) {
2022-09-15 22:24:04 -03:00
try {
meshService?.send(p)
} catch (ex: RemoteException) {
errormsg("Send DataPacket error: ${ex.message}")
}
}
fun sendReaction(emoji: String, replyId: Int, contactKey: String) = viewModelScope.launch {
radioConfigRepository.onServiceAction(ServiceAction.Reaction(emoji, replyId, contactKey))
}
private val _sharedContactRequested: MutableStateFlow<AdminProtos.SharedContact?> = MutableStateFlow(null)
val sharedContactRequested: StateFlow<AdminProtos.SharedContact?>
get() = _sharedContactRequested.asStateFlow()
fun setSharedContactRequested(sharedContact: AdminProtos.SharedContact?) {
_sharedContactRequested.value = sharedContact
}
fun addSharedContact(sharedContact: AdminProtos.SharedContact) =
viewModelScope.launch { radioConfigRepository.onServiceAction(ServiceAction.AddSharedContact(sharedContact)) }
fun requestTraceroute(destNum: Int) {
info("Requesting traceroute for '$destNum'")
try {
val packetId = meshService?.packetId ?: return
meshService?.requestTraceroute(packetId, destNum)
} catch (ex: RemoteException) {
errormsg("Request traceroute error: ${ex.message}")
}
}
fun removeNode(nodeNum: Int) = viewModelScope.launch(Dispatchers.IO) {
info("Removing node '$nodeNum'")
try {
val packetId = meshService?.packetId ?: return@launch
meshService?.removeByNodenum(packetId, nodeNum)
nodeDB.deleteNode(nodeNum)
} catch (ex: RemoteException) {
errormsg("Remove node error: ${ex.message}")
}
}
fun requestUserInfo(destNum: Int) {
info("Requesting UserInfo for '$destNum'")
try {
meshService?.requestUserInfo(destNum)
} catch (ex: RemoteException) {
errormsg("Request NodeInfo error: ${ex.message}")
}
}
fun requestPosition(destNum: Int, position: Position = Position(0.0, 0.0, 0)) {
info("Requesting position for '$destNum'")
try {
meshService?.requestPosition(destNum, position)
} catch (ex: RemoteException) {
errormsg("Request position error: ${ex.message}")
}
}
fun setMuteUntil(contacts: List<String>, until: Long) =
viewModelScope.launch(Dispatchers.IO) { packetRepository.setMuteUntil(contacts, until) }
fun deleteContacts(contacts: List<String>) =
viewModelScope.launch(Dispatchers.IO) { packetRepository.deleteContacts(contacts) }
fun deleteMessages(uuidList: List<Long>) =
viewModelScope.launch(Dispatchers.IO) { packetRepository.deleteMessages(uuidList) }
2022-09-15 22:24:04 -03:00
fun deleteWaypoint(id: Int) = viewModelScope.launch(Dispatchers.IO) { packetRepository.deleteWaypoint(id) }
2023-02-01 12:16:44 -03:00
2024-06-15 12:18:26 -03:00
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)
2024-06-15 12:18:26 -03:00
}
// Connection state to our radio device
val connectionState
get() = radioConfigRepository.connectionState
val isConnectedStateFlow =
radioConfigRepository.connectionState
.map { it.isConnected() }
.stateIn(viewModelScope, SharingStarted.Eagerly, false)
2021-02-27 11:44:05 +08:00
private val _requestChannelSet = MutableStateFlow<AppOnlyProtos.ChannelSet?>(null)
val requestChannelSet: StateFlow<AppOnlyProtos.ChannelSet?>
get() = _requestChannelSet
2022-05-17 17:29:21 -03:00
fun requestChannelUrl(url: Uri) = runCatching { _requestChannelSet.value = url.toChannelSet() }
.onFailure { ex ->
errormsg("Channel url error: ${ex.message}")
showSnackBar(R.string.channel_invalid)
}
2022-05-17 17:29:21 -03:00
val latestStableFirmwareRelease = firmwareReleaseRepository.stableRelease.mapNotNull { it?.asDeviceVersion() }
/** Called immediately after activity observes requestChannelUrl */
2022-05-17 17:29:21 -03:00
fun clearRequestChannelUrl() {
_requestChannelSet.value = null
2022-05-17 17:29:21 -03:00
}
2022-09-18 18:35:13 -03:00
var txEnabled: Boolean
get() = config.lora.txEnabled
set(value) {
updateLoraConfig { it.copy { txEnabled = value } }
}
2022-09-12 00:26:12 -03:00
var region: Config.LoRaConfig.RegionCode
2022-09-18 18:35:13 -03:00
get() = config.lora.region
set(value) {
2022-09-12 00:26:12 -03:00
updateLoraConfig { it.copy { region = value } }
2021-02-05 21:29:28 -08:00
}
fun favoriteNode(node: Node) = viewModelScope.launch {
try {
radioConfigRepository.onServiceAction(ServiceAction.Favorite(node))
} catch (ex: RemoteException) {
errormsg("Favorite node error:", ex)
}
}
fun ignoreNode(node: Node) = viewModelScope.launch {
try {
radioConfigRepository.onServiceAction(ServiceAction.Ignore(node))
} catch (ex: RemoteException) {
errormsg("Ignore node error:", ex)
}
}
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 -> {}
}
}
2025-09-09 07:37:56 +10:00
fun setNodeNotes(nodeNum: Int, notes: String) = viewModelScope.launch(Dispatchers.IO) {
try {
nodeDB.setNodeNotes(nodeNum, notes)
} catch (ex: java.io.IOException) {
errormsg("Set node notes IO error: ${ex.message}")
} catch (ex: java.sql.SQLException) {
errormsg("Set node notes SQL error: ${ex.message}")
}
}
2023-05-13 18:18:49 -03:00
// managed mode disables all access to configuration
val isManaged: Boolean
get() = config.device.isManaged || config.security.isManaged
2022-06-17 02:00:18 -03:00
val myNodeNum
get() = myNodeInfo.value?.myNodeNum
val maxChannels
get() = myNodeInfo.value?.maxChannels ?: 8
2020-05-13 17:00:23 -07:00
override fun onCleared() {
super.onCleared()
debug("ViewModel cleared")
}
private inline fun updateLoraConfig(crossinline body: (Config.LoRaConfig) -> Config.LoRaConfig) {
2022-09-12 00:26:12 -03:00
val data = body(config.lora)
2022-10-11 16:27:36 -03:00
setConfig(config { lora = data })
2022-09-12 00:26:12 -03:00
}
// Set the radio config (also updates our saved copy in preferences)
2022-10-11 16:27:36 -03:00
fun setConfig(config: Config) {
try {
meshService?.setConfig(config.toByteArray())
} catch (ex: RemoteException) {
errormsg("Set config error:", ex)
}
2021-02-27 11:44:05 +08:00
}
fun setChannel(channel: ChannelProtos.Channel) {
try {
meshService?.setChannel(channel.toByteArray())
} catch (ex: RemoteException) {
errormsg("Set channel error:", ex)
}
}
/** Set the radio config (also updates our saved copy in preferences). */
fun setChannels(channelSet: AppOnlyProtos.ChannelSet) = viewModelScope.launch {
getChannelList(channelSet.settingsList, channels.value.settingsList).forEach(::setChannel)
radioConfigRepository.replaceAllSettings(channelSet.settingsList)
2022-10-11 16:27:36 -03:00
val newConfig = config { lora = channelSet.loraConfig }
if (config.lora != newConfig.lora) setConfig(newConfig)
}
fun setOwner(name: String) {
val user =
ourNodeInfo.value?.user?.copy {
longName = name
shortName = getInitials(name)
} ?: return
try {
// Note: we use ?. here because we might be running in the emulator
meshService?.setRemoteOwner(myNodeNum ?: return, user.toByteArray())
} catch (ex: RemoteException) {
errormsg("Can't set username on device, is device offline? ${ex.message}")
}
2020-02-18 10:40:02 -08:00
}
2022-02-03 18:15:06 -08:00
fun addQuickChatAction(action: QuickChatAction) =
viewModelScope.launch(Dispatchers.IO) { quickChatActionRepository.upsert(action) }
2022-08-11 16:43:26 +01:00
fun deleteQuickChatAction(action: QuickChatAction) =
viewModelScope.launch(Dispatchers.IO) { quickChatActionRepository.delete(action) }
fun updateActionPositions(actions: List<QuickChatAction>) {
viewModelScope.launch(Dispatchers.IO) {
2022-08-16 12:25:40 +01:00
for (position in actions.indices) {
quickChatActionRepository.setItemPosition(actions[position].uuid, position)
}
}
}
val tracerouteResponse: LiveData<String?>
get() = radioConfigRepository.tracerouteResponse.asLiveData()
fun clearTracerouteResponse() {
radioConfigRepository.clearTracerouteResponse()
}
fun setNodeFilterText(text: String) {
2024-05-27 09:56:26 -03:00
nodeFilterText.value = text
}
val appIntroCompleted: StateFlow<Boolean> = uiPrefs.appIntroCompletedFlow
fun onAppIntroCompleted() {
uiPrefs.appIntroCompleted = true
}
}