diff options
| author | Claude <noreply@anthropic.com> | 2026-05-26 20:35:38 +0000 |
|---|---|---|
| committer | Claude <noreply@anthropic.com> | 2026-05-26 20:35:38 +0000 |
| commit | 0db33911ca4841c658b22912d3ecf32477e5e827 (patch) | |
| tree | 0005290ad49551f1182eef94d63f5486a9ac1569 /android-app/app/src/main/kotlin | |
| parent | 3f3e6ed586e580539ce42289a3f97536fe994c7b (diff) | |
Persist logbook JSON and photos in SAF so they survive reinstall
- LogbookStorage: reads/writes nav_logbook.json and photos into the SAF
logbook/ subfolder, using the same saf_tree_uri pref as TrackStorage.
Automatically migrates legacy getExternalFilesDir JSON on first SAF
write. Falls back to external files when SAF is not yet configured.
- LogbookRepository: expose cameraDir (cache-backed temp for FileProvider),
copyPhoto(Uri), and importPhoto(File) instead of the old photoDir.
- VoiceLogFragment / TrackDetailSheet: camera writes temp to cacheDir then
imports to SAF after capture; gallery delegates to copyPhoto(); photo
display and decodeThumbnail handle both content:// URIs and file paths.
- file_paths.xml: add cache-path entry so FileProvider can serve camera
temp files from cacheDir/nav_camera/.
https://claude.ai/code/session_01KXYBuAHZkvv63DeUG6XaZD
Diffstat (limited to 'android-app/app/src/main/kotlin')
4 files changed, 250 insertions, 68 deletions
diff --git a/android-app/app/src/main/kotlin/org/terst/nav/logbook/LogbookRepository.kt b/android-app/app/src/main/kotlin/org/terst/nav/logbook/LogbookRepository.kt index 0b4693e..dd65e4a 100644 --- a/android-app/app/src/main/kotlin/org/terst/nav/logbook/LogbookRepository.kt +++ b/android-app/app/src/main/kotlin/org/terst/nav/logbook/LogbookRepository.kt @@ -1,11 +1,21 @@ package org.terst.nav.logbook +import android.net.Uri +import java.io.File + class LogbookRepository(private val storage: LogbookStorage) { private val entries = mutableListOf<LogEntry>().apply { addAll(storage.loadAll()) } private var nextId = (entries.maxOfOrNull { it.id } ?: 0L) + 1L - val photoDir get() = storage.photoDir + /** Temp dir for FileProvider-backed camera captures. Not persistent across reinstall — use importPhoto() after capture. */ + val cameraDir get() = storage.cameraDir + + /** Copies a gallery URI into persistent storage. Returns content:// URI string or file path. */ + fun copyPhoto(uri: Uri): String? = storage.copyFromUri(uri) + + /** Moves a temp camera file into persistent storage, deleting the temp. Returns content:// URI string or file path. */ + fun importPhoto(file: File): String? = storage.importFile(file) fun save(entry: LogEntry): LogEntry { val saved = entry.copy(id = nextId++) 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 index 51f1f09..885748e 100644 --- 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 @@ -3,51 +3,188 @@ package org.terst.nav.logbook import android.content.Context import android.graphics.Bitmap import android.net.Uri -import android.os.Environment +import androidx.documentfile.provider.DocumentFile 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 +private const val PREF_NAME = "nav_track_uris" +private const val PREF_SAF_URI = "saf_tree_uri" + class LogbookStorage(private val context: Context) { - private val moshi = Moshi.Builder().addLast(KotlinJsonAdapterFactory()).build() + private val moshi = Moshi.Builder().addLast(KotlinJsonAdapterFactory()).build() private val adapter = moshi.adapter(LogEntryList::class.java) - private val storageFile: File + private val prefs by lazy { context.getSharedPreferences(PREF_NAME, Context.MODE_PRIVATE) } + + /** Temp dir for camera captures — FileProvider-accessible, not persistent across reinstall (expected). */ + val cameraDir: File + get() = File(context.cacheDir, "nav_camera").also { it.mkdirs() } + + // ── SAF helpers ────────────────────────────────────────────────────────────── + + private fun safTreeDoc(): DocumentFile? { + val uriStr = prefs.getString(PREF_SAF_URI, null) ?: return null + val uri = Uri.parse(uriStr) + val hasPerm = context.contentResolver.persistedUriPermissions.any { + it.uri == uri && it.isReadPermission && it.isWritePermission + } + if (!hasPerm) return null + return DocumentFile.fromTreeUri(context, uri) + } + + private fun safLogbookDir(): DocumentFile? { + val tree = safTreeDoc() ?: return null + return tree.findFile("logbook") ?: tree.createDirectory("logbook") + } + + private fun safPhotosDir(): DocumentFile? { + val lb = safLogbookDir() ?: return null + return lb.findFile("photos") ?: lb.createDirectory("photos") + } + + // ── JSON persistence ───────────────────────────────────────────────────────── + + fun loadAll(): List<LogEntry> { + val safDir = safLogbookDir() + if (safDir != null) { + val jsonFile = safDir.findFile("nav_logbook.json") + if (jsonFile != null) { + return runCatching { + context.contentResolver.openInputStream(jsonFile.uri)?.use { stream -> + adapter.fromJson(stream.bufferedReader().readText())?.entries ?: emptyList() + } ?: emptyList() + }.getOrElse { e -> + NavLogger.e("logbook", "SAF loadAll failed: ${e.message}") + emptyList() + } + } + // SAF configured but no JSON yet — migrate legacy if present + migrateLegacyJson(safDir) + } + return legacyLoadAll() + } + + fun saveAll(entries: List<LogEntry>): Boolean { + val safDir = safLogbookDir() + if (safDir != null) { + return runCatching { + val json = adapter.toJson(LogEntryList(entries)) + // DocumentFile doesn't truncate; delete then recreate. + safDir.findFile("nav_logbook.json")?.delete() + val file = safDir.createFile("application/json", "nav_logbook.json") + ?: return false + context.contentResolver.openOutputStream(file.uri)?.use { it.write(json.toByteArray()) } + true + }.getOrElse { e -> + NavLogger.e("logbook", "SAF saveAll failed: ${e.message}") + false + } + } + return legacySaveAll(entries) + } + + // ── Photo persistence ───────────────────────────────────────────────────────── + + /** Copies a gallery URI into persistent storage. Returns a content:// URI string (SAF) or file path (legacy). */ + fun copyFromUri(uri: Uri): String? { + val photosDir = safPhotosDir() + if (photosDir != null) { + return runCatching { + val doc = photosDir.createFile("image/jpeg", "log_${System.currentTimeMillis()}.jpg") + ?: return null + context.contentResolver.openInputStream(uri)?.use { inp -> + context.contentResolver.openOutputStream(doc.uri)?.use { out -> inp.copyTo(out) } + } + doc.uri.toString() + }.getOrNull() + } + return runCatching { + val dest = File(legacyPhotoDir.also { it.mkdirs() }, "log_${System.currentTimeMillis()}.jpg") + context.contentResolver.openInputStream(uri)?.use { inp -> + FileOutputStream(dest).use { out -> inp.copyTo(out) } + } + dest.absolutePath + }.getOrNull() + } + + /** Moves a temp camera file into persistent storage, deleting the temp. Returns content:// or file path. */ + fun importFile(file: File): String? { + val photosDir = safPhotosDir() + if (photosDir != null) { + return runCatching { + val doc = photosDir.createFile("image/jpeg", "log_${System.currentTimeMillis()}.jpg") + ?: return null + file.inputStream().use { inp -> + context.contentResolver.openOutputStream(doc.uri)?.use { out -> inp.copyTo(out) } + } + file.delete() + doc.uri.toString() + }.getOrNull() + } + return runCatching { + val dest = File(legacyPhotoDir.also { it.mkdirs() }, "log_${System.currentTimeMillis()}.jpg") + file.copyTo(dest, overwrite = true) + file.delete() + dest.absolutePath + }.getOrElse { file.absolutePath } + } + + fun saveBitmap(bitmap: Bitmap): String? { + val photosDir = safPhotosDir() + if (photosDir != null) { + return runCatching { + val doc = photosDir.createFile("image/jpeg", "log_${System.currentTimeMillis()}.jpg") + ?: return null + context.contentResolver.openOutputStream(doc.uri)?.use { bitmap.compress(Bitmap.CompressFormat.JPEG, 90, it) } + doc.uri.toString() + }.getOrNull() + } + return runCatching { + val dest = File(legacyPhotoDir.also { it.mkdirs() }, "log_${System.currentTimeMillis()}.jpg") + FileOutputStream(dest).use { bitmap.compress(Bitmap.CompressFormat.JPEG, 90, it) } + dest.absolutePath + }.getOrNull() + } + + // ── Legacy (fallback when SAF not configured) ───────────────────────────────── + + private val legacyStorageFile: File get() = File(context.getExternalFilesDir(null) ?: context.filesDir, "nav_logbook.json") - val photoDir: File - get() = File(context.getExternalFilesDir(Environment.DIRECTORY_PICTURES) ?: context.filesDir, "Nav") - .also { it.mkdirs() } + private val legacyPhotoDir: File + get() = File( + context.getExternalFilesDir(android.os.Environment.DIRECTORY_PICTURES) ?: context.filesDir, + "Nav" + ) - fun loadAll(): List<LogEntry> = runCatching { - if (!storageFile.exists()) return emptyList() - adapter.fromJson(storageFile.readText())?.entries ?: emptyList() + private fun legacyLoadAll(): List<LogEntry> = runCatching { + if (!legacyStorageFile.exists()) return emptyList() + adapter.fromJson(legacyStorageFile.readText())?.entries ?: emptyList() }.getOrElse { e -> - NavLogger.e("logbook", "loadAll failed: ${e.message}") + NavLogger.e("logbook", "legacyLoadAll failed: ${e.message}") emptyList() } - fun saveAll(entries: List<LogEntry>): Boolean = runCatching { - storageFile.parentFile?.mkdirs() - storageFile.writeText(adapter.toJson(LogEntryList(entries))) + private fun legacySaveAll(entries: List<LogEntry>): Boolean = runCatching { + legacyStorageFile.parentFile?.mkdirs() + legacyStorageFile.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") - val stream = context.contentResolver.openInputStream(uri) ?: return null - stream.use { input -> FileOutputStream(dest).use { output -> input.copyTo(output) } } - dest.absolutePath - }.getOrElse { null } + private fun migrateLegacyJson(safDir: DocumentFile) { + if (!legacyStorageFile.exists()) return + runCatching { + val text = legacyStorageFile.readText() + safDir.findFile("nav_logbook.json")?.delete() + val file = safDir.createFile("application/json", "nav_logbook.json") ?: return + context.contentResolver.openOutputStream(file.uri)?.use { it.write(text.toByteArray()) } + NavLogger.i("logbook", "Migrated legacy JSON to SAF") + }.onFailure { e -> NavLogger.e("logbook", "migrateLegacyJson failed: ${e.message}") } + } } data class LogEntryList(val entries: List<LogEntry>) diff --git a/android-app/app/src/main/kotlin/org/terst/nav/track/TrackDetailSheet.kt b/android-app/app/src/main/kotlin/org/terst/nav/track/TrackDetailSheet.kt index f101779..7dd0e07 100644 --- a/android-app/app/src/main/kotlin/org/terst/nav/track/TrackDetailSheet.kt +++ b/android-app/app/src/main/kotlin/org/terst/nav/track/TrackDetailSheet.kt @@ -27,6 +27,7 @@ import androidx.recyclerview.widget.RecyclerView import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +import java.io.InputStream import org.maplibre.android.camera.CameraUpdateFactory import org.maplibre.android.geometry.LatLng import org.maplibre.android.geometry.LatLngBounds @@ -47,7 +48,6 @@ import org.terst.nav.logbook.EntryType import org.terst.nav.logbook.LogEntry 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 @@ -86,10 +86,14 @@ class TrackDetailSheet : Fragment() { ) { result -> if (result.resultCode == Activity.RESULT_OK) { val path = pendingNotePhotoPath ?: return@registerForActivityResult - val bm = BitmapFactory.decodeFile(path) - if (bm != null) noteDialogPhotoView?.setImageBitmap(bm) - else noteDialogPhotoView?.setImageURI(Uri.fromFile(File(path))) - noteDialogPhotoFrame?.visibility = View.VISIBLE + lifecycleScope.launch { + val importedPath = withContext(Dispatchers.IO) { + NavApplication.logbookRepository.importPhoto(File(path)) + } ?: path + pendingNotePhotoPath = importedPath + noteDialogPhotoView?.setImageURI(photoUri(importedPath)) + noteDialogPhotoFrame?.visibility = View.VISIBLE + } } else { pendingNotePhotoPath?.let { File(it).delete() } pendingNotePhotoPath = null @@ -103,19 +107,19 @@ class TrackDetailSheet : Fragment() { lifecycleScope.launch { val result = withContext(Dispatchers.IO) { runCatching { - val dir = NavApplication.logbookRepository.photoDir - val dest = File(dir, "log_${System.currentTimeMillis()}.jpg") - requireContext().contentResolver.openInputStream(uri)?.use { inp -> - FileOutputStream(dest).use { out -> inp.copyTo(out) } + // Read EXIF from original URI before copying to SAF + val ts = requireContext().contentResolver.openInputStream(uri)?.use { stream -> + readExifTimestamp(stream) } - dest.absolutePath to readExifTimestamp(dest.absolutePath) + val pathOrUri = NavApplication.logbookRepository.copyPhoto(uri) ?: return@runCatching null + pathOrUri to ts }.getOrNull() } if (result != null) { - val (path, ts) = result - pendingNotePhotoPath = path + val (pathOrUri, ts) = result + pendingNotePhotoPath = pathOrUri pendingNotePhotoTimestampMs = ts - noteDialogPhotoView?.setImageURI(Uri.fromFile(File(path))) + noteDialogPhotoView?.setImageURI(photoUri(pathOrUri)) noteDialogPhotoFrame?.visibility = View.VISIBLE } } @@ -192,7 +196,7 @@ class TrackDetailSheet : Fragment() { noteDialogPhotoFrame?.visibility = View.GONE dialogView.findViewById<Button>(R.id.btn_note_camera).setOnClickListener { - val file = File(NavApplication.logbookRepository.photoDir, "log_${System.currentTimeMillis()}.jpg") + val file = File(NavApplication.logbookRepository.cameraDir, "log_${System.currentTimeMillis()}.jpg") pendingNotePhotoPath = file.absolutePath val uri = FileProvider.getUriForFile( requireContext(), "${requireContext().packageName}.fileprovider", file @@ -379,9 +383,9 @@ class TrackDetailSheet : Fragment() { } } - /** Reads EXIF DateTimeOriginal from a JPEG, parsed as device-local time. Returns null on failure. */ - private fun readExifTimestamp(filePath: String): Long? = runCatching { - val exif = ExifInterface(filePath) + /** Reads EXIF DateTimeOriginal from a stream, parsed as device-local time. Returns null on failure. */ + private fun readExifTimestamp(stream: InputStream): Long? = runCatching { + val exif = ExifInterface(stream) val dt = exif.getAttribute(ExifInterface.TAG_DATETIME_ORIGINAL) ?: exif.getAttribute(ExifInterface.TAG_DATETIME) ?: return@runCatching null @@ -390,6 +394,10 @@ class TrackDetailSheet : Fragment() { .parse(dt)?.time }.getOrNull() + private fun photoUri(pathOrUri: String): Uri = + if (pathOrUri.startsWith("content://")) Uri.parse(pathOrUri) + else Uri.fromFile(File(pathOrUri)) + private fun formatPointDetail(p: TrackPoint): String { val parts = mutableListOf("%.1f kt %.0f°".format(p.sogKnots, p.cogDeg)) p.windSpeedKnots?.let { parts += "W %.0f kt".format(it) } @@ -434,8 +442,8 @@ private class LogEventAdapter( holder.itemView.setOnClickListener { onTap(e) } val path = e.photoPath - if (path != null && File(path).exists()) { - val bm = decodeThumbnail(path, 160, 120) + if (path != null) { + val bm = decodeThumbnail(holder.itemView.context, path, 160, 120) if (bm != null) { holder.ivThumb.setImageBitmap(bm) holder.ivThumb.visibility = View.VISIBLE @@ -450,10 +458,23 @@ private class LogEventAdapter( } } -private fun decodeThumbnail(path: String, targetW: Int, targetH: Int): android.graphics.Bitmap? { +private fun decodeThumbnail(context: android.content.Context, pathOrUri: String, targetW: Int, targetH: Int): android.graphics.Bitmap? { + if (pathOrUri.startsWith("content://")) { + return runCatching { + val uri = Uri.parse(pathOrUri) + val opts = BitmapFactory.Options().apply { inJustDecodeBounds = true } + context.contentResolver.openInputStream(uri)?.use { BitmapFactory.decodeStream(it, null, opts) } + if (opts.outWidth <= 0) return null + val scale = maxOf(opts.outWidth / targetW, opts.outHeight / targetH).coerceAtLeast(1) + context.contentResolver.openInputStream(uri)?.use { stream -> + BitmapFactory.decodeStream(stream, null, BitmapFactory.Options().apply { inSampleSize = scale }) + } + }.getOrNull() + } + if (!File(pathOrUri).exists()) return null val opts = BitmapFactory.Options().apply { inJustDecodeBounds = true } - BitmapFactory.decodeFile(path, opts) + BitmapFactory.decodeFile(pathOrUri, opts) if (opts.outWidth <= 0) return null val scale = maxOf(opts.outWidth / targetW, opts.outHeight / targetH).coerceAtLeast(1) - return BitmapFactory.decodeFile(path, BitmapFactory.Options().apply { inSampleSize = scale }) + return BitmapFactory.decodeFile(pathOrUri, BitmapFactory.Options().apply { inSampleSize = scale }) } 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 2557eb4..8116c1a 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 @@ -44,7 +44,6 @@ import org.terst.nav.track.SavedTrackAdapter 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 @@ -79,10 +78,12 @@ class VoiceLogFragment : Fragment() { ) { result -> if (result.resultCode == Activity.RESULT_OK) { val path = pendingPhotoPath ?: return@registerForActivityResult - setPhoto(path) { - val bm = BitmapFactory.decodeFile(path) - if (bm != null) ivPhoto.setImageBitmap(bm) - else ivPhoto.setImageURI(pendingCameraUri) + lifecycleScope.launch { + val importedPath = withContext(Dispatchers.IO) { + NavApplication.logbookRepository.importPhoto(File(path)) + } ?: path + pendingPhotoPath = importedPath + setPhoto(importedPath) { displayPhoto(ivPhoto, importedPath) } } } else { pendingPhotoPath?.let { File(it).delete() } @@ -97,17 +98,10 @@ class VoiceLogFragment : Fragment() { 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() + NavApplication.logbookRepository.copyPhoto(uri) } if (path != null) { - setPhoto(path) { ivPhoto.setImageURI(Uri.fromFile(File(path))) } + setPhoto(path) { displayPhoto(ivPhoto, path) } } else { tvStatus.text = "Couldn't copy photo" } @@ -142,7 +136,7 @@ class VoiceLogFragment : Fragment() { fabMic.setOnClickListener { startListening() } btnCamera.setOnClickListener { - val file = File(NavApplication.logbookRepository.photoDir, "log_${System.currentTimeMillis()}.jpg") + val file = File(NavApplication.logbookRepository.cameraDir, "log_${System.currentTimeMillis()}.jpg") pendingPhotoPath = file.absolutePath pendingCameraUri = FileProvider.getUriForFile( requireContext(), "${requireContext().packageName}.fileprovider", file @@ -302,6 +296,13 @@ class VoiceLogFragment : Fragment() { framePhoto.visibility = View.VISIBLE } + private fun displayPhoto(view: ImageView, pathOrUri: String) { + view.setImageURI( + if (pathOrUri.startsWith("content://")) Uri.parse(pathOrUri) + else Uri.fromFile(File(pathOrUri)) + ) + } + private fun clearPhoto() { pendingPhotoPath = null pendingCameraUri = null @@ -375,12 +376,25 @@ class VoiceLogFragment : Fragment() { // ── Helpers ──────────────────────────────────────────────────────────────────── -private fun decodeThumbnail(path: String, targetW: Int, targetH: Int): android.graphics.Bitmap? { +private fun decodeThumbnail(context: android.content.Context, pathOrUri: String, targetW: Int, targetH: Int): android.graphics.Bitmap? { + if (pathOrUri.startsWith("content://")) { + return runCatching { + val uri = Uri.parse(pathOrUri) + val opts = BitmapFactory.Options().apply { inJustDecodeBounds = true } + context.contentResolver.openInputStream(uri)?.use { BitmapFactory.decodeStream(it, null, opts) } + if (opts.outWidth <= 0) return null + val scale = maxOf(opts.outWidth / targetW, opts.outHeight / targetH).coerceAtLeast(1) + context.contentResolver.openInputStream(uri)?.use { stream -> + BitmapFactory.decodeStream(stream, null, BitmapFactory.Options().apply { inSampleSize = scale }) + } + }.getOrNull() + } + if (!File(pathOrUri).exists()) return null val opts = BitmapFactory.Options().apply { inJustDecodeBounds = true } - BitmapFactory.decodeFile(path, opts) + BitmapFactory.decodeFile(pathOrUri, opts) if (opts.outWidth <= 0) return null val scale = maxOf(opts.outWidth / targetW, opts.outHeight / targetH).coerceAtLeast(1) - return BitmapFactory.decodeFile(path, BitmapFactory.Options().apply { inSampleSize = scale }) + return BitmapFactory.decodeFile(pathOrUri, BitmapFactory.Options().apply { inSampleSize = scale }) } // ── Logbook entries adapter ──────────────────────────────────────────────────── @@ -423,8 +437,8 @@ private class LogbookEntryAdapter : RecyclerView.Adapter<LogbookEntryAdapter.VH> } val photoPath = e.photoPath - if (photoPath != null && File(photoPath).exists()) { - val bm = decodeThumbnail(photoPath, 128, 96) + if (photoPath != null) { + val bm = decodeThumbnail(holder.itemView.context, photoPath, 128, 96) if (bm != null) { holder.ivPhoto.setImageBitmap(bm) holder.ivPhoto.visibility = View.VISIBLE |
