From d6a2e71c155f62a03e1ab466198a256c96c13b3b Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 20 May 2026 08:38:33 +0000 Subject: 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 --- android-app/app/build.gradle | 1 + android-app/app/src/main/AndroidManifest.xml | 20 +-- .../src/main/kotlin/org/terst/nav/MainActivity.kt | 20 ++- .../org/terst/nav/track/SavedTracksFragment.kt | 41 +++++- .../kotlin/org/terst/nav/track/TrackRepository.kt | 7 + .../kotlin/org/terst/nav/track/TrackStorage.kt | 150 ++++++++++++++------- .../main/kotlin/org/terst/nav/ui/MainViewModel.kt | 11 ++ .../src/main/res/layout/fragment_saved_tracks.xml | 29 +++- 8 files changed, 210 insertions(+), 69 deletions(-) (limited to 'android-app') diff --git a/android-app/app/build.gradle b/android-app/app/build.gradle index 472d678..9748d22 100644 --- a/android-app/app/build.gradle +++ b/android-app/app/build.gradle @@ -69,6 +69,7 @@ dependencies { // AndroidX core implementation 'androidx.core:core-ktx:1.12.0' + implementation 'androidx.documentfile:documentfile:1.0.1' implementation 'androidx.appcompat:appcompat:1.6.1' implementation 'com.google.android.material:material:1.11.0' implementation 'androidx.constraintlayout:constraintlayout:2.1.4' 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 @@ - + - + - + @@ -90,6 +87,11 @@ + + + + + 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(R.id.rv_saved_tracks) - val empty = view.findViewById(R.id.tv_empty) + val rv = view.findViewById(R.id.rv_saved_tracks) + val layoutEmpty = view.findViewById(R.id.layout_empty) + val tvEmpty = view.findViewById(R.id.tv_empty) + val btnSetup = view.findViewById(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, 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> { - 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> { + 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> { val tracks = mutableListOf>() - // 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) @@ -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.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.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 @@ - + android:padding="32dp" + android:visibility="gone"> + + + + + +