summaryrefslogtreecommitdiff
path: root/android-app/app/src/main
diff options
context:
space:
mode:
authorClaude <noreply@anthropic.com>2026-05-20 08:38:33 +0000
committerClaude <noreply@anthropic.com>2026-05-20 08:38:33 +0000
commitd6a2e71c155f62a03e1ab466198a256c96c13b3b (patch)
tree465800f928be7a7ae2e7440db12a29a6c997a736 /android-app/app/src/main
parent06fc6564b73ff2403eebabf5f9374a62abfe0772 (diff)
Fix GPX file opening, share sheet visibility, and add SAF track storage
Intent filter fixes: - Remove pathSuffix=".gpx" + scheme restriction from the application/octet-stream ACTION_VIEW filter; pathSuffix matches the URI path, not the filename, so it never fired against file manager content URIs (opaque paths like /provider/uuid). The existing display-name check in handleIncomingGpx() is the correct gate. - Add ACTION_SEND filter for application/octet-stream; Nav was invisible in share sheets because most file managers send this type for .gpx files. SAF-based track storage (API 29+): - Replace fragile SharedPrefs URI cache + OWNER_PACKAGE_NAME MediaStore query with Storage Access Framework as the primary write/read path. The user grants access to their tracks folder once via ACTION_OPEN_DOCUMENT_TREE; DocumentFile.listFiles() finds all GPX files without relying on MediaStore ownership (which Android clears on uninstall). After reinstall, a one-tap "Restore track access" button in Saved Tracks re-authorizes the same folder and all tracks immediately reappear. - MediaStore path (existing) is kept as fallback for tracks saved before this change - Add SafState enum (CONFIGURED / PERMISSION_LOST / NOT_CONFIGURED) in TrackStorage - TrackRepository exposes safState() and initSafDirectory() thin wrappers - MainViewModel exposes safState StateFlow and onSafDirectorySelected() - SavedTracksFragment observes safState and shows contextual setup / re-auth button - MainActivity registers safPickerLauncher and exposes launchSafPicker() - Add androidx.documentfile:documentfile:1.0.1 dependency https://claude.ai/code/session_01YUbuZNDAoLea4cf9UGQ9qn
Diffstat (limited to 'android-app/app/src/main')
-rw-r--r--android-app/app/src/main/AndroidManifest.xml20
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/MainActivity.kt20
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/track/SavedTracksFragment.kt41
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/track/TrackRepository.kt7
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/track/TrackStorage.kt150
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/ui/MainViewModel.kt11
-rw-r--r--android-app/app/src/main/res/layout/fragment_saved_tracks.xml29
7 files changed, 209 insertions, 69 deletions
diff --git a/android-app/app/src/main/AndroidManifest.xml b/android-app/app/src/main/AndroidManifest.xml
index 5090190..23817bb 100644
--- a/android-app/app/src/main/AndroidManifest.xml
+++ b/android-app/app/src/main/AndroidManifest.xml
@@ -56,20 +56,17 @@
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="application/xml" />
</intent-filter>
- <!-- Google Files on Android 16 sends application/octet-stream for .gpx because
- MimeTypeMap has no entry for that extension. The ExternalStorageProvider URI
- encodes the full file path, so pathSuffix=".gpx" keeps this targeted.
- Code double-checks via OpenableColumns.DISPLAY_NAME before parsing. -->
+ <!-- Many file managers send application/octet-stream with no path hints.
+ pathSuffix cannot match content URIs (opaque paths); code checks
+ OpenableColumns.DISPLAY_NAME instead. -->
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
- <data android:scheme="content" android:mimeType="application/octet-stream"
- android:pathSuffix=".gpx" />
+ <data android:mimeType="application/octet-stream" />
</intent-filter>
- <!-- Share-sheet filters (ACTION_SEND): file URI is in EXTRA_STREAM, not
- intent data, so pathSuffix can't help — these cover apps that type .gpx
- correctly. Code checks display name for the XML variants. -->
+ <!-- Share-sheet filters (ACTION_SEND): file URI is in EXTRA_STREAM.
+ Code checks display name for XML and octet-stream variants. -->
<intent-filter>
<action android:name="android.intent.action.SEND" />
<category android:name="android.intent.category.DEFAULT" />
@@ -90,6 +87,11 @@
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="application/xml" />
</intent-filter>
+ <intent-filter>
+ <action android:name="android.intent.action.SEND" />
+ <category android:name="android.intent.category.DEFAULT" />
+ <data android:mimeType="application/octet-stream" />
+ </intent-filter>
</activity>
</application>
</manifest>
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 05dbf12..8e4a2e1 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
@@ -3,6 +3,7 @@ package org.terst.nav
import android.Manifest
import android.content.Intent
import android.content.pm.PackageManager
+import android.provider.DocumentsContract
import android.provider.OpenableColumns
import android.util.Log
import kotlinx.coroutines.Dispatchers
@@ -540,12 +541,27 @@ class MainActivity : AppCompatActivity(), SafetyFragment.SafetyListener {
registerForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { permissions ->
if (permissions[Manifest.permission.ACCESS_FINE_LOCATION] == true) {
pendingServiceStart = true
- // The MediaStore query in init{} may have thrown SecurityException while
- // the permission dialog was active. Reload now that the system is settled.
viewModel.reloadPastTracks()
}
}
+ private val safPickerLauncher =
+ registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
+ if (result.resultCode == RESULT_OK) {
+ result.data?.data?.let { treeUri -> viewModel.onSafDirectorySelected(treeUri) }
+ }
+ }
+
+ fun launchSafPicker() {
+ val intent = Intent(Intent.ACTION_OPEN_DOCUMENT_TREE).apply {
+ if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.O) {
+ putExtra(DocumentsContract.EXTRA_INITIAL_URI,
+ android.provider.MediaStore.Files.getContentUri("external"))
+ }
+ }
+ safPickerLauncher.launch(intent)
+ }
+
private fun startServices() {
serviceStarted = true
val intent = Intent(this, LocationService::class.java).apply {
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 b2181c9..bef9df3 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
@@ -1,5 +1,6 @@
package org.terst.nav.track
+import android.os.Build
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
@@ -11,6 +12,7 @@ import androidx.lifecycle.lifecycleScope
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.launch
import org.terst.nav.MainActivity
import org.terst.nav.R
@@ -32,8 +34,11 @@ class SavedTracksFragment : Fragment() {
(requireActivity() as MainActivity).hideOverlays()
}
- val rv = view.findViewById<RecyclerView>(R.id.rv_saved_tracks)
- val empty = view.findViewById<TextView>(R.id.tv_empty)
+ val rv = view.findViewById<RecyclerView>(R.id.rv_saved_tracks)
+ val layoutEmpty = view.findViewById<View>(R.id.layout_empty)
+ val tvEmpty = view.findViewById<TextView>(R.id.tv_empty)
+ val btnSetup = view.findViewById<MaterialButton>(R.id.btn_setup_storage)
+
rv.layoutManager = LinearLayoutManager(requireContext())
rv.addItemDecoration(DividerItemDecoration(requireContext(), DividerItemDecoration.VERTICAL))
@@ -49,9 +54,35 @@ class SavedTracksFragment : Fragment() {
viewLifecycleOwner.lifecycleScope.launch {
viewModel.savedTracks.collect { tracks ->
- adapter.submitList(tracks)
- empty.visibility = if (tracks.isEmpty()) View.VISIBLE else View.GONE
- rv.visibility = if (tracks.isEmpty()) View.GONE else View.VISIBLE
+ val empty = tracks.isEmpty()
+ layoutEmpty.visibility = if (empty) View.VISIBLE else View.GONE
+ rv.visibility = if (empty) View.GONE else View.VISIBLE
+ }
+ }
+
+ // SAF storage setup prompt — only relevant on API 29+
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
+ viewLifecycleOwner.lifecycleScope.launch {
+ viewModel.safState.collect { state ->
+ when (state) {
+ SafState.NOT_CONFIGURED -> {
+ tvEmpty.text = "No saved tracks yet.\nRecord a trip to see it here."
+ btnSetup.text = "Set up track storage"
+ btnSetup.visibility = View.VISIBLE
+ }
+ SafState.PERMISSION_LOST -> {
+ tvEmpty.text = "Track storage needs re-authorization."
+ btnSetup.text = "Restore track access"
+ btnSetup.visibility = View.VISIBLE
+ }
+ SafState.CONFIGURED -> {
+ btnSetup.visibility = View.GONE
+ }
+ }
+ }
+ }
+ btnSetup.setOnClickListener {
+ (requireActivity() as MainActivity).launchSafPicker()
}
}
}
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 bd79985..0cc098b 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.net.Uri
import android.util.Log
import kotlin.math.atan2
import kotlin.math.cos
@@ -149,6 +150,12 @@ class TrackRepository(context: Context) {
.filter { it.isNotEmpty() }
.map { it.toSavedTrack() }
+ fun safState(): SafState = storage.safState()
+
+ fun initSafDirectory(treeUri: Uri) {
+ storage.initSafDirectory(treeUri)
+ }
+
companion object {
fun haversineNm(lat1: Double, lon1: Double, lat2: Double, lon2: Double): Double {
val r = 3440.065 // Earth radius in nautical miles
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 146d10f..405bba8 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
@@ -2,12 +2,15 @@ package org.terst.nav.track
import android.content.ContentValues
import android.content.Context
+import android.content.Intent
import android.media.MediaScannerConnection
import android.net.Uri
import android.os.Build
import android.os.Environment
+import android.provider.DocumentsContract
import android.provider.MediaStore
import android.util.Log
+import androidx.documentfile.provider.DocumentFile
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.withTimeoutOrNull
import org.terst.nav.ui.NavLogger
@@ -19,16 +22,24 @@ import java.time.format.DateTimeFormatter
private const val TAG = "TrackStorage"
private const val PREF_NAME = "nav_track_uris"
private const val PREF_KEY = "uris"
+private const val PREF_SAF_TREE_URI = "saf_tree_uri"
+
+enum class SafState { CONFIGURED, PERMISSION_LOST, NOT_CONFIGURED }
/**
* Persists completed tracks as GPX files in the shared Documents/Nav/ folder.
*
- * Files written here survive app uninstall because they live in user-owned
- * shared storage rather than app-private storage.
+ * On API 29+, the primary mechanism is Storage Access Framework (SAF): the user
+ * grants access to a directory once via ACTION_OPEN_DOCUMENT_TREE; the permission
+ * survives reboots and is re-grantable with a single tap after reinstall, making
+ * track files genuinely persistent across uninstalls.
+ *
+ * Fallback chain (API 29+):
+ * 1. SAF via DocumentFile (preferred — survives reinstall with one re-auth tap)
+ * 2. MediaStore via persisted URIs (works for tracks saved before SAF was configured)
+ * 3. MediaStore fallback query by OWNER_PACKAGE_NAME (migration path)
*
- * API 29+: uses MediaStore (no permission required for Documents/).
- * API < 29: writes directly to Environment.DIRECTORY_DOCUMENTS (requires
- * WRITE_EXTERNAL_STORAGE permission declared in the manifest).
+ * API < 29: writes directly to Environment.DIRECTORY_DOCUMENTS/Nav/.
*/
class TrackStorage(private val context: Context) {
@@ -44,33 +55,107 @@ class TrackStorage(private val context: Context) {
context.getSharedPreferences(PREF_NAME, Context.MODE_PRIVATE)
}
- /** Write a completed track to Documents/Nav/. Returns true on success. */
+ // ── SAF state ─────────────────────────────────────────────────────────────
+
+ fun safState(): SafState {
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) return SafState.CONFIGURED
+ val uriStr = prefs.getString(PREF_SAF_TREE_URI, null) ?: return SafState.NOT_CONFIGURED
+ val uri = Uri.parse(uriStr)
+ val hasPermission = context.contentResolver.persistedUriPermissions.any {
+ it.uri == uri && it.isReadPermission && it.isWritePermission
+ }
+ return if (hasPermission) SafState.CONFIGURED else SafState.PERMISSION_LOST
+ }
+
+ fun initSafDirectory(treeUri: Uri) {
+ context.contentResolver.takePersistableUriPermission(
+ treeUri,
+ Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION
+ )
+ prefs.edit().putString(PREF_SAF_TREE_URI, treeUri.toString()).apply()
+ NavLogger.i("track", "SAF directory configured: $treeUri")
+ }
+
+ private fun safTreeDoc(): DocumentFile? {
+ val uriStr = prefs.getString(PREF_SAF_TREE_URI, null) ?: return null
+ return DocumentFile.fromTreeUri(context, Uri.parse(uriStr))
+ }
+
+ // ── Public API ────────────────────────────────────────────────────────────
+
+ /** Write a completed track to the configured storage location. Returns true on success. */
fun saveTrack(points: List<TrackPoint>, startMs: Long): Boolean {
if (points.isEmpty()) return false
val name = trackName.format(Instant.ofEpochMilli(startMs))
val fileName = "nav_${fileTimestamp.format(Instant.ofEpochMilli(startMs))}.gpx"
val gpx = GpxSerializer.serialize(points, name)
- return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
- saveViaMediaStore(fileName, gpx)
- } else {
- saveViaFile(fileName, gpx)
+ return when {
+ Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q && safState() == SafState.CONFIGURED
+ -> saveViaSaf(fileName, gpx)
+ Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q
+ -> saveViaMediaStore(fileName, gpx)
+ else -> saveViaFile(fileName, gpx)
}
}
- /** Load all tracks previously saved to Documents/Nav/. */
+ /** Load all tracks from the configured storage location. */
fun loadAllTracks(): List<List<TrackPoint>> {
- return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
- loadViaMediaStore()
- } else {
- loadViaFile()
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) return loadViaFile()
+ return if (safState() == SafState.CONFIGURED) loadViaSaf()
+ else loadViaMediaStore()
+ }
+
+ // ── SAF ───────────────────────────────────────────────────────────────────
+
+ private fun saveViaSaf(fileName: String, gpx: String): Boolean {
+ val dir = safTreeDoc() ?: run {
+ NavLogger.e("track", "saveViaSaf: SAF tree doc is null")
+ return false
+ }
+ // Avoid duplicates — delete if a file with this name already exists
+ dir.findFile(fileName)?.delete()
+
+ val file = dir.createFile("application/gpx+xml", fileName) ?: run {
+ NavLogger.e("track", "saveViaSaf: createFile failed for $fileName")
+ return false
+ }
+ return runCatching {
+ context.contentResolver.openOutputStream(file.uri)?.use {
+ it.write(gpx.toByteArray())
+ } ?: run {
+ file.delete()
+ NavLogger.e("track", "saveViaSaf: openOutputStream null for $fileName")
+ return@runCatching false
+ }
+ persistUri(file.uri)
+ NavLogger.i("track", "saveViaSaf: saved $fileName → ${file.uri}")
+ true
+ }.getOrElse { e ->
+ NavLogger.e("track", "saveViaSaf: write failed for $fileName: ${e.javaClass.simpleName}: ${e.message}")
+ runCatching { file.delete() }
+ false
+ }
+ }
+
+ private fun loadViaSaf(): List<List<TrackPoint>> {
+ val dir = safTreeDoc() ?: return emptyList()
+ val files = dir.listFiles().filter { it.name?.endsWith(".gpx") == true }
+ NavLogger.i("track", "loadViaSaf: ${files.size} gpx files in SAF dir")
+ return files.mapNotNull { doc ->
+ runCatching {
+ context.contentResolver.openInputStream(doc.uri)?.use { GpxParser.parse(it) }
+ ?.takeIf { it.isNotEmpty() }
+ }.getOrElse { e ->
+ NavLogger.e("track", "loadViaSaf: parse error ${doc.name}: ${e.javaClass.simpleName}: ${e.message}")
+ null
+ }
}
}
- // ── API 29+ ──────────────────────────────────────────────────────────────
+ // ── MediaStore (API 29+ fallback) ─────────────────────────────────────────
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) {
val msg = "storage not mounted (state=$storageState) — cannot save $fileName"
@@ -78,9 +163,6 @@ class TrackStorage(private val context: Context) {
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")
@@ -106,16 +188,10 @@ class TrackStorage(private val context: Context) {
}
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)
- // Persist the URI so future loads don't need a MediaStore query.
- // Querying MediaStore.Files requires READ_EXTERNAL_STORAGE on Android ≤ 12
- // and has no clean permission path on Android 13+; owning the URI directly
- // lets openInputStream() work on any version without any permission.
persistUri(uri)
-
val msg = "saved $fileName (${gpx.length} bytes) → $uri"
Log.d(TAG, msg); NavLogger.i("track", msg)
true
@@ -130,8 +206,6 @@ class TrackStorage(private val context: Context) {
private fun loadViaMediaStore(): List<List<TrackPoint>> {
val tracks = mutableListOf<List<TrackPoint>>()
- // Primary: load from persisted URIs — no MediaStore query, no permissions needed.
- // Each URI was stored at save time and grants direct read access to the file.
val loaded = mutableSetOf<String>()
for (uriString in storedUris()) {
val fileUri = Uri.parse(uriString)
@@ -156,21 +230,12 @@ class TrackStorage(private val context: Context) {
}
NavLogger.i("track", "loadViaMediaStore: ${tracks.size} tracks from stored URIs")
- // Fallback: MediaStore query for tracks saved before URI persistence was added.
- // May fail with SecurityException on some Android versions (caught below).
- // Any track found here is immediately persisted so future loads skip the query.
val baseUri = MediaStore.Files.getContentUri("external")
val projection = arrayOf(
MediaStore.Files.FileColumns._ID,
MediaStore.Files.FileColumns.RELATIVE_PATH,
MediaStore.MediaColumns.IS_PENDING
)
- // Do NOT filter by RELATIVE_PATH — equality matching is fragile across
- // Android versions and devices (trailing-slash normalisation, volume prefix,
- // etc.). The display-name pattern is specific enough on its own.
- // Filtering by OWNER_PACKAGE_NAME tells the system we only want our own files.
- // Android grants access to a package's own MediaStore entries without any
- // storage permission, including after a reinstall with the same package name.
val selection = "${MediaStore.Files.FileColumns.DISPLAY_NAME} LIKE ? AND " +
"${MediaStore.Files.FileColumns.OWNER_PACKAGE_NAME} = ?"
val args = arrayOf("nav_%.gpx", context.packageName)
@@ -190,7 +255,6 @@ class TrackStorage(private val context: Context) {
val path = if (pathCol >= 0) c.getString(pathCol) else "?"
val pending = if (pendingCol >= 0) c.getInt(pendingCol) else -1
val fileUri = Uri.withAppendedPath(baseUri, id.toString())
- // Skip files already loaded via stored URIs
if (fileUri.toString() in loaded) continue
NavLogger.i("track", "loadViaMediaStore: fallback id=$id path=$path pending=$pending")
runCatching {
@@ -203,7 +267,6 @@ class TrackStorage(private val context: Context) {
NavLogger.i("track", "loadViaMediaStore: fallback id=$id → ${points.size} points")
if (points.isNotEmpty()) {
tracks.add(points)
- // Migrate: persist this URI so future launches skip the query
persistUri(fileUri)
}
}
@@ -259,15 +322,8 @@ class TrackStorage(private val context: Context) {
} ?: emptyList()
}
- // ── Post-reinstall recovery ───────────────────────────────────────────────
+ // ── Post-reinstall recovery (MediaStore path only) ────────────────────────
- /**
- * Asks the MediaScanner to re-index Documents/Nav/. When triggered by our
- * app, the scanner re-registers each file with OWNER_PACKAGE_NAME set to
- * our package, restoring ownership that Android clears on uninstall.
- * After this returns, the MediaStore query in [loadAllTracks] will find
- * the files again.
- */
suspend fun triggerNavDirRescan() {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) return
val navDir = File(
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 52344f2..01cec43 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
@@ -1,5 +1,6 @@
package org.terst.nav.ui
+import android.net.Uri
import android.util.Log
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
@@ -14,6 +15,7 @@ import org.terst.nav.ais.CpaThresholds
import org.terst.nav.data.api.AisHubApiService
import org.terst.nav.thermal.ThermalReading
import org.terst.nav.thermal.ThermalState
+import org.terst.nav.track.SafState
import org.terst.nav.track.SavedTrack
import org.terst.nav.track.TrackPoint
import org.terst.nav.track.TrackRepository
@@ -107,6 +109,15 @@ class MainViewModel(
}
}
+ private val _safState = MutableStateFlow(trackRepository.safState())
+ val safState: StateFlow<SafState> = _safState.asStateFlow()
+
+ fun onSafDirectorySelected(treeUri: Uri) {
+ trackRepository.initSafDirectory(treeUri)
+ _safState.value = trackRepository.safState()
+ viewModelScope.launch { reloadPastTracks() }
+ }
+
private val _thermalState = MutableStateFlow(ThermalState.OK)
val thermalState: StateFlow<ThermalState> = _thermalState.asStateFlow()
diff --git a/android-app/app/src/main/res/layout/fragment_saved_tracks.xml b/android-app/app/src/main/res/layout/fragment_saved_tracks.xml
index 7485e59..a95b368 100644
--- a/android-app/app/src/main/res/layout/fragment_saved_tracks.xml
+++ b/android-app/app/src/main/res/layout/fragment_saved_tracks.xml
@@ -34,15 +34,32 @@
</LinearLayout>
- <TextView
- android:id="@+id/tv_empty"
+ <LinearLayout
+ android:id="@+id/layout_empty"
android:layout_width="match_parent"
android:layout_height="match_parent"
+ android:orientation="vertical"
android:gravity="center"
- android:text="No saved tracks yet.\nRecord a trip to see it here."
- android:textAlignment="center"
- android:textColor="?attr/colorOnSurfaceVariant"
- android:visibility="gone" />
+ android:padding="32dp"
+ android:visibility="gone">
+
+ <TextView
+ android:id="@+id/tv_empty"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:text="No saved tracks yet.\nRecord a trip to see it here."
+ android:textAlignment="center"
+ android:textColor="?attr/colorOnSurfaceVariant" />
+
+ <com.google.android.material.button.MaterialButton
+ android:id="@+id/btn_setup_storage"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:layout_marginTop="24dp"
+ android:text="Set up track storage"
+ android:visibility="gone" />
+
+ </LinearLayout>
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/rv_saved_tracks"