From 3673ed0ef79d776c6da3bfa0db9de8b08326fd32 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 26 May 2026 07:46:09 +0000 Subject: Fix ANR, false tack detections, and empty-state flash on track load MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../org/terst/nav/track/SavedTracksFragment.kt | 14 ++++++------ .../kotlin/org/terst/nav/track/TackDetector.kt | 25 ++++++++++++++++------ .../kotlin/org/terst/nav/track/TrackDetailSheet.kt | 8 ++++++- .../kotlin/org/terst/nav/track/TrackRepository.kt | 3 ++- .../main/kotlin/org/terst/nav/ui/MainViewModel.kt | 6 ++++++ 5 files changed, 42 insertions(+), 14 deletions(-) (limited to 'android-app/app/src/main/kotlin/org') 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, 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, 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 = + suspend fun getSavedTracks(): List = 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 = _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 } } -- cgit v1.2.3