diff --git a/app/src/main/kotlin/org/meshtastic/app/navigation/NodesNavigation.kt b/app/src/main/kotlin/org/meshtastic/app/navigation/NodesNavigation.kt index 9161b113a..c89ad380c 100644 --- a/app/src/main/kotlin/org/meshtastic/app/navigation/NodesNavigation.kt +++ b/app/src/main/kotlin/org/meshtastic/app/navigation/NodesNavigation.kt @@ -35,7 +35,7 @@ import kotlinx.coroutines.flow.Flow import org.jetbrains.compose.resources.StringResource import org.koin.compose.viewmodel.koinViewModel 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.core.navigation.ContactsRoutes import org.meshtastic.core.navigation.NodeDetailRoutes @@ -116,7 +116,7 @@ fun EntryProviderScope.nodeDetailGraph( } entry { args -> - val metricsViewModel = koinViewModel() + val metricsViewModel = koinViewModel() metricsViewModel.setNodeId(args.destNum) TracerouteLogScreen( @@ -135,7 +135,7 @@ fun EntryProviderScope.nodeDetailGraph( } entry { args -> - val metricsViewModel = koinViewModel() + val metricsViewModel = koinViewModel() metricsViewModel.setNodeId(args.destNum) TracerouteMapScreen( @@ -177,7 +177,7 @@ private inline fun EntryProviderScope.addNodeDetailS crossinline getDestNum: (R) -> Int, ) { entry { args -> - val metricsViewModel = koinViewModel() + val metricsViewModel = koinViewModel() val destNum = getDestNum(args) metricsViewModel.setNodeId(destNum) diff --git a/app/src/main/kotlin/org/meshtastic/app/node/AndroidMetricsViewModel.kt b/app/src/main/kotlin/org/meshtastic/app/node/AndroidMetricsViewModel.kt deleted file mode 100644 index dfa4874bb..000000000 --- a/app/src/main/kotlin/org/meshtastic/app/node/AndroidMetricsViewModel.kt +++ /dev/null @@ -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 . - */ -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("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) -} diff --git a/feature/node/src/androidMain/kotlin/org/meshtastic/feature/node/metrics/PositionLog.kt b/feature/node/src/androidMain/kotlin/org/meshtastic/feature/node/metrics/PositionLog.kt index 3b491e3f4..ad05f9c97 100644 --- a/feature/node/src/androidMain/kotlin/org/meshtastic/feature/node/metrics/PositionLog.kt +++ b/feature/node/src/androidMain/kotlin/org/meshtastic/feature/node/metrics/PositionLog.kt @@ -16,6 +16,8 @@ */ package org.meshtastic.feature.node.metrics +import org.meshtastic.core.common.util.toMeshtasticUri + import android.app.Activity import android.content.Intent import androidx.activity.compose.rememberLauncherForActivityResult @@ -119,7 +121,7 @@ fun PositionLogScreen(viewModel: MetricsViewModel, onNavigateUp: () -> Unit) { val exportPositionLauncher = rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) { if (it.resultCode == Activity.RESULT_OK) { - it.data?.data?.let { uri -> viewModel.savePositionCSV(uri) } + it.data?.data?.let { uri -> viewModel.savePositionCSV(uri.toMeshtasticUri()) } } } diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/MetricsViewModel.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/MetricsViewModel.kt index a71b428c7..32b1af53c 100644 --- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/MetricsViewModel.kt +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/MetricsViewModel.kt @@ -66,6 +66,13 @@ import org.meshtastic.proto.PortNum import org.meshtastic.proto.Telemetry 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. */ @@ -81,6 +88,7 @@ open class MetricsViewModel( private val nodeRequestActions: NodeRequestActions, private val alertManager: AlertManager, private val getNodeDetailsUseCase: GetNodeDetailsUseCase, + private val fileService: FileService, ) : ViewModel() { private val nodeIdFromRoute: Int? @@ -315,8 +323,30 @@ open class MetricsViewModel( Logger.d { "MetricsViewModel cleared" } } - open fun savePositionCSV(uri: Any) { - // To be implemented in platform-specific subclass + fun savePositionCSV(uri: MeshtasticUri) { + 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") @@ -347,8 +377,7 @@ open class MetricsViewModel( return null } - protected open fun decodeBase64(base64: String): ByteArray { - // To be overridden in platform-specific subclass or use KMP library - return ByteArray(0) + protected fun decodeBase64(base64: String): ByteArray { + return base64.decodeBase64()?.toByteArray() ?: ByteArray(0) } }