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

606 lines
24 KiB
Kotlin

package com.geeksville.mesh.model
import android.app.Application
import android.content.Context
import android.content.SharedPreferences
import android.net.Uri
import android.os.RemoteException
import android.view.Menu
import androidx.core.content.edit
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.asLiveData
import androidx.lifecycle.viewModelScope
import com.geeksville.mesh.android.Logging
import com.geeksville.mesh.*
import com.geeksville.mesh.ChannelProtos.ChannelSettings
import com.geeksville.mesh.ConfigProtos.Config
import com.geeksville.mesh.database.MeshLogRepository
import com.geeksville.mesh.database.QuickChatActionRepository
import com.geeksville.mesh.database.entity.Packet
import com.geeksville.mesh.database.entity.MeshLog
import com.geeksville.mesh.database.entity.QuickChatAction
import com.geeksville.mesh.LocalOnlyProtos.LocalConfig
import com.geeksville.mesh.LocalOnlyProtos.LocalModuleConfig
import com.geeksville.mesh.database.PacketRepository
import com.geeksville.mesh.repository.datastore.RadioConfigRepository
import com.geeksville.mesh.repository.radio.RadioInterfaceService
import com.geeksville.mesh.service.MeshService
import com.geeksville.mesh.util.positionToMeter
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.mapLatest
import kotlinx.coroutines.flow.onEach
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. 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 {
val nchars = 4
val minchars = 2
val name = nameIn.trim()
val words = name.split(Regex("\\s+")).filter { it.isNotEmpty() }
val initials = when (words.size) {
in 0 until minchars -> {
val nm = if (name.isNotEmpty())
name.first() + name.drop(1).filterNot { c -> c.lowercase() in "aeiou" }
else
""
if (nm.length >= nchars) nm else name
}
else -> words.map { it.first() }.joinToString("")
}
return initials.take(nchars)
}
/**
* 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 to disable 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 { }
})
}
}
@HiltViewModel
class UIViewModel @Inject constructor(
private val app: Application,
private val radioConfigRepository: RadioConfigRepository,
private val radioInterfaceService: RadioInterfaceService,
private val meshLogRepository: MeshLogRepository,
private val packetRepository: PacketRepository,
private val quickChatActionRepository: QuickChatActionRepository,
private val preferences: SharedPreferences
) : ViewModel(), Logging {
var actionBarMenu: Menu? = null
val meshService: IMeshService? get() = radioConfigRepository.meshService
val nodeDB = NodeDB(this)
val bondedAddress get() = radioInterfaceService.getBondedDeviceAddress()
val selectedBluetooth get() = radioInterfaceService.getDeviceAddress()?.getOrNull(0) == 'x'
private val _meshLog = MutableStateFlow<List<MeshLog>>(emptyList())
val meshLog: StateFlow<List<MeshLog>> = _meshLog
private val _packets = MutableStateFlow<List<Packet>>(emptyList())
val packets: StateFlow<List<Packet>> = _packets
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 channelSet get() = channels.value
private val _quickChatActions = MutableStateFlow<List<QuickChatAction>>(emptyList())
val quickChatActions: StateFlow<List<QuickChatAction>> = _quickChatActions
private val _ourNodeInfo = MutableStateFlow<NodeInfo?>(null)
val ourNodeInfo: StateFlow<NodeInfo?> = _ourNodeInfo
private val requestIds = MutableStateFlow<HashMap<Int, Boolean>>(hashMapOf())
init {
radioConfigRepository.nodeInfoFlow().onEach(nodeDB::setNodes)
.launchIn(viewModelScope)
viewModelScope.launch {
meshLogRepository.getAllLogs().collect { logs ->
_meshLog.value = logs
}
}
viewModelScope.launch {
packetRepository.getAllPackets().collect { packets ->
_packets.value = packets
}
}
radioConfigRepository.localConfigFlow.onEach { config ->
_localConfig.value = config
}.launchIn(viewModelScope)
radioConfigRepository.moduleConfigFlow.onEach { config ->
_moduleConfig.value = config
}.launchIn(viewModelScope)
viewModelScope.launch {
quickChatActionRepository.getAllActions().collect { actions ->
_quickChatActions.value = actions
}
}
radioConfigRepository.channelSetFlow.onEach { channelSet ->
_channels.value = channelSet
}.launchIn(viewModelScope)
viewModelScope.launch {
combine(meshLogRepository.getAllLogs(9), requestIds) { list, ids ->
val unprocessed = ids.filterValues { !it }.keys.ifEmpty { return@combine emptyList() }
list.filter { log -> log.meshPacket?.decoded?.requestId in unprocessed }
}.collect { it.forEach(::processPacketResponse) }
}
debug("ViewModel created")
}
private val contactKey: MutableStateFlow<String> = MutableStateFlow(DataPacket.ID_BROADCAST)
fun setContactKey(contact: String) {
contactKey.value = contact
}
@OptIn(ExperimentalCoroutinesApi::class)
val messages: LiveData<List<Packet>> = contactKey.flatMapLatest { contactKey ->
packetRepository.getMessagesFrom(contactKey)
}.asLiveData()
@OptIn(ExperimentalCoroutinesApi::class)
val contacts: LiveData<Map<String, Packet>> = _packets.mapLatest { list ->
list.filter { it.port_num == Portnums.PortNum.TEXT_MESSAGE_APP_VALUE }
.associateBy { packet -> packet.contact_key }
}.asLiveData()
@OptIn(ExperimentalCoroutinesApi::class)
val waypoints: LiveData<Map<Int, Packet>> = _packets.mapLatest { list ->
list.filter { it.port_num == Portnums.PortNum.WAYPOINT_APP_VALUE }
.associateBy { packet -> packet.data.waypoint!!.id }
.filterValues { it.data.waypoint!!.expire > System.currentTimeMillis() / 1000 }
}.asLiveData()
private val _destNode = MutableStateFlow<NodeInfo?>(null)
val destNode: StateFlow<NodeInfo?> get() = if (_destNode.value != null) _destNode else _ourNodeInfo
/**
* Sets the destination [NodeInfo] used in Radio Configuration.
* @param node Destination [NodeInfo] (or null for our local NodeInfo).
*/
fun setDestNode(node: NodeInfo?) {
_destNode.value = node
}
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}") {
// 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)
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 requestTraceroute(destNum: Int) {
try {
val packetId = meshService?.packetId ?: return
meshService?.requestTraceroute(packetId, destNum)
requestIds.update { it.apply { put(packetId, false) } }
} catch (ex: RemoteException) {
errormsg("Request traceroute error: ${ex.message}")
}
}
fun requestPosition(destNum: Int, position: Position = Position(0.0, 0.0, 0)) {
try {
meshService?.requestPosition(destNum, position)
} catch (ex: RemoteException) {
errormsg("Request position error: ${ex.message}")
}
}
fun deleteAllLogs() = viewModelScope.launch(Dispatchers.IO) {
meshLogRepository.deleteAll()
}
fun deleteAllMessages() = viewModelScope.launch(Dispatchers.IO) {
packetRepository.deleteAllMessages()
}
fun deleteMessages(uuidList: List<Long>) = viewModelScope.launch(Dispatchers.IO) {
packetRepository.deleteMessages(uuidList)
}
fun deleteWaypoint(id: Int) = viewModelScope.launch(Dispatchers.IO) {
packetRepository.deleteWaypoint(id)
}
companion object {
fun getPreferences(context: Context): SharedPreferences =
context.getSharedPreferences("ui-prefs", Context.MODE_PRIVATE)
}
/// Connection state to our radio device
private val _connectionState = MutableLiveData(MeshService.ConnectionState.DISCONNECTED)
val connectionState: LiveData<MeshService.ConnectionState> get() = _connectionState
fun isConnected() = _connectionState.value != MeshService.ConnectionState.DISCONNECTED
fun setConnectionState(connectionState: MeshService.ConnectionState) {
_connectionState.value = connectionState
}
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
}
private val _snackbarText = MutableLiveData<Any?>(null)
val snackbarText: LiveData<Any?> get() = _snackbarText
fun showSnackbar(resString: Any) {
_snackbarText.value = resString
}
/**
* Called immediately after activity observes [snackbarText]
*/
fun clearSnackbarText() {
_snackbarText.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 } }
}
var ignoreIncomingList: MutableList<Int>
get() = config.lora.ignoreIncomingList
set(value) = updateLoraConfig {
it.copy {
ignoreIncoming.clear()
ignoreIncoming.addAll(value)
}
}
// managed mode disables all access to configuration
val isManaged: Boolean get() = config.device.isManaged
/// hardware info about our local device (can be null)
private val _myNodeInfo = MutableLiveData<MyNodeInfo?>()
val myNodeInfo: LiveData<MyNodeInfo?> get() = _myNodeInfo
val myNodeNum get() = _myNodeInfo.value?.myNodeNum
val maxChannels = myNodeInfo.value?.maxChannels ?: 8
fun setMyNodeInfo(info: MyNodeInfo?) {
_myNodeInfo.value = info
}
override fun onCleared() {
super.onCleared()
debug("ViewModel cleared")
}
/// Pull our latest node db from the device
fun updateNodesFromDevice() {
meshService?.let { service ->
// Update our nodeinfos based on data from the device
val nodes = service.nodes.associateBy { it.user?.id!! }
nodeDB.setNodes(nodes)
try {
// Pull down our real node ID - This must be done AFTER reading the nodedb because we need the DB to find our nodeinof object
val myId = service.myId
nodeDB.setMyId(myId)
_ourNodeInfo.value = nodes[myId]
} catch (ex: Exception) {
warn("Ignoring failure to get myId, service is probably just uninited... ${ex.message}")
}
}
}
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) {
meshService?.setConfig(config.toByteArray())
}
fun setChannel(channel: ChannelProtos.Channel) {
meshService?.setChannel(channel.toByteArray())
}
// 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)
}
val provideLocation = object : MutableLiveData<Boolean>(preferences.getBoolean("provide-location", false)) {
override fun setValue(value: Boolean) {
super.setValue(value)
preferences.edit {
this.putBoolean("provide-location", value)
}
}
}
fun setOwner(user: MeshUser) {
try {
// Note: we use ?. here because we might be running in the emulator
meshService?.setOwner(user)
} catch (ex: RemoteException) {
errormsg("Can't set username on device, is device offline? ${ex.message}")
}
}
val adminChannelIndex: Int /** matches [MeshService.adminChannelIndex] **/
get() = channelSet.settingsList.indexOfFirst { it.name.equals("admin", ignoreCase = true) }
.coerceAtLeast(0)
/**
* Write the persisted packet data out to a CSV file in the specified location.
*/
fun saveMessagesCSV(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.nodes.value ?: emptyMap()
val positionToPos: (MeshProtos.Position?) -> Position? = { meshPosition ->
meshPosition?.let { Position(it) }.takeIf {
it?.isValid() == true
}
}
writeToUri(uri) { writer ->
// Create a map of nodes keyed by their ID
val nodesById = nodes.values.associateBy { it.num }.toMutableMap()
val nodePositions = mutableMapOf<Int, MeshProtos.Position?>()
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] = position
}
}
// Filter out of our results any packet that doesn't report SNR. This
// is primarily ADMIN_APP.
if (proto.rxSnr != 0.0f) {
val rxDateTime = dateFormat.format(packet.received_date)
val rxFrom = proto.from.toUInt()
val senderName = nodesById[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 = "%f".format(proto.rxSnr)
// Calculate the distance if both positions are valid
val dist = if (senderPos == null || rxPos == null) {
""
} 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 != Portnums.PortNum.TEXT_MESSAGE_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
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(name: String, value: String, mode: QuickChatAction.Mode) {
viewModelScope.launch(Dispatchers.Main) {
val action = QuickChatAction(0, name, value, mode, _quickChatActions.value.size)
quickChatActionRepository.insert(action)
}
}
fun deleteQuickChatAction(action: QuickChatAction) {
viewModelScope.launch(Dispatchers.Main) {
quickChatActionRepository.delete(action)
}
}
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
)
quickChatActionRepository.update(newAction)
}
}
fun updateActionPositions(actions: List<QuickChatAction>) {
viewModelScope.launch(Dispatchers.Main) {
for (position in actions.indices) {
quickChatActionRepository.setItemPosition(actions[position].uuid, position)
}
}
}
private val _tracerouteResponse = MutableLiveData<String?>(null)
val tracerouteResponse: LiveData<String?> get() = _tracerouteResponse
fun clearTracerouteResponse() {
_tracerouteResponse.value = null
}
private fun processPacketResponse(log: MeshLog?) {
val packet = log?.meshPacket ?: return
val data = packet.decoded
requestIds.update { it.apply { put(data.requestId, true) } }
if (data?.portnumValue == Portnums.PortNum.TRACEROUTE_APP_VALUE) {
val parsed = MeshProtos.RouteDiscovery.parseFrom(data.payload)
fun nodeName(num: Int) = nodeDB.nodesByNum?.get(num)?.user?.longName
?: app.getString(R.string.unknown_username)
_tracerouteResponse.value = buildString {
append("${nodeName(packet.to)} --> ")
parsed.routeList.forEach { num -> append("${nodeName(num)} --> ") }
append(nodeName(packet.from))
}
}
}
}