Skip to main content

priv/native/android/MobPhotosBridge.kt

// mob_photos plugin — Android bridge (system Photo Picker).
//
// Extracted from mob-core's MobBridge photos_pick / handlePhotosResult plus
// MainActivity's photosPickerLauncher. Lives in the plugin's own package;
// MobPluginBootstrap.registerAll() calls register() at startup and hands it
// the Activity (MobActivityAware). It is NOT a MobPermissionProvider — the
// Photo Picker runs out of process, so no runtime permission dialog exists.
//
// The native thunks (nativeRegister + the two deliver hooks) are exported
// directly from the sibling zig NIF mob_photos_nif.zig.
//
// DESIGN NOTE vs core: core pre-registered the launcher in MainActivity's
// onCreate via registerForActivityResult (MainActivity.kt.eex:42-53), but
// that convenience API must run before the host reaches STARTED — a
// late-bound plugin can't meet that. mob's MainActivity is a
// ComponentActivity (Compose host), not a FragmentActivity, so a headless
// Fragment can't attach either. Instead this bridge registers directly on
// the ComponentActivity's ActivityResultRegistry (register(key, contract,
// callback) is callable any time) and unregisters in the callback —
// self-contained, no host MainActivity changes. (Same pattern as
// mob_camera's MobCameraBridge.)
//
// CONTRACT GOTCHA: PickMultipleVisualMedia(maxItems) throws when
// maxItems < 2, so max == 1 uses the single-item PickVisualMedia() contract
// instead — both feed the same handlePhotosResult. Core sidestepped this by
// ignoring max entirely (its pre-registered launcher always used the
// default multi-pick); honoring max here is a deliberate fidelity
// improvement over core, message shapes unchanged.
package io.mob.photos

import android.app.Activity
import android.net.Uri
import android.provider.MediaStore
import androidx.activity.result.ActivityResultLauncher
import androidx.activity.result.ActivityResultRegistryOwner
import androidx.activity.result.PickVisualMediaRequest
import androidx.activity.result.contract.ActivityResultContracts
import org.json.JSONArray
import org.json.JSONObject
import java.io.File
import java.lang.ref.WeakReference
import java.util.concurrent.atomic.AtomicLong

object MobPhotosBridge : io.mob.plugin.MobActivityAware, io.mob.plugin.MobPermissionProvider {
    private var activityRef: WeakReference<Activity>? = null

    @JvmStatic external fun nativeRegister()

    // {:photos, :cancelled}
    @JvmStatic external fun nativeDeliverPhotosCancelled(pid: Long)

    // {:mob_file_result, "photos", "picked", json} — decoded by core
    // Mob.Screen into {:photos, :picked, items} (lib/mob/screen.ex:343-391)
    @JvmStatic external fun nativeDeliverPhotosPicked(
        pid: Long,
        json: String,
    )

    // {:media, :listed, items} — json is a JSON array of metadata maps
    // (string keys), decoded by the zig NIF into a list of Elixir maps.
    @JvmStatic external fun nativeDeliverMediaListed(
        pid: Long,
        json: String,
    )

    @JvmStatic fun register() = nativeRegister()

    override fun setActivity(activity: Activity) {
        activityRef = WeakReference(activity)
    }

    // The :media capability maps to READ_MEDIA_IMAGES + READ_MEDIA_VIDEO on
    // API 33+ (READ_EXTERNAL_STORAGE on older). core's MobBridge.request_permission
    // falls through to MobPluginBootstrap.permissionsFor(cap) for caps it doesn't
    // know, which walks the registered providers — this is how :media is granted.
    override fun permissionsFor(cap: String): Array<String>? =
        if (cap == "media") {
            if (android.os.Build.VERSION.SDK_INT >= 33) {
                arrayOf(
                    android.Manifest.permission.READ_MEDIA_IMAGES,
                    android.Manifest.permission.READ_MEDIA_VIDEO,
                )
            } else {
                arrayOf(android.Manifest.permission.READ_EXTERNAL_STORAGE)
            }
        } else {
            null
        }

    private val pickSeq = AtomicLong(0L)

    // ── Pick (photos / videos) ────────────────────────────────────────────
    // Signature matches what the zig NIF calls: (JLjava/lang/String;)V —
    // core passed max as a decimal string (mob_nif.zig:2553-2566).
    @JvmStatic
    fun photos_pick(
        pid: Long,
        maxStr: String,
    ) {
        val max = maxStr.toIntOrNull() ?: 1
        val activity =
            activityRef?.get() ?: run {
                nativeDeliverPhotosCancelled(pid)
                return
            }
        val owner =
            activity as? ActivityResultRegistryOwner ?: run {
                nativeDeliverPhotosCancelled(pid)
                return
            }
        val request = PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageAndVideo)
        val key = "mob_photos_${pickSeq.incrementAndGet()}"
        if (max >= 2) {
            var launcher: ActivityResultLauncher<PickVisualMediaRequest>? = null
            launcher =
                owner.activityResultRegistry.register(
                    key,
                    ActivityResultContracts.PickMultipleVisualMedia(max),
                ) { uris: List<Uri> ->
                    handlePhotosResult(pid, uris)
                    launcher?.unregister()
                }
            launcher.launch(request)
        } else {
            // PickMultipleVisualMedia(maxItems) requires maxItems >= 2 — for a
            // single item use the dedicated single-pick contract.
            var launcher: ActivityResultLauncher<PickVisualMediaRequest>? = null
            launcher =
                owner.activityResultRegistry.register(
                    key,
                    ActivityResultContracts.PickVisualMedia(),
                ) { uri: Uri? ->
                    handlePhotosResult(pid, listOfNotNull(uri))
                    launcher?.unregister()
                }
            launcher.launch(request)
        }
    }

    // Result processing copied from core MobBridge.handlePhotosResult
    // (MobBridge.kt.eex:722-741): copy each content URI into a cacheDir tmp
    // file on a background thread, then deliver the JSON item array. The
    // JSON shape ({"path","type","width":0,"height":0}, type as a string,
    // dimensions not probed) is core parity — Mob.Screen atomizes only the
    // keys, so Android items reach user code with string type values.
    internal fun handlePhotosResult(
        pid: Long,
        uris: List<Uri>,
    ) {
        if (uris.isEmpty()) {
            nativeDeliverPhotosCancelled(pid)
            return
        }
        val activity =
            activityRef?.get() ?: run {
                nativeDeliverPhotosCancelled(pid)
                return
            }
        Thread {
            try {
                val items =
                    uris.mapIndexed { i, uri ->
                        val ext = if (uri.toString().contains("video")) "mp4" else "jpg"
                        val tmp = File(activity.cacheDir, "mob_pick_${System.currentTimeMillis()}_$i.$ext")
                        activity.contentResolver.openInputStream(uri)?.use { it.copyTo(tmp.outputStream()) }
                        val type = if (ext == "mp4") "video" else "image"
                        """{"path":"${tmp.absolutePath}","type":"$type","width":0,"height":0}"""
                    }
                val json = "[${items.joinToString(",")}]"
                nativeDeliverPhotosPicked(pid, json)
            } catch (e: Exception) {
                nativeDeliverPhotosCancelled(pid)
            }
        }.start()
    }

    // ── Library enumeration (MediaStore) ───────────────────────────────────
    // Signature matches what the zig NIF calls: (JLjava/lang/String;)V — the
    // opts JSON carries {"type":"image"|"video"|"all","limit":N}. Queries the
    // MediaStore via ContentResolver on a background thread (the NIF callback
    // arrives on a BEAM scheduler thread), builds a JSON array of metadata, and
    // delivers it as {:media, :listed, items}. Requires READ_MEDIA_* — without
    // it the cursor is empty and an empty list is delivered (not an error). This
    // lists metadata only; it does NOT copy bytes (unlike the picker).
    @JvmStatic
    fun media_list(
        pid: Long,
        optsJson: String,
    ) {
        val type =
            try {
                JSONObject(optsJson).optString("type", "all")
            } catch (_: Exception) {
                "all"
            }
        val limit =
            try {
                JSONObject(optsJson).optInt("limit", 200)
            } catch (_: Exception) {
                200
            }
        val activity =
            activityRef?.get() ?: run {
                nativeDeliverMediaListed(pid, "[]")
                return
            }
        Thread {
            val out = JSONArray()
            try {
                if (type == "image" || type == "all") {
                    queryInto(activity, MediaStore.Images.Media.EXTERNAL_CONTENT_URI, "image", limit, out)
                }
                if (type == "video" || type == "all") {
                    queryInto(activity, MediaStore.Video.Media.EXTERNAL_CONTENT_URI, "video", limit, out)
                }
            } catch (e: Exception) {
                // A SecurityException (permission not granted) or any query
                // failure yields whatever was gathered so far (often empty).
            }
            nativeDeliverMediaListed(pid, out.toString())
        }.start()
    }

    // DISPLAY_NAME / SIZE / DATE_ADDED / MIME_TYPE are shared column names
    // across MediaStore.Images and MediaStore.Video (MediaColumns), so one
    // projection serves both. date_added is already unix seconds.
    private fun queryInto(
        activity: Activity,
        collection: Uri,
        kind: String,
        limit: Int,
        out: JSONArray,
    ) {
        val projection =
            arrayOf(
                MediaStore.MediaColumns._ID,
                MediaStore.MediaColumns.DISPLAY_NAME,
                MediaStore.MediaColumns.SIZE,
                MediaStore.MediaColumns.DATE_ADDED,
                MediaStore.MediaColumns.MIME_TYPE,
            )
        val order = "${MediaStore.MediaColumns.DATE_ADDED} DESC"
        activity.contentResolver.query(collection, projection, null, null, order)?.use { c ->
            val idCol = c.getColumnIndexOrThrow(MediaStore.MediaColumns._ID)
            val nameCol = c.getColumnIndexOrThrow(MediaStore.MediaColumns.DISPLAY_NAME)
            val sizeCol = c.getColumnIndexOrThrow(MediaStore.MediaColumns.SIZE)
            val dateCol = c.getColumnIndexOrThrow(MediaStore.MediaColumns.DATE_ADDED)
            val mimeCol = c.getColumnIndexOrThrow(MediaStore.MediaColumns.MIME_TYPE)
            while (c.moveToNext()) {
                if (limit > 0 && out.length() >= limit) break
                val id = c.getLong(idCol)
                val uri = Uri.withAppendedPath(collection, id.toString())
                val o = JSONObject()
                o.put("uri", uri.toString())
                o.put("display_name", c.getString(nameCol) ?: "")
                o.put("size", c.getLong(sizeCol))
                o.put("date_added", c.getLong(dateCol))
                o.put("mime_type", c.getString(mimeCol) ?: "")
                o.put("type", kind)
                out.put(o)
            }
        }
    }
}