summaryrefslogtreecommitdiff
path: root/android-app/app/src
diff options
context:
space:
mode:
authorClaude <noreply@anthropic.com>2026-05-26 07:46:09 +0000
committerClaude <noreply@anthropic.com>2026-05-26 07:46:09 +0000
commit3673ed0ef79d776c6da3bfa0db9de8b08326fd32 (patch)
tree369ec57c831fb3b3de7220f4ba06fb3cb6d32440 /android-app/app/src
parente64d07cb49dd3313eb8595935777f31fdfb2f373 (diff)
Fix ANR, false tack detections, and empty-state flash on track load
ANR: getSavedTracks() had no IO dispatcher — TackDetector ran on main thread at O(n²) cost (~77M ops for a 2.5h track). Added withContext(IO). TackDetector: replaced O(n) list.filter with O(log n) binary-search subList for before/after windows; O(n²) → O(n log n). Raised MIN_DELTA 60°→75° to stop detecting ordinary course corrections as maneuvers. Removed MAX_DELTA cap entirely — 180° crash-tacks are valid; the circularMAD stability windows are the real quality gate. TrackDetailSheet: dropped the arbitrary abs(delta)>70 Tack/Jibe heuristic. Now uses true wind angle when available for classification; falls back to "Maneuver" when TWD is absent. SavedTracksFragment: added isLoadingTracks StateFlow (MainViewModel); combine() suppresses the empty-state layout while the initial IO load runs so it no longer flashes "No saved tracks yet" before data arrives. 16/16 TackDetector tests pass. https://claude.ai/code/session_01KXYBuAHZkvv63DeUG6XaZD
Diffstat (limited to 'android-app/app/src')
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/track/SavedTracksFragment.kt14
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/track/TackDetector.kt25
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/track/TrackDetailSheet.kt8
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/track/TrackRepository.kt3
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/ui/MainViewModel.kt6
5 files changed, 42 insertions, 14 deletions
diff --git a/android-app/app/src/main/kotlin/org/terst/nav/track/SavedTracksFragment.kt b/android-app/app/src/main/kotlin/org/terst/nav/track/SavedTracksFragment.kt
index 67f32f6..fca83cd 100644
--- a/android-app/app/src/main/kotlin/org/terst/nav/track/SavedTracksFragment.kt
+++ b/android-app/app/src/main/kotlin/org/terst/nav/track/SavedTracksFragment.kt
@@ -13,6 +13,7 @@ import androidx.recyclerview.widget.DividerItemDecoration
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import com.google.android.material.button.MaterialButton
+import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.launch
import org.terst.nav.MainActivity
import org.terst.nav.R
@@ -52,12 +53,13 @@ class SavedTracksFragment : Fragment() {
rv.adapter = adapter
viewLifecycleOwner.lifecycleScope.launch {
- viewModel.savedTracks.collect { tracks ->
- adapter.submitList(tracks)
- val empty = tracks.isEmpty()
- layoutEmpty.visibility = if (empty) View.VISIBLE else View.GONE
- rv.visibility = if (empty) View.GONE else View.VISIBLE
- }
+ viewModel.savedTracks
+ .combine(viewModel.isLoadingTracks) { tracks, loading -> tracks to loading }
+ .collect { (tracks, loading) ->
+ adapter.submitList(tracks)
+ layoutEmpty.visibility = if (!loading && tracks.isEmpty()) View.VISIBLE else View.GONE
+ rv.visibility = if (!loading && tracks.isNotEmpty()) View.VISIBLE else View.GONE
+ }
}
// SAF storage setup prompt — only relevant on API 29+
diff --git a/android-app/app/src/main/kotlin/org/terst/nav/track/TackDetector.kt b/android-app/app/src/main/kotlin/org/terst/nav/track/TackDetector.kt
index 763e693..429449c 100644
--- a/android-app/app/src/main/kotlin/org/terst/nav/track/TackDetector.kt
+++ b/android-app/app/src/main/kotlin/org/terst/nav/track/TackDetector.kt
@@ -13,7 +13,7 @@ import kotlin.math.sin
*
* For each GPS fix as candidate apex: collect before/after settle windows (excluding the guard zone),
* require MIN_PTS fixes in each, compute circularMean + circularMAD, reject if MAD > STAB_MAX,
- * accept if heading delta in [MIN_DELTA, MAX_DELTA].
+ * accept if heading delta ≥ MIN_DELTA (no upper cap — 180° crash-tacks are valid).
*
* De-duplicate: group candidates within MIN_GAP_MS, keep max-delta per group.
* Refine position: within the maneuver zone, find the point with the greatest instantaneous
@@ -29,8 +29,7 @@ object TackDetector {
private const val T_SETTLE = 30_000L // ms — stable heading window required before/after
private const val T_MANEUVER = 30_000L // ms — guard zone around apex (±15 s each side)
private const val STAB_MAX = 20.0 // ° — max circularMAD in settle windows
- private const val MIN_DELTA = 60.0 // ° — minimum heading change to count as tack/jibe
- private const val MAX_DELTA = 160.0 // ° — maximum heading change (beyond = U-turn, not a tack)
+ private const val MIN_DELTA = 75.0 // ° — minimum heading change to count as a maneuver
private const val MIN_GAP_MS = 60_000L // ms — minimum time between accepted events
private const val START_SKIP_MS = 60_000L // ms — skip first 60 s (cold-start noise)
private const val MIN_PTS = 3 // minimum GPS fixes required in each settle window
@@ -55,12 +54,12 @@ object TackDetector {
val beforeEnd = t - T_MANEUVER / 2
val beforeStart = beforeEnd - T_SETTLE
- val before = points.filter { it.timestampMs in beforeStart until beforeEnd }
+ val before = points.subList(lowerBound(points, beforeStart), lowerBound(points, beforeEnd))
if (before.size < MIN_PTS) continue
val afterStart = t + T_MANEUVER / 2
val afterEnd = afterStart + T_SETTLE
- val after = points.filter { it.timestampMs > afterStart && it.timestampMs <= afterEnd }
+ val after = points.subList(upperBound(points, afterStart), upperBound(points, afterEnd))
if (after.size < MIN_PTS) continue
val cogBefore = circularMean(before.map { it.cogDeg })
@@ -72,7 +71,7 @@ object TackDetector {
if (spreadAfter > STAB_MAX) continue
val delta = abs(angleDiff(cogBefore, cogAfter))
- if (delta < MIN_DELTA || delta > MAX_DELTA) continue
+ if (delta < MIN_DELTA) continue
raw += Candidate(i, t, delta, cogBefore, cogAfter)
}
@@ -112,6 +111,20 @@ object TackDetector {
return TackEvent(bestIdx, points[bestIdx].lat, points[bestIdx].lon, c.cogBefore, c.cogAfter)
}
+ // Binary search helpers — points must be sorted by timestampMs (always true for GPS tracks).
+ // lowerBound: first index where ts >= ms; upperBound: first index where ts > ms.
+ private fun lowerBound(points: List<TrackPoint>, ms: Long): Int {
+ var lo = 0; var hi = points.size
+ while (lo < hi) { val mid = (lo + hi) ushr 1; if (points[mid].timestampMs < ms) lo = mid + 1 else hi = mid }
+ return lo
+ }
+
+ private fun upperBound(points: List<TrackPoint>, ms: Long): Int {
+ var lo = 0; var hi = points.size
+ while (lo < hi) { val mid = (lo + hi) ushr 1; if (points[mid].timestampMs <= ms) lo = mid + 1 else hi = mid }
+ return lo
+ }
+
internal fun angleDiff(from: Double, to: Double): Double {
var diff = to - from
while (diff > 180) diff -= 360
diff --git a/android-app/app/src/main/kotlin/org/terst/nav/track/TrackDetailSheet.kt b/android-app/app/src/main/kotlin/org/terst/nav/track/TrackDetailSheet.kt
index 1d92e18..7c26a01 100644
--- a/android-app/app/src/main/kotlin/org/terst/nav/track/TrackDetailSheet.kt
+++ b/android-app/app/src/main/kotlin/org/terst/nav/track/TrackDetailSheet.kt
@@ -320,8 +320,14 @@ class TrackDetailSheet : Fragment() {
for (t in tacks) {
val p = points[t.index]
val delta = TackDetector.angleDiff(t.cogBefore, t.cogAfter)
- val type = if (abs(delta) > 70) "Tack" else "Jibe"
val dir = if (delta > 0) "→ stbd" else "→ port"
+ // Classify as tack/jibe using true wind angle if available; otherwise label as maneuver.
+ val twa = if (p.isTrueWind) p.windAngleDeg else null
+ val type = when {
+ twa == null -> "Maneuver"
+ twa < 90.0 || twa > 270.0 -> "Tack"
+ else -> "Jibe"
+ }
events += LogEvent(p.lat, p.lon, p.timestampMs, "⬡",
"$type $dir",
"%.0f° → %.0f° (Δ%.0f°)".format(t.cogBefore, t.cogAfter, abs(delta)))
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
index 0bf1dff..c40c9c2 100644
--- 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
@@ -145,7 +145,7 @@ class TrackRepository(context: Context) {
* Returns all completed tracks as [SavedTrack] wrappers (summary + tacks pre-computed).
* Internally delegates to [getPastTracks]; the first call loads from disk.
*/
- suspend fun getSavedTracks(): List<SavedTrack> =
+ suspend fun getSavedTracks(): List<SavedTrack> = withContext(Dispatchers.IO) {
getPastTracks()
.filter { it.isNotEmpty() }
.mapNotNull { points ->
@@ -153,6 +153,7 @@ class TrackRepository(context: Context) {
.onFailure { e -> NavLogger.e("track", "toSavedTrack failed: ${e.javaClass.simpleName}: ${e.message}") }
.getOrNull()
}
+ }
fun safState(): SafState = storage.safState()
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
index 49de36f..f664aa9 100644
--- 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
@@ -101,6 +101,9 @@ class MainViewModel(
val vesselRepository get() = NavApplication.vesselRepository
+ private val _isLoadingTracks = MutableStateFlow(true)
+ val isLoadingTracks: StateFlow<Boolean> = _isLoadingTracks.asStateFlow()
+
init {
viewModelScope.launch {
runCatching { trackRepository.getPastTracks() }
@@ -111,6 +114,7 @@ class MainViewModel(
.onFailure { e -> NavLogger.e("track", "init: getSavedTracks failed: ${e.javaClass.simpleName}: ${e.message}") }
}
.onFailure { e -> NavLogger.e("track", "init: failed to load past tracks: ${e.javaClass.simpleName}: ${e.message}") }
+ _isLoadingTracks.value = false
}
}
@@ -449,6 +453,7 @@ class MainViewModel(
*/
fun reloadPastTracks() {
viewModelScope.launch {
+ _isLoadingTracks.value = true
runCatching { trackRepository.reloadTracks() }
.onSuccess { tracks ->
_pastTracks.value = tracks
@@ -460,6 +465,7 @@ class MainViewModel(
.onFailure { e ->
NavLogger.e("track", "reloadPastTracks: failed: ${e.javaClass.simpleName}: ${e.message}")
}
+ _isLoadingTracks.value = false
}
}