diff options
| author | Peter Stone <thepeterstone@gmail.com> | 2026-04-07 06:42:51 +0000 |
|---|---|---|
| committer | Peter Stone <thepeterstone@gmail.com> | 2026-04-07 06:42:51 +0000 |
| commit | 5ee2dd8925afa858f466ae63db3f7df5c7516953 (patch) | |
| tree | 9a6e66a86746d58a4c283db45ee0401dff7945b8 /android-app/app/src/main/kotlin | |
| parent | d98b441f2f9ca8b11a04406240dd19ecc0cac7ab (diff) | |
fix(track): fix silent GPX save failure + add stop friction + quit buttonmain
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 <noreply@anthropic.com>
Diffstat (limited to 'android-app/app/src/main/kotlin')
3 files changed, 70 insertions, 5 deletions
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<List<TrackPoint>> { |
