summaryrefslogtreecommitdiff
path: root/android-app/app/src
diff options
context:
space:
mode:
Diffstat (limited to 'android-app/app/src')
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/MainActivity.kt32
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/track/TrackRepository.kt4
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/track/TrackStorage.kt39
-rw-r--r--android-app/app/src/main/res/layout/activity_main.xml18
4 files changed, 88 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>> {
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" />
+ <!-- Quit button — stops all services and exits -->
+ <com.google.android.material.button.MaterialButton
+ android:id="@+id/btn_quit"
+ style="@style/Widget.Material3.Button.IconButton.Filled.Tonal"
+ android:layout_width="40dp"
+ android:layout_height="40dp"
+ android:alpha="0.7"
+ app:icon="@drawable/ic_close"
+ app:iconSize="18dp"
+ app:iconGravity="textStart"
+ app:iconPadding="0dp"
+ app:cornerRadius="20dp"
+ app:elevation="4dp"
+ app:layout_constraintTop_toTopOf="parent"
+ app:layout_constraintEnd_toEndOf="parent"
+ android:layout_marginTop="16dp"
+ android:layout_marginEnd="16dp" />
+
<com.google.android.material.button.MaterialButton
android:id="@+id/fab_recenter"
android:layout_width="wrap_content"