camera: Android photo + preview + lifecycle (Phase 4a)

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<Recorder>).

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.
This commit is contained in:
agra
2026-05-13 17:25:28 +03:00
parent 35151bb325
commit 5cd3505272
7 changed files with 764 additions and 0 deletions

View File

@@ -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<NativePlugin> = listOf(
@@ -12,6 +13,7 @@ class UxPlugin : FlutterPlugin, ActivityAware {
ScannerPlugin(),
ClipboardPlugin(),
GalleryPlugin(),
CameraPlugin(),
CrashPlugin(),
)

View File

@@ -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<String, Any?>) -> 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<String>) -> 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<String, Any?>) {
val payload = HashMap<String, Any?>(extras.size + 1)
payload["handle"] = handle
payload.putAll(extras)
onEvent?.invoke(payload)
}
}

View File

@@ -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<Int, CameraInstance>()
private var nextHandle = 1
private val lensesInUse = mutableSetOf<String>()
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<Map<String, Any>>()
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<String, Any> {
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<String>,
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)
}
}

View File

@@ -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
}
}
}

View File

@@ -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 0359° 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
}

View File

@@ -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<String>) -> 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))
}
},
)
}
}

View File

@@ -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()
}
}