Files
ux/test/camera/camera_channel_test.dart
agra cc243b7b0a camera: previewRotationQuarterTurns + async previewSize event
Black-screen + extra-90°-rotation on Android both came from
AVFoundation vs CameraX behaving differently at the preview output:

  - AVFoundation: data-output connection's `videoOrientation`
    pre-rotates sample buffers. The Flutter Texture displays them
    upright; `device.activeFormat` reports the sensor-native size
    synchronously.
  - CameraX: the SurfaceProvider hands back a Surface; CameraX
    writes raw sensor frames into it. Rotation is a *transform hint*
    via Preview.setTargetRotation that consumers must apply
    themselves. And the final negotiated resolution isn't known
    until the first SurfaceRequest fires — which happens AFTER
    bindToLifecycle, AFTER lifecycle.start, async on the camera
    executor. So `create` was returning Size(0,0).

Surface extension to bridge the gap:

  - UxCameraValue.previewRotationQuarterTurns (int 0/1/2/3).
    iOS native always emits 0; Android native emits
    `(sensorRotationDegrees / 90) % 4` for the active camera.
    [UxCameraPreview] wraps the Texture in a RotatedBox by that many
    quarter-turns (applied *before* the front-cam mirror so the
    flip lives in screen space, not sensor space).

  - UxCameraPreviewSizeChanged event. Android emits this from
    PreviewSink.onResize whenever a SurfaceRequest carries a new
    resolution; the controller copies it into value.previewSize.
    First emission is what unblocks the camera_thumb's SizedBox
    from its initial 0x0 = "render nothing" state.

  - UxCameraBackend.setDescription's return changed from `Size` to
    `({Size previewSize, int previewRotationQuarterTurns})` so
    a lens swap can both update the rotation and signal that a new
    previewSizeChanged event is incoming.

iOS continues to send previewSize in the create result (the active
format is known synchronously); no previewSizeChanged emission is
needed there. The new field is set to 0 in both create and
setDescription results on iOS.
2026-05-13 17:44:45 +03:00

227 lines
7.2 KiB
Dart

import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:ux/ux.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
group('MethodChannelUxCameraBackend — arg/return parsing', () {
const channel = MethodChannel('ux/camera');
late MethodChannelUxCameraBackend backend;
late List<MethodCall> calls;
setUp(() {
backend = MethodChannelUxCameraBackend();
calls = [];
});
tearDown(() {
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(channel, null);
});
void handle(Object? Function(MethodCall) reply) {
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(channel, (call) async {
calls.add(call);
return reply(call);
});
}
test('availableCameras decodes the platform list', () async {
handle((_) => [
{'id': 'a', 'lens': 'front', 'sensorOrientation': 270},
{'id': 'b', 'lens': 'back', 'sensorOrientation': 90},
]);
final result = await backend.availableCameras();
expect(calls.single.method, 'availableCameras');
expect(result, [
const UxCameraDescription(
id: 'a', lens: UxCameraLens.front, sensorOrientation: 270),
const UxCameraDescription(
id: 'b', lens: UxCameraLens.back, sensorOrientation: 90),
]);
});
test('create sends cameraId/enableAudio/preset and decodes the tuple',
() async {
handle((_) => {
'handle': 42,
'textureId': 7,
'previewSize': {'width': 1920, 'height': 1080},
});
final result = await backend.create(
cameraId: 'cam-1',
enableAudio: true,
preset: UxResolutionPreset.high,
);
expect(calls.single.method, 'create');
expect(calls.single.arguments, {
'cameraId': 'cam-1',
'enableAudio': true,
'preset': 'high',
});
expect(result.handle, 42);
expect(result.textureId, 7);
expect(result.previewSize, const Size(1920, 1080));
});
test('initialize / dispose send only the handle', () async {
handle((_) => null);
await backend.initialize(11);
await backend.disposeInstance(11);
expect(calls.map((c) => c.method).toList(), ['initialize', 'dispose']);
expect(calls.every((c) => (c.arguments as Map)['handle'] == 11), isTrue);
});
test('setDescription returns previewSize + rotation', () async {
handle((_) => {
'previewSize': {'width': 1280, 'height': 720},
'previewRotationQuarterTurns': 1,
});
final r = await backend.setDescription(7, 'next');
expect(calls.single.method, 'setDescription');
expect(calls.single.arguments, {'handle': 7, 'cameraId': 'next'});
expect(r.previewSize, const Size(1280, 720));
expect(r.previewRotationQuarterTurns, 1);
});
test('setFlashMode encodes the enum', () async {
handle((_) => null);
await backend.setFlashMode(3, UxFlashMode.always);
await backend.setFlashMode(3, UxFlashMode.off);
expect(calls.map((c) => (c.arguments as Map)['mode']).toList(),
['always', 'off']);
});
test('lock/unlockCaptureOrientation encode the orientation string',
() async {
handle((_) => null);
await backend.lockCaptureOrientation(5, DeviceOrientation.portraitUp);
await backend.lockCaptureOrientation(5, DeviceOrientation.landscapeLeft);
await backend.lockCaptureOrientation(5, DeviceOrientation.landscapeRight);
await backend.lockCaptureOrientation(5, DeviceOrientation.portraitDown);
await backend.unlockCaptureOrientation(5);
expect(
calls.take(4).map((c) => (c.arguments as Map)['orientation']).toList(),
['portraitUp', 'landscapeLeft', 'landscapeRight', 'portraitDown'],
);
expect(calls.last.method, 'unlockCaptureOrientation');
});
test('takePicture sends snapshotOrientation and decodes the file path',
() async {
handle((_) => {'path': '/tmp/a.jpg'});
final file = await backend.takePicture(
9, DeviceOrientation.landscapeLeft);
expect(calls.single.method, 'takePicture');
expect(calls.single.arguments,
{'handle': 9, 'snapshotOrientation': 'landscapeLeft'});
expect(file.path, '/tmp/a.jpg');
});
test('startVideoRecording sends snapshotOrientation', () async {
handle((_) => null);
await backend.startVideoRecording(9, DeviceOrientation.landscapeRight);
expect(calls.single.method, 'startVideoRecording');
expect(calls.single.arguments,
{'handle': 9, 'snapshotOrientation': 'landscapeRight'});
});
test('stopVideoRecording decodes the file path', () async {
handle((_) => {'path': '/tmp/v.mp4'});
final file = await backend.stopVideoRecording(9);
expect(calls.single.method, 'stopVideoRecording');
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(
code: 'device_busy',
message: 'front camera in use',
));
await expectLater(
backend.initialize(1),
throwsA(isA<UxCameraException>()
.having((e) => e.code, 'code', 'device_busy')
.having((e) => e.description, 'description', 'front camera in use')),
);
});
});
}