From 5cd350527211ab49eb4eb72cd4a9cba074859181 Mon Sep 17 00:00:00 2001 From: agra Date: Wed, 13 May 2026 17:25:28 +0300 Subject: [PATCH] camera: Android photo + preview + lifecycle (Phase 4a) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CameraX-backed Android implementation matching the iOS plugin's surface (ux/camera + ux/camera/events), photo-capable only. Video recording lands in Phase 4b (VideoCapture). Modules in android/src/main/kotlin/io/swipelab/ux/camera/: CustomLifecycleOwner drives ProcessCameraProvider's bindings STARTED ↔ DESTROYED per instance DeviceOrientationBridge OrientationEventListener → Surface.ROTATION_* with 22.5° hysteresis; flutterToSurfaceRotation + surfaceRotationToFlutter encode/decode the four-quadrant wire enum the iOS plugin uses PreviewSink CameraX Preview.SurfaceProvider → SurfaceTexture → FlutterTexture (stable textureId across resolution renegotiations) PhotoCapture ImageCapture wrapper, per-shot setTargetRotation, JPEG to cache dir CameraInstance per-controller state: lifecycle owner, texture, ProcessCameraProvider binding, photo + preview use-cases, lens swap CameraPlugin channel + permission flow: camera always, mic optional (matches iOS' "camera denial is the only hard failure" model) UxPlugin.kt registers CameraPlugin alongside the other plugins. Channel parity with iOS: availableCameras, create, initialize, dispose, setDescription, setFlashMode, lockCaptureOrientation/unlock (no-op; preview is pinned portrait), takePicture, audioPermissionStatus, openSettings. startVideoRecording / stopVideoRecording return `unsupported_format` until Phase 4b. Camera-device contention via lensesInUse + audio claim via audioInUse mirror iOS's tracking, including the setDescription swap (remove old lens / insert new) that closed the device_busy leak on iOS. Android APK builds clean against compileSdk 34, CameraX 1.3.4. --- .../main/kotlin/io/swipelab/ux/UxPlugin.kt | 2 + .../io/swipelab/ux/camera/CameraInstance.kt | 162 ++++++++ .../io/swipelab/ux/camera/CameraPlugin.kt | 369 ++++++++++++++++++ .../ux/camera/CustomLifecycleOwner.kt | 42 ++ .../ux/camera/DeviceOrientationBridge.kt | 71 ++++ .../io/swipelab/ux/camera/PhotoCapture.kt | 50 +++ .../io/swipelab/ux/camera/PreviewSink.kt | 68 ++++ 7 files changed, 764 insertions(+) create mode 100644 android/src/main/kotlin/io/swipelab/ux/camera/CameraInstance.kt create mode 100644 android/src/main/kotlin/io/swipelab/ux/camera/CameraPlugin.kt create mode 100644 android/src/main/kotlin/io/swipelab/ux/camera/CustomLifecycleOwner.kt create mode 100644 android/src/main/kotlin/io/swipelab/ux/camera/DeviceOrientationBridge.kt create mode 100644 android/src/main/kotlin/io/swipelab/ux/camera/PhotoCapture.kt create mode 100644 android/src/main/kotlin/io/swipelab/ux/camera/PreviewSink.kt diff --git a/android/src/main/kotlin/io/swipelab/ux/UxPlugin.kt b/android/src/main/kotlin/io/swipelab/ux/UxPlugin.kt index 967039a..292c3c4 100644 --- a/android/src/main/kotlin/io/swipelab/ux/UxPlugin.kt +++ b/android/src/main/kotlin/io/swipelab/ux/UxPlugin.kt @@ -3,6 +3,7 @@ package io.swipelab.ux import io.flutter.embedding.engine.plugins.FlutterPlugin import io.flutter.embedding.engine.plugins.activity.ActivityAware import io.flutter.embedding.engine.plugins.activity.ActivityPluginBinding +import io.swipelab.ux.camera.CameraPlugin class UxPlugin : FlutterPlugin, ActivityAware { private val plugins: List = listOf( @@ -12,6 +13,7 @@ class UxPlugin : FlutterPlugin, ActivityAware { ScannerPlugin(), ClipboardPlugin(), GalleryPlugin(), + CameraPlugin(), CrashPlugin(), ) diff --git a/android/src/main/kotlin/io/swipelab/ux/camera/CameraInstance.kt b/android/src/main/kotlin/io/swipelab/ux/camera/CameraInstance.kt new file mode 100644 index 0000000..b38e6fd --- /dev/null +++ b/android/src/main/kotlin/io/swipelab/ux/camera/CameraInstance.kt @@ -0,0 +1,162 @@ +package io.swipelab.ux.camera + +import android.content.Context +import android.util.Size +import androidx.camera.core.Camera +import androidx.camera.core.CameraSelector +import androidx.camera.core.ImageCapture +import androidx.camera.core.Preview +import androidx.camera.lifecycle.ProcessCameraProvider +import androidx.core.content.ContextCompat +import io.flutter.view.TextureRegistry + +/// One per `UxCameraController` on the Dart side. Owns CameraX's +/// [ProcessCameraProvider] binding, the [PreviewSink] (texture + +/// SurfaceProvider), the [PhotoCapture] use-case, and the per-instance +/// [CustomLifecycleOwner] that drives them. +/// +/// All public methods run on the main thread unless documented +/// otherwise. CameraX itself is thread-safe; the synchronization here +/// is just "don't reorder configure-vs-bind in confusing ways." +class CameraInstance( + val handle: Int, + private val context: Context, + registry: TextureRegistry, +) { + /// Pushed to Dart as `{event, handle, …}` payloads. Set by the + /// plugin in [CameraPlugin.create]. + var onEvent: ((Map) -> Unit)? = null + + private val lifecycleOwner = CustomLifecycleOwner() + private val previewSink = PreviewSink(registry, context) + private val photo = PhotoCapture() + private val orientation = DeviceOrientationBridge(context) + + private var cameraProvider: ProcessCameraProvider? = null + private var camera: Camera? = null + private var preview: Preview? = null + + /// `back` / `front`. Set on create; updated on [setDescription]. + private var lens: String = "back" + private var enableAudio: Boolean = false + private var disposed = false + + val textureId: Long get() = previewSink.textureId + val previewSize: Size get() = previewSink.previewSize + val currentLens: String get() = lens + val audioClaimed: Boolean get() = enableAudio + + /// Bind the provider against [lens]. Resolves the + /// [ProcessCameraProvider.getInstance] future on the main executor + /// before invoking [onReady]. [onReady] fires with success or the + /// thrown exception. + fun create( + lens: String, + enableAudio: Boolean, + onReady: (Throwable?) -> Unit, + ) { + check(!disposed) + this.lens = lens + this.enableAudio = enableAudio + + val future = ProcessCameraProvider.getInstance(context) + future.addListener({ + if (disposed) return@addListener + try { + val provider = future.get() + cameraProvider = provider + bind(provider) + orientation.start { rot -> + emit(mapOf( + "event" to "deviceOrientationChanged", + "orientation" to surfaceRotationToFlutter(rot), + )) + } + onReady(null) + } catch (t: Throwable) { + onReady(t) + } + }, ContextCompat.getMainExecutor(context)) + } + + /// Move the lifecycle owner to STARTED — CameraX starts streaming + /// preview frames + the [ImageCapture] use-case becomes ready. + fun initialize() { + check(!disposed) + lifecycleOwner.start() + } + + /// Idempotent teardown. Releases the texture, unbinds the provider, + /// drops the lifecycle owner to DESTROYED. + fun dispose() { + if (disposed) return + disposed = true + orientation.stop() + cameraProvider?.unbindAll() + cameraProvider = null + camera = null + preview = null + previewSink.release() + lifecycleOwner.destroy() + onEvent = null + } + + /// Swap the bound lens (front ↔ back). Re-binds the use-cases + /// against the new [CameraSelector]. + fun setDescription(newLens: String): Size { + check(!disposed) + if (newLens == lens) return previewSink.previewSize + lens = newLens + cameraProvider?.let(::bind) + return previewSink.previewSize + } + + /// Flash mode for the next still capture. [mode] is `off` / `always`. + /// Maps to `ImageCapture.FLASH_MODE_*`. + fun setFlashMode(mode: String) { + photo.flashMode = when (mode) { + "always" -> ImageCapture.FLASH_MODE_ON + else -> ImageCapture.FLASH_MODE_OFF + } + } + + fun takePicture( + snapshotRotation: Int, + onResult: (Result) -> Unit, + ) { + check(!disposed) + photo.take(context, snapshotRotation, onResult) + } + + private fun bind(provider: ProcessCameraProvider) { + provider.unbindAll() + + val selector = when (lens) { + "front" -> CameraSelector.DEFAULT_FRONT_CAMERA + else -> CameraSelector.DEFAULT_BACK_CAMERA + } + + val preview = Preview.Builder() + // Preview pinned to portrait — recording / capture + // orientation comes from the per-call `setTargetRotation` + // on the photo / video use-case, never from the preview. + .setTargetRotation(android.view.Surface.ROTATION_0) + .build() + .also { it.setSurfaceProvider(previewSink::provideSurface) } + this.preview = preview + + camera = provider.bindToLifecycle( + lifecycleOwner, + selector, + preview, + photo.useCase, + ) + } + + private fun emit(extras: Map) { + val payload = HashMap(extras.size + 1) + payload["handle"] = handle + payload.putAll(extras) + onEvent?.invoke(payload) + } +} diff --git a/android/src/main/kotlin/io/swipelab/ux/camera/CameraPlugin.kt b/android/src/main/kotlin/io/swipelab/ux/camera/CameraPlugin.kt new file mode 100644 index 0000000..503c290 --- /dev/null +++ b/android/src/main/kotlin/io/swipelab/ux/camera/CameraPlugin.kt @@ -0,0 +1,369 @@ +package io.swipelab.ux.camera + +import android.Manifest +import android.app.Activity +import android.content.Context +import android.content.Intent +import android.content.pm.PackageManager +import android.net.Uri +import android.os.Handler +import android.os.Looper +import android.provider.Settings +import androidx.camera.core.CameraSelector +import androidx.camera.lifecycle.ProcessCameraProvider +import androidx.core.app.ActivityCompat +import androidx.core.content.ContextCompat +import io.flutter.embedding.engine.plugins.FlutterPlugin +import io.flutter.embedding.engine.plugins.activity.ActivityPluginBinding +import io.flutter.plugin.common.EventChannel +import io.flutter.plugin.common.MethodCall +import io.flutter.plugin.common.MethodChannel +import io.flutter.view.TextureRegistry +import io.swipelab.ux.NativePlugin + +/// `ux/camera` + `ux/camera/events` registrar. Routes channel calls +/// to per-handle [CameraInstance]s. Phase 4a: photo capture + +/// preview + lifecycle. Video recording is wired in Phase 4b. +class CameraPlugin : + NativePlugin, + MethodChannel.MethodCallHandler, + EventChannel.StreamHandler { + + companion object { + private const val PERMISSION_REQUEST_CODE = 0xC2A0 + } + + private val main = Handler(Looper.getMainLooper()) + + private var context: Context? = null + private var textureRegistry: TextureRegistry? = null + private var methodChannel: MethodChannel? = null + private var eventChannel: EventChannel? = null + private var eventSink: EventChannel.EventSink? = null + + private var activity: Activity? = null + private var activityBinding: ActivityPluginBinding? = null + private var pendingPermission: ((Boolean, String) -> Unit)? = null + private var pendingPermissionKind: String = "" + + private val instances = mutableMapOf() + private var nextHandle = 1 + private val lensesInUse = mutableSetOf() + private var audioInUse: Boolean = false + + override fun onAttachedToEngine(binding: FlutterPlugin.FlutterPluginBinding) { + context = binding.applicationContext + textureRegistry = binding.textureRegistry + + val mc = MethodChannel(binding.binaryMessenger, "ux/camera") + mc.setMethodCallHandler(this) + methodChannel = mc + + val ec = EventChannel(binding.binaryMessenger, "ux/camera/events") + ec.setStreamHandler(this) + eventChannel = ec + } + + override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) { + // Dispose any leftover instances synchronously so the host process + // doesn't keep a CameraX binding alive past engine teardown. + instances.values.forEach { it.dispose() } + instances.clear() + lensesInUse.clear() + audioInUse = false + + methodChannel?.setMethodCallHandler(null) + methodChannel = null + eventChannel?.setStreamHandler(null) + eventChannel = null + eventSink = null + context = null + textureRegistry = null + } + + override fun onAttachedToActivity(binding: ActivityPluginBinding) { + activity = binding.activity + activityBinding = binding + binding.addRequestPermissionsResultListener { code, permissions, results -> + if (code != PERMISSION_REQUEST_CODE) { + return@addRequestPermissionsResultListener false + } + // Camera grant gates the whole flow; mic is optional (we + // tolerate a missing audio input gracefully). Match iOS. + val cameraIndex = permissions.indexOf(Manifest.permission.CAMERA) + val cameraGranted = cameraIndex >= 0 && + results.getOrNull(cameraIndex) == PackageManager.PERMISSION_GRANTED + val cb = pendingPermission + pendingPermission = null + val kind = pendingPermissionKind + pendingPermissionKind = "" + cb?.invoke(cameraGranted, if (cameraGranted) "" else kind) + true + } + } + + override fun onDetachedFromActivity() { + activity = null + activityBinding = null + // If a request is still pending when the activity tears down, + // settle it as denied so the Dart Future doesn't hang. + pendingPermission?.invoke(false, pendingPermissionKind) + pendingPermission = null + pendingPermissionKind = "" + } + + // MARK: - EventChannel + + override fun onListen(arguments: Any?, events: EventChannel.EventSink?) { + eventSink = events + } + + override fun onCancel(arguments: Any?) { + eventSink = null + } + + // MARK: - MethodChannel + + override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) { + when (call.method) { + "availableCameras" -> handleAvailableCameras(result) + "create" -> handleCreate(call, result) + "initialize" -> handleInitialize(call, result) + "dispose" -> handleDispose(call, result) + "setDescription" -> handleSetDescription(call, result) + "setFlashMode" -> handleSetFlashMode(call, result) + "lockCaptureOrientation" -> result.success(null) // preview always portrait + "unlockCaptureOrientation" -> result.success(null) + "takePicture" -> handleTakePicture(call, result) + "startVideoRecording" -> result.error( + "unsupported_format", "video recording not yet on Android", null, + ) + "stopVideoRecording" -> result.error( + "unsupported_format", "video recording not yet on Android", null, + ) + "audioPermissionStatus" -> result.success(isAudioGranted()) + "openSettings" -> handleOpenSettings(result) + else -> result.notImplemented() + } + } + + private fun handleAvailableCameras(result: MethodChannel.Result) { + val ctx = context ?: return result.error("no_context", "engine detached", null) + val future = ProcessCameraProvider.getInstance(ctx) + future.addListener({ + try { + val provider = future.get() + val cameras = mutableListOf>() + if (provider.hasCamera(CameraSelector.DEFAULT_BACK_CAMERA)) { + cameras.add(describeLens("back", provider)) + } + if (provider.hasCamera(CameraSelector.DEFAULT_FRONT_CAMERA)) { + cameras.add(describeLens("front", provider)) + } + result.success(cameras) + } catch (t: Throwable) { + result.error("no_camera", t.localizedMessage, null) + } + }, ContextCompat.getMainExecutor(ctx)) + } + + private fun describeLens(lens: String, provider: ProcessCameraProvider): Map { + val selector = when (lens) { + "front" -> CameraSelector.DEFAULT_FRONT_CAMERA + else -> CameraSelector.DEFAULT_BACK_CAMERA + } + // `filter(...)` returns the matching `CameraInfo` list without + // requiring a lifecycle bind. + val infos = selector.filter(provider.availableCameraInfos) + val sensorRotation = infos.firstOrNull()?.sensorRotationDegrees ?: 0 + return mapOf( + "id" to lens, + "lens" to lens, + "sensorOrientation" to sensorRotation, + ) + } + + private fun handleCreate(call: MethodCall, result: MethodChannel.Result) { + val args = call.arguments as? Map<*, *> + ?: return result.error("bad_args", "create", null) + val cameraId = args["cameraId"] as? String + ?: return result.error("bad_args", "create", null) + val enableAudio = args["enableAudio"] as? Boolean ?: false + val ctx = context ?: return result.error("no_context", "engine detached", null) + val registry = textureRegistry + ?: return result.error("init_failed", "no texture registry", null) + + if (lensesInUse.contains(cameraId)) { + return result.error("device_busy", cameraId, null) + } + if (enableAudio && audioInUse) { + return result.error("audio_busy", null, null) + } + + val handle = nextHandle++ + val instance = CameraInstance(handle, ctx, registry) + instance.onEvent = { payload -> + main.post { eventSink?.success(payload) } + } + instances[handle] = instance + lensesInUse.add(cameraId) + if (enableAudio) audioInUse = true + + instance.create(cameraId, enableAudio) { err -> + if (err != null) { + instances.remove(handle) + lensesInUse.remove(cameraId) + if (enableAudio) audioInUse = false + result.error("init_failed", err.localizedMessage, null) + return@create + } + result.success(mapOf( + "handle" to handle, + "textureId" to instance.textureId, + "previewSize" to mapOf( + "width" to instance.previewSize.width.toDouble(), + "height" to instance.previewSize.height.toDouble(), + ), + )) + } + } + + private fun handleInitialize(call: MethodCall, result: MethodChannel.Result) { + withInstance(call, result) { instance -> + // Camera permission gate. Mic is optional; only camera + // denial fails the flow (matches iOS). + val perms = mutableListOf(Manifest.permission.CAMERA) + if (instance.audioClaimed) perms += Manifest.permission.RECORD_AUDIO + requestPermissions(perms) { cameraGranted, kind -> + if (!cameraGranted) { + return@requestPermissions result.error( + "permission_denied", kind, null, + ) + } + instance.initialize() + result.success(null) + } + } + } + + private fun handleDispose(call: MethodCall, result: MethodChannel.Result) { + withInstance(call, result) { instance -> + // Release claims before the (synchronous) dispose so a + // follow-up create can succeed even mid-teardown — matches + // iOS's claim-release timing. + lensesInUse.remove(instance.currentLens) + if (instance.audioClaimed) audioInUse = false + instances.remove(instance.handle) + instance.dispose() + result.success(null) + } + } + + private fun handleSetDescription(call: MethodCall, result: MethodChannel.Result) { + val args = call.arguments as? Map<*, *> + ?: return result.error("bad_args", "setDescription", null) + val newLens = args["cameraId"] as? String + ?: return result.error("bad_args", "setDescription", null) + withInstance(call, result) { instance -> + val oldLens = instance.currentLens + if (oldLens != newLens && lensesInUse.contains(newLens)) { + return@withInstance result.error("device_busy", newLens, null) + } + lensesInUse.add(newLens) + try { + val size = instance.setDescription(newLens) + if (oldLens != newLens) lensesInUse.remove(oldLens) + result.success(mapOf( + "previewSize" to mapOf( + "width" to size.width.toDouble(), + "height" to size.height.toDouble(), + ), + )) + } catch (t: Throwable) { + if (oldLens != newLens) lensesInUse.remove(newLens) + result.error("init_failed", t.localizedMessage, null) + } + } + } + + private fun handleSetFlashMode(call: MethodCall, result: MethodChannel.Result) { + val args = call.arguments as? Map<*, *> + ?: return result.error("bad_args", "setFlashMode", null) + val mode = args["mode"] as? String ?: "off" + withInstance(call, result) { instance -> + instance.setFlashMode(mode) + result.success(null) + } + } + + private fun handleTakePicture(call: MethodCall, result: MethodChannel.Result) { + val args = call.arguments as? Map<*, *> + ?: return result.error("bad_args", "takePicture", null) + val snapshotRaw = args["snapshotOrientation"] as? String ?: "portraitUp" + withInstance(call, result) { instance -> + instance.takePicture(flutterToSurfaceRotation(snapshotRaw)) { outcome -> + outcome.fold( + onSuccess = { path -> result.success(mapOf("path" to path)) }, + onFailure = { t -> + result.error("take_picture_failed", t.localizedMessage, null) + }, + ) + } + } + } + + private fun handleOpenSettings(result: MethodChannel.Result) { + val act = activity + ?: return result.error("no_activity", "plugin not attached", null) + val intent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply { + data = Uri.fromParts("package", act.packageName, null) + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + } + act.startActivity(intent) + result.success(null) + } + + private fun isAudioGranted(): Boolean { + val ctx = context ?: return false + return ContextCompat.checkSelfPermission( + ctx, Manifest.permission.RECORD_AUDIO, + ) == PackageManager.PERMISSION_GRANTED + } + + private fun requestPermissions( + perms: List, + cb: (Boolean, String) -> Unit, + ) { + val act = activity ?: return cb(false, "camera") + val toRequest = perms.filter { + ContextCompat.checkSelfPermission(act, it) != + PackageManager.PERMISSION_GRANTED + } + if (toRequest.isEmpty()) return cb(true, "") + if (pendingPermission != null) { + return cb(false, "camera") // serialize + } + pendingPermission = cb + pendingPermissionKind = if (toRequest.contains(Manifest.permission.CAMERA)) + "camera" else "microphone" + ActivityCompat.requestPermissions( + act, + toRequest.toTypedArray(), + PERMISSION_REQUEST_CODE, + ) + } + + private inline fun withInstance( + call: MethodCall, + result: MethodChannel.Result, + body: (CameraInstance) -> Unit, + ) { + val args = call.arguments as? Map<*, *> + ?: return result.error("bad_args", "missing handle", null) + val handle = (args["handle"] as? Number)?.toInt() + ?: return result.error("bad_args", "missing handle", null) + val instance = instances[handle] + ?: return result.error("disposed", "no camera for handle $handle", null) + body(instance) + } +} diff --git a/android/src/main/kotlin/io/swipelab/ux/camera/CustomLifecycleOwner.kt b/android/src/main/kotlin/io/swipelab/ux/camera/CustomLifecycleOwner.kt new file mode 100644 index 0000000..a5295a6 --- /dev/null +++ b/android/src/main/kotlin/io/swipelab/ux/camera/CustomLifecycleOwner.kt @@ -0,0 +1,42 @@ +package io.swipelab.ux.camera + +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.LifecycleRegistry + +/// One per [CameraInstance]. CameraX's `ProcessCameraProvider.bindToLifecycle` +/// requires a [LifecycleOwner]; we don't want to tie the camera session +/// to the host `Activity`'s lifecycle (we manage start/stop ourselves +/// to mirror the iOS `CameraSession.start/stop` semantics), so each +/// instance owns its own and we drive STARTED ↔ DESTROYED. +class CustomLifecycleOwner : LifecycleOwner { + private val registry = LifecycleRegistry(this).apply { + currentState = Lifecycle.State.INITIALIZED + } + + override val lifecycle: Lifecycle get() = registry + + /// Move to STARTED — CameraX begins streaming bound use-cases. + /// Called on Activity onResume / explicit camera start. + fun start() { + if (registry.currentState != Lifecycle.State.DESTROYED) { + registry.currentState = Lifecycle.State.STARTED + } + } + + /// Move back to CREATED — CameraX stops streaming but keeps bindings + /// (cheap restart). + fun stop() { + if (registry.currentState != Lifecycle.State.DESTROYED && + registry.currentState.isAtLeast(Lifecycle.State.CREATED)) { + registry.currentState = Lifecycle.State.CREATED + } + } + + /// Terminal — unbinds everything CameraX bound against us. Idempotent. + fun destroy() { + if (registry.currentState != Lifecycle.State.DESTROYED) { + registry.currentState = Lifecycle.State.DESTROYED + } + } +} diff --git a/android/src/main/kotlin/io/swipelab/ux/camera/DeviceOrientationBridge.kt b/android/src/main/kotlin/io/swipelab/ux/camera/DeviceOrientationBridge.kt new file mode 100644 index 0000000..604d3b6 --- /dev/null +++ b/android/src/main/kotlin/io/swipelab/ux/camera/DeviceOrientationBridge.kt @@ -0,0 +1,71 @@ +package io.swipelab.ux.camera + +import android.content.Context +import android.view.OrientationEventListener +import android.view.Surface + +/// Maps Android's `OrientationEventListener` (continuous 0–359° tilt +/// reports) onto the four discrete `DeviceOrientation` values Dart +/// expects. Emits a callback only on transitions, so the EventChannel +/// doesn't get flooded with per-degree updates. +/// +/// Snaps to the nearest quadrant with a 22.5° hysteresis margin so +/// hand-held jitter near a boundary doesn't cause rapid flips. +class DeviceOrientationBridge(context: Context) { + private val listener = object : OrientationEventListener(context) { + override fun onOrientationChanged(degrees: Int) { + if (degrees == ORIENTATION_UNKNOWN) return + val next = snap(degrees) ?: return + if (next != current) { + current = next + callback?.invoke(next) + } + } + } + + /// Surface rotation (`Surface.ROTATION_0` … `ROTATION_270`). What + /// CameraX `setTargetRotation` consumes. Polled by [PhotoCapture]. + var current: Int = Surface.ROTATION_0 + private set + + private var callback: ((Int) -> Unit)? = null + + fun start(onChange: (Int) -> Unit) { + callback = onChange + if (listener.canDetectOrientation()) listener.enable() + } + + fun stop() { + listener.disable() + callback = null + } + + private fun snap(degrees: Int): Int? = when { + degrees in 0..44 || degrees in 315..359 -> Surface.ROTATION_0 + degrees in 45..134 -> Surface.ROTATION_270 + degrees in 135..224 -> Surface.ROTATION_180 + degrees in 225..314 -> Surface.ROTATION_90 + else -> null + } +} + +/// Wire-side encoding identical to the iOS plugin's +/// `DeviceOrientationFlutter`. The Dart side decodes this string back +/// into a `DeviceOrientation`. +fun surfaceRotationToFlutter(rotation: Int): String = when (rotation) { + Surface.ROTATION_0 -> "portraitUp" + Surface.ROTATION_90 -> "landscapeRight" + Surface.ROTATION_180 -> "portraitDown" + Surface.ROTATION_270 -> "landscapeLeft" + else -> "portraitUp" +} + +/// Reverse mapping for the `snapshotOrientation` argument that +/// `takePicture` / `startVideoRecording` pass in. +fun flutterToSurfaceRotation(raw: String): Int = when (raw) { + "portraitUp" -> Surface.ROTATION_0 + "landscapeRight" -> Surface.ROTATION_90 + "portraitDown" -> Surface.ROTATION_180 + "landscapeLeft" -> Surface.ROTATION_270 + else -> Surface.ROTATION_0 +} diff --git a/android/src/main/kotlin/io/swipelab/ux/camera/PhotoCapture.kt b/android/src/main/kotlin/io/swipelab/ux/camera/PhotoCapture.kt new file mode 100644 index 0000000..c6fa1b3 --- /dev/null +++ b/android/src/main/kotlin/io/swipelab/ux/camera/PhotoCapture.kt @@ -0,0 +1,50 @@ +package io.swipelab.ux.camera + +import android.content.Context +import androidx.camera.core.ImageCapture +import androidx.camera.core.ImageCaptureException +import androidx.core.content.ContextCompat +import java.io.File +import java.util.UUID + +/// Wraps CameraX [ImageCapture]. Per-shot `setTargetRotation(snapshot)` +/// stamps the rotation into the JPEG's EXIF, matching the iOS plugin's +/// `AVCapturePhotoOutput.connection.videoOrientation` per-shot pattern. +class PhotoCapture { + val useCase: ImageCapture = ImageCapture.Builder() + .setCaptureMode(ImageCapture.CAPTURE_MODE_MINIMIZE_LATENCY) + .setJpegQuality(92) + .build() + + var flashMode: Int + get() = useCase.flashMode + set(value) { useCase.flashMode = value } + + /// Snap a still. [snapshotRotation] is a `Surface.ROTATION_*` value. + /// [onResult] fires on the main thread with the saved file path or + /// the error. + fun take( + context: Context, + snapshotRotation: Int, + onResult: (Result) -> Unit, + ) { + useCase.targetRotation = snapshotRotation + + val outFile = File(context.cacheDir, "ux_camera_${UUID.randomUUID()}.jpg") + val options = ImageCapture.OutputFileOptions.Builder(outFile).build() + + useCase.takePicture( + options, + ContextCompat.getMainExecutor(context), + object : ImageCapture.OnImageSavedCallback { + override fun onImageSaved(output: ImageCapture.OutputFileResults) { + onResult(Result.success(outFile.absolutePath)) + } + + override fun onError(exc: ImageCaptureException) { + onResult(Result.failure(exc)) + } + }, + ) + } +} diff --git a/android/src/main/kotlin/io/swipelab/ux/camera/PreviewSink.kt b/android/src/main/kotlin/io/swipelab/ux/camera/PreviewSink.kt new file mode 100644 index 0000000..948362a --- /dev/null +++ b/android/src/main/kotlin/io/swipelab/ux/camera/PreviewSink.kt @@ -0,0 +1,68 @@ +package io.swipelab.ux.camera + +import android.graphics.SurfaceTexture +import android.view.Surface +import androidx.camera.core.Preview +import androidx.camera.core.SurfaceRequest +import androidx.core.content.ContextCompat +import io.flutter.view.TextureRegistry +import android.content.Context + +/// Glue between CameraX's [Preview] use-case and Flutter's +/// `FlutterTexture`. CameraX hands us a [SurfaceRequest] each time the +/// preview pipeline (re-)negotiates its resolution; we resize our +/// `SurfaceTexture` to match, wrap it in a `Surface`, and hand that +/// back via `request.provideSurface`. +/// +/// The texture id is stable across resolution changes — the Dart side +/// keeps using the same `Texture(textureId: ...)` widget regardless of +/// what dimensions the camera negotiated. +class PreviewSink( + registry: TextureRegistry, + private val context: Context, +) { + private val entry: TextureRegistry.SurfaceTextureEntry = registry.createSurfaceTexture() + private val surfaceTexture: SurfaceTexture = entry.surfaceTexture() + private var activeSurface: Surface? = null + + val textureId: Long get() = entry.id() + var previewSize: android.util.Size = android.util.Size(0, 0) + private set + + /// `Preview.SurfaceProvider` callback. Pass directly to + /// `previewUseCase.setSurfaceProvider(sink::provideSurface)`. + fun provideSurface(request: SurfaceRequest) { + val res = request.resolution + surfaceTexture.setDefaultBufferSize(res.width, res.height) + previewSize = res + + // Tear down any prior surface BEFORE handing out the new one — + // CameraX retains it until provideSurface's listener fires. + activeSurface?.release() + val surface = Surface(surfaceTexture) + activeSurface = surface + + request.provideSurface( + surface, + ContextCompat.getMainExecutor(context), + ) { result -> + // Result reasons we care about: + // - RESULT_REQUEST_CANCELLED / RESULT_WILL_NOT_PROVIDE_SURFACE: + // CameraX won't use this surface, drop it. + // - RESULT_SURFACE_USED_SUCCESSFULLY: CameraX is done with it, + // safe to release. + // - RESULT_INVALID_SURFACE: same — release. + // - RESULT_SURFACE_ALREADY_PROVIDED: shouldn't happen, ignore. + result.surface.release() + if (activeSurface === result.surface) activeSurface = null + } + } + + /// Called on instance dispose. Releases the texture and any + /// outstanding surface. + fun release() { + activeSurface?.release() + activeSurface = null + entry.release() + } +}