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:
@@ -3,6 +3,7 @@ package io.swipelab.ux
|
|||||||
import io.flutter.embedding.engine.plugins.FlutterPlugin
|
import io.flutter.embedding.engine.plugins.FlutterPlugin
|
||||||
import io.flutter.embedding.engine.plugins.activity.ActivityAware
|
import io.flutter.embedding.engine.plugins.activity.ActivityAware
|
||||||
import io.flutter.embedding.engine.plugins.activity.ActivityPluginBinding
|
import io.flutter.embedding.engine.plugins.activity.ActivityPluginBinding
|
||||||
|
import io.swipelab.ux.camera.CameraPlugin
|
||||||
|
|
||||||
class UxPlugin : FlutterPlugin, ActivityAware {
|
class UxPlugin : FlutterPlugin, ActivityAware {
|
||||||
private val plugins: List<NativePlugin> = listOf(
|
private val plugins: List<NativePlugin> = listOf(
|
||||||
@@ -12,6 +13,7 @@ class UxPlugin : FlutterPlugin, ActivityAware {
|
|||||||
ScannerPlugin(),
|
ScannerPlugin(),
|
||||||
ClipboardPlugin(),
|
ClipboardPlugin(),
|
||||||
GalleryPlugin(),
|
GalleryPlugin(),
|
||||||
|
CameraPlugin(),
|
||||||
CrashPlugin(),
|
CrashPlugin(),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
162
android/src/main/kotlin/io/swipelab/ux/camera/CameraInstance.kt
Normal file
162
android/src/main/kotlin/io/swipelab/ux/camera/CameraInstance.kt
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
369
android/src/main/kotlin/io/swipelab/ux/camera/CameraPlugin.kt
Normal file
369
android/src/main/kotlin/io/swipelab/ux/camera/CameraPlugin.kt
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
68
android/src/main/kotlin/io/swipelab/ux/camera/PreviewSink.kt
Normal file
68
android/src/main/kotlin/io/swipelab/ux/camera/PreviewSink.kt
Normal 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()
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user