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:
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user