diff --git a/app/src/main/java/com/geeksville/mesh/database/MeshLogRepository.kt b/app/src/main/java/com/geeksville/mesh/database/MeshLogRepository.kt index ed9b3d329..05753c5f2 100644 --- a/app/src/main/java/com/geeksville/mesh/database/MeshLogRepository.kt +++ b/app/src/main/java/com/geeksville/mesh/database/MeshLogRepository.kt @@ -18,6 +18,7 @@ package com.geeksville.mesh.database import com.geeksville.mesh.CoroutineDispatchers +import com.geeksville.mesh.MeshProtos import com.geeksville.mesh.MeshProtos.MeshPacket import com.geeksville.mesh.Portnums import com.geeksville.mesh.TelemetryProtos.Telemetry @@ -32,68 +33,60 @@ import kotlinx.coroutines.withContext import javax.inject.Inject @Suppress("TooManyFunctions") -class MeshLogRepository @Inject constructor( +class MeshLogRepository +@Inject +constructor( private val meshLogDaoLazy: dagger.Lazy, private val dispatchers: CoroutineDispatchers, ) { - private val meshLogDao by lazy { - meshLogDaoLazy.get() - } + private val meshLogDao by lazy { meshLogDaoLazy.get() } - fun getAllLogs(maxItems: Int = MAX_ITEMS): Flow> = meshLogDao.getAllLogs(maxItems) - .flowOn(dispatchers.io) - .conflate() + fun getAllLogs(maxItems: Int = MAX_ITEMS): Flow> = + meshLogDao.getAllLogs(maxItems).flowOn(dispatchers.io).conflate() fun getAllLogsInReceiveOrder(maxItems: Int = MAX_ITEMS): Flow> = - meshLogDao.getAllLogsInReceiveOrder(maxItems) - .flowOn(dispatchers.io) - .conflate() + meshLogDao.getAllLogsInReceiveOrder(maxItems).flowOn(dispatchers.io).conflate() private fun parseTelemetryLog(log: MeshLog): Telemetry? = runCatching { Telemetry.parseFrom(log.fromRadio.packet.decoded.payload) - .toBuilder().setTime((log.received_date / MILLIS_TO_SECONDS).toInt()).build() - }.getOrNull() + .toBuilder() + .setTime((log.received_date / MILLIS_TO_SECONDS).toInt()) + .build() + } + .getOrNull() - fun getTelemetryFrom(nodeNum: Int): Flow> = - meshLogDao.getLogsFrom(nodeNum, Portnums.PortNum.TELEMETRY_APP_VALUE, MAX_MESH_PACKETS) - .distinctUntilChanged() - .mapLatest { list -> list.mapNotNull(::parseTelemetryLog) } - .flowOn(dispatchers.io) + fun getTelemetryFrom(nodeNum: Int): Flow> = meshLogDao + .getLogsFrom(nodeNum, Portnums.PortNum.TELEMETRY_APP_VALUE, MAX_MESH_PACKETS) + .distinctUntilChanged() + .mapLatest { list -> list.mapNotNull(::parseTelemetryLog) } + .flowOn(dispatchers.io) fun getLogsFrom( nodeNum: Int, portNum: Int = Portnums.PortNum.UNKNOWN_APP_VALUE, maxItem: Int = MAX_MESH_PACKETS, - ): Flow> = meshLogDao.getLogsFrom(nodeNum, portNum, maxItem) - .distinctUntilChanged() - .flowOn(dispatchers.io) + ): Flow> = + meshLogDao.getLogsFrom(nodeNum, portNum, maxItem).distinctUntilChanged().flowOn(dispatchers.io) /* * Retrieves MeshPackets matching 'nodeNum' and 'portNum'. * If 'portNum' is not specified, returns all MeshPackets. Otherwise, filters by 'portNum'. */ - fun getMeshPacketsFrom( - nodeNum: Int, - portNum: Int = Portnums.PortNum.UNKNOWN_APP_VALUE, - ): Flow> = getLogsFrom(nodeNum, portNum) - .mapLatest { list -> list.map { it.fromRadio.packet } } + fun getMeshPacketsFrom(nodeNum: Int, portNum: Int = Portnums.PortNum.UNKNOWN_APP_VALUE): Flow> = + getLogsFrom(nodeNum, portNum).mapLatest { list -> list.map { it.fromRadio.packet } }.flowOn(dispatchers.io) + + fun getMyNodeInfo(): Flow = getLogsFrom(0, 0) + .mapLatest { list -> list.firstOrNull { it.myNodeInfo != null }?.myNodeInfo } .flowOn(dispatchers.io) - suspend fun insert(log: MeshLog) = withContext(dispatchers.io) { - meshLogDao.insert(log) - } + suspend fun insert(log: MeshLog) = withContext(dispatchers.io) { meshLogDao.insert(log) } - suspend fun deleteAll() = withContext(dispatchers.io) { - meshLogDao.deleteAll() - } + suspend fun deleteAll() = withContext(dispatchers.io) { meshLogDao.deleteAll() } - suspend fun deleteLog(uuid: String) = withContext(dispatchers.io) { - meshLogDao.deleteLog(uuid) - } + suspend fun deleteLog(uuid: String) = withContext(dispatchers.io) { meshLogDao.deleteLog(uuid) } - suspend fun deleteLogs(nodeNum: Int, portNum: Int) = withContext(dispatchers.io) { - meshLogDao.deleteLogs(nodeNum, portNum) - } + suspend fun deleteLogs(nodeNum: Int, portNum: Int) = + withContext(dispatchers.io) { meshLogDao.deleteLogs(nodeNum, portNum) } companion object { private const val MAX_ITEMS = 500 diff --git a/app/src/main/java/com/geeksville/mesh/model/MetricsViewModel.kt b/app/src/main/java/com/geeksville/mesh/model/MetricsViewModel.kt index 10c9cbde4..b0ad89f5e 100644 --- a/app/src/main/java/com/geeksville/mesh/model/MetricsViewModel.kt +++ b/app/src/main/java/com/geeksville/mesh/model/MetricsViewModel.kt @@ -33,6 +33,7 @@ import com.geeksville.mesh.DataPacket import com.geeksville.mesh.MeshProtos import com.geeksville.mesh.MeshProtos.MeshPacket import com.geeksville.mesh.MeshProtos.Position +import com.geeksville.mesh.Portnums import com.geeksville.mesh.Portnums.PortNum import com.geeksville.mesh.R import com.geeksville.mesh.TelemetryProtos.Telemetry @@ -47,7 +48,6 @@ import com.geeksville.mesh.repository.api.FirmwareReleaseRepository import com.geeksville.mesh.repository.datastore.RadioConfigRepository import com.geeksville.mesh.service.ServiceAction import com.geeksville.mesh.ui.map.MAP_STYLE_ID -import com.geeksville.mesh.Portnums import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -56,6 +56,7 @@ import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.mapLatest import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.toList @@ -87,16 +88,23 @@ data class MetricsState( val positionLogs: List = emptyList(), val deviceHardware: DeviceHardware? = null, val isLocalDevice: Boolean = false, + val firmwareEdition: MeshProtos.FirmwareEdition? = null, val latestStableFirmware: FirmwareRelease = FirmwareRelease(), val latestAlphaFirmware: FirmwareRelease = FirmwareRelease(), val paxMetrics: List = emptyList(), ) { fun hasDeviceMetrics() = deviceMetrics.isNotEmpty() + fun hasSignalMetrics() = signalMetrics.isNotEmpty() + fun hasPowerMetrics() = powerMetrics.isNotEmpty() + fun hasTracerouteLogs() = tracerouteRequests.isNotEmpty() + fun hasPositionLogs() = positionLogs.isNotEmpty() + fun hasHostMetrics() = hostMetrics.isNotEmpty() + fun hasPaxMetrics() = paxMetrics.isNotEmpty() fun deviceMetricsFiltered(timeFrame: TimeFrame): List { @@ -119,20 +127,16 @@ data class MetricsState( } } -/** - * Supported time frames used to display data. - */ +/** Supported time frames used to display data. */ @Suppress("MagicNumber") -enum class TimeFrame( - val seconds: Long, - @StringRes val strRes: Int -) { +enum class TimeFrame(val seconds: Long, @StringRes val strRes: Int) { TWENTY_FOUR_HOURS(TimeUnit.DAYS.toSeconds(1), R.string.twenty_four_hours), FORTY_EIGHT_HOURS(TimeUnit.DAYS.toSeconds(2), R.string.forty_eight_hours), ONE_WEEK(TimeUnit.DAYS.toSeconds(7), R.string.one_week), TWO_WEEKS(TimeUnit.DAYS.toSeconds(14), R.string.two_weeks), FOUR_WEEKS(TimeUnit.DAYS.toSeconds(28), R.string.four_weeks), - MAX(0L, R.string.max); + MAX(0L, R.string.max), + ; fun calculateOldestTime(): Long = if (this == MAX) { MAX.seconds @@ -141,42 +145,29 @@ enum class TimeFrame( } /** - * The time interval to draw the vertical lines representing - * time on the x-axis. + * The time interval to draw the vertical lines representing time on the x-axis. * * @return seconds epoch seconds */ - fun lineInterval(): Long { - return when (this.ordinal) { - TWENTY_FOUR_HOURS.ordinal -> - TimeUnit.HOURS.toSeconds(6) + fun lineInterval(): Long = when (this.ordinal) { + TWENTY_FOUR_HOURS.ordinal -> TimeUnit.HOURS.toSeconds(6) - FORTY_EIGHT_HOURS.ordinal -> - TimeUnit.HOURS.toSeconds(12) + FORTY_EIGHT_HOURS.ordinal -> TimeUnit.HOURS.toSeconds(12) - ONE_WEEK.ordinal, - TWO_WEEKS.ordinal -> - TimeUnit.DAYS.toSeconds(1) + ONE_WEEK.ordinal, + TWO_WEEKS.ordinal, + -> TimeUnit.DAYS.toSeconds(1) - else -> - TimeUnit.DAYS.toSeconds(7) - } + else -> TimeUnit.DAYS.toSeconds(7) } - /** - * Used to detect a significant time separation between [Telemetry]s. - */ - fun timeThreshold(): Long { - return when (this.ordinal) { - TWENTY_FOUR_HOURS.ordinal -> - TimeUnit.HOURS.toSeconds(6) + /** Used to detect a significant time separation between [Telemetry]s. */ + fun timeThreshold(): Long = when (this.ordinal) { + TWENTY_FOUR_HOURS.ordinal -> TimeUnit.HOURS.toSeconds(6) - FORTY_EIGHT_HOURS.ordinal -> - TimeUnit.HOURS.toSeconds(12) + FORTY_EIGHT_HOURS.ordinal -> TimeUnit.HOURS.toSeconds(12) - else -> - TimeUnit.DAYS.toSeconds(1) - } + else -> TimeUnit.DAYS.toSeconds(1) } /** @@ -203,7 +194,9 @@ private fun MeshPacket.toPosition(): Position? = if (!decoded.wantResponse) { @Suppress("LongParameterList") @HiltViewModel -class MetricsViewModel @Inject constructor( +class MetricsViewModel +@Inject +constructor( savedStateHandle: SavedStateHandle, private val app: Application, private val dispatchers: CoroutineDispatchers, @@ -212,56 +205,50 @@ class MetricsViewModel @Inject constructor( private val deviceHardwareRepository: DeviceHardwareRepository, private val firmwareReleaseRepository: FirmwareReleaseRepository, private val preferences: SharedPreferences, -) : ViewModel(), Logging { +) : ViewModel(), + Logging { private val destNum = savedStateHandle.toRoute().destNum - private fun MeshLog.hasValidTraceroute(): Boolean = with(fromRadio.packet) { - hasDecoded() && decoded.wantResponse && from == 0 && to == destNum - } + private fun MeshLog.hasValidTraceroute(): Boolean = + with(fromRadio.packet) { hasDecoded() && decoded.wantResponse && from == 0 && to == destNum } /** - * Creates a fallback node for hidden clients or nodes not yet in the database. - * This prevents the detail screen from freezing when viewing unknown nodes. + * Creates a fallback node for hidden clients or nodes not yet in the database. This prevents the detail screen from + * freezing when viewing unknown nodes. */ private fun createFallbackNode(nodeNum: Int): Node { val userId = DataPacket.nodeNumToDefaultId(nodeNum) val safeUserId = userId.padStart(DEFAULT_ID_SUFFIX_LENGTH, '0').takeLast(DEFAULT_ID_SUFFIX_LENGTH) val longName = app.getString(R.string.fallback_node_name, safeUserId) - val defaultUser = MeshProtos.User.newBuilder() - .setId(userId) - .setLongName(longName) - .setShortName(safeUserId) - .setHwModel(MeshProtos.HardwareModel.UNSET) - .build() + val defaultUser = + MeshProtos.User.newBuilder() + .setId(userId) + .setLongName(longName) + .setShortName(safeUserId) + .setHwModel(MeshProtos.HardwareModel.UNSET) + .build() - return Node( - num = nodeNum, - user = defaultUser, - ) + return Node(num = nodeNum, user = defaultUser) } fun getUser(nodeNum: Int) = radioConfigRepository.getUser(nodeNum) - val tileSource get() = CustomTileSource.getTileSource(preferences.getInt(MAP_STYLE_ID, 0)) - fun deleteLog(uuid: String) = viewModelScope.launch(dispatchers.io) { - meshLogRepository.deleteLog(uuid) - } + val tileSource + get() = CustomTileSource.getTileSource(preferences.getInt(MAP_STYLE_ID, 0)) + + fun deleteLog(uuid: String) = viewModelScope.launch(dispatchers.io) { meshLogRepository.deleteLog(uuid) } fun clearPosition() = viewModelScope.launch(dispatchers.io) { - destNum?.let { - meshLogRepository.deleteLogs(it, PortNum.POSITION_APP_VALUE) - } + destNum?.let { meshLogRepository.deleteLogs(it, PortNum.POSITION_APP_VALUE) } } - fun onServiceAction(action: ServiceAction) = viewModelScope.launch { - radioConfigRepository.onServiceAction(action) - } + fun onServiceAction(action: ServiceAction) = viewModelScope.launch { radioConfigRepository.onServiceAction(action) } private val _state = MutableStateFlow(MetricsState.Empty) val state: StateFlow = _state - private val _envState = MutableStateFlow(EnvironmentMetricsState()) - val environmentState: StateFlow = _envState + private val _environmentState = MutableStateFlow(EnvironmentMetricsState()) + val environmentState: StateFlow = _environmentState private val _timeFrame = MutableStateFlow(TimeFrame.TWENTY_FOUR_HOURS) val timeFrame: StateFlow = _timeFrame @@ -274,53 +261,56 @@ class MetricsViewModel @Inject constructor( .onEach { (node, ourNode) -> // Create a fallback node if not found in database (for hidden clients, etc.) val actualNode = node ?: createFallbackNode(destNum) - val deviceHardware = actualNode.user.hwModel.number.let { - deviceHardwareRepository.getDeviceHardwareByModel(it) + val deviceHardware = + actualNode.user.hwModel.number.let { deviceHardwareRepository.getDeviceHardwareByModel(it) } + _state.update { state -> + state.copy(node = actualNode, isLocal = destNum == ourNode, deviceHardware = deviceHardware) } + } + .launchIn(viewModelScope) + + radioConfigRepository.deviceProfileFlow + .onEach { profile -> + val moduleConfig = profile.moduleConfig _state.update { state -> state.copy( - node = actualNode, - isLocal = destNum == ourNode, - deviceHardware = deviceHardware + isManaged = profile.config.security.isManaged, + isFahrenheit = moduleConfig.telemetry.environmentDisplayFahrenheit, + displayUnits = profile.config.display.units, ) } - }.launchIn(viewModelScope) - - radioConfigRepository.deviceProfileFlow.onEach { profile -> - val moduleConfig = profile.moduleConfig - _state.update { state -> - state.copy( - isManaged = profile.config.security.isManaged, - isFahrenheit = moduleConfig.telemetry.environmentDisplayFahrenheit, - displayUnits = profile.config.display.units - ) } - }.launchIn(viewModelScope) + .launchIn(viewModelScope) - meshLogRepository.getTelemetryFrom(destNum).onEach { telemetry -> - _state.update { state -> - state.copy( - deviceMetrics = telemetry.filter { it.hasDeviceMetrics() }, - powerMetrics = telemetry.filter { it.hasPowerMetrics() }, - hostMetrics = telemetry.filter { it.hasHostMetrics() }, - ) - } - _envState.update { state -> - state.copy( - environmentMetrics = telemetry.filter { - it.hasEnvironmentMetrics() && + meshLogRepository + .getTelemetryFrom(destNum) + .onEach { telemetry -> + _state.update { state -> + state.copy( + deviceMetrics = telemetry.filter { it.hasDeviceMetrics() }, + powerMetrics = telemetry.filter { it.hasPowerMetrics() }, + hostMetrics = telemetry.filter { it.hasHostMetrics() }, + ) + } + _environmentState.update { state -> + state.copy( + environmentMetrics = + telemetry.filter { + it.hasEnvironmentMetrics() && it.environmentMetrics.relativeHumidity >= 0f && !it.environmentMetrics.temperature.isNaN() - }, - ) + }, + ) + } } - }.launchIn(viewModelScope) + .launchIn(viewModelScope) - meshLogRepository.getMeshPacketsFrom(destNum).onEach { meshPackets -> - _state.update { state -> - state.copy(signalMetrics = meshPackets.filter { it.hasValidSignal() }) + meshLogRepository + .getMeshPacketsFrom(destNum) + .onEach { meshPackets -> + _state.update { state -> state.copy(signalMetrics = meshPackets.filter { it.hasValidSignal() }) } } - }.launchIn(viewModelScope) + .launchIn(viewModelScope) combine( meshLogRepository.getLogsFrom(nodeNum = 0, PortNum.TRACEROUTE_APP_VALUE), @@ -332,38 +322,46 @@ class MetricsViewModel @Inject constructor( tracerouteResults = response, ) } - }.launchIn(viewModelScope) + } + .launchIn(viewModelScope) - meshLogRepository.getMeshPacketsFrom(destNum, PortNum.POSITION_APP_VALUE) + meshLogRepository + .getMeshPacketsFrom(destNum, PortNum.POSITION_APP_VALUE) .onEach { packets -> val distinctPositions = - packets.mapNotNull { it.toPosition() }.asFlow() + packets + .mapNotNull { it.toPosition() } + .asFlow() .distinctUntilChanged { old, new -> old.time == new.time || - (old.latitudeI == new.latitudeI && old.longitudeI == new.longitudeI) - }.toList() - _state.update { state -> - state.copy(positionLogs = distinctPositions) - } - }.launchIn(viewModelScope) - - meshLogRepository.getLogsFrom(destNum, Portnums.PortNum.PAXCOUNTER_APP_VALUE).onEach { logs -> - _state.update { state -> - state.copy(paxMetrics = logs) + (old.latitudeI == new.latitudeI && old.longitudeI == new.longitudeI) + } + .toList() + _state.update { state -> state.copy(positionLogs = distinctPositions) } } - }.launchIn(viewModelScope) + .launchIn(viewModelScope) - firmwareReleaseRepository.stableRelease.filterNotNull().onEach { latestStable -> - _state.update { state -> - state.copy(latestStableFirmware = latestStable) - } - }.launchIn(viewModelScope) + meshLogRepository + .getLogsFrom(destNum, Portnums.PortNum.PAXCOUNTER_APP_VALUE) + .onEach { logs -> _state.update { state -> state.copy(paxMetrics = logs) } } + .launchIn(viewModelScope) - firmwareReleaseRepository.alphaRelease.filterNotNull().onEach { latestAlpha -> - _state.update { state -> - state.copy(latestAlphaFirmware = latestAlpha) - } - }.launchIn(viewModelScope) + firmwareReleaseRepository.stableRelease + .filterNotNull() + .onEach { latestStable -> _state.update { state -> state.copy(latestStableFirmware = latestStable) } } + .launchIn(viewModelScope) + + firmwareReleaseRepository.alphaRelease + .filterNotNull() + .onEach { latestAlpha -> _state.update { state -> state.copy(latestAlphaFirmware = latestAlpha) } } + .launchIn(viewModelScope) + + meshLogRepository + .getMyNodeInfo() + .map { it?.firmwareEdition } + .distinctUntilChanged() + .onEach { firmwareEdition -> _state.update { state -> state.copy(firmwareEdition = firmwareEdition) } } + .launchIn(viewModelScope) debug("MetricsViewModel created") } else { @@ -380,16 +378,15 @@ class MetricsViewModel @Inject constructor( _timeFrame.value = timeFrame } - /** - * Write the persisted Position data out to a CSV file in the specified location. - */ + /** Write the persisted Position data out to a CSV file in the specified location. */ fun savePositionCSV(uri: Uri) = viewModelScope.launch(dispatchers.main) { val positions = state.value.positionLogs writeToUri(uri) { writer -> - writer.appendLine("\"date\",\"time\",\"latitude\",\"longitude\",\"altitude\",\"satsInView\",\"speed\",\"heading\"") + writer.appendLine( + "\"date\",\"time\",\"latitude\",\"longitude\",\"altitude\",\"satsInView\",\"speed\",\"heading\"", + ) - val dateFormat = - SimpleDateFormat("\"yyyy-MM-dd\",\"HH:mm:ss\"", Locale.getDefault()) + val dateFormat = SimpleDateFormat("\"yyyy-MM-dd\",\"HH:mm:ss\"", Locale.getDefault()) positions.forEach { position -> val rxDateTime = dateFormat.format(position.time * 1000L) @@ -401,23 +398,23 @@ class MetricsViewModel @Inject constructor( val heading = "%.2f".format(position.groundTrack * 1e-5) // date,time,latitude,longitude,altitude,satsInView,speed,heading - writer.appendLine("$rxDateTime,\"$latitude\",\"$longitude\",\"$altitude\",\"$satsInView\",\"$speed\",\"$heading\"") + writer.appendLine( + "$rxDateTime,\"$latitude\",\"$longitude\",\"$altitude\",\"$satsInView\",\"$speed\",\"$heading\"", + ) } } } - 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) } + 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}") } - } catch (ex: FileNotFoundException) { - errormsg("Can't write file error: ${ex.message}") } - } } diff --git a/app/src/main/java/com/geeksville/mesh/model/UIState.kt b/app/src/main/java/com/geeksville/mesh/model/UIState.kt index 5381c4f2b..3e4362f7c 100644 --- a/app/src/main/java/com/geeksville/mesh/model/UIState.kt +++ b/app/src/main/java/com/geeksville/mesh/model/UIState.kt @@ -94,7 +94,8 @@ 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 +// 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. @@ -104,51 +105,52 @@ fun getInitials(nameIn: String): String { val name = nameIn.trim().withoutEmojis() 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 { - "" + 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 } - if (nm.length >= nchars) nm else name - } - else -> words.map { it.first() }.joinToString("") - } + else -> words.map { it.first() }.joinToString("") + } return initials.take(nchars) } 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. + * 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, - old: List, -): List = 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 { } - } - ) +internal fun getChannelList(new: List, old: List): List = + 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, @@ -177,12 +179,14 @@ data class Contact( val messageCount: Int, val isMuted: Boolean, val isUnmessageable: Boolean, - val nodeColors: Pair? = null + val nodeColors: Pair? = null, ) @Suppress("LongParameterList", "LargeClass") @HiltViewModel -class UIViewModel @Inject constructor( +class UIViewModel +@Inject +constructor( private val app: Application, private val nodeDB: NodeRepository, private val radioConfigRepository: RadioConfigRepository, @@ -193,12 +197,13 @@ class UIViewModel @Inject constructor( private val locationRepository: LocationRepository, firmwareReleaseRepository: FirmwareReleaseRepository, private val preferences: SharedPreferences, - private val meshServiceNotifications: MeshServiceNotifications -) : ViewModel(), Logging { + private val meshServiceNotifications: MeshServiceNotifications, +) : ViewModel(), + Logging { - private val _theme = - MutableStateFlow(preferences.getInt("theme", AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM)) + private val _theme = MutableStateFlow(preferences.getInt("theme", AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM)) val theme: StateFlow = _theme.asStateFlow() + fun setTheme(theme: Int) { _theme.value = theme preferences.edit { putInt("theme", theme) } @@ -211,13 +216,12 @@ class UIViewModel @Inject constructor( val excludedModulesUnlocked: StateFlow = _excludedModulesUnlocked.asStateFlow() fun unlockExcludedModules() { - viewModelScope.launch { - _excludedModulesUnlocked.value = true - } + viewModelScope.launch { _excludedModulesUnlocked.value = true } } - val clientNotification: StateFlow = - radioConfigRepository.clientNotification + val firmwareEdition = meshLogRepository.getMyNodeInfo().map { nodeInfo -> nodeInfo?.firmwareEdition } + + val clientNotification: StateFlow = radioConfigRepository.clientNotification fun clearClientNotification(notification: MeshProtos.ClientNotification) { radioConfigRepository.clearClientNotification() @@ -253,9 +257,7 @@ class UIViewModel @Inject constructor( onConfirm?.invoke() dismissAlert() }, - onDismiss = { - if (dismissable) dismissAlert() - }, + onDismiss = { if (dismissable) dismissAlert() }, choices = choices, ) } @@ -266,52 +268,61 @@ class UIViewModel @Inject constructor( private val _title = MutableStateFlow("") val title: StateFlow = _title.asStateFlow() - fun setTitle(title: String) { - viewModelScope.launch { - _title.value = title - } + fun setTitle(title: String) { + viewModelScope.launch { _title.value = title } } - val receivingLocationUpdates: StateFlow get() = locationRepository.receivingLocationUpdates - val meshService: IMeshService? get() = radioConfigRepository.meshService + val receivingLocationUpdates: StateFlow + get() = locationRepository.receivingLocationUpdates - val selectedBluetooth get() = radioInterfaceService.getDeviceAddress()?.getOrNull(0) == 'x' + val meshService: IMeshService? + get() = radioConfigRepository.meshService + + val selectedBluetooth + get() = radioInterfaceService.getDeviceAddress()?.getOrNull(0) == 'x' private val _localConfig = MutableStateFlow(LocalConfig.getDefaultInstance()) val localConfig: StateFlow = _localConfig - val config get() = _localConfig.value + val config + get() = _localConfig.value - private val _moduleConfig = - MutableStateFlow(LocalModuleConfig.getDefaultInstance()) + private val _moduleConfig = MutableStateFlow(LocalModuleConfig.getDefaultInstance()) val moduleConfig: StateFlow = _moduleConfig - val module get() = _moduleConfig.value + val module + get() = _moduleConfig.value private val _channels = MutableStateFlow(channelSet {}) - val channels: StateFlow get() = _channels + val channels: StateFlow + get() = _channels val quickChatActions - get() = quickChatActionRepository.getAllActions() - .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList()) + 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 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 onlyFavorites = MutableStateFlow(preferences.getBoolean("only-favorites", false)) - private val showWaypointsOnMap = - MutableStateFlow(preferences.getBoolean("show-waypoints-on-map", true)) + private val showWaypointsOnMap = MutableStateFlow(preferences.getBoolean("show-waypoints-on-map", true)) private val showPrecisionCircleOnMap = MutableStateFlow(preferences.getBoolean("show-precision-circle-on-map", true)) private val showIgnored = MutableStateFlow(preferences.getBoolean("show-ignored", false)) + fun toggleShowIgnored() { showIgnored.value = !showIgnored.value preferences.edit { putBoolean("show-ignored", showIgnored.value) } @@ -365,218 +376,237 @@ class UIViewModel @Inject constructor( val showIgnored: Boolean, ) - val nodeFilterStateFlow: Flow = combine( - nodeFilterText, - includeUnknown, - onlyOnline, - onlyDirect, - showIgnored, - ) { filterText, includeUnknown, onlyOnline, onlyDirect, showIgnored -> - NodeFilterState(filterText, includeUnknown, onlyOnline, onlyDirect, showIgnored) - } - - val nodesUiState: StateFlow = 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, - gpsFormat = profile.config.display.gpsFormat.number, - 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> = nodeDB.getNodes().stateIn( - scope = viewModelScope, - started = SharingStarted.WhileSubscribed(5_000), - initialValue = emptyList(), - ) - - val nodeList: StateFlow> = 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, - ) - - val filteredNodeList: StateFlow> = nodeList.mapLatest { list -> - list.filter { node -> - !node.isIgnored + val nodeFilterStateFlow: Flow = + combine(nodeFilterText, includeUnknown, onlyOnline, onlyDirect, showIgnored) { + filterText, + includeUnknown, + onlyOnline, + onlyDirect, + showIgnored, + -> + NodeFilterState(filterText, includeUnknown, onlyOnline, onlyDirect, showIgnored) } - }.stateIn( - scope = viewModelScope, - started = SharingStarted.WhileSubscribed(5_000), - initialValue = emptyList(), - ) - data class MapFilterState( - val onlyFavorites: Boolean, - val showWaypoints: Boolean, - val showPrecisionCircle: Boolean, - ) + val nodesUiState: StateFlow = + 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, + gpsFormat = profile.config.display.gpsFormat.number, + 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 mapFilterStateFlow: StateFlow = combine( - onlyFavorites, - showWaypointsOnMap, - showPrecisionCircleOnMap, - ) { favoritesOnly, showWaypoints, showPrecisionCircle -> - MapFilterState(favoritesOnly, showWaypoints, showPrecisionCircle) - }.stateIn( - scope = viewModelScope, - started = SharingStarted.WhileSubscribed(5_000), - initialValue = MapFilterState(false, true, true) - ) + val unfilteredNodeList: StateFlow> = + nodeDB + .getNodes() + .stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5_000), + initialValue = emptyList(), + ) + + val nodeList: StateFlow> = + 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, + ) + + val filteredNodeList: StateFlow> = + nodeList + .mapLatest { list -> list.filter { node -> !node.isIgnored } } + .stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5_000), + initialValue = emptyList(), + ) + + data class MapFilterState(val onlyFavorites: Boolean, val showWaypoints: Boolean, val showPrecisionCircle: Boolean) + + val mapFilterStateFlow: StateFlow = + combine(onlyFavorites, showWaypointsOnMap, showPrecisionCircleOnMap) { + favoritesOnly, + showWaypoints, + showPrecisionCircle, + -> + MapFilterState(favoritesOnly, showWaypoints, showPrecisionCircle) + } + .stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5_000), + initialValue = MapFilterState(false, true, true), + ) // hardware info about our local device (can be null) - val myNodeInfo: StateFlow get() = nodeDB.myNodeInfo - val ourNodeInfo: StateFlow get() = nodeDB.ourNodeInfo + val myNodeInfo: StateFlow + get() = nodeDB.myNodeInfo - val nodesWithPosition get() = nodeDB.nodeDBbyNum.value.values.filter { it.validPosition != null } + val ourNodeInfo: StateFlow + get() = nodeDB.ourNodeInfo + + val nodesWithPosition + get() = nodeDB.nodeDBbyNum.value.values.filter { it.validPosition != null } var mapStyleId: Int get() = preferences.getInt(MAP_STYLE_ID, 0) set(value) = preferences.edit { putInt(MAP_STYLE_ID, value) } fun getNode(userId: String?) = nodeDB.getNode(userId ?: DataPacket.ID_BROADCAST) + fun getUser(userId: String?) = nodeDB.getUser(userId ?: DataPacket.ID_BROADCAST) val snackbarState = SnackbarHostState() + fun showSnackbar(text: Int) = showSnackbar(app.getString(text)) - fun showSnackbar(text: String) = viewModelScope.launch { - snackbarState.showSnackbar(text) - } + + fun showSnackbar(text: String) = viewModelScope.launch { snackbarState.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.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) + 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 + 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(), - ) + .stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5_000), + initialValue = emptyList(), + ) fun getMessagesFrom(contactKey: String): StateFlow> { - _contactKeyForMessages.value = contactKey + contactKeyForMessages.value = contactKey return messagesForContactKey } - private val _contactKeyForMessages: MutableStateFlow = MutableStateFlow(null) + private val contactKeyForMessages: MutableStateFlow = MutableStateFlow(null) private val messagesForContactKey: StateFlow> = - _contactKeyForMessages.filterNotNull().flatMapLatest { contactKey -> - packetRepository.getMessagesFrom(contactKey, ::getNode) - }.stateIn( - scope = viewModelScope, - started = SharingStarted.WhileSubscribed(5_000), - initialValue = emptyList(), - ) + contactKeyForMessages + .filterNotNull() + .flatMapLatest { contactKey -> packetRepository.getMessagesFrom(contactKey, ::getNode) } + .stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5_000), + initialValue = emptyList(), + ) - val waypoints = packetRepository.getWaypoints().mapLatest { list -> - list.associateBy { packet -> packet.data.waypoint!!.id } - .filterValues { - it.data.waypoint!!.expire == 0 || it.data.waypoint!!.expire > System.currentTimeMillis() / 1000 - } - } + val waypoints = + packetRepository.getWaypoints().mapLatest { list -> + list + .associateBy { packet -> packet.data.waypoint!!.id } + .filterValues { + it.data.waypoint!!.expire == 0 || it.data.waypoint!!.expire > System.currentTimeMillis() / 1000 + } + } fun generatePacketId(): Int? { return try { @@ -625,18 +655,16 @@ class UIViewModel @Inject constructor( radioConfigRepository.onServiceAction(ServiceAction.Reaction(emoji, replyId, contactKey)) } - private val _sharedContactRequested: MutableStateFlow = - MutableStateFlow(null) - val sharedContactRequested: StateFlow get() = _sharedContactRequested.asStateFlow() + private val _sharedContactRequested: MutableStateFlow = MutableStateFlow(null) + val sharedContactRequested: StateFlow + get() = _sharedContactRequested.asStateFlow() + fun setSharedContactRequested(sharedContact: AdminProtos.SharedContact?) { - viewModelScope.launch { - _sharedContactRequested.value = sharedContact - } + viewModelScope.launch { _sharedContactRequested.value = sharedContact } } - fun addSharedContact(sharedContact: AdminProtos.SharedContact) = viewModelScope.launch { - radioConfigRepository.onServiceAction(ServiceAction.AddSharedContact(sharedContact)) - } + fun addSharedContact(sharedContact: AdminProtos.SharedContact) = + viewModelScope.launch { radioConfigRepository.onServiceAction(ServiceAction.AddSharedContact(sharedContact)) } fun requestTraceroute(destNum: Int) { info("Requesting traceroute for '$destNum'") @@ -677,21 +705,16 @@ class UIViewModel @Inject constructor( } } - fun setMuteUntil(contacts: List, until: Long) = viewModelScope.launch(Dispatchers.IO) { - packetRepository.setMuteUntil(contacts, until) - } + fun setMuteUntil(contacts: List, until: Long) = + viewModelScope.launch(Dispatchers.IO) { packetRepository.setMuteUntil(contacts, until) } - fun deleteContacts(contacts: List) = viewModelScope.launch(Dispatchers.IO) { - packetRepository.deleteContacts(contacts) - } + fun deleteContacts(contacts: List) = + viewModelScope.launch(Dispatchers.IO) { packetRepository.deleteContacts(contacts) } - fun deleteMessages(uuidList: List) = viewModelScope.launch(Dispatchers.IO) { - packetRepository.deleteMessages(uuidList) - } + fun deleteMessages(uuidList: List) = + viewModelScope.launch(Dispatchers.IO) { packetRepository.deleteMessages(uuidList) } - fun deleteWaypoint(id: Int) = viewModelScope.launch(Dispatchers.IO) { - packetRepository.deleteWaypoint(id) - } + 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) @@ -705,27 +728,26 @@ class UIViewModel @Inject constructor( } // Connection state to our radio device - val connectionState get() = radioConfigRepository.connectionState + val connectionState + get() = radioConfigRepository.connectionState + fun isConnected() = connectionState.value != MeshService.ConnectionState.DISCONNECTED - val isConnected = - radioConfigRepository.connectionState.map { it != MeshService.ConnectionState.DISCONNECTED } + + val isConnected = radioConfigRepository.connectionState.map { it != MeshService.ConnectionState.DISCONNECTED } private val _requestChannelSet = MutableStateFlow(null) - val requestChannelSet: StateFlow get() = _requestChannelSet + val requestChannelSet: StateFlow + get() = _requestChannelSet - fun requestChannelUrl(url: Uri) = runCatching { - _requestChannelSet.value = url.toChannelSet() - }.onFailure { ex -> - errormsg("Channel url error: ${ex.message}") - showSnackbar(R.string.channel_invalid) - } + 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() } + val latestStableFirmwareRelease = firmwareReleaseRepository.stableRelease.mapNotNull { it?.asDeviceVersion() } - /** - * Called immediately after activity observes requestChannelUrl - */ + /** Called immediately after activity observes requestChannelUrl */ fun clearRequestChannelUrl() { _requestChannelSet.value = null } @@ -758,9 +780,7 @@ class UIViewModel @Inject constructor( } } - fun handleNodeMenuAction( - action: NodeMenuAction, - ) { + fun handleNodeMenuAction(action: NodeMenuAction) { when (action) { is NodeMenuAction.Remove -> removeNode(action.node.num) is NodeMenuAction.Ignore -> ignoreNode(action.node) @@ -777,10 +797,14 @@ class UIViewModel @Inject constructor( } // managed mode disables all access to configuration - val isManaged: Boolean get() = config.device.isManaged || config.security.isManaged + val isManaged: Boolean + get() = config.device.isManaged || config.security.isManaged - val myNodeNum get() = myNodeInfo.value?.myNodeNum - val maxChannels get() = myNodeInfo.value?.maxChannels ?: 8 + val myNodeNum + get() = myNodeInfo.value?.myNodeNum + + val maxChannels + get() = myNodeInfo.value?.maxChannels ?: 8 override fun onCleared() { super.onCleared() @@ -809,9 +833,7 @@ class UIViewModel @Inject constructor( } } - /** - * Set the radio config (also updates our saved copy in preferences). - */ + /** 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) @@ -821,9 +843,7 @@ class UIViewModel @Inject constructor( } fun refreshProvideLocation() { - viewModelScope.launch { - setProvideLocation(getProvidePref()) - } + viewModelScope.launch { setProvideLocation(getProvidePref()) } } private fun getProvidePref(): Boolean { @@ -831,9 +851,9 @@ class UIViewModel @Inject constructor( return value } - private val _provideLocation = - MutableStateFlow(getProvidePref()) - val provideLocation: StateFlow get() = _provideLocation.asStateFlow() + private val _provideLocation = MutableStateFlow(getProvidePref()) + val provideLocation: StateFlow + get() = _provideLocation.asStateFlow() fun setProvideLocation(value: Boolean) { viewModelScope.launch { @@ -848,10 +868,11 @@ class UIViewModel @Inject constructor( } fun setOwner(name: String) { - val user = ourNodeInfo.value?.user?.copy { - longName = name - shortName = getInitials(name) - } ?: return + 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 @@ -861,12 +882,11 @@ class UIViewModel @Inject constructor( } } - /** - * Write the persisted packet data out to a CSV file in the specified location. - */ + /** 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 + // Extract distances to this device from position messages and put (node,SNR,distance) + // in // the file_uri val myNodeNum = myNodeNum ?: return@launch @@ -874,105 +894,105 @@ class UIViewModel @Inject constructor( val nodes = nodeDB.nodeDBbyNum.value val positionToPos: (MeshProtos.Position?) -> Position? = { meshPosition -> - meshPosition?.let { Position(it) }.takeIf { - it?.isValid() == true - } + meshPosition?.let { Position(it) }.takeIf { it?.isValid() == true } } writeToUri(uri) { writer -> val nodePositions = mutableMapOf() - - writer.appendLine("\"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( + "\"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 + 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 } } - 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 - } - } + // 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 = nodes[proto.from]?.user?.longName ?: "" - // 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 = 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 ?: "" - // 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 - // 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 - // Calculate the distance if both positions are valid - - val dist = if (senderPos == null || rxPos == null) { + 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() + 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 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}>" + 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.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\"") - } + // 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 - ) { + 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) - } + BufferedWriter(fileWriter).use { writer -> block.invoke(writer) } } } } catch (ex: FileNotFoundException) { @@ -981,13 +1001,11 @@ class UIViewModel @Inject constructor( } } - fun addQuickChatAction(action: QuickChatAction) = viewModelScope.launch(Dispatchers.IO) { - quickChatActionRepository.upsert(action) - } + fun addQuickChatAction(action: QuickChatAction) = + viewModelScope.launch(Dispatchers.IO) { quickChatActionRepository.upsert(action) } - fun deleteQuickChatAction(action: QuickChatAction) = viewModelScope.launch(Dispatchers.IO) { - quickChatActionRepository.delete(action) - } + fun deleteQuickChatAction(action: QuickChatAction) = + viewModelScope.launch(Dispatchers.IO) { quickChatActionRepository.delete(action) } fun updateActionPositions(actions: List) { viewModelScope.launch(Dispatchers.IO) { diff --git a/app/src/main/java/com/geeksville/mesh/ui/Main.kt b/app/src/main/java/com/geeksville/mesh/ui/Main.kt index 079ba5089..d276b3923 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/Main.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/Main.kt @@ -78,7 +78,9 @@ import androidx.navigation.NavHostController import androidx.navigation.compose.currentBackStackEntryAsState import androidx.navigation.compose.rememberNavController import com.geeksville.mesh.BuildConfig +import com.geeksville.mesh.MeshProtos import com.geeksville.mesh.R +import com.geeksville.mesh.android.BuildUtils.debug import com.geeksville.mesh.model.BluetoothViewModel import com.geeksville.mesh.model.DeviceVersion import com.geeksville.mesh.model.Node @@ -293,9 +295,28 @@ private fun VersionChecks(viewModel: UIViewModel) { val connectionState by viewModel.connectionState.collectAsStateWithLifecycle() val myNodeInfo by viewModel.myNodeInfo.collectAsStateWithLifecycle() val context = LocalContext.current + + val firmwareEdition by viewModel.firmwareEdition.collectAsStateWithLifecycle(null) + val latestStableFirmwareRelease by viewModel.latestStableFirmwareRelease.collectAsState(DeviceVersion("2.6.4")) + LaunchedEffect(connectionState, firmwareEdition) { + if (connectionState == MeshService.ConnectionState.CONNECTED) { + firmwareEdition?.let { edition -> + debug("FirmwareEdition: ${edition.name}") + when (edition) { + MeshProtos.FirmwareEdition.VANILLA -> { + // Handle any specific logic for VANILLA firmware edition if needed + } + + else -> { + // Handle other firmware editions if needed + } + } + } + } + } // Check if the device is running an old app version or firmware version - LaunchedEffect(connectionState, myNodeInfo) { + LaunchedEffect(connectionState, myNodeInfo, firmwareEdition) { if (connectionState == MeshService.ConnectionState.CONNECTED) { myNodeInfo?.let { info -> val isOld = info.minAppVersion > BuildConfig.VERSION_CODE diff --git a/app/src/main/java/com/geeksville/mesh/ui/node/NodeDetail.kt b/app/src/main/java/com/geeksville/mesh/ui/node/NodeDetail.kt index e18a98d03..6df159ee7 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/node/NodeDetail.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/node/NodeDetail.kt @@ -255,13 +255,16 @@ private fun handleNodeAction( val channel = if (hasPKC) DataPacket.PKC_CHANNEL_INDEX else node.channel navigateToMessages("$channel${node.user.id}") } + is NodeMenuAction.Remove -> { uiViewModel.handleNodeMenuAction(menuAction) onNavigateUp() } + else -> uiViewModel.handleNodeMenuAction(menuAction) } } + is NodeDetailAction.ShareContact -> { /* Handled in NodeDetailContent */ } @@ -438,11 +441,19 @@ private fun AdministrationSection( } } - node.metadata?.firmwareVersion?.let { firmwareVersion -> - val latestStable = metricsState.latestStableFirmware - val latestAlpha = metricsState.latestAlphaFirmware + PreferenceCategory(stringResource(R.string.firmware)) { + val firmwareEdition = metricsState.firmwareEdition + firmwareEdition?.let { + NodeDetailRow( + label = stringResource(R.string.firmware_edition), + icon = Icons.Default.Download, + value = it.name, + ) + } + node.metadata?.firmwareVersion?.let { firmwareVersion -> + val latestStable = metricsState.latestStableFirmware + val latestAlpha = metricsState.latestAlphaFirmware - PreferenceCategory(stringResource(R.string.firmware)) { val deviceVersion = DeviceVersion(firmwareVersion.substringBeforeLast(".")) val statusColor = deviceVersion.determineFirmwareStatusColor(latestStable, latestAlpha) diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 70b9a3df1..8f1307f6a 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -803,4 +803,5 @@ Latest stable Latest alpha Supported by Meshtastic Community + Firmware Edition