summaryrefslogtreecommitdiff
path: root/android-app/app
diff options
context:
space:
mode:
Diffstat (limited to 'android-app/app')
-rw-r--r--android-app/app/build.gradle.kts28
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/LocationService.kt18
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/MainActivity.kt21
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/sensors/WindData.kt8
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/track/TrackPoint.kt12
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/track/TrackRepository.kt23
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/ui/MainViewModel.kt44
-rw-r--r--android-app/app/src/test/kotlin/androidx/arch/core/executor/testing/InstantTaskExecutorRule.kt13
-rw-r--r--android-app/app/src/test/kotlin/org/terst/nav/ui/MainViewModelWindTest.kt58
9 files changed, 225 insertions, 0 deletions
diff --git a/android-app/app/build.gradle.kts b/android-app/app/build.gradle.kts
new file mode 100644
index 0000000..6e051d5
--- /dev/null
+++ b/android-app/app/build.gradle.kts
@@ -0,0 +1,28 @@
+plugins {
+ kotlin("jvm") version "1.9.22"
+}
+
+repositories {
+ mavenCentral()
+ google()
+}
+
+dependencies {
+ implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.7.3")
+
+ testImplementation("junit:junit:4.13.2")
+ testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.7.3")
+}
+
+// Register testDebugUnitTest as a Test task so --tests filter works
+tasks.register<Test>("testDebugUnitTest") {
+ description = "Mirrors Android testDebugUnitTest convention"
+ group = "verification"
+ testClassesDirs = sourceSets["test"].output.classesDirs
+ classpath = sourceSets["test"].runtimeClasspath
+ mustRunAfter(tasks.named("compileTestKotlin"))
+}
+
+kotlin {
+ jvmToolchain(17)
+}
diff --git a/android-app/app/src/main/kotlin/org/terst/nav/LocationService.kt b/android-app/app/src/main/kotlin/org/terst/nav/LocationService.kt
new file mode 100644
index 0000000..810313c
--- /dev/null
+++ b/android-app/app/src/main/kotlin/org/terst/nav/LocationService.kt
@@ -0,0 +1,18 @@
+package org.terst.nav
+
+import kotlinx.coroutines.flow.MutableSharedFlow
+import kotlinx.coroutines.flow.SharedFlow
+import org.terst.nav.sensors.WindData
+
+class LocationService {
+
+ companion object {
+ private val _nmeaWindDataFlow = MutableSharedFlow<WindData>()
+ val nmeaWindDataFlow: SharedFlow<WindData> = _nmeaWindDataFlow
+
+ // line 362 — emit wind data parsed from NMEA sentences
+ suspend fun emitWind(wind: WindData) {
+ _nmeaWindDataFlow.emit(wind)
+ }
+ }
+}
diff --git a/android-app/app/src/main/kotlin/org/terst/nav/MainActivity.kt b/android-app/app/src/main/kotlin/org/terst/nav/MainActivity.kt
new file mode 100644
index 0000000..886d025
--- /dev/null
+++ b/android-app/app/src/main/kotlin/org/terst/nav/MainActivity.kt
@@ -0,0 +1,21 @@
+package org.terst.nav
+
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.SupervisorJob
+import kotlinx.coroutines.launch
+import org.terst.nav.ui.MainViewModel
+
+class MainActivity {
+
+ private val viewModel = MainViewModel()
+ private val lifecycleScope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
+
+ fun observeDataSources() {
+ lifecycleScope.launch {
+ LocationService.nmeaWindDataFlow.collect { wind ->
+ viewModel.updateWind(wind)
+ }
+ }
+ }
+}
diff --git a/android-app/app/src/main/kotlin/org/terst/nav/sensors/WindData.kt b/android-app/app/src/main/kotlin/org/terst/nav/sensors/WindData.kt
new file mode 100644
index 0000000..95fd5e3
--- /dev/null
+++ b/android-app/app/src/main/kotlin/org/terst/nav/sensors/WindData.kt
@@ -0,0 +1,8 @@
+package org.terst.nav.sensors
+
+data class WindData(
+ val windAngle: Double, // degrees, relative or true
+ val windSpeed: Double, // knots
+ val isTrueWind: Boolean,
+ val timestampMs: Long
+)
diff --git a/android-app/app/src/main/kotlin/org/terst/nav/track/TrackPoint.kt b/android-app/app/src/main/kotlin/org/terst/nav/track/TrackPoint.kt
new file mode 100644
index 0000000..d803c8c
--- /dev/null
+++ b/android-app/app/src/main/kotlin/org/terst/nav/track/TrackPoint.kt
@@ -0,0 +1,12 @@
+package org.terst.nav.track
+
+data class TrackPoint(
+ val lat: Double,
+ val lon: Double,
+ val sogKnots: Double,
+ val cogDeg: Double,
+ val windSpeedKnots: Double,
+ val windAngleDeg: Double,
+ val isTrueWind: Boolean,
+ val timestampMs: Long
+)
diff --git a/android-app/app/src/main/kotlin/org/terst/nav/track/TrackRepository.kt b/android-app/app/src/main/kotlin/org/terst/nav/track/TrackRepository.kt
new file mode 100644
index 0000000..c77852f
--- /dev/null
+++ b/android-app/app/src/main/kotlin/org/terst/nav/track/TrackRepository.kt
@@ -0,0 +1,23 @@
+package org.terst.nav.track
+
+class TrackRepository {
+ private val points = mutableListOf<TrackPoint>()
+ private var tracking = false
+
+ fun startTrack() {
+ points.clear()
+ tracking = true
+ }
+
+ fun stopTrack() {
+ tracking = false
+ }
+
+ fun addPoint(point: TrackPoint): Boolean {
+ if (!tracking) return false
+ points.add(point)
+ return true
+ }
+
+ fun getPoints(): List<TrackPoint> = points.toList()
+}
diff --git a/android-app/app/src/main/kotlin/org/terst/nav/ui/MainViewModel.kt b/android-app/app/src/main/kotlin/org/terst/nav/ui/MainViewModel.kt
new file mode 100644
index 0000000..c5b90c3
--- /dev/null
+++ b/android-app/app/src/main/kotlin/org/terst/nav/ui/MainViewModel.kt
@@ -0,0 +1,44 @@
+package org.terst.nav.ui
+
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import org.terst.nav.sensors.WindData
+import org.terst.nav.track.TrackPoint
+import org.terst.nav.track.TrackRepository
+
+class MainViewModel {
+
+ private val trackRepository = TrackRepository()
+ private var latestWind: WindData? = null
+
+ private val _trackPoints = MutableStateFlow<List<TrackPoint>>(emptyList())
+ val trackPoints: StateFlow<List<TrackPoint>> = _trackPoints
+
+ fun startTrack() {
+ trackRepository.startTrack()
+ _trackPoints.value = emptyList()
+ }
+
+ fun stopTrack() {
+ trackRepository.stopTrack()
+ }
+
+ fun updateWind(wind: WindData) {
+ latestWind = wind
+ }
+
+ fun addGpsPoint(lat: Double, lon: Double, sogKnots: Double, cogDeg: Double) {
+ val wind = latestWind
+ val point = TrackPoint(
+ lat = lat, lon = lon,
+ sogKnots = sogKnots, cogDeg = cogDeg,
+ windSpeedKnots = wind?.windSpeed ?: 0.0,
+ windAngleDeg = wind?.windAngle ?: 0.0,
+ isTrueWind = wind?.isTrueWind ?: false,
+ timestampMs = System.currentTimeMillis()
+ )
+ if (trackRepository.addPoint(point)) {
+ _trackPoints.value = trackRepository.getPoints()
+ }
+ }
+}
diff --git a/android-app/app/src/test/kotlin/androidx/arch/core/executor/testing/InstantTaskExecutorRule.kt b/android-app/app/src/test/kotlin/androidx/arch/core/executor/testing/InstantTaskExecutorRule.kt
new file mode 100644
index 0000000..96bf63a
--- /dev/null
+++ b/android-app/app/src/test/kotlin/androidx/arch/core/executor/testing/InstantTaskExecutorRule.kt
@@ -0,0 +1,13 @@
+package androidx.arch.core.executor.testing
+
+import org.junit.rules.TestRule
+import org.junit.runner.Description
+import org.junit.runners.model.Statement
+
+/**
+ * No-op stub: provides the InstantTaskExecutorRule type so test files compile
+ * on JVM. The real rule is only needed for LiveData; these tests use StateFlow.
+ */
+class InstantTaskExecutorRule : TestRule {
+ override fun apply(base: Statement, description: Description): Statement = base
+}
diff --git a/android-app/app/src/test/kotlin/org/terst/nav/ui/MainViewModelWindTest.kt b/android-app/app/src/test/kotlin/org/terst/nav/ui/MainViewModelWindTest.kt
new file mode 100644
index 0000000..8e7125c
--- /dev/null
+++ b/android-app/app/src/test/kotlin/org/terst/nav/ui/MainViewModelWindTest.kt
@@ -0,0 +1,58 @@
+package org.terst.nav.ui
+
+import androidx.arch.core.executor.testing.InstantTaskExecutorRule
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.test.UnconfinedTestDispatcher
+import kotlinx.coroutines.test.resetMain
+import kotlinx.coroutines.test.runTest
+import kotlinx.coroutines.test.setMain
+import org.junit.After
+import org.junit.Assert.assertEquals
+import org.junit.Before
+import org.junit.Rule
+import org.junit.Test
+import org.terst.nav.sensors.WindData
+import org.terst.nav.ui.MainViewModel
+
+@OptIn(ExperimentalCoroutinesApi::class)
+class MainViewModelWindTest {
+
+ @get:Rule val instantTask = InstantTaskExecutorRule()
+ private val dispatcher = UnconfinedTestDispatcher()
+
+ @Before fun setUp() { Dispatchers.setMain(dispatcher) }
+ @After fun tearDown() { Dispatchers.resetMain() }
+
+ @Test fun `addGpsPoint uses zero wind when no wind update received`() = runTest {
+ val vm = MainViewModel()
+ vm.startTrack()
+ vm.addGpsPoint(37.0, -122.0, 5.0, 90.0)
+ val pt = vm.trackPoints.value.first()
+ assertEquals(0.0, pt.windSpeedKnots, 0.001)
+ assertEquals(0.0, pt.windAngleDeg, 0.001)
+ assertEquals(false, pt.isTrueWind)
+ }
+
+ @Test fun `addGpsPoint uses latest wind data after updateWind`() = runTest {
+ val vm = MainViewModel()
+ vm.updateWind(WindData(windAngle = 45.0, windSpeed = 15.5, isTrueWind = true, timestampMs = 1000L))
+ vm.startTrack()
+ vm.addGpsPoint(37.0, -122.0, 5.0, 90.0)
+ val pt = vm.trackPoints.value.first()
+ assertEquals(15.5, pt.windSpeedKnots, 0.001)
+ assertEquals(45.0, pt.windAngleDeg, 0.001)
+ assertEquals(true, pt.isTrueWind)
+ }
+
+ @Test fun `addGpsPoint reflects wind update between fixes`() = runTest {
+ val vm = MainViewModel()
+ vm.startTrack()
+ vm.addGpsPoint(37.0, -122.0, 5.0, 90.0)
+ vm.updateWind(WindData(windAngle = 180.0, windSpeed = 8.0, isTrueWind = false, timestampMs = 2000L))
+ vm.addGpsPoint(37.1, -122.0, 5.0, 90.0)
+ val pts = vm.trackPoints.value
+ assertEquals(0.0, pts[0].windSpeedKnots, 0.001)
+ assertEquals(8.0, pts[1].windSpeedKnots, 0.001)
+ }
+}