Skip to main content

priv/native/android/MobCameraBridge.kt

// mob_camera plugin — Android bridge (CameraX).
//
// Extracted from mob-core's MobBridge camera_* methods. Lives in the plugin's
// own package; MobPluginBootstrap.registerAll() calls register() at startup,
// hands it the Activity (MobActivityAware), and records it as a permission
// provider (MobPermissionProvider, :camera -> CAMERA).
//
// The native thunks (nativeRegister + the deliver hooks) are exported directly
// from the sibling zig NIF mob_camera_nif.zig.
//
// DESIGN NOTE vs core: capture (TakePicture/CaptureVideo) needs an
// ActivityResultLauncher. core registered it in MainActivity.onCreate via
// registerForActivityResult, 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.
//
// The live PREVIEW component (MobCameraPreview) is NOT here yet: it's a Compose
// native-view bound to this bridge's observable state, which needs the plugin
// Compose native-view path (cf. mob_demo_signature_pad). See EXTRACTION.md.
package io.mob.camera

import android.app.Activity
import android.content.pm.PackageManager
import android.graphics.Bitmap
import android.graphics.Matrix
import android.net.Uri
import android.util.Log
import androidx.activity.result.ActivityResultLauncher
import androidx.activity.result.ActivityResultRegistryOwner
import androidx.activity.result.contract.ActivityResultContracts
import androidx.camera.core.ImageProxy
import androidx.core.content.ContextCompat
import androidx.core.content.FileProvider
import org.json.JSONObject
import java.io.File
import java.lang.ref.WeakReference
import java.nio.ByteBuffer
import java.nio.ByteOrder
import java.util.concurrent.Executors
import java.util.concurrent.atomic.AtomicLong

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

    @JvmStatic external fun nativeRegister()

    // Frame delivery: {:camera, :frame, %{...}}
    @JvmStatic external fun nativeDeliverCameraFrame(
        pid: Long,
        bytes: ByteArray,
        width: Int,
        height: Int,
        format: String,
        timestampMs: Long,
        dropped: Long,
    )

    // Capture result: path -> {:camera, :photo|:video, %{path,...}}; kind=="cancelled" -> {:camera, :cancelled}
    @JvmStatic external fun nativeDeliverCameraFile(
        pid: Long,
        kind: String,
        path: String,
    )

    @JvmStatic external fun nativeDeliverCameraCancelled(pid: Long)

    @JvmStatic fun register() = nativeRegister()

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

    override fun permissionsFor(cap: String): Array<String>? = if (cap == "camera") arrayOf(android.Manifest.permission.CAMERA) else null

    // ── Capture (photo / video) ───────────────────────────────────────────
    private var pendingPid: Long = 0L

    @JvmStatic
    fun camera_capture_photo(
        pid: Long,
        quality: String,
    ) = launchCapture(pid, video = false)

    @JvmStatic
    fun camera_capture_video(
        pid: Long,
        maxDuration: String,
    ) = launchCapture(pid, video = true)

    private val captureSeq = AtomicLong(0L)

    private fun launchCapture(
        pid: Long,
        video: Boolean,
    ) {
        pendingPid = pid
        // mob's MainActivity is a ComponentActivity (Compose host), NOT a
        // FragmentActivity, so a headless Fragment can't attach. ComponentActivity
        // is an ActivityResultRegistryOwner, so register against its registry
        // directly. The register(key, contract, callback) overload (no
        // LifecycleOwner) is callable any time — unlike registerForActivityResult,
        // which must run before the host reaches STARTED, a constraint a late-bound
        // plugin can't meet. We unregister inside the callback.
        val activity =
            activityRef?.get() ?: run {
                nativeDeliverCameraCancelled(pid)
                return
            }
        // ActivityResultRegistry.register() and launcher.launch() must run on the
        // Android main thread. launchCapture is invoked from the camera NIF on a BEAM
        // scheduler thread, so registering/launching directly here throws
        // IllegalStateException (or wedges the UI toolkit). Hop to the UI thread for
        // the registration + launch.
        activity.runOnUiThread {
            val owner =
                activity as? ActivityResultRegistryOwner ?: run {
                    nativeDeliverCameraCancelled(pid)
                    return@runOnUiThread
                }
            val outUri = captureUri(activity, video)
            val contract =
                if (video) {
                    ActivityResultContracts.CaptureVideo()
                } else {
                    ActivityResultContracts.TakePicture()
                }
            val key = "mob_camera_capture_${captureSeq.incrementAndGet()}"
            var launcher: ActivityResultLauncher<Uri>? = null
            launcher =
                owner.activityResultRegistry.register(key, contract) { ok: Boolean ->
                    onCaptureResult(if (ok) outUri else null, video)
                    launcher?.unregister()
                }
            launcher.launch(outUri)
        }
    }

    internal fun onCaptureResult(
        uri: Uri?,
        video: Boolean,
    ) {
        val pid = pendingPid
        val activity = activityRef?.get()
        if (uri == null || activity == null) {
            nativeDeliverCameraCancelled(pid)
            return
        }
        Thread {
            try {
                val ext = if (video) "mp4" else "jpg"
                val tmp = File(activity.cacheDir, "mob_cam_${System.currentTimeMillis()}.$ext")
                activity.contentResolver.openInputStream(uri)?.use { it.copyTo(tmp.outputStream()) }
                nativeDeliverCameraFile(pid, if (video) "video" else "photo", tmp.absolutePath)
            } catch (e: Exception) {
                nativeDeliverCameraCancelled(pid)
            }
        }.start()
    }

    internal fun captureUri(
        activity: Activity,
        video: Boolean,
    ): Uri {
        val ext = if (video) "mp4" else "jpg"
        val f = File(activity.cacheDir, "mob_cam_out_${System.currentTimeMillis()}.$ext")
        return FileProvider.getUriForFile(activity, "${activity.packageName}.fileprovider", f)
    }

    // ── Live frame stream ─────────────────────────────────────────────────
    // The MobCameraPreview Compose native-view (plugin component, pending)
    // observes this state and binds CameraX ImageAnalysis with deliverFrame.
    internal val frameStreamRev = AtomicLong(0L)
    internal var frameStreamActive = false
    internal var frameStreamPid: Long = 0L
    internal var frameStreamWidth = 640
    internal var frameStreamHeight = 640
    internal var frameStreamFormat = "rgb_f32"
    internal var frameStreamThrottleMs = 0
    private var lastDeliveryMs = 0L
    private var droppedCount = 0L
    internal var previewFacing: String? = null
    internal val analysisExecutor = Executors.newSingleThreadExecutor()

    @JvmStatic
    fun camera_start_preview(
        pid: Long,
        optsJson: String,
    ) {
        previewFacing =
            try {
                JSONObject(optsJson).optString("facing", "back")
            } catch (_: Exception) {
                "back"
            }
        frameStreamRev.incrementAndGet()
    }

    @JvmStatic
    fun camera_stop_preview() {
        previewFacing = null
        frameStreamRev.incrementAndGet()
    }

    @JvmStatic
    fun camera_start_frame_stream(
        pid: Long,
        optsJson: String,
    ) {
        try {
            val o = JSONObject(optsJson)
            frameStreamPid = pid
            frameStreamWidth = o.optInt("width", 640).coerceIn(1, 4096)
            frameStreamHeight = o.optInt("height", 640).coerceIn(1, 4096)
            frameStreamFormat = o.optString("format", "rgb_f32")
            frameStreamThrottleMs = o.optInt("throttle_ms", 0)
            if (previewFacing != o.optString("facing", "back")) previewFacing = o.optString("facing", "back")
            lastDeliveryMs = 0L
            droppedCount = 0L
            frameStreamActive = true
            frameStreamRev.incrementAndGet()
        } catch (e: Exception) {
            Log.e("MobCamera", "start_frame_stream failed: ${e.message}")
        }
    }

    @JvmStatic
    fun camera_stop_frame_stream() {
        frameStreamActive = false
        frameStreamRev.incrementAndGet()
    }

    // Called from the CameraX analyzer thread (by MobCameraPreview).
    internal fun deliverFrame(image: ImageProxy) {
        try {
            val now = System.currentTimeMillis()
            if (frameStreamThrottleMs > 0 && (now - lastDeliveryMs) < frameStreamThrottleMs.toLong()) {
                droppedCount++
                return
            }
            val rotated = rotateIfNeeded(image.toBitmap(), image.imageInfo.rotationDegrees)
            val cropped = centerCropAndScale(rotated, frameStreamWidth, frameStreamHeight)
            val bytes = if (frameStreamFormat == "bgra_u8") bitmapToBgraU8(cropped) else bitmapToRgbF32(cropped)
            nativeDeliverCameraFrame(frameStreamPid, bytes, cropped.width, cropped.height, frameStreamFormat, now, droppedCount)
            lastDeliveryMs = now
            droppedCount = 0L
        } catch (e: Throwable) {
            Log.e("MobCamera", "deliverFrame failed: ${e.message}")
        } finally {
            image.close()
        }
    }

    private fun rotateIfNeeded(
        bm: Bitmap,
        deg: Int,
    ): Bitmap {
        if (deg == 0) return bm
        val m = Matrix().apply { postRotate(deg.toFloat()) }
        return Bitmap.createBitmap(bm, 0, 0, bm.width, bm.height, m, true)
    }

    private fun centerCropAndScale(
        src: Bitmap,
        w: Int,
        h: Int,
    ): Bitmap {
        val srcAspect = src.width.toDouble() / src.height
        val dstAspect = w.toDouble() / h
        val (cropX, cropY, cropW, cropH) =
            when {
                srcAspect > dstAspect -> {
                    val cw = (src.height * dstAspect).toInt()
                    arrayOf((src.width - cw) / 2, 0, cw, src.height)
                }

                srcAspect < dstAspect -> {
                    val ch = (src.width / dstAspect).toInt()
                    arrayOf(0, (src.height - ch) / 2, src.width, ch)
                }

                else -> {
                    arrayOf(0, 0, src.width, src.height)
                }
            }
        val cropped = Bitmap.createBitmap(src, cropX, cropY, cropW, cropH)
        return if (cropped.width != w || cropped.height != h) {
            Bitmap.createScaledBitmap(cropped, w, h, true)
        } else {
            cropped
        }
    }

    private fun bitmapToRgbF32(bm: Bitmap): ByteArray {
        val w = bm.width
        val h = bm.height
        val pixels = IntArray(w * h)
        bm.getPixels(pixels, 0, w, 0, 0, w, h)
        val out = ByteArray(w * h * 3 * 4)
        val bb = ByteBuffer.wrap(out).order(ByteOrder.LITTLE_ENDIAN)
        for (i in 0 until w * h) {
            val px = pixels[i]
            bb.putFloat(((px shr 16) and 0xff) / 255f)
            bb.putFloat(((px shr 8) and 0xff) / 255f)
            bb.putFloat((px and 0xff) / 255f)
        }
        return out
    }

    private fun bitmapToBgraU8(bm: Bitmap): ByteArray {
        val w = bm.width
        val h = bm.height
        val pixels = IntArray(w * h)
        bm.getPixels(pixels, 0, w, 0, 0, w, h)
        val out = ByteArray(w * h * 4)
        for (i in 0 until w * h) {
            val px = pixels[i]
            out[i * 4 + 0] = (px and 0xff).toByte()
            out[i * 4 + 1] = ((px shr 8) and 0xff).toByte()
            out[i * 4 + 2] = ((px shr 16) and 0xff).toByte()
            out[i * 4 + 3] = ((px shr 24) and 0xff).toByte()
        }
        return out
    }
}