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.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(),
|
||||
)
|
||||
|
||||
|
||||
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