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.
This commit is contained in:
agra
2026-05-13 16:56:49 +03:00
parent 45aac312a8
commit 6d6a871c53
18 changed files with 2337 additions and 22 deletions

View File

@@ -152,6 +152,60 @@ void main() {
expect(file.path, '/tmp/v.mp4');
});
test('events stream filters by handle and decodes diagnostic events',
() async {
const eventsChannel = EventChannel('ux/camera/events');
final messenger =
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger;
messenger.setMockStreamHandler(
eventsChannel,
MockStreamHandler.inline(
onListen: (_, sink) {
sink.success({
'event': 'diagnostic',
'handle': 4,
'message': 'video input added',
});
sink.success({
'event': 'diagnostic',
'handle': 7,
'message': 'audio input added sr=44100 ch=1',
});
sink.endOfStream();
},
),
);
addTearDown(() => messenger.setMockStreamHandler(eventsChannel, null));
final received = <UxCameraEvent>[];
await backend.events(4).forEach(received.add);
expect(received, hasLength(1));
final e = received.single as UxCameraDiagnostic;
expect(e.handle, 4);
expect(e.message, 'video input added');
});
test('audioPermissionStatus + openSettings round-trip', () async {
var permissionReply = true;
handle((call) {
if (call.method == 'audioPermissionStatus') return permissionReply;
if (call.method == 'openSettings') return null;
return null;
});
expect(await backend.audioPermissionGranted(), isTrue);
permissionReply = false;
expect(await backend.audioPermissionGranted(), isFalse);
await backend.openSettings();
expect(
calls.map((c) => c.method).toList(),
['audioPermissionStatus', 'audioPermissionStatus', 'openSettings'],
);
});
test('PlatformException maps to UxCameraException carrying code/message',
() async {
handle((_) => throw PlatformException(

View File

@@ -68,6 +68,31 @@ void main() {
expect(ctrl.value.deviceOrientation, DeviceOrientation.portraitDown);
});
test('diagnostic events route through Log.tag("camera") and do not '
'mutate value', () async {
final records = <LogRecord>[];
final prevSink = Log.sink;
Log.configure(
minLevel: LogLevel.info,
sink: _CapturingSink(records),
captureCrashes: () {},
);
addTearDown(() => Log.configure(sink: prevSink, captureCrashes: () {}));
final ctrl = UxCameraController(_front, UxResolutionPreset.high, enableAudio: false);
addTearDown(ctrl.dispose);
await ctrl.initialize();
final beforeValue = ctrl.value;
fake.emitDiagnostic(1, 'video input added');
await Future<void>.delayed(Duration.zero);
expect(ctrl.value, beforeValue);
final diag = records.singleWhere((r) => r.tag == 'camera');
expect(diag.level, LogLevel.info);
expect(diag.message, 'recorder: video input added');
});
test('sessionError events surface as value.errorDescription', () async {
final ctrl = UxCameraController(_front, UxResolutionPreset.high, enableAudio: false);
addTearDown(ctrl.dispose);
@@ -216,6 +241,36 @@ void main() {
expect(b.value.deviceOrientation, DeviceOrientation.landscapeRight);
});
test('initialize captures audioPermissionGranted into value', () async {
fake.audioPermission = false;
final ctrl = UxCameraController(_front, UxResolutionPreset.high, enableAudio: true);
addTearDown(ctrl.dispose);
await ctrl.initialize();
expect(ctrl.value.audioPermissionGranted, isFalse);
expect(fake.audioPermissionCalls, 1);
});
test('refreshAudioPermission re-polls and updates value', () async {
fake.audioPermission = false;
final ctrl = UxCameraController(_front, UxResolutionPreset.high, enableAudio: true);
addTearDown(ctrl.dispose);
await ctrl.initialize();
expect(ctrl.value.audioPermissionGranted, isFalse);
fake.audioPermission = true;
await ctrl.refreshAudioPermission();
expect(ctrl.value.audioPermissionGranted, isTrue);
expect(fake.audioPermissionCalls, 2);
});
test('openSystemSettings dispatches to the backend', () async {
await UxCameraController.openSystemSettings();
expect(fake.openSettingsCalls, 1);
});
test('initialize propagates UxCameraException("permission_denied")', () async {
fake.createError = const UxCameraException('permission_denied', 'camera');
final ctrl = UxCameraController(_front, UxResolutionPreset.high, enableAudio: false);
@@ -225,3 +280,15 @@ void main() {
throwsA(isA<UxCameraException>().having((e) => e.code, 'code', 'permission_denied')));
});
}
class _CapturingSink extends LogSink {
_CapturingSink(this.records);
final List<LogRecord> records;
@override
LogLevel get minLevel => LogLevel.trace;
@override
void emit(LogRecord record) => records.add(record);
}