From 0db33911ca4841c658b22912d3ecf32477e5e827 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 26 May 2026 20:35:38 +0000 Subject: 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 --- .../org/terst/nav/logbook/LogbookRepository.kt | 12 +- .../kotlin/org/terst/nav/logbook/LogbookStorage.kt | 187 ++++++++++++++++++--- .../kotlin/org/terst/nav/track/TrackDetailSheet.kt | 65 ++++--- .../org/terst/nav/ui/voicelog/VoiceLogFragment.kt | 54 +++--- android-app/app/src/main/res/xml/file_paths.xml | 1 + 5 files changed, 251 insertions(+), 68 deletions(-) (limited to 'android-app') 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().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 { + 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): 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 = runCatching { - if (!storageFile.exists()) return emptyList() - adapter.fromJson(storageFile.readText())?.entries ?: emptyList() + private fun legacyLoadAll(): List = 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): Boolean = runCatching { - storageFile.parentFile?.mkdirs() - storageFile.writeText(adapter.toJson(LogEntryList(entries))) + private fun legacySaveAll(entries: List): 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) 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