Files
ux/ios/Classes/Camera/DeviceOrientationBridge.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

121 lines
4.6 KiB
Swift

import AVFoundation
import UIKit
/// Translates between Flutter's `DeviceOrientation` (4 enum values
/// shipped as strings across the channel) and AVFoundation's
/// `AVCaptureVideoOrientation`, and bridges physical-device rotation
/// notifications to a closure callback.
///
/// Observed orientation source is `UIDevice.current.orientation`
/// independent of any UI orientation lock, so this fires even while
/// the app's window is portrait-locked. `.faceUp` / `.faceDown` are
/// ignored (no useful direction).
///
/// `UIDevice.beginGeneratingDeviceOrientationNotifications()` must be
/// called on main, balanced with `end()`; this class enforces both.
final class DeviceOrientationBridge {
typealias Listener = (DeviceOrientationFlutter) -> Void
private var listener: Listener?
private var observer: NSObjectProtocol?
/// Most recent valid orientation observed. Initialised to
/// `portraitUp` so callers have a starting value before the first
/// rotation event.
private(set) var current: DeviceOrientationFlutter = .portraitUp
/// Starts observing. Safe to call multiple times; subsequent calls
/// replace the listener but don't re-register.
func start(listener: @escaping Listener) {
self.listener = listener
guard observer == nil else { return }
// beginGeneratingDeviceOrientationNotifications is main-only.
if Thread.isMainThread {
UIDevice.current.beginGeneratingDeviceOrientationNotifications()
} else {
DispatchQueue.main.sync {
UIDevice.current.beginGeneratingDeviceOrientationNotifications()
}
}
// Seed `current` from the device's reported orientation if it
// is already a valid one.
if let seed = DeviceOrientationFlutter(uiDevice: UIDevice.current.orientation) {
current = seed
}
observer = NotificationCenter.default.addObserver(
forName: UIDevice.orientationDidChangeNotification,
object: UIDevice.current,
queue: .main
) { [weak self] _ in
guard let self = self else { return }
guard let next = DeviceOrientationFlutter(uiDevice: UIDevice.current.orientation) else {
return
}
guard next != self.current else { return }
self.current = next
self.listener?(next)
}
}
func stop() {
listener = nil
if let observer = observer {
NotificationCenter.default.removeObserver(observer)
self.observer = nil
DispatchQueue.main.async {
UIDevice.current.endGeneratingDeviceOrientationNotifications()
}
}
}
deinit { stop() }
}
/// Mirrors Flutter's `DeviceOrientation` the four cardinal values
/// that travel over the `ux/camera` channel as wire strings. `public`
/// so the host app's XCTest target can verify the
/// `DeviceOrientationFlutter` / `AVCaptureVideoOrientation` mapping
/// without `@testable import`.
public enum DeviceOrientationFlutter: String {
case portraitUp
case landscapeLeft
case portraitDown
case landscapeRight
/// Parse a wire string. Returns `.portraitUp` for unknown inputs
/// (matches the Dart-side fallback in `MethodChannelUxCameraBackend`).
public static func parse(_ raw: String?) -> DeviceOrientationFlutter {
return DeviceOrientationFlutter(rawValue: raw ?? "") ?? .portraitUp
}
/// `UIDeviceOrientation` Flutter convention is a direct 1:1 by
/// name. Despite the AV-side flip, `UIDeviceOrientation.landscapeLeft`
/// and Flutter's `landscapeLeft` describe the same physical pose
/// (home button on the right). The flip lives in
/// [avVideoOrientation], not here.
public init?(uiDevice: UIDeviceOrientation) {
switch uiDevice {
case .portrait: self = .portraitUp
case .portraitUpsideDown: self = .portraitDown
case .landscapeLeft: self = .landscapeLeft
case .landscapeRight: self = .landscapeRight
default: return nil
}
}
/// AVFoundation video orientation. Translates Flutter's portrait-
/// relative convention to AVFoundation's hardware-relative one.
/// Used to drive `AVCaptureConnection.videoOrientation`.
public var avVideoOrientation: AVCaptureVideoOrientation {
switch self {
case .portraitUp: return .portrait
case .portraitDown: return .portraitUpsideDown
case .landscapeLeft: return .landscapeRight
case .landscapeRight: return .landscapeLeft
}
}
}