diff --git a/packages/camera/camera_web/example/integration_test/camera_test.dart b/packages/camera/camera_web/example/integration_test/camera_test.dart new file mode 100644 index 000000000000..0f1dcf7049d9 --- /dev/null +++ b/packages/camera/camera_web/example/integration_test/camera_test.dart @@ -0,0 +1,487 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'dart:html'; + +import 'package:camera_platform_interface/camera_platform_interface.dart'; +import 'package:camera_web/src/camera.dart'; +import 'package:camera_web/src/types/camera_error_codes.dart'; +import 'package:camera_web/src/types/camera_options.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; +import 'package:mocktail/mocktail.dart'; + +import 'helpers/helpers.dart'; + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + group('Camera', () { + late Window window; + late Navigator navigator; + late MediaStream mediaStream; + late MediaDevices mediaDevices; + + setUp(() { + window = MockWindow(); + navigator = MockNavigator(); + mediaDevices = MockMediaDevices(); + + final videoElement = VideoElement() + ..src = + 'https://flutter.github.io/assets-for-api-docs/assets/videos/bee.mp4' + ..preload = 'true' + ..width = 10 + ..height = 10; + + mediaStream = videoElement.captureStream(); + + when(() => window.navigator).thenReturn(navigator); + when(() => navigator.mediaDevices).thenReturn(mediaDevices); + when( + () => mediaDevices.getUserMedia(any()), + ).thenAnswer((_) async => mediaStream); + }); + + group('initialize', () { + testWidgets( + 'creates a video element ' + 'with correct properties', (tester) async { + const audioConstraints = AudioConstraints(enabled: true); + + final camera = Camera( + textureId: 1, + options: CameraOptions( + audio: audioConstraints, + ), + window: window, + ); + + await camera.initialize(); + + expect(camera.videoElement, isNotNull); + expect(camera.videoElement.autoplay, isFalse); + expect(camera.videoElement.muted, !audioConstraints.enabled); + expect(camera.videoElement.srcObject, mediaStream); + expect(camera.videoElement.attributes.keys, contains('playsinline')); + + expect( + camera.videoElement.style.transformOrigin, equals('center center')); + expect(camera.videoElement.style.pointerEvents, equals('none')); + expect(camera.videoElement.style.width, equals('100%')); + expect(camera.videoElement.style.height, equals('100%')); + expect(camera.videoElement.style.objectFit, equals('cover')); + expect(camera.videoElement.style.transform, equals('scaleX(-1)')); + }); + + testWidgets( + 'creates a wrapping div element ' + 'with correct properties', (tester) async { + final camera = Camera( + textureId: 1, + window: window, + ); + + await camera.initialize(); + + expect(camera.divElement, isNotNull); + expect(camera.divElement.style.objectFit, equals('cover')); + expect(camera.divElement.children, contains(camera.videoElement)); + }); + + testWidgets('calls getUserMedia with provided options', (tester) async { + final options = CameraOptions( + video: VideoConstraints( + facingMode: FacingModeConstraint.exact(CameraType.user), + width: VideoSizeConstraint(ideal: 200), + ), + ); + + final optionsJson = await options.toJson(); + + final camera = Camera( + textureId: 1, + options: options, + window: window, + ); + + await camera.initialize(); + + verify(() => mediaDevices.getUserMedia(optionsJson)).called(1); + }); + + group('throws CameraException', () { + testWidgets( + 'with notSupported error ' + 'when there are no media devices', (tester) async { + when(() => navigator.mediaDevices).thenReturn(null); + + final camera = Camera( + textureId: 1, + window: window, + ); + + expect( + camera.initialize, + throwsA( + isA().having( + (e) => e.code, + 'code', + CameraErrorCodes.notSupported, + ), + ), + ); + }); + + testWidgets( + 'with notFound error ' + 'when getUserMedia throws DomException ' + 'with NotFoundError', (tester) async { + when(() => mediaDevices.getUserMedia(any())) + .thenThrow(FakeDomException('NotFoundError')); + + final camera = Camera( + textureId: 1, + window: window, + ); + + expect( + camera.initialize, + throwsA( + isA().having( + (e) => e.code, + 'code', + CameraErrorCodes.notFound, + ), + ), + ); + }); + + testWidgets( + 'with notFound error ' + 'when getUserMedia throws DomException ' + 'with DevicesNotFoundError', (tester) async { + when(() => mediaDevices.getUserMedia(any())) + .thenThrow(FakeDomException('DevicesNotFoundError')); + + final camera = Camera( + textureId: 1, + window: window, + ); + + expect( + camera.initialize, + throwsA( + isA().having( + (e) => e.code, + 'code', + CameraErrorCodes.notFound, + ), + ), + ); + }); + + testWidgets( + 'with notReadable error ' + 'when getUserMedia throws DomException ' + 'with NotReadableError', (tester) async { + when(() => mediaDevices.getUserMedia(any())) + .thenThrow(FakeDomException('NotReadableError')); + + final camera = Camera( + textureId: 1, + window: window, + ); + + expect( + camera.initialize, + throwsA( + isA().having( + (e) => e.code, + 'code', + CameraErrorCodes.notReadable, + ), + ), + ); + }); + + testWidgets( + 'with notReadable error ' + 'when getUserMedia throws DomException ' + 'with TrackStartError', (tester) async { + when(() => mediaDevices.getUserMedia(any())) + .thenThrow(FakeDomException('TrackStartError')); + + final camera = Camera( + textureId: 1, + window: window, + ); + + expect( + camera.initialize, + throwsA( + isA().having( + (e) => e.code, + 'code', + CameraErrorCodes.notReadable, + ), + ), + ); + }); + + testWidgets( + 'with overconstrained error ' + 'when getUserMedia throws DomException ' + 'with OverconstrainedError', (tester) async { + when(() => mediaDevices.getUserMedia(any())) + .thenThrow(FakeDomException('OverconstrainedError')); + + final camera = Camera( + textureId: 1, + window: window, + ); + + expect( + camera.initialize, + throwsA( + isA().having( + (e) => e.code, + 'code', + CameraErrorCodes.overconstrained, + ), + ), + ); + }); + + testWidgets( + 'with overconstrained error ' + 'when getUserMedia throws DomException ' + 'with ConstraintNotSatisfiedError', (tester) async { + when(() => mediaDevices.getUserMedia(any())) + .thenThrow(FakeDomException('ConstraintNotSatisfiedError')); + + final camera = Camera( + textureId: 1, + window: window, + ); + + expect( + camera.initialize, + throwsA( + isA().having( + (e) => e.code, + 'code', + CameraErrorCodes.overconstrained, + ), + ), + ); + }); + + testWidgets( + 'with permissionDenied error ' + 'when getUserMedia throws DomException ' + 'with NotAllowedError', (tester) async { + when(() => mediaDevices.getUserMedia(any())) + .thenThrow(FakeDomException('NotAllowedError')); + + final camera = Camera( + textureId: 1, + window: window, + ); + + expect( + camera.initialize, + throwsA( + isA().having( + (e) => e.code, + 'code', + CameraErrorCodes.permissionDenied, + ), + ), + ); + }); + + testWidgets( + 'with permissionDenied error ' + 'when getUserMedia throws DomException ' + 'with PermissionDeniedError', (tester) async { + when(() => mediaDevices.getUserMedia(any())) + .thenThrow(FakeDomException('PermissionDeniedError')); + + final camera = Camera( + textureId: 1, + window: window, + ); + + expect( + camera.initialize, + throwsA( + isA().having( + (e) => e.code, + 'code', + CameraErrorCodes.permissionDenied, + ), + ), + ); + }); + + testWidgets( + 'with type error ' + 'when getUserMedia throws DomException ' + 'with TypeError', (tester) async { + when(() => mediaDevices.getUserMedia(any())) + .thenThrow(FakeDomException('TypeError')); + + final camera = Camera( + textureId: 1, + window: window, + ); + + expect( + camera.initialize, + throwsA( + isA().having( + (e) => e.code, + 'code', + CameraErrorCodes.type, + ), + ), + ); + }); + + testWidgets( + 'with unknown error ' + 'when getUserMedia throws DomException ' + 'with an unknown error', (tester) async { + when(() => mediaDevices.getUserMedia(any())) + .thenThrow(FakeDomException('Unknown')); + + final camera = Camera( + textureId: 1, + window: window, + ); + + expect( + camera.initialize, + throwsA( + isA().having( + (e) => e.code, + 'code', + CameraErrorCodes.unknown, + ), + ), + ); + }); + + testWidgets( + 'with unknown error ' + 'when getUserMedia throws an unknown exception', (tester) async { + when(() => mediaDevices.getUserMedia(any())).thenThrow(Exception()); + + final camera = Camera( + textureId: 1, + window: window, + ); + + expect( + camera.initialize, + throwsA( + isA().having( + (e) => e.code, + 'code', + CameraErrorCodes.unknown, + ), + ), + ); + }); + }); + }); + + group('play', () { + testWidgets('starts playing the video element', (tester) async { + var startedPlaying = false; + + final camera = Camera( + textureId: 1, + window: window, + ); + + await camera.initialize(); + + camera.videoElement.onPlay.listen((event) => startedPlaying = true); + + await camera.play(); + + expect(startedPlaying, isTrue); + }); + + testWidgets( + 'assigns media stream to the video element\'s source ' + 'if it does not exist', (tester) async { + final camera = Camera( + textureId: 1, + window: window, + ); + + await camera.initialize(); + + /// Remove the video element's source + /// by stopping the camera. + // ignore: cascade_invocations + camera.stop(); + + await camera.play(); + + expect(camera.videoElement.srcObject, mediaStream); + }); + }); + + group('stop', () { + testWidgets('resets the video element\'s source', (tester) async { + final camera = Camera( + textureId: 1, + window: window, + ); + + await camera.initialize(); + await camera.play(); + + camera.stop(); + + expect(camera.videoElement.srcObject, isNull); + }); + }); + + group('takePicture', () { + testWidgets('returns a captured picture', (tester) async { + final camera = Camera( + textureId: 1, + window: window, + ); + + await camera.initialize(); + await camera.play(); + + final pictureFile = await camera.takePicture(); + + expect(pictureFile, isNotNull); + }); + }); + + group('dispose', () { + testWidgets('resets the video element\'s source', (tester) async { + final camera = Camera( + textureId: 1, + window: window, + ); + + await camera.initialize(); + + camera.dispose(); + + expect(camera.videoElement.srcObject, isNull); + }); + }); + }); +} diff --git a/packages/camera/camera_web/lib/src/camera.dart b/packages/camera/camera_web/lib/src/camera.dart new file mode 100644 index 000000000000..41692d548882 --- /dev/null +++ b/packages/camera/camera_web/lib/src/camera.dart @@ -0,0 +1,196 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'dart:html' as html; +import 'shims/dart_ui.dart' as ui; + +import 'package:camera_platform_interface/camera_platform_interface.dart'; +import 'package:camera_web/src/types/camera_error_codes.dart'; +import 'package:camera_web/src/types/camera_options.dart'; + +String _getViewType(int cameraId) => 'plugins.flutter.io/camera_$cameraId'; + +/// A camera initialized from the media devices in the current [window]. +/// The obtained camera is constrained by the [options] used when +/// querying the media input in [_getMediaStream]. +/// +/// The camera stream is displayed in the [videoElement] wrapped in the +/// [divElement] to avoid overriding the custom styles applied to +/// the video element in [_applyDefaultVideoStyles]. +/// See: https://github.com/flutter/flutter/issues/79519 +/// +/// The camera can be played/stopped by calling [play]/[stop] +/// or may capture a picture by [takePicture]. +/// +/// The [textureId] is used to register a camera view with the id +/// returned by [_getViewType]. +class Camera { + /// Creates a new instance of [Camera] + /// with the given [textureId] and optional + /// [options] and [window]. + Camera({ + required this.textureId, + this.options = const CameraOptions(), + html.Window? window, + }) : window = window ?? html.window; + + /// The texture id used to register the camera view. + final int textureId; + + /// The camera options used to initialize a camera, empty by default. + final CameraOptions options; + + /// The current browser window used to access device cameras. + final html.Window window; + + /// The video element that displays the camera stream. + /// Initialized in [initialize]. + late html.VideoElement videoElement; + + /// The wrapping element for the [videoElement] to avoid overriding + /// the custom styles applied in [_applyDefaultVideoStyles]. + /// Initialized in [initialize]. + late html.DivElement divElement; + + /// Initializes the camera stream displayed in the [videoElement]. + /// Registers the camera view with [textureId] under [_getViewType] type. + Future initialize() async { + final isSupported = window.navigator.mediaDevices?.getUserMedia != null; + if (!isSupported) { + throw CameraException( + CameraErrorCodes.notSupported, + 'The camera is not supported on this device.', + ); + } + + videoElement = html.VideoElement(); + _applyDefaultVideoStyles(videoElement); + + divElement = html.DivElement() + ..style.setProperty('object-fit', 'cover') + ..append(videoElement); + + ui.platformViewRegistry.registerViewFactory( + _getViewType(textureId), + (_) => divElement, + ); + + final stream = await _getMediaStream(); + videoElement + ..autoplay = false + ..muted = !options.audio.enabled + ..srcObject = stream + ..setAttribute('playsinline', ''); + } + + Future _getMediaStream() async { + try { + final constraints = await options.toJson(); + return await window.navigator.mediaDevices!.getUserMedia(constraints); + } on html.DomException catch (e) { + switch (e.name) { + case 'NotFoundError': + case 'DevicesNotFoundError': + throw CameraException( + CameraErrorCodes.notFound, + 'No camera found for the given camera options.', + ); + case 'NotReadableError': + case 'TrackStartError': + throw CameraException( + CameraErrorCodes.notReadable, + 'The camera is not readable due to a hardware error ' + 'that prevented access to the device.', + ); + case 'OverconstrainedError': + case 'ConstraintNotSatisfiedError': + throw CameraException( + CameraErrorCodes.overconstrained, + 'The camera options are impossible to satisfy.', + ); + case 'NotAllowedError': + case 'PermissionDeniedError': + throw CameraException( + CameraErrorCodes.permissionDenied, + 'The camera cannot be used or the permission ' + 'to access the camera is not granted.', + ); + case 'TypeError': + throw CameraException( + CameraErrorCodes.type, + 'The camera options are incorrect or attempted' + 'to access the media input from an insecure context.', + ); + default: + throw CameraException( + CameraErrorCodes.unknown, + 'An unknown error occured when initializing the camera.', + ); + } + } catch (_) { + throw CameraException( + CameraErrorCodes.unknown, + 'An unknown error occured when initializing the camera.', + ); + } + } + + /// Starts the camera stream. + /// + /// Initializes the camera source if the camera was previously stopped. + Future play() async { + if (videoElement.srcObject == null) { + final stream = await _getMediaStream(); + videoElement.srcObject = stream; + } + await videoElement.play(); + } + + /// Stops the camera stream and resets the camera source. + void stop() { + final tracks = videoElement.srcObject?.getTracks(); + if (tracks != null) { + for (final track in tracks) { + track.stop(); + } + } + videoElement.srcObject = null; + } + + /// Captures a picture and returns the saved file in a JPEG format. + Future takePicture() async { + final videoWidth = videoElement.videoWidth; + final videoHeight = videoElement.videoHeight; + final canvas = html.CanvasElement(width: videoWidth, height: videoHeight); + canvas.context2D + ..translate(videoWidth, 0) + ..scale(-1, 1) + ..drawImageScaled(videoElement, 0, 0, videoWidth, videoHeight); + final blob = await canvas.toBlob('image/jpeg'); + return XFile(html.Url.createObjectUrl(blob)); + } + + /// Disposes the camera by stopping the camera stream + /// and reloading the camera source. + void dispose() { + /// Stop the camera stream. + stop(); + + /// Reset the [videoElement] to its initial state. + videoElement + ..srcObject = null + ..load(); + } + + /// Applies default styles to the video [element]. + void _applyDefaultVideoStyles(html.VideoElement element) { + element.style + ..transformOrigin = 'center' + ..pointerEvents = 'none' + ..width = '100%' + ..height = '100%' + ..objectFit = 'cover' + ..transform = 'scaleX(-1)'; + } +} diff --git a/packages/camera/camera_web/lib/src/shims/dart_ui.dart b/packages/camera/camera_web/lib/src/shims/dart_ui.dart new file mode 100644 index 000000000000..5eacec5fe867 --- /dev/null +++ b/packages/camera/camera_web/lib/src/shims/dart_ui.dart @@ -0,0 +1,10 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +/// This file shims dart:ui in web-only scenarios, getting rid of the need to +/// suppress analyzer warnings. + +// TODO(flutter/flutter#55000) Remove this file once web-only dart:ui APIs +// are exposed from a dedicated place. +export 'dart_ui_fake.dart' if (dart.library.html) 'dart_ui_real.dart'; diff --git a/packages/camera/camera_web/lib/src/shims/dart_ui_fake.dart b/packages/camera/camera_web/lib/src/shims/dart_ui_fake.dart new file mode 100644 index 000000000000..f2862af8b704 --- /dev/null +++ b/packages/camera/camera_web/lib/src/shims/dart_ui_fake.dart @@ -0,0 +1,28 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'dart:html' as html; + +// Fake interface for the logic that this package needs from (web-only) dart:ui. +// This is conditionally exported so the analyzer sees these methods as available. + +/// Shim for web_ui engine.PlatformViewRegistry +/// https://github.com/flutter/engine/blob/master/lib/web_ui/lib/ui.dart#L62 +class platformViewRegistry { + /// Shim for registerViewFactory + /// https://github.com/flutter/engine/blob/master/lib/web_ui/lib/ui.dart#L72 + static registerViewFactory( + String viewTypeId, html.Element Function(int viewId) viewFactory) {} +} + +/// Shim for web_ui engine.AssetManager. +/// https://github.com/flutter/engine/blob/master/lib/web_ui/lib/src/engine/assets.dart#L12 +class webOnlyAssetManager { + /// Shim for getAssetUrl. + /// https://github.com/flutter/engine/blob/master/lib/web_ui/lib/src/engine/assets.dart#L45 + static getAssetUrl(String asset) {} +} + +/// Signature of callbacks that have no arguments and return no data. +typedef VoidCallback = void Function(); diff --git a/packages/camera/camera_web/lib/src/shims/dart_ui_real.dart b/packages/camera/camera_web/lib/src/shims/dart_ui_real.dart new file mode 100644 index 000000000000..276b768c76c5 --- /dev/null +++ b/packages/camera/camera_web/lib/src/shims/dart_ui_real.dart @@ -0,0 +1,5 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +export 'dart:ui'; diff --git a/packages/camera/camera_web/lib/src/types/camera_error_codes.dart b/packages/camera/camera_web/lib/src/types/camera_error_codes.dart new file mode 100644 index 000000000000..f8dc5dfc4e32 --- /dev/null +++ b/packages/camera/camera_web/lib/src/types/camera_error_codes.dart @@ -0,0 +1,29 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +/// Error codes that may occur during the camera initialization or streaming. +abstract class CameraErrorCodes { + /// The camera is not supported. + static const notSupported = 'cameraNotSupported'; + + /// The camera is not found. + static const notFound = 'cameraNotFound'; + + /// The camera is not readable. + static const notReadable = 'cameraNotReadable'; + + /// The camera options are impossible to satisfy. + static const overconstrained = 'cameraOverconstrained'; + + /// The camera cannot be used or the permission + /// to access the camera is not granted. + static const permissionDenied = 'cameraPermission'; + + /// The camera options are incorrect or attempted + /// to access the media input from an insecure context. + static const type = 'cameraType'; + + /// An unknown camera error. + static const unknown = 'cameraUnknown'; +} diff --git a/packages/camera/camera_web/lib/src/types/types.dart b/packages/camera/camera_web/lib/src/types/types.dart index deccd32da4c0..fc1f931679ff 100644 --- a/packages/camera/camera_web/lib/src/types/types.dart +++ b/packages/camera/camera_web/lib/src/types/types.dart @@ -2,4 +2,5 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. +export 'camera_error_codes.dart'; export 'camera_options.dart';