mirror of
https://github.com/meshtastic/Meshtastic-Android.git
synced 2026-04-20 22:23:37 +00:00
1012 lines
40 KiB
Kotlin
1012 lines
40 KiB
Kotlin
/*
|
|
* 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/>.
|
|
*/
|
|
|
|
package com.geeksville.mesh.model
|
|
|
|
import android.app.Application
|
|
import android.content.SharedPreferences
|
|
import android.net.Uri
|
|
import android.os.RemoteException
|
|
import androidx.appcompat.app.AppCompatDelegate
|
|
import androidx.compose.material3.SnackbarHostState
|
|
import androidx.core.content.edit
|
|
import androidx.lifecycle.LiveData
|
|
import androidx.lifecycle.ViewModel
|
|
import androidx.lifecycle.asLiveData
|
|
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
|
|
import com.geeksville.mesh.ConfigProtos.Config
|
|
import com.geeksville.mesh.DataPacket
|
|
import com.geeksville.mesh.IMeshService
|
|
import com.geeksville.mesh.LocalOnlyProtos.LocalConfig
|
|
import com.geeksville.mesh.LocalOnlyProtos.LocalModuleConfig
|
|
import com.geeksville.mesh.MeshProtos
|
|
import com.geeksville.mesh.Portnums
|
|
import com.geeksville.mesh.Position
|
|
import com.geeksville.mesh.R
|
|
import com.geeksville.mesh.android.Logging
|
|
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
|
|
import com.geeksville.mesh.database.MeshLogRepository
|
|
import com.geeksville.mesh.database.NodeRepository
|
|
import com.geeksville.mesh.database.PacketRepository
|
|
import com.geeksville.mesh.database.QuickChatActionRepository
|
|
import com.geeksville.mesh.database.entity.MyNodeEntity
|
|
import com.geeksville.mesh.database.entity.Packet
|
|
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.location.LocationRepository
|
|
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.MainMenuAction
|
|
import com.geeksville.mesh.ui.node.components.NodeMenuAction
|
|
import com.geeksville.mesh.util.getShortDate
|
|
import com.geeksville.mesh.util.positionToMeter
|
|
import com.geeksville.mesh.util.toggleBooleanPreference
|
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
|
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
|
|
import kotlinx.coroutines.flow.combine
|
|
import kotlinx.coroutines.flow.filterNotNull
|
|
import kotlinx.coroutines.flow.first
|
|
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 kotlinx.coroutines.withContext
|
|
import java.io.BufferedWriter
|
|
import java.io.FileNotFoundException
|
|
import java.io.FileWriter
|
|
import java.text.SimpleDateFormat
|
|
import java.util.Locale
|
|
import javax.inject.Inject
|
|
import kotlin.math.roundToInt
|
|
|
|
// 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
|
|
}
|
|
|
|
else -> words.map { it.first() }.joinToString("")
|
|
}
|
|
return initials.take(maxInitialLength)
|
|
}
|
|
|
|
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 {}
|
|
},
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
data class NodesUiState(
|
|
val sort: NodeSortOption = NodeSortOption.LAST_HEARD,
|
|
val filter: String = "",
|
|
val includeUnknown: Boolean = false,
|
|
val onlyOnline: Boolean = false,
|
|
val onlyDirect: Boolean = false,
|
|
val distanceUnits: Int = 0,
|
|
val tempInFahrenheit: Boolean = false,
|
|
val showDetails: Boolean = false,
|
|
val showIgnored: Boolean = false,
|
|
) {
|
|
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,
|
|
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,
|
|
private val meshLogRepository: MeshLogRepository,
|
|
private val deviceHardwareRepository: DeviceHardwareRepository,
|
|
private val packetRepository: PacketRepository,
|
|
private val quickChatActionRepository: QuickChatActionRepository,
|
|
private val locationRepository: LocationRepository,
|
|
firmwareReleaseRepository: FirmwareReleaseRepository,
|
|
private val preferences: SharedPreferences,
|
|
private val meshServiceNotifications: MeshServiceNotifications,
|
|
) : ViewModel(),
|
|
Logging {
|
|
|
|
private val _theme = MutableStateFlow(preferences.getInt("theme", AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM))
|
|
val theme: StateFlow<Int> = _theme.asStateFlow()
|
|
|
|
fun setTheme(theme: Int) {
|
|
_theme.value = theme
|
|
preferences.edit { putInt("theme", theme) }
|
|
}
|
|
|
|
private val _lastTraceRouteTime = MutableStateFlow<Long?>(null)
|
|
val lastTraceRouteTime: StateFlow<Long?> = _lastTraceRouteTime.asStateFlow()
|
|
|
|
private val _excludedModulesUnlocked = MutableStateFlow(false)
|
|
val excludedModulesUnlocked: StateFlow<Boolean> = _excludedModulesUnlocked.asStateFlow()
|
|
|
|
fun unlockExcludedModules() {
|
|
viewModelScope.launch { _excludedModulesUnlocked.value = true }
|
|
}
|
|
|
|
val firmwareVersion = myNodeInfo.mapNotNull { nodeInfo -> nodeInfo?.firmwareVersion }
|
|
|
|
val firmwareEdition = meshLogRepository.getMyNodeInfo().map { nodeInfo -> nodeInfo?.firmwareEdition }
|
|
|
|
val deviceHardware: StateFlow<DeviceHardware?> =
|
|
ourNodeInfo
|
|
.mapNotNull { nodeInfo ->
|
|
nodeInfo?.user?.hwModel?.let {
|
|
deviceHardwareRepository.getDeviceHardwareByModel(it.number).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)
|
|
|
|
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(),
|
|
)
|
|
|
|
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(),
|
|
) {
|
|
_currentAlert.value =
|
|
AlertData(
|
|
title = title,
|
|
message = message,
|
|
html = html,
|
|
onConfirm = {
|
|
onConfirm?.invoke()
|
|
dismissAlert()
|
|
},
|
|
onDismiss = { if (dismissable) dismissAlert() },
|
|
choices = choices,
|
|
)
|
|
}
|
|
|
|
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 }
|
|
}
|
|
|
|
val receivingLocationUpdates: StateFlow<Boolean>
|
|
get() = locationRepository.receivingLocationUpdates
|
|
|
|
val meshService: IMeshService?
|
|
get() = radioConfigRepository.meshService
|
|
|
|
private val _localConfig = MutableStateFlow<LocalConfig>(LocalConfig.getDefaultInstance())
|
|
val localConfig: StateFlow<LocalConfig> = _localConfig
|
|
val config
|
|
get() = _localConfig.value
|
|
|
|
private val _moduleConfig = MutableStateFlow<LocalModuleConfig>(LocalModuleConfig.getDefaultInstance())
|
|
val moduleConfig: StateFlow<LocalModuleConfig> = _moduleConfig
|
|
val module
|
|
get() = _moduleConfig.value
|
|
|
|
private val _channels = MutableStateFlow(channelSet {})
|
|
val channels: StateFlow<AppOnlyProtos.ChannelSet>
|
|
get() = _channels
|
|
|
|
val quickChatActions
|
|
get() =
|
|
quickChatActionRepository
|
|
.getAllActions()
|
|
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
|
|
|
|
private val nodeFilterText = MutableStateFlow("")
|
|
private val nodeSortOption =
|
|
MutableStateFlow(
|
|
NodeSortOption.entries.getOrElse(
|
|
preferences.getInt("node-sort-option", NodeSortOption.VIA_FAVORITE.ordinal),
|
|
) {
|
|
NodeSortOption.VIA_FAVORITE
|
|
},
|
|
)
|
|
private val includeUnknown = MutableStateFlow(preferences.getBoolean("include-unknown", false))
|
|
private val showDetails = MutableStateFlow(preferences.getBoolean("show-details", false))
|
|
private val onlyOnline = MutableStateFlow(preferences.getBoolean("only-online", false))
|
|
private val onlyDirect = MutableStateFlow(preferences.getBoolean("only-direct", false))
|
|
|
|
private val _showIgnored = MutableStateFlow(preferences.getBoolean("show-ignored", false))
|
|
val showIgnored: StateFlow<Boolean> = _showIgnored
|
|
|
|
private val _showQuickChat = MutableStateFlow(preferences.getBoolean("show-quick-chat", false))
|
|
val showQuickChat: StateFlow<Boolean> = _showQuickChat
|
|
|
|
private val _hasShownNotPairedWarning =
|
|
MutableStateFlow(preferences.getBoolean(HAS_SHOWN_NOT_PAIRED_WARNING_PREF, false))
|
|
|
|
val hasShownNotPairedWarning: StateFlow<Boolean> = _hasShownNotPairedWarning.asStateFlow()
|
|
|
|
fun suppressNoPairedWarning() {
|
|
_hasShownNotPairedWarning.value = true
|
|
preferences.edit { putBoolean(HAS_SHOWN_NOT_PAIRED_WARNING_PREF, true) }
|
|
}
|
|
|
|
fun toggleShowIgnored() = preferences.toggleBooleanPreference(_showIgnored, "show-ignored")
|
|
|
|
fun toggleShowQuickChat() = preferences.toggleBooleanPreference(_showQuickChat, "show-quick-chat")
|
|
|
|
fun setSortOption(sort: NodeSortOption) {
|
|
nodeSortOption.value = sort
|
|
preferences.edit { putInt("node-sort-option", sort.ordinal) }
|
|
}
|
|
|
|
fun toggleShowDetails() = preferences.toggleBooleanPreference(showDetails, "show-details")
|
|
|
|
fun toggleIncludeUnknown() = preferences.toggleBooleanPreference(includeUnknown, "include-unknown")
|
|
|
|
fun toggleOnlyOnline() = preferences.toggleBooleanPreference(onlyOnline, "only-online")
|
|
|
|
fun toggleOnlyDirect() = preferences.toggleBooleanPreference(onlyDirect, "only-direct")
|
|
|
|
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)
|
|
}
|
|
|
|
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(),
|
|
)
|
|
|
|
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)
|
|
|
|
private val snackBarHostState = SnackbarHostState()
|
|
|
|
fun showSnackBar(text: Int) = showSnackBar(app.getString(text))
|
|
|
|
fun showSnackBar(text: String) = viewModelScope.launch { snackBarHostState.showSnackbar(text) }
|
|
|
|
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)
|
|
|
|
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(),
|
|
)
|
|
|
|
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) {
|
|
// 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()
|
|
val dest = if (channel != null) contactKey.substring(1) else contactKey
|
|
|
|
val p = DataPacket(dest, channel ?: 0, wpt)
|
|
if (wpt.id != 0) sendDataPacket(p)
|
|
}
|
|
|
|
private fun sendDataPacket(p: DataPacket) {
|
|
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?) {
|
|
viewModelScope.launch { _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) }
|
|
|
|
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)
|
|
}
|
|
|
|
companion object {
|
|
const val HAS_SHOWN_NOT_PAIRED_WARNING_PREF = "has_shown_not_paired_warning"
|
|
}
|
|
|
|
// Connection state to our radio device
|
|
val connectionState
|
|
get() = radioConfigRepository.connectionState
|
|
|
|
val isConnectedStateFlow =
|
|
radioConfigRepository.connectionState
|
|
.map { it.isConnected() }
|
|
.stateIn(viewModelScope, SharingStarted.Eagerly, false)
|
|
|
|
private val _requestChannelSet = MutableStateFlow<AppOnlyProtos.ChannelSet?>(null)
|
|
val requestChannelSet: StateFlow<AppOnlyProtos.ChannelSet?>
|
|
get() = _requestChannelSet
|
|
|
|
fun requestChannelUrl(url: Uri) = runCatching { _requestChannelSet.value = url.toChannelSet() }
|
|
.onFailure { ex ->
|
|
errormsg("Channel url error: ${ex.message}")
|
|
showSnackBar(R.string.channel_invalid)
|
|
}
|
|
|
|
val latestStableFirmwareRelease = firmwareReleaseRepository.stableRelease.mapNotNull { it?.asDeviceVersion() }
|
|
|
|
/** Called immediately after activity observes requestChannelUrl */
|
|
fun clearRequestChannelUrl() {
|
|
_requestChannelSet.value = null
|
|
}
|
|
|
|
var txEnabled: Boolean
|
|
get() = config.lora.txEnabled
|
|
set(value) {
|
|
updateLoraConfig { it.copy { txEnabled = value } }
|
|
}
|
|
|
|
var region: Config.LoRaConfig.RegionCode
|
|
get() = config.lora.region
|
|
set(value) {
|
|
updateLoraConfig { it.copy { region = value } }
|
|
}
|
|
|
|
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 -> {}
|
|
}
|
|
}
|
|
|
|
// managed mode disables all access to configuration
|
|
val isManaged: Boolean
|
|
get() = config.device.isManaged || config.security.isManaged
|
|
|
|
val myNodeNum
|
|
get() = myNodeInfo.value?.myNodeNum
|
|
|
|
val maxChannels
|
|
get() = myNodeInfo.value?.maxChannels ?: 8
|
|
|
|
override fun onCleared() {
|
|
super.onCleared()
|
|
debug("ViewModel cleared")
|
|
}
|
|
|
|
private inline fun updateLoraConfig(crossinline body: (Config.LoRaConfig) -> Config.LoRaConfig) {
|
|
val data = body(config.lora)
|
|
setConfig(config { lora = data })
|
|
}
|
|
|
|
// Set the radio config (also updates our saved copy in preferences)
|
|
fun setConfig(config: Config) {
|
|
try {
|
|
meshService?.setConfig(config.toByteArray())
|
|
} catch (ex: RemoteException) {
|
|
errormsg("Set config error:", ex)
|
|
}
|
|
}
|
|
|
|
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)
|
|
|
|
val newConfig = config { lora = channelSet.loraConfig }
|
|
if (config.lora != newConfig.lora) setConfig(newConfig)
|
|
}
|
|
|
|
fun refreshProvideLocation() {
|
|
viewModelScope.launch { setProvideLocation(getProvidePref()) }
|
|
}
|
|
|
|
private fun getProvidePref(): Boolean {
|
|
val value = preferences.getBoolean("provide-location-$myNodeNum", false)
|
|
return value
|
|
}
|
|
|
|
private val _provideLocation = MutableStateFlow(getProvidePref())
|
|
val provideLocation: StateFlow<Boolean>
|
|
get() = _provideLocation.asStateFlow()
|
|
|
|
fun setProvideLocation(value: Boolean) {
|
|
viewModelScope.launch {
|
|
preferences.edit { putBoolean("provide-location-$myNodeNum", value) }
|
|
_provideLocation.value = value
|
|
if (value) {
|
|
meshService?.startProvideLocation()
|
|
} else {
|
|
meshService?.stopProvideLocation()
|
|
}
|
|
}
|
|
}
|
|
|
|
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}")
|
|
}
|
|
}
|
|
|
|
/** Write the persisted packet data out to a CSV file in the specified location. */
|
|
@Suppress("detekt:CyclomaticComplexMethod", "detekt:LongMethod")
|
|
fun saveRangetestCSV(uri: Uri) {
|
|
viewModelScope.launch(Dispatchers.Main) {
|
|
// Extract distances to this device from position messages and put (node,SNR,distance)
|
|
// in
|
|
// the file_uri
|
|
val myNodeNum = myNodeNum ?: return@launch
|
|
|
|
// Capture the current node value while we're still on main thread
|
|
val nodes = nodeDB.nodeDBbyNum.value
|
|
|
|
val positionToPos: (MeshProtos.Position?) -> Position? = { meshPosition ->
|
|
meshPosition?.let { Position(it) }.takeIf { it?.isValid() == true }
|
|
}
|
|
|
|
writeToUri(uri) { writer ->
|
|
val nodePositions = mutableMapOf<Int, MeshProtos.Position?>()
|
|
|
|
@Suppress("MaxLineLength")
|
|
writer.appendLine(
|
|
"\"date\",\"time\",\"from\",\"sender name\",\"sender lat\",\"sender long\",\"rx lat\",\"rx long\",\"rx elevation\",\"rx snr\",\"distance\",\"hop limit\",\"payload\"",
|
|
)
|
|
|
|
// Packets are ordered by time, we keep most recent position of
|
|
// our device in localNodePosition.
|
|
val dateFormat = SimpleDateFormat("\"yyyy-MM-dd\",\"HH:mm:ss\"", Locale.getDefault())
|
|
meshLogRepository.getAllLogsInReceiveOrder(Int.MAX_VALUE).first().forEach { packet ->
|
|
// If we get a NodeInfo packet, use it to update our position data (if valid)
|
|
packet.nodeInfo?.let { nodeInfo ->
|
|
positionToPos.invoke(nodeInfo.position)?.let { nodePositions[nodeInfo.num] = nodeInfo.position }
|
|
}
|
|
|
|
packet.meshPacket?.let { proto ->
|
|
// If the packet contains position data then use it to update, if valid
|
|
packet.position?.let { position ->
|
|
positionToPos.invoke(position)?.let {
|
|
nodePositions[
|
|
proto.from.takeIf { it != 0 } ?: myNodeNum,
|
|
] = position
|
|
}
|
|
}
|
|
|
|
// Only look at range test messages, with SNR reported.
|
|
if (
|
|
proto.decoded.portnumValue == Portnums.PortNum.RANGE_TEST_APP_VALUE && proto.rxSnr != 0.0f
|
|
) {
|
|
val rxDateTime = dateFormat.format(packet.received_date)
|
|
val rxFrom = proto.from.toUInt()
|
|
val senderName = nodes[proto.from]?.user?.longName ?: ""
|
|
|
|
// sender lat & long
|
|
val senderPosition = nodePositions[proto.from]
|
|
val senderPos = positionToPos.invoke(senderPosition)
|
|
val senderLat = senderPos?.latitude ?: ""
|
|
val senderLong = senderPos?.longitude ?: ""
|
|
|
|
// rx lat, long, and elevation
|
|
val rxPosition = nodePositions[myNodeNum]
|
|
val rxPos = positionToPos.invoke(rxPosition)
|
|
val rxLat = rxPos?.latitude ?: ""
|
|
val rxLong = rxPos?.longitude ?: ""
|
|
val rxAlt = rxPos?.altitude ?: ""
|
|
val rxSnr = proto.rxSnr
|
|
|
|
// Calculate the distance if both positions are valid
|
|
|
|
val dist =
|
|
if (senderPos == null || rxPos == null) {
|
|
""
|
|
} else {
|
|
positionToMeter(
|
|
Position(rxPosition!!), // Use rxPosition but only if rxPos was
|
|
// valid
|
|
Position(senderPosition!!), // Use senderPosition but only if
|
|
// senderPos was valid
|
|
)
|
|
.roundToInt()
|
|
.toString()
|
|
}
|
|
|
|
val hopLimit = proto.hopLimit
|
|
|
|
val payload =
|
|
when {
|
|
proto.decoded.portnumValue !in
|
|
setOf(
|
|
Portnums.PortNum.TEXT_MESSAGE_APP_VALUE,
|
|
Portnums.PortNum.RANGE_TEST_APP_VALUE,
|
|
) -> "<${proto.decoded.portnum}>"
|
|
|
|
proto.hasDecoded() -> proto.decoded.payload.toStringUtf8().replace("\"", "\"\"")
|
|
|
|
proto.hasEncrypted() -> "${proto.encrypted.size()} encrypted bytes"
|
|
else -> ""
|
|
}
|
|
|
|
// date,time,from,sender name,sender lat,sender long,rx lat,rx long,rx
|
|
// elevation,rx
|
|
// snr,distance,hop limit,payload
|
|
@Suppress("MaxLineLength")
|
|
writer.appendLine(
|
|
"$rxDateTime,\"$rxFrom\",\"$senderName\",\"$senderLat\",\"$senderLong\",\"$rxLat\",\"$rxLong\",\"$rxAlt\",\"$rxSnr\",\"$dist\",\"$hopLimit\",\"$payload\"",
|
|
)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private suspend inline fun writeToUri(uri: Uri, crossinline block: suspend (BufferedWriter) -> Unit) {
|
|
withContext(Dispatchers.IO) {
|
|
try {
|
|
app.contentResolver.openFileDescriptor(uri, "wt")?.use { parcelFileDescriptor ->
|
|
FileWriter(parcelFileDescriptor.fileDescriptor).use { fileWriter ->
|
|
BufferedWriter(fileWriter).use { writer -> block.invoke(writer) }
|
|
}
|
|
}
|
|
} catch (ex: FileNotFoundException) {
|
|
errormsg("Can't write file error: ${ex.message}")
|
|
}
|
|
}
|
|
}
|
|
|
|
fun addQuickChatAction(action: QuickChatAction) =
|
|
viewModelScope.launch(Dispatchers.IO) { quickChatActionRepository.upsert(action) }
|
|
|
|
fun deleteQuickChatAction(action: QuickChatAction) =
|
|
viewModelScope.launch(Dispatchers.IO) { quickChatActionRepository.delete(action) }
|
|
|
|
fun updateActionPositions(actions: List<QuickChatAction>) {
|
|
viewModelScope.launch(Dispatchers.IO) {
|
|
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) {
|
|
nodeFilterText.value = text
|
|
}
|
|
|
|
// region Main menu actions logic
|
|
|
|
private val _showAppIntro: MutableStateFlow<Boolean> =
|
|
MutableStateFlow(preferences.getBoolean("app_intro_completed", false).not())
|
|
val showAppIntro: StateFlow<Boolean> = _showAppIntro.asStateFlow()
|
|
|
|
fun onMainMenuAction(action: MainMenuAction) {
|
|
when (action) {
|
|
MainMenuAction.SHOW_INTRO -> _showAppIntro.update { true }
|
|
|
|
else -> Unit
|
|
}
|
|
}
|
|
|
|
// endregion
|
|
|
|
fun onAppIntroCompleted() {
|
|
preferences.edit { putBoolean("app_intro_completed", true) }
|
|
_showAppIntro.update { false }
|
|
}
|
|
}
|