Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
8e9a082
docs(media): add MacDive photo import (reference-in-place) design spec
ericgriffin May 21, 2026
92528e6
docs(media): correct caption handling in MacDive photo import spec
ericgriffin May 21, 2026
273de4e
docs(media): add MacDive photo import (reference-in-place) implementa…
ericgriffin May 21, 2026
739e2cb
feat(import): add ImportImageRef value object
ericgriffin May 21, 2026
984b1af
feat(import): add ImportPayload.imageRefs field
ericgriffin May 21, 2026
b21a270
feat(import): extract MacDive SQLite ZDIVEIMAGE photos into imageRefs
ericgriffin May 21, 2026
ab92891
feat(import): extract MacDive XML <photos> into imageRefs
ericgriffin May 21, 2026
7632f5e
test(import): MacDive SQLite parser surfaces imageRefs end-to-end
ericgriffin May 21, 2026
488f070
feat(import): expose sourceUuidToDiveId from UddfEntityImporter
ericgriffin May 21, 2026
223111b
feat(import): add DirectoryScanner abstraction with desktop impl
ericgriffin May 21, 2026
4c38d77
feat(import): add PhotoResolver consuming DirectoryScanner
ericgriffin May 21, 2026
1cc0df8
refactor(media): extract LocalMediaLinker from FilesTabNotifier._pers…
ericgriffin May 21, 2026
aeab921
feat(import): add ImportPhotoLinkController orchestration
ericgriffin May 21, 2026
d8cec2c
feat(import): post-import photo-locate prompt on the wizard summary (…
ericgriffin May 21, 2026
4b60d70
test(import): desktop photo-link end-to-end + gated MacDive real-sample
ericgriffin May 21, 2026
d14ad2d
feat(media): iOS scoped-directory enumeration channel + IosDirectoryS…
ericgriffin May 21, 2026
86d1dc2
feat(media): Android SAF tree enumeration channel + AndroidDirectoryS…
ericgriffin May 21, 2026
8b59a72
feat(media): native mobile folder-grant pickers (Android SAF tree + i…
ericgriffin May 22, 2026
3072737
chore(media): lint cleanup + document iOS read-only picker limitation
ericgriffin May 22, 2026
e4aabb9
docs(changelog): MacDive photo import
ericgriffin May 22, 2026
ae69eca
fix(import): seed photo-link controller once so a rebuild can't clear…
ericgriffin May 22, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,16 @@ All notable changes to Submersion are documented in this file.

### Added

- **MacDive photo import.** Photos referenced by MacDive native XML
(`<photos>`) and SQLite (`ZDIVEIMAGE`) imports are now linked to dives.
After importing dives, the summary screen offers to locate the photos:
pick a folder and Submersion scans it, matches each recorded photo by
path (direct/rebase) or filename, and links matches to their dive as
standard local-file media that reference the files in place (no copying).
Works on desktop and mobile; re-picking another folder is safe (linking
is idempotent per dive + filename). Photos link to both newly-imported
and matched-existing duplicate dives. Non-image references are skipped
and counted; missing photos are reported in a summary.
- MacDive UDDF imports now capture substantially richer dive data: boat
name and captain, dive operator, surface conditions, weather (stored
in the existing weather description field), plus site water type, body
Expand Down
122 changes: 122 additions & 0 deletions android/app/src/main/kotlin/app/submersion/LocalMediaHandler.kt
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
package app.submersion

import android.app.Activity
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.provider.DocumentsContract
import androidx.documentfile.provider.DocumentFile
import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel
Expand All @@ -17,6 +19,12 @@ import io.flutter.plugin.common.MethodChannel.MethodCallHandler
* Calls ContentResolver.takePersistableUriPermission with read flag
* and returns the URI string itself (which Dart-side stores as
* MediaItem.bookmarkRef).
* - pickPersistableTreeUri(): String?
* Launches ACTION_OPEN_DOCUMENT_TREE, takes a persistable READ
* permission on the chosen tree, and returns its content://tree/...
* URI string. Returns null if the user cancelled. Requires an Activity
* (set via [attachActivity]) to launch the picker and receive its
* result through [onPickTreeResult].
* - resolveBookmark(bookmarkRef: String): String?
* Returns the URI as a string if the resource still exists, null if
* the underlying file is gone.
Expand All @@ -31,21 +39,92 @@ class LocalMediaHandler(
private val channel: MethodChannel,
) : MethodCallHandler {

/**
* The hosting Activity, required to launch the document-tree picker.
* FlutterActivity extends plain android.app.Activity (not a
* ComponentActivity), so we cannot use the AndroidX ActivityResult APIs;
* instead MainActivity forwards startActivityForResult outcomes to
* [onPickTreeResult].
*/
private var activity: Activity? = null

/** Result callback awaiting the folder-tree picker outcome, if any. */
private var pendingPickResult: MethodChannel.Result? = null

init {
channel.setMethodCallHandler(this)
}

/** Wire (or clear) the hosting Activity. Called from MainActivity. */
fun attachActivity(activity: Activity?) {
this.activity = activity
}

override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
when (call.method) {
"takePersistableUri" -> takePersistableUri(call, result)
"pickPersistableTreeUri" -> pickPersistableTreeUri(result)
"resolveBookmark" -> resolveBookmark(call, result)
"releaseBookmark" -> releaseBookmark(call, result)
"listPersistedUris" -> listPersistedUris(result)
"readUriBytes" -> readUriBytes(call, result)
"enumerateTree" -> enumerateTree(call, result)
else -> result.notImplemented()
}
}

private fun pickPersistableTreeUri(result: MethodChannel.Result) {
val act = activity
if (act == null) {
result.error("NO_ACTIVITY", "No Activity available to launch picker", null)
return
}
if (pendingPickResult != null) {
result.error("ALREADY_PICKING", "A folder pick is already in progress", null)
return
}
pendingPickResult = result
val intent = Intent(Intent.ACTION_OPEN_DOCUMENT_TREE).apply {
addFlags(
Intent.FLAG_GRANT_READ_URI_PERMISSION or
Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION,
)
}
try {
act.startActivityForResult(intent, REQUEST_PICK_TREE)
} catch (e: Exception) {
pendingPickResult = null
result.error("PICK_FAILED", e.localizedMessage, null)
}
}

/**
* Forwarded from MainActivity.onActivityResult. Takes a persistable READ
* permission on the chosen tree and completes the pending Dart result
* with the tree URI string (or null on cancellation). Returns true if
* this handler consumed the result.
*/
fun onPickTreeResult(requestCode: Int, resultCode: Int, data: Intent?): Boolean {
if (requestCode != REQUEST_PICK_TREE) return false
val result = pendingPickResult ?: return true
pendingPickResult = null
val treeUri = data?.data
if (resultCode != Activity.RESULT_OK || treeUri == null) {
result.success(null) // cancelled / no selection
return true
}
try {
context.contentResolver.takePersistableUriPermission(
treeUri,
Intent.FLAG_GRANT_READ_URI_PERMISSION,
)
result.success(treeUri.toString())
} catch (e: SecurityException) {
result.error("PERMISSION_DENIED", e.localizedMessage, null)
}
return true
}

private fun takePersistableUri(call: MethodCall, result: MethodChannel.Result) {
val uriStr = call.argument<String>("uri")
?: return result.error("INVALID_ARGS", "uri required", null)
Expand Down Expand Up @@ -113,7 +192,50 @@ class LocalMediaHandler(
}
}

private fun enumerateTree(call: MethodCall, result: MethodChannel.Result) {
val treeUriStr = call.argument<String>("treeUri")
?: return result.error("INVALID_ARGS", "treeUri required", null)
try {
val resolver = context.contentResolver
val treeUri = Uri.parse(treeUriStr)
val out = ArrayList<HashMap<String, Any>>()
val stack = ArrayDeque<String>()
stack.addLast(DocumentsContract.getTreeDocumentId(treeUri))
while (stack.isNotEmpty()) {
val parentDocId = stack.removeLast()
val childrenUri = DocumentsContract.buildChildDocumentsUriUsingTree(treeUri, parentDocId)
resolver.query(
childrenUri,
arrayOf(
DocumentsContract.Document.COLUMN_DOCUMENT_ID,
DocumentsContract.Document.COLUMN_DISPLAY_NAME,
DocumentsContract.Document.COLUMN_MIME_TYPE,
),
null, null, null,
)?.use { c ->
while (c.moveToNext()) {
val docId = c.getString(0)
val name = c.getString(1)
val mime = c.getString(2) ?: ""
if (mime == DocumentsContract.Document.MIME_TYPE_DIR) {
stack.addLast(docId)
} else {
val docUri = DocumentsContract.buildDocumentUriUsingTree(treeUri, docId)
out.add(hashMapOf("basename" to name, "contentUri" to docUri.toString()))
}
Comment on lines +217 to +225
}
}
}
result.success(out)
} catch (e: Exception) {
result.error("ENUMERATE_FAILED", e.message, null)
}
}

companion object {
const val CHANNEL = "com.submersion.app/local_media"

/** startActivityForResult request code for the document-tree picker. */
const val REQUEST_PICK_TREE = 0x5542 // 'SU'
}
}
22 changes: 21 additions & 1 deletion android/app/src/main/kotlin/app/submersion/MainActivity.kt
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package app.submersion

import android.content.Intent
import io.flutter.embedding.android.FlutterActivity
import io.flutter.embedding.engine.FlutterEngine
import io.flutter.plugin.common.MethodChannel
Expand All @@ -21,6 +22,25 @@ class MainActivity : FlutterActivity() {
flutterEngine.dartExecutor.binaryMessenger,
LocalMediaHandler.CHANNEL,
)
localMediaHandler = LocalMediaHandler(applicationContext, localMediaChannel)
// ContentResolver work runs against the application context, but the
// document-tree picker needs an Activity to startActivityForResult and
// a path back through onActivityResult (FlutterActivity is a plain
// android.app.Activity, so the AndroidX ActivityResult APIs are not
// available here).
localMediaHandler = LocalMediaHandler(applicationContext, localMediaChannel).also {
it.attachActivity(this)
}
}

override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
if (localMediaHandler?.onPickTreeResult(requestCode, resultCode, data) == true) {
return
}
super.onActivityResult(requestCode, resultCode, data)
}

override fun onDestroy() {
localMediaHandler?.attachActivity(null)
super.onDestroy()
}
}
Loading
Loading