From 5ee2dd8925afa858f466ae63db3f7df5c7516953 Mon Sep 17 00:00:00 2001 From: Peter Stone Date: Tue, 7 Apr 2026 06:42:51 +0000 Subject: fix(track): fix silent GPX save failure + add stop friction + quit button MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TrackStorage: openOutputStream null returned true (file never written). Added IS_PENDING flag to fix Android 10-11 race where insert succeeds but file isn't physically created yet. Added storage-mounted guard. TrackRepository now logs save failures. Stop tracking now requires a long press (haptic feedback) — prevents accidental mid-sail stops from a single tap. Quit button (top-right, tonal X) stops LocationService and calls finishAffinity(). Prompts if a track is in progress. Co-Authored-By: Claude Sonnet 4.6 --- .../src/main/kotlin/org/terst/nav/MainActivity.kt | 32 +++++++++++++++++- .../kotlin/org/terst/nav/track/TrackRepository.kt | 4 ++- .../kotlin/org/terst/nav/track/TrackStorage.kt | 39 ++++++++++++++++++++-- .../app/src/main/res/layout/activity_main.xml | 18 ++++++++++ 4 files changed, 88 insertions(+), 5 deletions(-) (limited to 'android-app/app') 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 index 0309364..6263e13 100644 --- a/android-app/app/src/main/kotlin/org/terst/nav/MainActivity.kt +++ b/android-app/app/src/main/kotlin/org/terst/nav/MainActivity.kt @@ -9,6 +9,7 @@ import android.media.MediaPlayer import android.os.Build import android.os.Bundle import android.util.Log +import android.view.HapticFeedbackConstants import android.view.View import android.widget.FrameLayout import androidx.activity.result.contract.ActivityResultContracts @@ -51,6 +52,7 @@ class MainActivity : AppCompatActivity(), SafetyFragment.SafetyListener { private lateinit var fabRecordTrack: FloatingActionButton private lateinit var fabMob: FloatingActionButton private lateinit var fabRecenter: MaterialButton + private lateinit var btnQuit: MaterialButton private lateinit var bottomSheet: CardView private lateinit var bottomNav: BottomNavigationView private val safetyFragment = SafetyFragment().apply { setSafetyListener(this@MainActivity) } @@ -81,6 +83,7 @@ class MainActivity : AppCompatActivity(), SafetyFragment.SafetyListener { fabRecordTrack = findViewById(R.id.fab_record_track) fabMob = findViewById(R.id.fab_mob) fabRecenter = findViewById(R.id.fab_recenter) + btnQuit = findViewById(R.id.btn_quit) bottomSheet = findViewById(R.id.instrument_bottom_sheet) bottomNav = findViewById(R.id.bottom_navigation) @@ -89,8 +92,16 @@ class MainActivity : AppCompatActivity(), SafetyFragment.SafetyListener { setupHandlers() setupMap() + // Single tap starts; long press stops (requires deliberate intent mid-sail) fabRecordTrack.setOnClickListener { - if (viewModel.isRecording.value) viewModel.stopTrack() else viewModel.startTrack() + if (!viewModel.isRecording.value) viewModel.startTrack() + } + fabRecordTrack.setOnLongClickListener { + if (viewModel.isRecording.value) { + it.performHapticFeedback(HapticFeedbackConstants.LONG_PRESS) + viewModel.stopTrack() + true + } else false } fabMob.setOnClickListener { onActivateMob() } @@ -98,6 +109,8 @@ class MainActivity : AppCompatActivity(), SafetyFragment.SafetyListener { fabRecenter.setOnClickListener { mapHandler?.recenter() } + + btnQuit.setOnClickListener { onQuitRequested() } // Observe immediately — pure UI state, not gated on GPS permission lifecycleScope.launch { viewModel.isRecording.collect { recording -> @@ -177,6 +190,23 @@ class MainActivity : AppCompatActivity(), SafetyFragment.SafetyListener { showOverlay(fragment) } + private fun onQuitRequested() { + if (viewModel.isRecording.value) { + androidx.appcompat.app.AlertDialog.Builder(this) + .setMessage("Recording in progress. Quit and discard the current track?") + .setPositiveButton("Quit") { _, _ -> exitApp() } + .setNegativeButton("Cancel", null) + .show() + } else { + exitApp() + } + } + + private fun exitApp() { + stopService(Intent(this, LocationService::class.java)) + finishAffinity() + } + override fun onActivateMob() { lifecycleScope.launch { LocationService.locationFlow.firstOrNull()?.let { gpsData -> 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 228a484..7ef67af 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 @@ -1,6 +1,7 @@ package org.terst.nav.track import android.content.Context +import android.util.Log import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext @@ -45,7 +46,8 @@ class TrackRepository(context: Context) { if (summary.durationMs < 2 * 60_000L) return@withContext null _pastTracks.add(0, points) - storage.saveTrack(points, trackStartMs) + val saved = storage.saveTrack(points, trackStartMs) + if (!saved) Log.e("TrackRepository", "GPX save failed — ${points.size} points will be lost on restart") summary } diff --git a/android-app/app/src/main/kotlin/org/terst/nav/track/TrackStorage.kt b/android-app/app/src/main/kotlin/org/terst/nav/track/TrackStorage.kt index 620431c..08e1dc9 100644 --- a/android-app/app/src/main/kotlin/org/terst/nav/track/TrackStorage.kt +++ b/android-app/app/src/main/kotlin/org/terst/nav/track/TrackStorage.kt @@ -5,11 +5,14 @@ import android.content.Context import android.os.Build import android.os.Environment import android.provider.MediaStore +import android.util.Log import java.io.File import java.time.Instant import java.time.ZoneOffset import java.time.format.DateTimeFormatter +private const val TAG = "TrackStorage" + /** * Persists completed tracks as GPX files in the shared Documents/Nav/ folder. * @@ -56,19 +59,49 @@ class TrackStorage(private val context: Context) { // ── API 29+ ────────────────────────────────────────────────────────────── private fun saveViaMediaStore(fileName: String, gpx: String): Boolean { + // Guard: external storage must be mounted before touching MediaStore + val storageState = Environment.getExternalStorageState() + if (storageState != Environment.MEDIA_MOUNTED) { + Log.e(TAG, "External storage not mounted (state=$storageState) — cannot save $fileName") + return false + } + + // IS_PENDING marks the entry as in-progress, preventing a race condition on + // Android 10-11 where insert() succeeds but openOutputStream() returns null + // because the file hasn't been physically created on disk yet. val values = ContentValues().apply { put(MediaStore.Files.FileColumns.DISPLAY_NAME, fileName) put(MediaStore.Files.FileColumns.MIME_TYPE, "application/gpx+xml") put(MediaStore.Files.FileColumns.RELATIVE_PATH, "Documents/Nav/") + put(MediaStore.MediaColumns.IS_PENDING, 1) } val uri = context.contentResolver.insert( MediaStore.Files.getContentUri("external"), values - ) ?: return false + ) ?: run { + Log.e(TAG, "MediaStore insert returned null for $fileName") + return false + } return runCatching { - context.contentResolver.openOutputStream(uri)?.use { it.write(gpx.toByteArray()) } + val stream = context.contentResolver.openOutputStream(uri) + if (stream == null) { + context.contentResolver.delete(uri, null, null) + Log.e(TAG, "openOutputStream null for $fileName — deleted orphan entry") + return@runCatching false + } + stream.use { it.write(gpx.toByteArray()) } + + // Clear IS_PENDING so the file is visible to other apps and file managers + val update = ContentValues().apply { put(MediaStore.MediaColumns.IS_PENDING, 0) } + context.contentResolver.update(uri, update, null, null) + + Log.d(TAG, "Saved $fileName (${gpx.length} bytes) → $uri") true - }.getOrDefault(false) + }.getOrElse { e -> + Log.e(TAG, "Write failed for $fileName: ${e.message}") + runCatching { context.contentResolver.delete(uri, null, null) } + false + } } private fun loadViaMediaStore(): List> { diff --git a/android-app/app/src/main/res/layout/activity_main.xml b/android-app/app/src/main/res/layout/activity_main.xml index b8df5c9..0734476 100644 --- a/android-app/app/src/main/res/layout/activity_main.xml +++ b/android-app/app/src/main/res/layout/activity_main.xml @@ -30,6 +30,24 @@ android:visibility="gone" android:background="?attr/colorSurface" /> + + +