camera: Android video recording (Phase 4b)

CameraX VideoCapture<Recorder> wired alongside Preview + ImageCapture
in a single bindToLifecycle call. Mirrors the iOS surface contract:

  startVideoRecording(handle, snapshotOrientation) → null
    Records to a UUID-named MP4 in cacheDir. Audio enabled iff the
    instance was created with enableAudio: true AND RECORD_AUDIO has
    been granted (no SecurityException if the user denied mic but
    asked for audio — recording proceeds silent). targetRotation set
    per-call so the file's rotation metadata matches how the device
    was held at recording start.

  stopVideoRecording(handle) → {path}
    Resolves when CameraX delivers the VideoRecordEvent.Finalize.

Telegram-fidelity mirror: VideoCapture.Builder.setMirrorMode(MIRROR_MODE_OFF)
overrides CameraX's default MIRROR_MODE_ON_FRONT_ONLY so selfie
videos record the raw sensor feed ("as others see you"). Preview-side
mirror stays a CameraX-managed SurfaceTexture transform; the recorded
file is independent.

Quality: Quality.HD (720p) — keeps file sizes reasonable for chat
composer use and well within mid-range Android devices' budget for
binding all three use cases (Preview + ImageCapture + VideoCapture).
Fallback for devices that reject the 3-use-case bind would be next
iteration.

instance.dispose() now hard-cancels any in-flight recording (drops
file, no caller waiting) — matches iOS' recorder.cancel() path.
This commit is contained in:
agra
2026-05-13 18:21:16 +03:00
parent 181fce6ab9
commit c4a8eb634f
3 changed files with 211 additions and 6 deletions

View File

@@ -30,6 +30,7 @@ class CameraInstance(
private val lifecycleOwner = CustomLifecycleOwner()
private val previewSink = PreviewSink(registry, context)
private val photo = PhotoCapture()
private val video = VideoCapture()
private val orientation = DeviceOrientationBridge(context)
private var cameraProvider: ProcessCameraProvider? = null
@@ -113,6 +114,10 @@ class CameraInstance(
fun dispose() {
if (disposed) return
disposed = true
// Mirror iOS' dispose: any in-flight recording is hard-cancelled
// (file discarded) rather than left to flush, since there's no
// caller waiting on the result anymore.
if (video.isRecording) video.cancel()
orientation.stop()
cameraProvider?.unbindAll()
cameraProvider = null
@@ -150,6 +155,26 @@ class CameraInstance(
photo.take(context, snapshotRotation, onResult)
}
/// Begin recording. [snapshotRotation] is baked into the file's
/// rotation metadata; the file plays back rotated even if the
/// device returns to portrait mid-recording. [onStartResult] fires
/// when CameraX's `Recording.start()` returns (immediately on
/// success), not when the first frame lands.
fun startVideoRecording(
snapshotRotation: Int,
onStartResult: (Result<Unit>) -> Unit,
) {
check(!disposed)
video.start(context, enableAudio, snapshotRotation, onStartResult)
}
/// Stop the in-flight recording. [onResult] fires asynchronously
/// when CameraX delivers the Finalize event.
fun stopVideoRecording(onResult: (Result<String>) -> Unit) {
check(!disposed)
video.stop(onResult)
}
private fun bind(provider: ProcessCameraProvider) {
provider.unbindAll()
@@ -167,11 +192,17 @@ class CameraInstance(
.also { it.setSurfaceProvider(previewSink::provideSurface) }
this.preview = preview
// Preview + ImageCapture + VideoCapture as a single binding.
// CameraX 1.4 supports this combination on essentially every
// device that supports video — if a device can't deliver all
// three simultaneously, `bindToLifecycle` throws and we'd
// need to fall back. Not observed on Pixel-class hardware.
camera = provider.bindToLifecycle(
lifecycleOwner,
selector,
preview,
photo.useCase,
video.useCase,
)
}

View File

@@ -135,12 +135,8 @@ class CameraPlugin :
"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,
)
"startVideoRecording" -> handleStartVideo(call, result)
"stopVideoRecording" -> handleStopVideo(call, result)
"audioPermissionStatus" -> result.success(isAudioGranted())
"openSettings" -> handleOpenSettings(result)
else -> result.notImplemented()
@@ -314,6 +310,36 @@ class CameraPlugin :
}
}
private fun handleStartVideo(call: MethodCall, result: MethodChannel.Result) {
val args = call.arguments as? Map<*, *>
?: return result.error("bad_args", "startVideoRecording", null)
val snapshotRaw = args["snapshotOrientation"] as? String ?: "portraitUp"
withInstance(call, result) { instance ->
instance.startVideoRecording(flutterToSurfaceRotation(snapshotRaw)) {
outcome ->
outcome.fold(
onSuccess = { result.success(null) },
onFailure = { t ->
result.error("recorder_failed", t.localizedMessage, null)
},
)
}
}
}
private fun handleStopVideo(call: MethodCall, result: MethodChannel.Result) {
withInstance(call, result) { instance ->
instance.stopVideoRecording { outcome ->
outcome.fold(
onSuccess = { path -> result.success(mapOf("path" to path)) },
onFailure = { t ->
result.error("recorder_failed", t.localizedMessage, null)
},
)
}
}
}
private fun handleOpenSettings(result: MethodChannel.Result) {
val act = activity
?: return result.error("no_activity", "plugin not attached", null)

View File

@@ -0,0 +1,148 @@
package io.swipelab.ux.camera
import android.Manifest
import android.annotation.SuppressLint
import android.content.Context
import android.content.pm.PackageManager
import androidx.camera.core.MirrorMode
import androidx.camera.video.FileOutputOptions
import androidx.camera.video.Quality
import androidx.camera.video.QualitySelector
import androidx.camera.video.Recorder
import androidx.camera.video.Recording
import androidx.camera.video.VideoRecordEvent
import androidx.core.content.ContextCompat
import java.io.File
import java.util.UUID
/// Wraps CameraX `VideoCapture<Recorder>`. One per [CameraInstance];
/// the use case is bound alongside Preview + ImageCapture in
/// `CameraInstance.bind`. Telegram-fidelity mirror policy: raw sensor
/// feed in the recorded MP4 (no `MIRROR_MODE_ON_FRONT_ONLY` default
/// CameraX would otherwise apply).
class VideoCapture {
private val recorder = Recorder.Builder()
// HD (720p) matches iOS' AVCaptureSession.Preset.high default
// negotiation for 1280×720; keeps file sizes reasonable for
// chat composer use and well within the 3-use-case binding
// budget for mid-range Android devices.
.setQualitySelector(QualitySelector.from(Quality.HD))
.build()
val useCase: androidx.camera.video.VideoCapture<Recorder> =
androidx.camera.video.VideoCapture.Builder(recorder)
// Raw sensor capture — selfie videos are "as others see
// you"; the preview-only mirror lives in UxCameraPreview.
.setMirrorMode(MirrorMode.MIRROR_MODE_OFF)
.build()
private var recording: Recording? = null
private var stopCompletion: ((Result<String>) -> Unit)? = null
private var outFile: File? = null
val isRecording: Boolean get() = recording != null
/// Begin recording into a UUID-named MP4 in [context]'s cache dir.
/// [snapshotRotation] is a `Surface.ROTATION_*`. Audio is enabled
/// only when [enableAudio] AND the RECORD_AUDIO permission is
/// already granted — calling `withAudioEnabled()` without the
/// permission throws `SecurityException`.
///
/// [onStartResult] fires synchronously after `Recording.start()`
/// returns (with success) — CameraX's `Recording` is "live" by
/// then; the asynchronous Start/Status/Finalize events still flow
/// to our listener but the caller has already moved on. Any
/// failure raised by `start()` is surfaced as a failed result.
/// The eventual Finalize event flows to the [stop] completion.
@SuppressLint("MissingPermission")
fun start(
context: Context,
enableAudio: Boolean,
snapshotRotation: Int,
onStartResult: (Result<Unit>) -> Unit,
) {
if (recording != null) {
onStartResult(Result.failure(
IllegalStateException("Recording already in flight"),
))
return
}
useCase.targetRotation = snapshotRotation
val file = File(context.cacheDir, "ux_camera_${UUID.randomUUID()}.mp4")
outFile = file
val options = FileOutputOptions.Builder(file).build()
val audioGranted = ContextCompat.checkSelfPermission(
context, Manifest.permission.RECORD_AUDIO,
) == PackageManager.PERMISSION_GRANTED
var pending = recorder.prepareRecording(context, options)
if (enableAudio && audioGranted) {
pending = pending.withAudioEnabled()
}
try {
recording = pending.start(
ContextCompat.getMainExecutor(context),
) { event ->
if (event is VideoRecordEvent.Finalize) {
val cb = stopCompletion
stopCompletion = null
recording = null
val target = outFile
outFile = null
if (event.hasError()) {
cb?.invoke(Result.failure(
RuntimeException(
"VideoRecordEvent.Finalize error " +
"${event.error}: ${event.cause?.localizedMessage}",
),
))
} else {
cb?.invoke(Result.success(target?.absolutePath ?: ""))
}
}
}
onStartResult(Result.success(Unit))
} catch (t: Throwable) {
recording = null
outFile = null
onStartResult(Result.failure(t))
}
}
/// Stop the in-flight recording. [onResult] fires when the
/// Finalize event lands — typically within ~100300 ms of the
/// stop call. If no recording is in flight, fires failure.
fun stop(onResult: (Result<String>) -> Unit) {
val r = recording
if (r == null) {
onResult(Result.failure(IllegalStateException("No recording in flight")))
return
}
// Stash the completion BEFORE telling the recorder to stop —
// the Finalize callback can fire before `stop()` returns on
// fast hardware.
stopCompletion = onResult
r.stop()
}
/// Drop any in-flight recording without delivering its file.
/// Used by [CameraInstance.dispose] when the instance is torn
/// down mid-recording — mirrors the iOS path's `recorder.cancel()`.
fun cancel() {
val r = recording
if (r != null) {
recording = null
r.stop()
}
stopCompletion?.invoke(Result.failure(
RuntimeException("Recording cancelled"),
))
stopCompletion = null
outFile?.delete()
outFile = null
}
}