summaryrefslogtreecommitdiff
path: root/android-app
diff options
context:
space:
mode:
authorPeter Stone <thepeterstone@gmail.com>2026-05-12 22:43:45 -1000
committerGitHub <noreply@github.com>2026-05-12 22:43:45 -1000
commitbf4658ea210ad941772cd2d9626b66588dde35dc (patch)
tree6a7e11efd515137ea994fafcbe9b0a2007b11c25 /android-app
parent7c82f77cbbc1d5c2664e7a01db30317c42d00525 (diff)
fix(track): load past tracks via persisted URIs, no MediaStore query needed (#4)
MediaStore.Files.getContentUri("external") queries require READ_EXTERNAL_STORAGE on Android ≤ 12 and have no clean permission path on Android 13+ for non-media files. openInputStream() on a content URI the app owns works on all versions without any permission — so the fix is to persist each URI at save time. On save: call persistUri(uri) after the IS_PENDING=0 update, storing the content URI in SharedPreferences (nav_track_uris). On load: iterate stored URIs first and open each with openInputStream() — no MediaStore query, no permissions needed. The existing MediaStore query is kept as a fallback for tracks saved before this change; any track found there is auto-migrated into SharedPreferences so future launches skip the query too. Invalid URIs (file deleted) are cleaned up from SharedPreferences automatically. https://claude.ai/code/session_01DNjbYxiC1cco83dArNGW3G Co-authored-by: Claude <noreply@anthropic.com>
Diffstat (limited to 'android-app')
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/track/TrackStorage.kt87
1 files changed, 77 insertions, 10 deletions
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 4147128..2eb8a87 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,6 +2,7 @@ package org.terst.nav.track
import android.content.ContentValues
import android.content.Context
+import android.net.Uri
import android.os.Build
import android.os.Environment
import android.provider.MediaStore
@@ -13,6 +14,8 @@ import java.time.ZoneOffset
import java.time.format.DateTimeFormatter
private const val TAG = "TrackStorage"
+private const val PREF_NAME = "nav_track_uris"
+private const val PREF_KEY = "uris"
/**
* Persists completed tracks as GPX files in the shared Documents/Nav/ folder.
@@ -34,6 +37,10 @@ class TrackStorage(private val context: Context) {
.ofPattern("yyyy-MM-dd HH:mm")
.withZone(ZoneOffset.UTC)
+ private val prefs by lazy {
+ context.getSharedPreferences(PREF_NAME, Context.MODE_PRIVATE)
+ }
+
/** Write a completed track to Documents/Nav/. Returns true on success. */
fun saveTrack(points: List<TrackPoint>, startMs: Long): Boolean {
if (points.isEmpty()) return false
@@ -100,6 +107,12 @@ class TrackStorage(private val context: Context) {
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
@@ -113,7 +126,37 @@ class TrackStorage(private val context: Context) {
private fun loadViaMediaStore(): List<List<TrackPoint>> {
val tracks = mutableListOf<List<TrackPoint>>()
- val uri = MediaStore.Files.getContentUri("external")
+
+ // 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)
+ runCatching {
+ val stream = context.contentResolver.openInputStream(fileUri)
+ if (stream == null) {
+ NavLogger.e("track", "loadViaMediaStore: openInputStream null for stored uri=$uriString — removing")
+ removeUri(uriString)
+ } else {
+ stream.use { s ->
+ val points = GpxParser.parse(s)
+ NavLogger.i("track", "loadViaMediaStore: stored uri → ${points.size} points")
+ if (points.isNotEmpty()) {
+ tracks.add(points)
+ loaded.add(uriString)
+ }
+ }
+ }
+ }.onFailure { e ->
+ NavLogger.e("track", "loadViaMediaStore: stored uri=$uriString failed: ${e.javaClass.simpleName}: ${e.message}")
+ }
+ }
+ 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,
@@ -126,11 +169,11 @@ class TrackStorage(private val context: Context) {
val args = arrayOf("nav_%.gpx")
val cursor = runCatching {
- context.contentResolver.query(uri, projection, selection, args, null)
+ context.contentResolver.query(baseUri, projection, selection, args, null)
}.onFailure { e ->
NavLogger.e("track", "loadViaMediaStore: query failed (${e.javaClass.simpleName}: ${e.message})")
}.getOrNull()
- NavLogger.i("track", "loadViaMediaStore: cursor=${cursor?.count ?: "null"} rows")
+ NavLogger.i("track", "loadViaMediaStore: fallback cursor=${cursor?.count ?: "null"} rows")
cursor?.use { c ->
val idCol = c.getColumnIndexOrThrow(MediaStore.Files.FileColumns._ID)
val pathCol = c.getColumnIndex(MediaStore.Files.FileColumns.RELATIVE_PATH)
@@ -139,28 +182,52 @@ class TrackStorage(private val context: Context) {
val id = c.getLong(idCol)
val path = if (pathCol >= 0) c.getString(pathCol) else "?"
val pending = if (pendingCol >= 0) c.getInt(pendingCol) else -1
- val fileUri = android.net.Uri.withAppendedPath(uri, id.toString())
- NavLogger.i("track", "loadViaMediaStore: id=$id path=$path pending=$pending")
+ 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 {
val stream = context.contentResolver.openInputStream(fileUri)
if (stream == null) {
- NavLogger.e("track", "loadViaMediaStore: openInputStream null for id=$id")
+ NavLogger.e("track", "loadViaMediaStore: fallback openInputStream null for id=$id")
} else {
stream.use { s ->
val points = GpxParser.parse(s)
- NavLogger.i("track", "loadViaMediaStore: id=$id → ${points.size} points")
- if (points.isNotEmpty()) tracks.add(points)
+ 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)
+ }
}
}
}.onFailure { e ->
- NavLogger.e("track", "loadViaMediaStore: parse error id=$id: ${e.javaClass.simpleName}: ${e.message}")
+ NavLogger.e("track", "loadViaMediaStore: fallback parse error id=$id: ${e.javaClass.simpleName}: ${e.message}")
}
}
}
- NavLogger.i("track", "loadViaMediaStore: done — ${tracks.size} tracks loaded")
+ NavLogger.i("track", "loadViaMediaStore: done — ${tracks.size} tracks total")
return tracks
}
+ // ── URI persistence ───────────────────────────────────────────────────────
+
+ private fun persistUri(uri: Uri) {
+ val set = prefs.getStringSet(PREF_KEY, emptySet())?.toMutableSet() ?: mutableSetOf()
+ set.add(uri.toString())
+ prefs.edit().putStringSet(PREF_KEY, set).apply()
+ }
+
+ private fun removeUri(uriString: String) {
+ val set = prefs.getStringSet(PREF_KEY, emptySet())?.toMutableSet() ?: mutableSetOf()
+ if (set.remove(uriString)) {
+ prefs.edit().putStringSet(PREF_KEY, set).apply()
+ }
+ }
+
+ private fun storedUris(): Set<String> =
+ prefs.getStringSet(PREF_KEY, emptySet())?.toSet() ?: emptySet()
+
// ── API < 29 ─────────────────────────────────────────────────────────────
private fun navDir(): File {