refactor(feature/node): Extract MetricsViewModel to commonMain

This commit is contained in:
James Rich 2026-03-16 11:03:01 -05:00
parent 8dd1113dfd
commit 52c2f6ef08
4 changed files with 41 additions and 123 deletions

View file

@ -35,7 +35,7 @@ import kotlinx.coroutines.flow.Flow
import org.jetbrains.compose.resources.StringResource import org.jetbrains.compose.resources.StringResource
import org.koin.compose.viewmodel.koinViewModel import org.koin.compose.viewmodel.koinViewModel
import org.meshtastic.app.map.node.NodeMapScreen import org.meshtastic.app.map.node.NodeMapScreen
import org.meshtastic.app.node.AndroidMetricsViewModel import org.meshtastic.feature.node.metrics.MetricsViewModel
import org.meshtastic.app.ui.node.AdaptiveNodeListScreen import org.meshtastic.app.ui.node.AdaptiveNodeListScreen
import org.meshtastic.core.navigation.ContactsRoutes import org.meshtastic.core.navigation.ContactsRoutes
import org.meshtastic.core.navigation.NodeDetailRoutes import org.meshtastic.core.navigation.NodeDetailRoutes
@ -116,7 +116,7 @@ fun EntryProviderScope<NavKey>.nodeDetailGraph(
} }
entry<NodeDetailRoutes.TracerouteLog> { args -> entry<NodeDetailRoutes.TracerouteLog> { args ->
val metricsViewModel = koinViewModel<AndroidMetricsViewModel>() val metricsViewModel = koinViewModel<MetricsViewModel>()
metricsViewModel.setNodeId(args.destNum) metricsViewModel.setNodeId(args.destNum)
TracerouteLogScreen( TracerouteLogScreen(
@ -135,7 +135,7 @@ fun EntryProviderScope<NavKey>.nodeDetailGraph(
} }
entry<NodeDetailRoutes.TracerouteMap> { args -> entry<NodeDetailRoutes.TracerouteMap> { args ->
val metricsViewModel = koinViewModel<AndroidMetricsViewModel>() val metricsViewModel = koinViewModel<MetricsViewModel>()
metricsViewModel.setNodeId(args.destNum) metricsViewModel.setNodeId(args.destNum)
TracerouteMapScreen( TracerouteMapScreen(
@ -177,7 +177,7 @@ private inline fun <reified R : Route> EntryProviderScope<NavKey>.addNodeDetailS
crossinline getDestNum: (R) -> Int, crossinline getDestNum: (R) -> Int,
) { ) {
entry<R> { args -> entry<R> { args ->
val metricsViewModel = koinViewModel<AndroidMetricsViewModel>() val metricsViewModel = koinViewModel<MetricsViewModel>()
val destNum = getDestNum(args) val destNum = getDestNum(args)
metricsViewModel.setNodeId(destNum) metricsViewModel.setNodeId(destNum)

View file

@ -1,113 +0,0 @@
/*
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.app.node
import android.app.Application
import android.net.Uri
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.viewModelScope
import co.touchlab.kermit.Logger
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.koin.core.annotation.KoinViewModel
import org.meshtastic.core.common.util.toDate
import org.meshtastic.core.common.util.toInstant
import org.meshtastic.core.data.repository.TracerouteSnapshotRepository
import org.meshtastic.core.di.CoroutineDispatchers
import org.meshtastic.core.repository.MeshLogRepository
import org.meshtastic.core.repository.NodeRepository
import org.meshtastic.core.repository.ServiceRepository
import org.meshtastic.core.ui.util.AlertManager
import org.meshtastic.feature.node.detail.NodeRequestActions
import org.meshtastic.feature.node.domain.usecase.GetNodeDetailsUseCase
import org.meshtastic.feature.node.metrics.MetricsViewModel
import java.io.BufferedWriter
import java.io.FileNotFoundException
import java.io.FileWriter
import java.text.SimpleDateFormat
import java.util.Locale
@KoinViewModel
class AndroidMetricsViewModel(
savedStateHandle: SavedStateHandle,
private val app: Application,
dispatchers: CoroutineDispatchers,
meshLogRepository: MeshLogRepository,
serviceRepository: ServiceRepository,
nodeRepository: NodeRepository,
tracerouteSnapshotRepository: TracerouteSnapshotRepository,
nodeRequestActions: NodeRequestActions,
alertManager: AlertManager,
getNodeDetailsUseCase: GetNodeDetailsUseCase,
) : MetricsViewModel(
savedStateHandle.get<Int>("destNum") ?: 0,
dispatchers,
meshLogRepository,
serviceRepository,
nodeRepository,
tracerouteSnapshotRepository,
nodeRequestActions,
alertManager,
getNodeDetailsUseCase,
) {
override fun savePositionCSV(uri: Any) {
if (uri is Uri) {
savePositionCSVAndroid(uri)
}
}
private fun savePositionCSVAndroid(uri: Uri) = viewModelScope.launch(dispatchers.main) {
val positions = state.value.positionLogs
writeToUri(uri) { writer ->
writer.appendLine(
"\"date\",\"time\",\"latitude\",\"longitude\",\"altitude\",\"satsInView\",\"speed\",\"heading\"",
)
val dateFormat = SimpleDateFormat("\"yyyy-MM-dd\",\"HH:mm:ss\"", Locale.getDefault())
positions.forEach { position ->
val rxDateTime = dateFormat.format((position.time.toLong() * 1000L).toInstant().toDate())
val latitude = (position.latitude_i ?: 0) * 1e-7
val longitude = (position.longitude_i ?: 0) * 1e-7
val altitude = position.altitude
val satsInView = position.sats_in_view
val speed = position.ground_speed
val heading = "%.2f".format((position.ground_track ?: 0) * 1e-5)
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) }
}
}
} catch (ex: FileNotFoundException) {
Logger.e(ex) { "Can't write file error" }
}
}
override fun decodeBase64(base64: String): ByteArray =
android.util.Base64.decode(base64, android.util.Base64.DEFAULT)
}

View file

@ -16,6 +16,8 @@
*/ */
package org.meshtastic.feature.node.metrics package org.meshtastic.feature.node.metrics
import org.meshtastic.core.common.util.toMeshtasticUri
import android.app.Activity import android.app.Activity
import android.content.Intent import android.content.Intent
import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.compose.rememberLauncherForActivityResult
@ -119,7 +121,7 @@ fun PositionLogScreen(viewModel: MetricsViewModel, onNavigateUp: () -> Unit) {
val exportPositionLauncher = val exportPositionLauncher =
rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) { rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) {
if (it.resultCode == Activity.RESULT_OK) { if (it.resultCode == Activity.RESULT_OK) {
it.data?.data?.let { uri -> viewModel.savePositionCSV(uri) } it.data?.data?.let { uri -> viewModel.savePositionCSV(uri.toMeshtasticUri()) }
} }
} }

View file

@ -66,6 +66,13 @@ import org.meshtastic.proto.PortNum
import org.meshtastic.proto.Telemetry import org.meshtastic.proto.Telemetry
import org.meshtastic.proto.Paxcount as ProtoPaxcount import org.meshtastic.proto.Paxcount as ProtoPaxcount
import org.meshtastic.core.repository.FileService
import org.meshtastic.core.common.util.MeshtasticUri
import kotlinx.datetime.Instant
import kotlinx.datetime.TimeZone
import kotlinx.datetime.toLocalDateTime
import okio.ByteString.Companion.decodeBase64
/** /**
* ViewModel responsible for managing and graphing metrics (telemetry, signal strength, paxcount) for a specific node. * ViewModel responsible for managing and graphing metrics (telemetry, signal strength, paxcount) for a specific node.
*/ */
@ -81,6 +88,7 @@ open class MetricsViewModel(
private val nodeRequestActions: NodeRequestActions, private val nodeRequestActions: NodeRequestActions,
private val alertManager: AlertManager, private val alertManager: AlertManager,
private val getNodeDetailsUseCase: GetNodeDetailsUseCase, private val getNodeDetailsUseCase: GetNodeDetailsUseCase,
private val fileService: FileService,
) : ViewModel() { ) : ViewModel() {
private val nodeIdFromRoute: Int? private val nodeIdFromRoute: Int?
@ -315,8 +323,30 @@ open class MetricsViewModel(
Logger.d { "MetricsViewModel cleared" } Logger.d { "MetricsViewModel cleared" }
} }
open fun savePositionCSV(uri: Any) { fun savePositionCSV(uri: MeshtasticUri) {
// To be implemented in platform-specific subclass viewModelScope.launch(dispatchers.main) {
val positions = state.value.positionLogs
fileService.write(uri) { sink ->
sink.writeUtf8("\"date\",\"time\",\"latitude\",\"longitude\",\"altitude\",\"satsInView\",\"speed\",\"heading\"\n")
positions.forEach { position ->
val localDateTime = Instant.fromEpochSeconds(position.time.toLong())
.toLocalDateTime(TimeZone.currentSystemDefault())
val rxDateTime = "\"${localDateTime.date}\",\"${localDateTime.time}\""
val latitude = (position.latitude_i ?: 0) * 1e-7
val longitude = (position.longitude_i ?: 0) * 1e-7
val altitude = position.altitude
val satsInView = position.sats_in_view
val speed = position.ground_speed
// Kotlin string format is available in common code on 1.9.20+ via String.format,
// but we can just do basic string manipulation if needed.
val heading = "%.2f".format((position.ground_track ?: 0) * 1e-5)
sink.writeUtf8("$rxDateTime,\"$latitude\",\"$longitude\",\"$altitude\",\"$satsInView\",\"$speed\",\"$heading\"\n")
}
}
}
} }
@Suppress("MagicNumber", "CyclomaticComplexMethod", "ReturnCount") @Suppress("MagicNumber", "CyclomaticComplexMethod", "ReturnCount")
@ -347,8 +377,7 @@ open class MetricsViewModel(
return null return null
} }
protected open fun decodeBase64(base64: String): ByteArray { protected fun decodeBase64(base64: String): ByteArray {
// To be overridden in platform-specific subclass or use KMP library return base64.decodeBase64()?.toByteArray() ?: ByteArray(0)
return ByteArray(0)
} }
} }