From bf4658ea210ad941772cd2d9626b66588dde35dc Mon Sep 17 00:00:00 2001 From: Peter Stone Date: Tue, 12 May 2026 22:43:45 -1000 Subject: fix(track): load past tracks via persisted URIs, no MediaStore query needed (#4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../kotlin/org/terst/nav/track/TrackStorage.kt | 87 +++++++++++++++++++--- 1 file changed, 77 insertions(+), 10 deletions(-) (limited to 'android-app') 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, 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> { val tracks = mutableListOf>() - 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() + 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 = + prefs.getStringSet(PREF_KEY, emptySet())?.toSet() ?: emptySet() + // ── API < 29 ───────────────────────────────────────────────────────────── private fun navDir(): File { -- cgit v1.2.3