Files
ux/ios/Classes/Camera/CameraPlugin.swift
agra 6d6a871c53 camera: iOS implementation (Phase 2+3)
Native plugin owning AVCaptureSession + AVAssetWriter, mirroring
telegram-iOS's Camera module decomposition. Photo + video capture with
the writer-track transform set from a per-call orientation snapshot
(the three-way preview/capture/device split that camera_avfoundation
can't give us).

Modules:
  CameraPlugin           channels + per-handle instance map
  CameraInstance         session + texture + outputs + recorder
  CameraSession          AVCaptureSession + runtime-error/interrupt obs
  CaptureDevice          front/back discovery, per-device config
  PhotoOutput            AVCapturePhotoOutput, per-shot orientation
  VideoRecorder          AVAssetWriter, lazy inputs, pending-audio queue,
                         stop()/cancel() pair (matches telegram)
  PreviewSink            CVPixelBuffer → FlutterTexture
  AudioSession           setCategory + setActive(true) (only-widen)
  DeviceOrientationBridge

Recorder details:
  - Lazy videoInput/audioInput on first sample, sourceFormatHint:.
  - Audio settings derived from CMAudioFormatDescriptionGet*
    + recommendedAudioSettingsForAssetWriter, gated startWriting.
  - Stop sets stopSampleTime; next sample crossing it triggers
    maybeFinish → finishWriting. No watchdog — telegram pattern.
  - cancel() drops pending audio + cancelWriting + deletes file,
    used by CameraInstance.dispose when teardown finds in-flight
    recording.
  - Diagnostic stream → ux/camera/events {event: "diagnostic"}.

Dart surface extensions over Phase 1:
  - UxCameraValue.audioPermissionGranted
  - UxCameraController.refreshAudioPermission()
  - Static UxCameraController.audioPermissionGranted() /
    openSystemSettings()
  - UxCameraDiagnostic event variant
  - FakeUxCameraBackend.{emitDiagnostic, audioPermission,
    openSettingsCalls}

Tests: 32/32 in test/camera (controller + channel) green.
2026-05-13 16:56:49 +03:00

440 lines
16 KiB
Swift

import AVFoundation
import Flutter
import UIKit
/// `ux/camera` + `ux/camera/events` registrar. Routes channel calls
/// to per-handle [CameraInstance]s. Enforces device + audio claims
/// across instances so a second controller against an already-held
/// camera or audio session fails fast with the right error code.
///
/// Phase 2 scope: no video recording. `startVideoRecording` /
/// `stopVideoRecording` return `unsupported_format`.
public class CameraPlugin: NSObject, NativePlugin, FlutterStreamHandler {
private weak var textureRegistry: FlutterTextureRegistry?
private var instances: [Int: CameraInstance] = [:]
private var nextHandle: Int = 1
// Multi-instance contention bookkeeping.
private var devicesInUse: Set<String> = []
private var audioInUse: Bool = false
private var eventSink: FlutterEventSink?
public func register(with registrar: FlutterPluginRegistrar) {
textureRegistry = registrar.textures()
let methods = FlutterMethodChannel(
name: "ux/camera",
binaryMessenger: registrar.messenger()
)
methods.setMethodCallHandler { [weak self] call, result in
self?.handle(call, result: result)
}
let events = FlutterEventChannel(
name: "ux/camera/events",
binaryMessenger: registrar.messenger()
)
events.setStreamHandler(self)
}
// MARK: - FlutterStreamHandler
public func onListen(
withArguments arguments: Any?,
eventSink events: @escaping FlutterEventSink
) -> FlutterError? {
eventSink = events
return nil
}
public func onCancel(withArguments arguments: Any?) -> FlutterError? {
eventSink = nil
return nil
}
// MARK: - Method dispatch
private func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) {
switch call.method {
case "availableCameras":
availableCameras(result: result)
case "create":
create(args: call.arguments, result: result)
case "initialize":
withInstance(call.arguments, result: result) { instance in
self.requestPermissions(audio: instance.audioClaimed) { granted, kind in
guard granted else {
result(FlutterError(
code: "permission_denied",
message: kind,
details: nil
))
return
}
instance.sessionQueueAsync {
instance.initialize()
DispatchQueue.main.async { result(nil) }
}
}
}
case "dispose":
withInstance(call.arguments, result: result) { instance in
// Release the device + audio claim *immediately* on .main
// so a follow-up `create` for the same camera can succeed
// even while the slow session teardown
// (stopRunning / removeInput / removeOutput) is still in
// flight on the per-instance sessionQueue. Two
// AVCaptureSessions can briefly coexist against the same
// device AVFoundation serializes the underlying hardware
// access. Without this, a quick page-pop re-push hits
// `device_busy` because the old claim hasn't been
// released yet.
self.releaseClaim(for: instance)
self.instances.removeValue(forKey: instance.handle)
instance.sessionQueueAsync {
instance.dispose()
DispatchQueue.main.async { result(nil) }
}
}
case "setDescription":
guard let args = call.arguments as? [String: Any],
let cameraId = args["cameraId"] as? String else {
result(badArgs("setDescription"))
return
}
withInstance(args, result: result) { instance in
instance.sessionQueueAsync {
do {
let size = try instance.setDescription(cameraId: cameraId)
DispatchQueue.main.async {
result([
"previewSize": [
"width": size.width,
"height": size.height,
]
])
}
} catch let error as NSError {
DispatchQueue.main.async {
result(FlutterError(
code: "init_failed",
message: error.localizedDescription,
details: nil
))
}
}
}
}
case "setFlashMode":
guard let args = call.arguments as? [String: Any],
let modeName = args["mode"] as? String else {
result(badArgs("setFlashMode"))
return
}
withInstance(args, result: result) { instance in
instance.sessionQueueAsync {
instance.setFlashMode(flashMode(from: modeName))
DispatchQueue.main.async { result(nil) }
}
}
case "lockCaptureOrientation":
guard let args = call.arguments as? [String: Any],
let raw = args["orientation"] as? String else {
result(badArgs("lockCaptureOrientation"))
return
}
withInstance(args, result: result) { instance in
let o = DeviceOrientationFlutter.parse(raw)
instance.sessionQueueAsync {
instance.lockCaptureOrientation(o)
DispatchQueue.main.async { result(nil) }
}
}
case "unlockCaptureOrientation":
withInstance(call.arguments, result: result) { instance in
instance.sessionQueueAsync {
instance.unlockCaptureOrientation()
DispatchQueue.main.async { result(nil) }
}
}
case "takePicture":
guard let args = call.arguments as? [String: Any],
let raw = args["snapshotOrientation"] as? String else {
result(badArgs("takePicture"))
return
}
withInstance(args, result: result) { instance in
let snapshot = DeviceOrientationFlutter.parse(raw)
instance.sessionQueueAsync {
instance.takePicture(snapshot: snapshot) { outcome in
switch outcome {
case .success(let path):
result(["path": path])
case .failure(let error):
result(FlutterError(
code: "take_picture_failed",
message: error.localizedDescription,
details: nil
))
}
}
}
}
case "startVideoRecording":
guard let args = call.arguments as? [String: Any],
let raw = args["snapshotOrientation"] as? String else {
result(badArgs("startVideoRecording"))
return
}
withInstance(args, result: result) { instance in
let snapshot = DeviceOrientationFlutter.parse(raw)
instance.sessionQueueAsync {
do {
try instance.startVideoRecording(snapshot: snapshot)
DispatchQueue.main.async { result(nil) }
} catch let error as NSError {
DispatchQueue.main.async {
result(FlutterError(
code: "recorder_failed",
message: error.localizedDescription,
details: nil
))
}
}
}
}
case "stopVideoRecording":
withInstance(call.arguments, result: result) { instance in
instance.sessionQueueAsync {
instance.stopVideoRecording { outcome in
// `outcome` arrives on recorderQueue from
// VideoRecorder.deliver. Bounce to main for
// the FlutterResult.
DispatchQueue.main.async {
switch outcome {
case .success(let url):
result(["path": url.path])
case .failure(let error):
result(FlutterError(
code: "recorder_failed",
message: error.localizedDescription,
details: nil
))
}
}
}
}
}
case "audioPermissionStatus":
let granted = AVCaptureDevice.authorizationStatus(for: .audio) == .authorized
result(granted)
case "openSettings":
DispatchQueue.main.async {
if let url = URL(string: UIApplication.openSettingsURLString) {
UIApplication.shared.open(url)
}
result(nil)
}
default:
result(FlutterMethodNotImplemented)
}
}
// MARK: - Method implementations
private func availableCameras(result: @escaping FlutterResult) {
let cameras = CaptureDevice.discover().map { $0.toWire() }
result(cameras)
}
private func create(args: Any?, result: @escaping FlutterResult) {
guard let args = args as? [String: Any],
let cameraId = args["cameraId"] as? String,
let enableAudio = args["enableAudio"] as? Bool else {
result(badArgs("create"))
return
}
guard let registry = textureRegistry else {
result(FlutterError(
code: "init_failed",
message: "Texture registry unavailable",
details: nil
))
return
}
if devicesInUse.contains(cameraId) {
result(FlutterError(
code: "device_busy",
message: cameraId,
details: nil
))
return
}
if enableAudio && audioInUse {
result(FlutterError(
code: "audio_busy",
message: nil,
details: nil
))
return
}
let handle = nextHandle
nextHandle += 1
let instance = CameraInstance(handle: handle)
instance.onEvent = { [weak self] payload in
self?.eventSink?(payload)
}
instances[handle] = instance
devicesInUse.insert(cameraId)
if enableAudio { audioInUse = true }
instance.sessionQueueAsync {
do {
try instance.create(
cameraId: cameraId,
enableAudio: enableAudio,
registry: registry
)
let size = instance.previewSize
DispatchQueue.main.async {
result([
"handle": handle,
"textureId": instance.textureId,
"previewSize": [
"width": size.width,
"height": size.height,
],
])
}
} catch let error as NSError {
DispatchQueue.main.async {
self.releaseClaim(for: instance)
self.instances.removeValue(forKey: handle)
result(FlutterError(
code: "init_failed",
message: error.localizedDescription,
details: nil
))
}
}
}
}
// MARK: - Helpers
private func withInstance(
_ args: Any?,
result: @escaping FlutterResult,
body: @escaping (CameraInstance) -> Void
) {
guard let map = args as? [String: Any],
let handle = (map["handle"] as? NSNumber)?.intValue else {
result(badArgs("missing handle"))
return
}
guard let instance = instances[handle] else {
result(FlutterError(
code: "disposed",
message: "No camera for handle \(handle)",
details: nil
))
return
}
body(instance)
}
private func releaseClaim(for instance: CameraInstance) {
if let id = instance.currentCameraId {
devicesInUse.remove(id)
}
if instance.audioClaimed {
audioInUse = false
}
}
/// Request camera (always) and microphone (when [audio]) access.
/// Completion fires on `.main` with `(true, "")` on camera grant.
/// A denied microphone is **not** a fatal failure the recording
/// pipeline tolerates a missing audio output cleanly (no audio
/// track in the file), and the user is more annoyed by "camera
/// won't even open" than by "video has no sound." Camera denial
/// is the only hard failure path here.
private func requestPermissions(
audio: Bool,
_ completion: @escaping (Bool, String) -> Void
) {
requestAccess(for: .video) { videoGranted in
guard videoGranted else {
completion(false, "camera")
return
}
guard audio else {
completion(true, "")
return
}
// Prompt for mic if we haven't asked yet; result doesn't
// gate initialize either way. CameraInstance.configureSession
// already tolerates a missing mic (the audio device input
// throws on `AVCaptureDeviceInput(device:)`, the catch
// swallows, and audioDeviceInput stays nil which
// [CameraInstance.startVideoRecording] reads as "skip
// adding an audio input to the writer").
self.requestAccess(for: .audio) { _ in
completion(true, "")
}
}
}
private func requestAccess(
for mediaType: AVMediaType,
_ completion: @escaping (Bool) -> Void
) {
switch AVCaptureDevice.authorizationStatus(for: mediaType) {
case .authorized:
DispatchQueue.main.async { completion(true) }
case .notDetermined:
AVCaptureDevice.requestAccess(for: mediaType) { granted in
DispatchQueue.main.async { completion(granted) }
}
default:
DispatchQueue.main.async { completion(false) }
}
}
}
// MARK: - Free functions
private func badArgs(_ where_: String) -> FlutterError {
return FlutterError(
code: "bad_args",
message: "Bad arguments for \(where_)",
details: nil
)
}
private func flashMode(from raw: String) -> AVCaptureDevice.FlashMode {
switch raw {
case "always": return .on
case "off": return .off
default: return .off
}
}