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

734 lines
28 KiB
Kotlin
Raw Normal View History

2020-02-17 13:34:52 -08:00
package com.geeksville.mesh.model
import android.app.Application
2020-02-18 10:40:02 -08:00
import android.content.Context
2020-03-02 08:41:16 -08:00
import android.content.SharedPreferences
2020-03-17 11:35:19 -07:00
import android.net.Uri
2020-02-18 12:22:45 -08:00
import android.os.RemoteException
2020-07-17 17:06:29 -04:00
import android.view.Menu
2020-02-18 10:40:02 -08:00
import androidx.core.content.edit
2022-04-22 17:22:06 -03:00
import androidx.lifecycle.LiveData
2020-04-07 17:42:31 -07:00
import androidx.lifecycle.MutableLiveData
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
2022-02-03 18:15:06 -08:00
import com.geeksville.mesh.*
import com.geeksville.mesh.ChannelProtos.ChannelSettings
2022-09-12 00:26:12 -03:00
import com.geeksville.mesh.ConfigProtos.Config
import com.geeksville.mesh.LocalOnlyProtos.LocalConfig
2022-11-22 22:01:37 -03:00
import com.geeksville.mesh.LocalOnlyProtos.LocalModuleConfig
2024-07-28 05:04:50 -05:00
import com.geeksville.mesh.android.Logging
import com.geeksville.mesh.database.MeshLogRepository
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.NodeEntity
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.toNodeInfo
import com.geeksville.mesh.repository.datastore.RadioConfigRepository
import com.geeksville.mesh.repository.radio.RadioInterfaceService
import com.geeksville.mesh.service.MeshService
2022-05-20 11:20:13 -03:00
import com.geeksville.mesh.util.positionToMeter
import dagger.hilt.android.lifecycle.HiltViewModel
2020-09-23 22:47:45 -04:00
import kotlinx.coroutines.Dispatchers
2022-09-15 22:24:04 -03:00
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted.Companion.Eagerly
import kotlinx.coroutines.flow.StateFlow
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
2022-09-15 22:24:04 -03:00
import kotlinx.coroutines.flow.mapLatest
import kotlinx.coroutines.flow.onEach
2024-05-27 09:56:26 -03:00
import kotlinx.coroutines.flow.stateIn
2020-09-23 22:47:45 -04:00
import kotlinx.coroutines.launch
2022-02-03 18:15:06 -08:00
import kotlinx.coroutines.withContext
import java.io.BufferedWriter
2022-02-13 08:09:26 -03:00
import java.io.FileNotFoundException
2022-02-03 18:15:06 -08:00
import java.io.FileWriter
import java.text.DateFormat
2022-02-03 18:15:06 -08:00
import java.text.SimpleDateFormat
import java.util.Date
2022-10-16 12:40:05 -03:00
import java.util.Locale
import java.util.concurrent.TimeUnit
import javax.inject.Inject
2022-02-03 18:15:06 -08:00
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. 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(nameIn: String): String {
2022-11-15 12:19:59 -03:00
val nchars = 4
val minchars = 2
val name = nameIn.trim()
val words = name.split(Regex("\\s+")).filter { it.isNotEmpty() }
val initials = when (words.size) {
2022-05-26 16:23:47 -03:00
in 0 until minchars -> {
val nm = if (name.isNotEmpty())
2022-01-24 18:23:09 -03:00
name.first() + name.drop(1).filterNot { c -> c.lowercase() in "aeiou" }
2020-09-27 14:05:10 -07:00
else
""
if (nm.length >= nchars) nm else name
}
else -> words.map { it.first() }.joinToString("")
}
return initials.take(nchars)
}
2020-03-15 16:30:12 -07:00
/**
* 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 { }
})
}
}
2024-05-27 09:56:26 -03:00
data class NodesUiState(
val sort: NodeSortOption = NodeSortOption.LAST_HEARD,
val filter: String = "",
val includeUnknown: Boolean = false,
2024-07-28 05:04:50 -05:00
val gpsFormat: Int = 0,
val distanceUnits: Int = 0,
val tempInFahrenheit: Boolean = false,
val ignoreIncomingList: List<Int> = emptyList(),
2024-07-28 05:04:50 -05:00
val showDetails: 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,
)
// return time if within 24 hours, otherwise date
internal fun getShortDateTime(time: Long): String? {
val date = if (time != 0L) Date(time) else return null
val isWithin24Hours = System.currentTimeMillis() - date.time <= TimeUnit.DAYS.toMillis(1)
return if (isWithin24Hours) {
DateFormat.getTimeInstance(DateFormat.SHORT).format(date)
} else {
DateFormat.getDateInstance(DateFormat.SHORT).format(date)
}
}
@Suppress("LongParameterList")
@HiltViewModel
class UIViewModel @Inject constructor(
private val app: Application,
private val nodeDB: NodeDB,
private val radioConfigRepository: RadioConfigRepository,
private val radioInterfaceService: RadioInterfaceService,
2022-09-13 22:49:38 -03:00
private val meshLogRepository: MeshLogRepository,
2022-09-14 01:54:13 -03:00
private val packetRepository: PacketRepository,
2022-08-11 16:43:26 +01:00
private val quickChatActionRepository: QuickChatActionRepository,
private val preferences: SharedPreferences
) : ViewModel(), Logging {
2020-09-23 22:47:45 -04:00
2023-02-02 17:13:44 -03:00
var actionBarMenu: Menu? = null
val meshService: IMeshService? get() = radioConfigRepository.meshService
2023-02-02 17:13:44 -03:00
val bondedAddress get() = radioInterfaceService.getBondedDeviceAddress()
val selectedBluetooth get() = radioInterfaceService.getDeviceAddress()?.getOrNull(0) == 'x'
2022-09-12 00:26:12 -03:00
private val _localConfig = MutableStateFlow<LocalConfig>(LocalConfig.getDefaultInstance())
val localConfig: StateFlow<LocalConfig> = _localConfig
val config get() = _localConfig.value
2022-06-20 22:46:45 -03:00
2022-11-22 22:01:37 -03:00
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
2022-09-12 19:07:30 -03:00
2022-08-23 21:39:08 -03:00
private val _quickChatActions = MutableStateFlow<List<QuickChatAction>>(emptyList())
2022-08-11 16:43:26 +01:00
val quickChatActions: StateFlow<List<QuickChatAction>> = _quickChatActions
private val _focusedNode = MutableStateFlow<NodeInfo?>(null)
val focusedNode: StateFlow<NodeInfo?> = _focusedNode
2024-05-27 09:56:26 -03:00
private val nodeFilterText = MutableStateFlow("")
private val nodeSortOption = MutableStateFlow(NodeSortOption.LAST_HEARD)
private val includeUnknown = MutableStateFlow(false)
2024-07-28 05:04:50 -05:00
private val showDetails = MutableStateFlow(false)
2024-05-27 09:56:26 -03:00
fun setSortOption(sort: NodeSortOption) {
nodeSortOption.value = sort
}
2024-07-28 05:04:50 -05:00
fun toggleShowDetails() {
showDetails.value = !showDetails.value
}
2024-05-27 09:56:26 -03:00
fun toggleIncludeUnknown() {
includeUnknown.value = !includeUnknown.value
}
val nodesUiState: StateFlow<NodesUiState> = combine(
2024-05-27 09:56:26 -03:00
nodeFilterText,
nodeSortOption,
includeUnknown,
2024-07-28 05:04:50 -05:00
showDetails,
radioConfigRepository.deviceProfileFlow,
2024-07-28 05:04:50 -05:00
) { filter, sort, includeUnknown, showDetails, profile ->
2024-05-27 09:56:26 -03:00
NodesUiState(
sort = sort,
filter = filter,
includeUnknown = includeUnknown,
gpsFormat = profile.config.display.gpsFormat.number,
distanceUnits = profile.config.display.units.number,
tempInFahrenheit = profile.moduleConfig.telemetry.environmentDisplayFahrenheit,
ignoreIncomingList = profile.config.lora.ignoreIncomingList,
2024-07-28 05:04:50 -05:00
showDetails = showDetails,
2024-05-27 09:56:26 -03:00
)
}.stateIn(
scope = viewModelScope,
started = Eagerly,
2024-05-27 09:56:26 -03:00
initialValue = NodesUiState.Empty,
)
@OptIn(ExperimentalCoroutinesApi::class)
val nodeList: StateFlow<List<NodeEntity>> = nodesUiState.flatMapLatest { state ->
2024-05-27 09:56:26 -03:00
nodeDB.getNodes(state.sort, state.filter, state.includeUnknown)
}.stateIn(
2024-05-27 09:56:26 -03:00
scope = viewModelScope,
started = Eagerly,
2024-05-27 09:56:26 -03:00
initialValue = emptyList(),
)
// hardware info about our local device (can be null)
val myNodeInfo: StateFlow<MyNodeInfo?> get() = nodeDB.myNodeInfo
val ourNodeInfo: StateFlow<NodeEntity?> get() = nodeDB.ourNodeInfo
// FIXME only used in MapFragment
val initialNodes get() = nodeDB.nodeDBbyNum.value.values.map { it.toNodeInfo() }
fun getUser(userId: String?) = nodeDB.getUser(userId) ?: user {
id = userId.orEmpty()
longName = app.getString(R.string.unknown_username)
shortName = app.getString(R.string.unknown_node_short_name)
hwModel = MeshProtos.HardwareModel.UNSET
}
2023-02-02 17:13:44 -03:00
private val _snackbarText = MutableLiveData<Any?>(null)
val snackbarText: LiveData<Any?> get() = _snackbarText
2020-04-07 17:42:31 -07:00
init {
radioConfigRepository.errorMessage.filterNotNull().onEach {
_snackbarText.value = it
radioConfigRepository.clearErrorMessage()
}.launchIn(viewModelScope)
radioConfigRepository.localConfigFlow.onEach { config ->
_localConfig.value = config
}.launchIn(viewModelScope)
radioConfigRepository.moduleConfigFlow.onEach { config ->
2022-11-22 22:01:37 -03:00
_moduleConfig.value = config
}.launchIn(viewModelScope)
2022-08-11 16:43:26 +01:00
viewModelScope.launch {
quickChatActionRepository.getAllActions().collect { actions ->
_quickChatActions.value = actions
}
}
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 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 = getShortDateTime(data.time),
lastMessageText = if (fromLocal) data.text else "$shortName: ${data.text}",
unreadCount = packetRepository.getUnreadCount(contactKey),
messageCount = packetRepository.getMessageCount(contactKey),
isMuted = settings[contactKey]?.isMuted == true,
)
}
}.stateIn(
scope = viewModelScope,
started = Eagerly,
initialValue = emptyList(),
)
@OptIn(ExperimentalCoroutinesApi::class)
fun getMessagesFrom(contactKey: String) = packetRepository.getMessagesFrom(contactKey).mapLatest { list ->
list.map {
Message(
uuid = it.uuid,
receivedTime = it.received_time,
user = MeshUser(getUser(it.data.from)), // FIXME convert to proto User
text = it.data.text.orEmpty(),
time = it.data.time,
read = it.read,
status = it.data.status,
routingError = it.routingError,
)
}
}
2022-09-15 22:24:04 -03:00
@OptIn(ExperimentalCoroutinesApi::class)
val waypoints = packetRepository.getWaypoints().mapLatest { list ->
list.associateBy { packet -> packet.data.waypoint!!.id }
2023-02-03 19:07:15 -03:00
.filterValues { it.data.waypoint!!.expire > System.currentTimeMillis() / 1000 }
}
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}") {
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
val p = DataPacket(dest, channel ?: 0, str)
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 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 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)
}
2022-09-15 22:24:04 -03:00
fun deleteMessages(uuidList: List<Long>) = viewModelScope.launch(Dispatchers.IO) {
packetRepository.deleteMessages(uuidList)
}
2023-02-01 12:16:44 -03:00
fun deleteWaypoint(id: Int) = viewModelScope.launch(Dispatchers.IO) {
packetRepository.deleteWaypoint(id)
}
2024-06-15 12:18:26 -03:00
fun clearUnreadCount(contact: String, timestamp: Long) = viewModelScope.launch(Dispatchers.IO) {
packetRepository.clearUnreadCount(contact, timestamp)
}
2020-04-07 17:42:31 -07:00
companion object {
fun getPreferences(context: Context): SharedPreferences =
context.getSharedPreferences("ui-prefs", Context.MODE_PRIVATE)
}
// Connection state to our radio device
val connectionState get() = radioConfigRepository.connectionState.asLiveData()
fun isConnected() = connectionState.value != MeshService.ConnectionState.DISCONNECTED
2021-02-27 11:44:05 +08:00
2022-05-17 17:29:21 -03:00
private val _requestChannelUrl = MutableLiveData<Uri?>(null)
val requestChannelUrl: LiveData<Uri?> get() = _requestChannelUrl
fun setRequestChannelUrl(channelUrl: Uri) {
_requestChannelUrl.value = channelUrl
}
/**
* Called immediately after activity observes requestChannelUrl
*/
fun clearRequestChannelUrl() {
_requestChannelUrl.value = null
}
fun showSnackbar(resString: Any) {
_snackbarText.value = resString
}
/**
* Called immediately after activity observes [snackbarText]
*/
fun clearSnackbarText() {
_snackbarText.value = null
}
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
}
var ignoreIncomingList: MutableList<Int>
get() = config.lora.ignoreIncomingList
set(value) = updateLoraConfig {
it.copy {
ignoreIncoming.clear()
ignoreIncoming.addAll(value)
}
}
2023-05-13 18:18:49 -03:00
// managed mode disables all access to configuration
2024-08-25 09:32:31 -03:00
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). By default, this will replace
* all channels in the existing radio config. Otherwise, it will append all [ChannelSettings] that
* are unique in [channelSet] to the existing radio config.
*/
fun setChannels(channelSet: AppOnlyProtos.ChannelSet, overwrite: Boolean = true) = viewModelScope.launch {
val newRadioSettings: List<ChannelSettings> = if (overwrite) {
channelSet.settingsList
} else {
// To guarantee consistent ordering, using a LinkedHashSet which iterates through it's
// entries according to the order an item was *first* inserted.
// https://kotlinlang.org/api/latest/jvm/stdlib/kotlin.collections/-linked-hash-set/
LinkedHashSet(channels.value.settingsList + channelSet.settingsList).toList()
}
2022-10-11 16:27:36 -03:00
getChannelList(newRadioSettings, channels.value.settingsList).forEach(::setChannel)
radioConfigRepository.replaceAllSettings(newRadioSettings)
val newConfig = config { lora = channelSet.loraConfig }
if (overwrite && config.lora != newConfig.lora) setConfig(newConfig)
}
2022-06-22 22:07:55 -03:00
val provideLocation = object : MutableLiveData<Boolean>(preferences.getBoolean("provide-location", false)) {
2021-06-23 12:41:44 -07:00
override fun setValue(value: Boolean) {
super.setValue(value)
2022-04-22 17:22:06 -03:00
preferences.edit {
2022-06-22 22:07:55 -03:00
this.putBoolean("provide-location", value)
2021-06-23 12:41:44 -07:00
}
}
}
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
/**
* Write the persisted packet data out to a CSV file in the specified location.
*/
fun saveMessagesCSV(uri: Uri) {
2022-02-03 18:15:06 -08:00
viewModelScope.launch(Dispatchers.Main) {
// Extract distances to this device from position messages and put (node,SNR,distance) in
// the file_uri
2023-02-03 19:07:15 -03:00
val myNodeNum = myNodeNum ?: return@launch
2022-02-03 18:15:06 -08:00
// Capture the current node value while we're still on main thread
val nodes = nodeDB.nodeDBbyNum.value
2022-02-03 18:15:06 -08:00
val positionToPos: (MeshProtos.Position?) -> Position? = { meshPosition ->
meshPosition?.let { Position(it) }.takeIf {
it?.isValid() == true
}
}
writeToUri(uri) { writer ->
val nodePositions = mutableMapOf<Int, MeshProtos.Position?>()
2022-02-03 18:15:06 -08:00
writer.appendLine("\"date\",\"time\",\"from\",\"sender name\",\"sender lat\",\"sender long\",\"rx lat\",\"rx long\",\"rx elevation\",\"rx snr\",\"distance\",\"hop limit\",\"payload\"")
2022-02-03 18:15:06 -08:00
// 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())
2022-09-13 22:49:38 -03:00
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
2022-02-03 18:15:06 -08:00
packet.position?.let { position ->
positionToPos.invoke(position)?.let {
nodePositions[proto.from.takeIf { it != 0 } ?: myNodeNum] = position
}
}
// Filter out of our results any packet that doesn't report SNR. This
// is primarily ADMIN_APP.
2022-10-26 17:03:51 -03:00
if (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) {
""
2022-02-03 18:15:06 -08:00
} else {
positionToMeter(
rxPosition!!, // Use rxPosition but only if rxPos was valid
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 -> ""
2022-02-03 18:15:06 -08:00
}
// date,time,from,sender name,sender lat,sender long,rx lat,rx long,rx elevation,rx snr,distance,hop limit,payload
writer.appendLine("$rxDateTime,\"$rxFrom\",\"$senderName\",\"$senderLat\",\"$senderLong\",\"$rxLat\",\"$rxLong\",\"$rxAlt\",\"$rxSnr\",\"$dist\",\"$hopLimit\",\"$payload\"")
2022-02-03 18:15:06 -08:00
}
}
}
}
}
}
private suspend inline fun writeToUri(uri: Uri, crossinline block: suspend (BufferedWriter) -> Unit) {
withContext(Dispatchers.IO) {
2022-02-13 08:09:26 -03:00
try {
app.contentResolver.openFileDescriptor(uri, "wt")?.use { parcelFileDescriptor ->
FileWriter(parcelFileDescriptor.fileDescriptor).use { fileWriter ->
BufferedWriter(fileWriter).use { writer ->
block.invoke(writer)
}
2022-02-03 18:15:06 -08:00
}
}
2022-02-13 08:09:26 -03:00
} catch (ex: FileNotFoundException) {
errormsg("Can't write file error: ${ex.message}")
2022-02-03 18:15:06 -08:00
}
}
}
2022-08-10 17:29:17 +01:00
fun addQuickChatAction(name: String, value: String, mode: QuickChatAction.Mode) {
2022-08-11 16:43:26 +01:00
viewModelScope.launch(Dispatchers.Main) {
val action = QuickChatAction(0, name, value, mode, _quickChatActions.value.size)
2022-08-11 16:43:26 +01:00
quickChatActionRepository.insert(action)
}
}
fun deleteQuickChatAction(action: QuickChatAction) {
viewModelScope.launch(Dispatchers.Main) {
quickChatActionRepository.delete(action)
2022-08-11 16:43:26 +01:00
}
}
fun updateQuickChatAction(
action: QuickChatAction,
name: String?,
message: String?,
mode: QuickChatAction.Mode?
) {
viewModelScope.launch(Dispatchers.Main) {
val newAction = QuickChatAction(
action.uuid,
name ?: action.name,
message ?: action.message,
mode ?: action.mode,
action.position
2022-08-11 16:43:26 +01:00
)
quickChatActionRepository.update(newAction)
}
2022-08-10 17:29:17 +01:00
}
fun updateActionPositions(actions: List<QuickChatAction>) {
viewModelScope.launch(Dispatchers.Main) {
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()
}
private val _currentTab = MutableLiveData(0)
val currentTab: LiveData<Int> get() = _currentTab
fun setCurrentTab(tab: Int) {
_currentTab.value = tab
}
fun focusUserNode(node: NodeInfo?) {
_currentTab.value = 1
_focusedNode.value = node
}
fun setNodeFilterText(text: String) {
2024-05-27 09:56:26 -03:00
nodeFilterText.value = text
}
}