From 48ae3b0225cb1a79539711be3a70cbd630c53638 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 19 May 2026 22:00:37 +0000 Subject: Reduce location resource usage: economy mode by default, lifecycle throttling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Start LocationService at ECONOMY (5 s, PRIORITY_BALANCED_POWER_ACCURACY) instead of FULL (1 Hz, PRIORITY_HIGH_ACCURACY); promote to FULL only while a track is actively recording and demote back when recording stops - Use PRIORITY_BALANCED_POWER_ACCURACY for ECONOMY and ANCHOR_WATCH modes so the GPS chip idles and network/passive sources handle light-duty fixes - Add ACTION_START_ECONOMY / ACTION_START_FULL intents so MainActivity can drive mode transitions from lifecycle callbacks - onPause: drop to ECONOMY if not recording or anchor-watching; onResume: restore the appropriate mode so the first visible frame has a fresh rate - Stop-anchor-watch now returns to ECONOMY instead of FULL (anchor drop rarely coincides with active track recording) - Remove ACCESS_BACKGROUND_LOCATION from manifest — the foreground service with foregroundServiceType="location" already covers background access - Add 5 m minimum-distance filter to DeviceGpsProvider so stationary devices don't generate spurious location callbacks https://claude.ai/code/session_01YUbuZNDAoLea4cf9UGQ9qn --- android-app/app/src/main/AndroidManifest.xml | 1 - 1 file changed, 1 deletion(-) (limited to 'android-app/app/src/main/AndroidManifest.xml') diff --git a/android-app/app/src/main/AndroidManifest.xml b/android-app/app/src/main/AndroidManifest.xml index a10d503..5090190 100644 --- a/android-app/app/src/main/AndroidManifest.xml +++ b/android-app/app/src/main/AndroidManifest.xml @@ -3,7 +3,6 @@ - -- cgit v1.2.3 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/app/src/main/AndroidManifest.xml') 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"> + + + + + + Date: Mon, 25 May 2026 18:12:11 +0000 Subject: Persist log entries and photos; stamp with GPS location - LogbookStorage: Moshi-backed JSON persistence to getExternalFilesDir, photos saved to Pictures/Nav/ instead of cache - LogbookRepository: file-backed replacement for in-memory store, loads saved entries on init so they survive app restarts - VoiceLogViewModel: expose entries StateFlow, accept lat/lon in save() - VoiceLogFragment: switch camera to TakePicture(Uri) via FileProvider for full-res photos; gallery copies content URI to persistent storage; passes current GPS position to save(); shows scrollable entries list - MainViewModel: expose currentPosition StateFlow updated on each GPS fix - AndroidManifest: add FileProvider for Pictures/Nav/ directory - TripReportViewModel: updated to use LogbookRepository https://claude.ai/code/session_01YUbuZNDAoLea4cf9UGQ9qn --- android-app/app/src/main/AndroidManifest.xml | 9 ++ .../main/kotlin/org/terst/nav/NavApplication.kt | 4 +- .../org/terst/nav/logbook/LogbookRepository.kt | 18 +++ .../kotlin/org/terst/nav/logbook/LogbookStorage.kt | 54 ++++++++ .../org/terst/nav/logbook/VoiceLogViewModel.kt | 15 ++- .../terst/nav/tripreport/TripReportViewModel.kt | 4 +- .../main/kotlin/org/terst/nav/ui/MainViewModel.kt | 4 + .../org/terst/nav/ui/voicelog/VoiceLogFragment.kt | 148 +++++++++++++++++++-- .../app/src/main/res/layout/fragment_voice_log.xml | 28 ++++ .../app/src/main/res/layout/item_logbook_entry.xml | 50 +++++++ android-app/app/src/main/res/xml/file_paths.xml | 4 + 11 files changed, 314 insertions(+), 24 deletions(-) create mode 100644 android-app/app/src/main/kotlin/org/terst/nav/logbook/LogbookRepository.kt create mode 100644 android-app/app/src/main/kotlin/org/terst/nav/logbook/LogbookStorage.kt create mode 100644 android-app/app/src/main/res/layout/item_logbook_entry.xml create mode 100644 android-app/app/src/main/res/xml/file_paths.xml (limited to 'android-app/app/src/main/AndroidManifest.xml') diff --git a/android-app/app/src/main/AndroidManifest.xml b/android-app/app/src/main/AndroidManifest.xml index 23817bb..1701e17 100644 --- a/android-app/app/src/main/AndroidManifest.xml +++ b/android-app/app/src/main/AndroidManifest.xml @@ -23,6 +23,15 @@ + + + ().apply { addAll(storage.loadAll()) } + private var nextId = (entries.maxOfOrNull { it.id } ?: 0L) + 1L + + val photoDir get() = storage.photoDir + + fun save(entry: LogEntry): LogEntry { + val saved = entry.copy(id = nextId++) + entries.add(saved) + storage.saveAll(entries) + return saved + } + + fun getAll(): List = entries.toList() +} diff --git a/android-app/app/src/main/kotlin/org/terst/nav/logbook/LogbookStorage.kt b/android-app/app/src/main/kotlin/org/terst/nav/logbook/LogbookStorage.kt new file mode 100644 index 0000000..35349f0 --- /dev/null +++ b/android-app/app/src/main/kotlin/org/terst/nav/logbook/LogbookStorage.kt @@ -0,0 +1,54 @@ +package org.terst.nav.logbook + +import android.content.Context +import android.graphics.Bitmap +import android.net.Uri +import android.os.Environment +import com.squareup.moshi.Moshi +import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory +import org.terst.nav.ui.NavLogger +import java.io.File +import java.io.FileOutputStream + +class LogbookStorage(private val context: Context) { + + private val moshi = Moshi.Builder().addLast(KotlinJsonAdapterFactory()).build() + private val adapter = moshi.adapter(LogEntryList::class.java) + + private val storageFile: File + get() = File(context.getExternalFilesDir(null), "nav_logbook.json") + + val photoDir: File + get() = File(context.getExternalFilesDir(Environment.DIRECTORY_PICTURES), "Nav") + .also { it.mkdirs() } + + fun loadAll(): List = runCatching { + if (!storageFile.exists()) return emptyList() + adapter.fromJson(storageFile.readText())?.entries ?: emptyList() + }.getOrElse { e -> + NavLogger.e("logbook", "loadAll failed: ${e.message}") + emptyList() + } + + fun saveAll(entries: List): Boolean = runCatching { + storageFile.parentFile?.mkdirs() + storageFile.writeText(adapter.toJson(LogEntryList(entries))) + true + }.getOrElse { false } + + fun saveBitmap(bitmap: Bitmap): String? = runCatching { + val dest = File(photoDir, "log_${System.currentTimeMillis()}.jpg") + FileOutputStream(dest).use { bitmap.compress(Bitmap.CompressFormat.JPEG, 90, it) } + dest.absolutePath + }.getOrElse { null } + + fun copyFromUri(uri: Uri): String? = runCatching { + val dest = File(photoDir, "log_${System.currentTimeMillis()}.jpg") + context.contentResolver.openInputStream(uri)?.use { input -> + FileOutputStream(dest).use { output -> input.copyTo(output) } + } + dest.absolutePath + }.getOrElse { null } +} + +data class LogEntryList(val entries: List) diff --git a/android-app/app/src/main/kotlin/org/terst/nav/logbook/VoiceLogViewModel.kt b/android-app/app/src/main/kotlin/org/terst/nav/logbook/VoiceLogViewModel.kt index 0a68ba8..67e1062 100644 --- a/android-app/app/src/main/kotlin/org/terst/nav/logbook/VoiceLogViewModel.kt +++ b/android-app/app/src/main/kotlin/org/terst/nav/logbook/VoiceLogViewModel.kt @@ -3,11 +3,14 @@ package org.terst.nav.logbook import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow -class VoiceLogViewModel(private val repository: InMemoryLogbookRepository) { +class VoiceLogViewModel(private val repository: LogbookRepository) { private val _state = MutableStateFlow(VoiceLogState.Idle) val state: StateFlow = _state + private val _entries = MutableStateFlow>(repository.getAll().reversed()) + val entries: StateFlow> = _entries + fun onListeningStarted() { _state.value = VoiceLogState.Listening } @@ -20,20 +23,18 @@ class VoiceLogViewModel(private val repository: InMemoryLogbookRepository) { _state.value = VoiceLogState.Error(message) } - /** - * Save an entry with the given text and optional photo path. - * Called directly by the fragment from the Save button so the user - * can have edited the recognized text in the EditText beforehand. - */ - fun save(text: String, photoPath: String? = null) { + fun save(text: String, photoPath: String? = null, lat: Double? = null, lon: Double? = null) { if (text.isBlank() && photoPath == null) return val entry = LogEntry( timestampMs = System.currentTimeMillis(), text = text.ifBlank { "(photo only)" }, entryType = EntryType.GENERAL, + lat = lat, + lon = lon, photoPath = photoPath ) val saved = repository.save(entry) + _entries.value = repository.getAll().reversed() _state.value = VoiceLogState.Saved(saved) } diff --git a/android-app/app/src/main/kotlin/org/terst/nav/tripreport/TripReportViewModel.kt b/android-app/app/src/main/kotlin/org/terst/nav/tripreport/TripReportViewModel.kt index e474cd2..45cbed5 100644 --- a/android-app/app/src/main/kotlin/org/terst/nav/tripreport/TripReportViewModel.kt +++ b/android-app/app/src/main/kotlin/org/terst/nav/tripreport/TripReportViewModel.kt @@ -6,7 +6,7 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch -import org.terst.nav.logbook.InMemoryLogbookRepository +import org.terst.nav.logbook.LogbookRepository import org.terst.nav.track.TrackRepository sealed class TripReportState { @@ -18,7 +18,7 @@ sealed class TripReportState { class TripReportViewModel( private val trackRepository: TrackRepository, - private val logbookRepository: InMemoryLogbookRepository, + private val logbookRepository: LogbookRepository, private val generator: TripReportGenerator = TripReportGenerator() ) : ViewModel() { 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 01cec43..49ce0f5 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 @@ -92,6 +92,9 @@ class MainViewModel( private var lastSog: Double = 0.0 private var lastCog: Double = 0.0 + private val _currentPosition = MutableStateFlow?>(null) + val currentPosition: StateFlow?> = _currentPosition.asStateFlow() + private val aisRepository = AisRepository() private val trackRepository = NavApplication.trackRepository @@ -221,6 +224,7 @@ class MainViewModel( fun addGpsPoint(lat: Double, lon: Double, sogKnots: Double, cogDeg: Double) { lastLat = lat; lastLon = lon; lastSog = sogKnots; lastCog = cogDeg + _currentPosition.value = lat to lon val conditions = _marineConditions.value val forecast = _forecast.value.firstOrNull() val point = TrackPoint( diff --git a/android-app/app/src/main/kotlin/org/terst/nav/ui/voicelog/VoiceLogFragment.kt b/android-app/app/src/main/kotlin/org/terst/nav/ui/voicelog/VoiceLogFragment.kt index fb1001a..31d9715 100644 --- a/android-app/app/src/main/kotlin/org/terst/nav/ui/voicelog/VoiceLogFragment.kt +++ b/android-app/app/src/main/kotlin/org/terst/nav/ui/voicelog/VoiceLogFragment.kt @@ -3,7 +3,7 @@ package org.terst.nav.ui.voicelog import android.Manifest import android.content.Intent import android.content.pm.PackageManager -import android.graphics.Bitmap +import android.graphics.BitmapFactory import android.net.Uri import android.os.Build import android.os.Bundle @@ -18,6 +18,7 @@ import android.widget.ImageView import android.widget.TextView import androidx.activity.result.contract.ActivityResultContracts import androidx.core.content.ContextCompat +import androidx.core.content.FileProvider import androidx.fragment.app.Fragment import androidx.fragment.app.activityViewModels import androidx.lifecycle.lifecycleScope @@ -27,9 +28,13 @@ import androidx.recyclerview.widget.RecyclerView import com.google.android.material.button.MaterialButton import com.google.android.material.floatingactionbutton.FloatingActionButton import com.google.android.material.textfield.TextInputEditText +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import org.terst.nav.MainActivity +import org.terst.nav.NavApplication import org.terst.nav.R +import org.terst.nav.logbook.LogEntry import org.terst.nav.logbook.VoiceLogState import org.terst.nav.logbook.VoiceLogViewModel import org.terst.nav.track.SafState @@ -38,12 +43,15 @@ import org.terst.nav.track.TrackDetailSheet import org.terst.nav.ui.MainViewModel import java.io.File import java.io.FileOutputStream +import java.time.Instant +import java.time.ZoneId +import java.time.format.DateTimeFormatter import java.util.Locale class VoiceLogFragment : Fragment() { private val logViewModel by lazy { - VoiceLogViewModel(repository = org.terst.nav.NavApplication.logbookRepository) + VoiceLogViewModel(repository = NavApplication.logbookRepository) } private val mainViewModel: MainViewModel by activityViewModels() @@ -62,25 +70,47 @@ class VoiceLogFragment : Fragment() { private lateinit var tvSavedConfirmation: TextView private var pendingPhotoPath: String? = null + private var pendingCameraUri: Uri? = null private val cameraLauncher = registerForActivityResult( - ActivityResultContracts.TakePicturePreview() - ) { bitmap: Bitmap? -> - if (bitmap != null) { - val file = File(requireContext().cacheDir, "log_${System.currentTimeMillis()}.jpg") - try { - FileOutputStream(file).use { bitmap.compress(Bitmap.CompressFormat.JPEG, 85, it) } - setPhoto(file.absolutePath) { ivPhoto.setImageBitmap(bitmap) } - } catch (_: Exception) { - tvStatus.text = "Couldn't save photo" + ActivityResultContracts.TakePicture() + ) { success: Boolean -> + if (success) { + val path = pendingPhotoPath ?: return@registerForActivityResult + setPhoto(path) { + val bm = BitmapFactory.decodeFile(path) + if (bm != null) ivPhoto.setImageBitmap(bm) + else ivPhoto.setImageURI(pendingCameraUri) } + } else { + pendingPhotoPath?.let { File(it).delete() } + pendingPhotoPath = null + pendingCameraUri = null } } private val galleryLauncher = registerForActivityResult( ActivityResultContracts.GetContent() ) { uri: Uri? -> - if (uri != null) setPhoto(uri.toString()) { ivPhoto.setImageURI(uri) } + if (uri != null) { + lifecycleScope.launch { + val path = withContext(Dispatchers.IO) { + runCatching { + val dir = NavApplication.logbookRepository.photoDir + val dest = File(dir, "log_${System.currentTimeMillis()}.jpg") + requireContext().contentResolver.openInputStream(uri)?.use { input -> + FileOutputStream(dest).use { output -> input.copyTo(output) } + } + dest.absolutePath + }.getOrNull() + } + if (path != null) { + setPhoto(path) { ivPhoto.setImageURI(Uri.fromFile(File(path))) } + } else { + tvStatus.text = "Couldn't copy photo" + } + } + } } override fun onCreateView( @@ -108,11 +138,28 @@ class VoiceLogFragment : Fragment() { setupSpeechRecognizer() fabMic.setOnClickListener { startListening() } - btnCamera.setOnClickListener { cameraLauncher.launch(null) } + + btnCamera.setOnClickListener { + val dir = NavApplication.logbookRepository.photoDir + val file = File(dir, "log_${System.currentTimeMillis()}.jpg") + pendingPhotoPath = file.absolutePath + pendingCameraUri = FileProvider.getUriForFile( + requireContext(), "${requireContext().packageName}.fileprovider", file + ) + cameraLauncher.launch(pendingCameraUri!!) + } + btnGallery.setOnClickListener { galleryLauncher.launch("image/*") } btnRemovePhoto.setOnClickListener { clearPhoto() } + btnSave.setOnClickListener { - logViewModel.save(etNote.text?.toString()?.trim() ?: "", pendingPhotoPath) + val pos = mainViewModel.currentPosition.value + logViewModel.save( + text = etNote.text?.toString()?.trim() ?: "", + photoPath = pendingPhotoPath, + lat = pos?.first, + lon = pos?.second + ) } btnClear.setOnClickListener { clearEntry() } @@ -130,6 +177,23 @@ class VoiceLogFragment : Fragment() { } } + // Log entries list + val tvNoEntries = view.findViewById(R.id.tv_no_log_entries) + val rvLogEntries = view.findViewById(R.id.rv_log_entries) + rvLogEntries.layoutManager = LinearLayoutManager(requireContext()) + rvLogEntries.isNestedScrollingEnabled = false + rvLogEntries.addItemDecoration(DividerItemDecoration(requireContext(), DividerItemDecoration.VERTICAL)) + val logAdapter = LogbookEntryAdapter() + rvLogEntries.adapter = logAdapter + + viewLifecycleOwner.lifecycleScope.launch { + logViewModel.entries.collect { entries -> + logAdapter.submitList(entries) + tvNoEntries.visibility = if (entries.isEmpty()) View.VISIBLE else View.GONE + rvLogEntries.visibility = if (entries.isEmpty()) View.GONE else View.VISIBLE + } + } + // Inline track list val rv = view.findViewById(R.id.rv_saved_tracks) val layoutEmpty = view.findViewById(R.id.layout_empty) @@ -235,6 +299,7 @@ class VoiceLogFragment : Fragment() { private fun clearPhoto() { pendingPhotoPath = null + pendingCameraUri = null ivPhoto.setImageDrawable(null) framePhoto.visibility = View.GONE } @@ -302,3 +367,58 @@ class VoiceLogFragment : Fragment() { private const val RC_AUDIO = 1001 } } + +// ── Logbook entries adapter ──────────────────────────────────────────────────── + +private val TIME_FMT: DateTimeFormatter = + DateTimeFormatter.ofPattern("dd MMM HH:mm", Locale.getDefault()) + .withZone(ZoneId.systemDefault()) + +private class LogbookEntryAdapter : RecyclerView.Adapter() { + + private var items: List = emptyList() + + fun submitList(list: List) { + items = list + notifyDataSetChanged() + } + + inner class VH(view: View) : RecyclerView.ViewHolder(view) { + val ivPhoto = view.findViewById(R.id.iv_logbook_photo) + val tvTime = view.findViewById(R.id.tv_logbook_timestamp) + val tvText = view.findViewById(R.id.tv_logbook_text) + val tvLocation = view.findViewById(R.id.tv_logbook_location) + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): VH = + VH(LayoutInflater.from(parent.context).inflate(R.layout.item_logbook_entry, parent, false)) + + override fun getItemCount() = items.size + + override fun onBindViewHolder(holder: VH, position: Int) { + val e = items[position] + holder.tvTime.text = TIME_FMT.format(Instant.ofEpochMilli(e.timestampMs)) + holder.tvText.text = e.text + + if (e.lat != null && e.lon != null) { + holder.tvLocation.text = "%.4f°, %.4f°".format(e.lat, e.lon) + holder.tvLocation.visibility = View.VISIBLE + } else { + holder.tvLocation.visibility = View.GONE + } + + val photoPath = e.photoPath + if (photoPath != null && File(photoPath).exists()) { + val bm = BitmapFactory.decodeFile(photoPath) + if (bm != null) { + holder.ivPhoto.setImageBitmap(bm) + holder.ivPhoto.visibility = View.VISIBLE + } else { + holder.ivPhoto.visibility = View.GONE + } + } else { + holder.ivPhoto.setImageDrawable(null) + holder.ivPhoto.visibility = View.GONE + } + } +} diff --git a/android-app/app/src/main/res/layout/fragment_voice_log.xml b/android-app/app/src/main/res/layout/fragment_voice_log.xml index 389add1..4a2ff14 100644 --- a/android-app/app/src/main/res/layout/fragment_voice_log.xml +++ b/android-app/app/src/main/res/layout/fragment_voice_log.xml @@ -160,6 +160,34 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/android-app/app/src/main/res/xml/file_paths.xml b/android-app/app/src/main/res/xml/file_paths.xml new file mode 100644 index 0000000..c8f824e --- /dev/null +++ b/android-app/app/src/main/res/xml/file_paths.xml @@ -0,0 +1,4 @@ + + + + -- cgit v1.2.3