mirror of
https://github.com/meshtastic/Meshtastic-Android.git
synced 2026-04-20 22:23:37 +00:00
feat: Complete ViewModel extraction and update documentation (#4817)
This commit is contained in:
parent
80cae8e620
commit
6e81ceec91
65 changed files with 952 additions and 633 deletions
|
|
@ -54,6 +54,7 @@ import androidx.compose.ui.unit.dp
|
|||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
import org.meshtastic.core.common.util.nowSeconds
|
||||
import org.meshtastic.core.common.util.toMeshtasticUri
|
||||
import org.meshtastic.core.resources.Res
|
||||
import org.meshtastic.core.resources.clear
|
||||
import org.meshtastic.core.resources.save
|
||||
|
|
@ -119,7 +120,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()) }
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -35,9 +35,14 @@ import kotlinx.coroutines.flow.map
|
|||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.datetime.Instant
|
||||
import kotlinx.datetime.TimeZone
|
||||
import kotlinx.datetime.toLocalDateTime
|
||||
import okio.ByteString.Companion.decodeBase64
|
||||
import org.jetbrains.compose.resources.StringResource
|
||||
import org.koin.core.annotation.InjectedParam
|
||||
import org.koin.core.annotation.KoinViewModel
|
||||
import org.meshtastic.core.common.util.MeshtasticUri
|
||||
import org.meshtastic.core.common.util.nowSeconds
|
||||
import org.meshtastic.core.data.repository.TracerouteSnapshotRepository
|
||||
import org.meshtastic.core.di.CoroutineDispatchers
|
||||
|
|
@ -46,6 +51,7 @@ import org.meshtastic.core.model.Node
|
|||
import org.meshtastic.core.model.TelemetryType
|
||||
import org.meshtastic.core.model.evaluateTracerouteMapAvailability
|
||||
import org.meshtastic.core.model.util.UnitConversions
|
||||
import org.meshtastic.core.repository.FileService
|
||||
import org.meshtastic.core.repository.MeshLogRepository
|
||||
import org.meshtastic.core.repository.NodeRepository
|
||||
import org.meshtastic.core.repository.ServiceRepository
|
||||
|
|
@ -81,6 +87,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 +322,35 @@ 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 +381,5 @@ 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 = base64.decodeBase64()?.toByteArray() ?: ByteArray(0)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,155 @@
|
|||
/*
|
||||
* Copyright (c) 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.feature.node.metrics
|
||||
|
||||
import io.mockk.coEvery
|
||||
import io.mockk.coVerify
|
||||
import io.mockk.mockk
|
||||
import io.mockk.slot
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.test.advanceUntilIdle
|
||||
import kotlinx.coroutines.test.resetMain
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import kotlinx.coroutines.test.setMain
|
||||
import okio.Buffer
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.meshtastic.core.common.util.MeshtasticUri
|
||||
import org.meshtastic.core.data.repository.TracerouteSnapshotRepository
|
||||
import org.meshtastic.core.di.CoroutineDispatchers
|
||||
import org.meshtastic.core.repository.FileService
|
||||
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.NodeDetailUiState
|
||||
import org.meshtastic.feature.node.detail.NodeRequestActions
|
||||
import org.meshtastic.feature.node.domain.usecase.GetNodeDetailsUseCase
|
||||
import org.meshtastic.feature.node.model.MetricsState
|
||||
import org.meshtastic.proto.Position
|
||||
|
||||
class MetricsViewModelTest {
|
||||
private val dispatchers =
|
||||
CoroutineDispatchers(
|
||||
main = kotlinx.coroutines.Dispatchers.Unconfined,
|
||||
io = kotlinx.coroutines.Dispatchers.Unconfined,
|
||||
default = kotlinx.coroutines.Dispatchers.Unconfined,
|
||||
)
|
||||
private val meshLogRepository: MeshLogRepository = mockk(relaxed = true)
|
||||
private val serviceRepository: ServiceRepository = mockk(relaxed = true)
|
||||
private val nodeRepository: NodeRepository = mockk(relaxed = true)
|
||||
private val tracerouteSnapshotRepository: TracerouteSnapshotRepository = mockk(relaxed = true)
|
||||
private val nodeRequestActions: NodeRequestActions = mockk(relaxed = true)
|
||||
private val alertManager: AlertManager = mockk(relaxed = true)
|
||||
private val getNodeDetailsUseCase: GetNodeDetailsUseCase = mockk(relaxed = true)
|
||||
private val fileService: FileService = mockk(relaxed = true)
|
||||
|
||||
private lateinit var viewModel: MetricsViewModel
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
Dispatchers.setMain(dispatchers.main)
|
||||
|
||||
viewModel =
|
||||
MetricsViewModel(
|
||||
destNum = 1234,
|
||||
dispatchers = dispatchers,
|
||||
meshLogRepository = meshLogRepository,
|
||||
serviceRepository = serviceRepository,
|
||||
nodeRepository = nodeRepository,
|
||||
tracerouteSnapshotRepository = tracerouteSnapshotRepository,
|
||||
nodeRequestActions = nodeRequestActions,
|
||||
alertManager = alertManager,
|
||||
getNodeDetailsUseCase = getNodeDetailsUseCase,
|
||||
fileService = fileService,
|
||||
)
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
Dispatchers.resetMain()
|
||||
}
|
||||
|
||||
@Test fun testInitialization() = runTest { assertNotNull(viewModel) }
|
||||
|
||||
@Test
|
||||
fun testSavePositionCSV() = runTest {
|
||||
val testPosition =
|
||||
Position(
|
||||
latitude_i = 123456789,
|
||||
longitude_i = -987654321,
|
||||
altitude = 100,
|
||||
sats_in_view = 5,
|
||||
ground_speed = 10,
|
||||
ground_track = 123456,
|
||||
time = 1700000000,
|
||||
)
|
||||
|
||||
coEvery { getNodeDetailsUseCase(any()) } returns
|
||||
flowOf(NodeDetailUiState(metricsState = MetricsState(positionLogs = listOf(testPosition))))
|
||||
|
||||
// Re-init view model so it picks up the mocked flow
|
||||
viewModel =
|
||||
MetricsViewModel(
|
||||
destNum = 1234,
|
||||
dispatchers = dispatchers,
|
||||
meshLogRepository = meshLogRepository,
|
||||
serviceRepository = serviceRepository,
|
||||
nodeRepository = nodeRepository,
|
||||
tracerouteSnapshotRepository = tracerouteSnapshotRepository,
|
||||
nodeRequestActions = nodeRequestActions,
|
||||
alertManager = alertManager,
|
||||
getNodeDetailsUseCase = getNodeDetailsUseCase,
|
||||
fileService = fileService,
|
||||
)
|
||||
|
||||
// Wait for state to populate
|
||||
val collectionJob = backgroundScope.launch { viewModel.state.collect {} }
|
||||
kotlinx.coroutines.yield()
|
||||
advanceUntilIdle()
|
||||
|
||||
val uri = MeshtasticUri("content://test")
|
||||
val blockSlot = slot<suspend (okio.BufferedSink) -> Unit>()
|
||||
|
||||
coEvery { fileService.write(uri, capture(blockSlot)) } returns true
|
||||
|
||||
viewModel.savePositionCSV(uri)
|
||||
|
||||
advanceUntilIdle()
|
||||
|
||||
coVerify { fileService.write(uri, any()) }
|
||||
|
||||
val buffer = Buffer()
|
||||
blockSlot.captured.invoke(buffer)
|
||||
|
||||
val csvOutput = buffer.readUtf8()
|
||||
assertEquals(
|
||||
"\"date\",\"time\",\"latitude\",\"longitude\",\"altitude\",\"satsInView\",\"speed\",\"heading\"\n",
|
||||
csvOutput.substringBefore("\n") + "\n",
|
||||
)
|
||||
assert(csvOutput.contains("12.345")) { "Missing latitude in $csvOutput" }
|
||||
assert(csvOutput.contains("-98.765")) { "Missing longitude in $csvOutput" }
|
||||
assert(csvOutput.contains("\"100\",\"5\",\"10\",\"1.23\"\n")) { "Missing rest in $csvOutput" }
|
||||
|
||||
collectionJob.cancel()
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue