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

72 lines
2.8 KiB
Swift

import AVFoundation
/// Idempotent helpers for upgrading the app's shared `AVAudioSession`
/// to the union of categories needed for capture without trampling
/// what other modules (audio player, video_player) had set. Pattern
/// mirrors `camera_avfoundation`'s `upgradeAudioSessionCategory`
/// only ever WIDENS the category, never narrows.
///
/// We pair this with the session's
/// `automaticallyConfiguresApplicationAudioSession = false` +
/// `usesApplicationAudioSession = true` (set in [CameraSession.init])
/// so AVCaptureSession doesn't yank the category back.
enum AudioSession {
/// Upgrade the shared category to include `.playAndRecord` (and
/// the given options union'd with whatever's already set). No-op
/// when the union equals the current state, so this is cheap to
/// call on every recording start.
static func upgradeForRecording() {
upgrade(
requestedCategory: .playAndRecord,
options: [.defaultToSpeaker, .allowBluetoothA2DP, .allowAirPlay]
)
}
private static func upgrade(
requestedCategory: AVAudioSession.Category,
options: AVAudioSession.CategoryOptions
) {
let playCategories: Set<AVAudioSession.Category> = [.playback, .playAndRecord]
let recordCategories: Set<AVAudioSession.Category> = [.record, .playAndRecord]
let currentCategory = AVAudioSession.sharedInstance().category
let requiredCategories: Set<AVAudioSession.Category> = [
requestedCategory, currentCategory,
]
let requiresPlay = !requiredCategories.isDisjoint(with: playCategories)
let requiresRecord = !requiredCategories.isDisjoint(with: recordCategories)
var finalCategory = requestedCategory
if requiresPlay && requiresRecord {
finalCategory = .playAndRecord
} else if requiresPlay {
finalCategory = .playback
} else if requiresRecord {
finalCategory = .record
}
let finalOptions = AVAudioSession.sharedInstance().categoryOptions
.union(options)
if finalCategory == currentCategory
&& finalOptions == AVAudioSession.sharedInstance().categoryOptions
{
return
}
try? AVAudioSession.sharedInstance().setCategory(
finalCategory,
options: finalOptions
)
// With AVCaptureSession.usesApplicationAudioSession = true and
// automaticallyConfiguresApplicationAudioSession = false, the
// app owns activation without this, the mic input never
// delivers sample buffers. Telegram does the same from
// ManagedAudioSession.activate (setActive(true)).
try? AVAudioSession.sharedInstance().setActive(
true,
options: [.notifyOthersOnDeactivation]
)
}
}