From a1ce331afe436ef165a6a1d17841426391b78929 Mon Sep 17 00:00:00 2001 From: Harkirat Singh <65155920+0xharkirat@users.noreply.github.com> Date: Thu, 27 Aug 2026 20:36:37 +1000 Subject: [PATCH] [camera_web] Fix TypeError when reading the torch capability Browsers do not report the `torch` capability in the shape the Image Capture specification describes. The specification declares `sequence torch`, and `package:web` is generated from it, but both Chromium and WebKit declare a bare `boolean torch` in their IDL. `NonStandardFieldsOnMediaTrackCapabilities.torchNullable` mirrored the specification, so reading it on a camera that has a flash threw before the torch could ever be applied. It surfaced differently per compiler: DDC threw a `TypeError`, dart2js a `NoSuchMethodError`, and dart2wasm an `ArgumentError`. Both `setFlashMode` and `takePicture` reach it. Reads the capability as `JSAny?` and adds `canEnableTorch`, which accepts either shape and reports false for anything else, so a browser that reports something unexpected raises `torchModeNotSupported` rather than crashing. Reading a sequence now also answers with `any` rather than `first`, which was both wrong for `[false, true]` and threw a `StateError` on an empty sequence. Fixes https://github.com/flutter/flutter/issues/191384 --- packages/camera/camera_web/CHANGELOG.md | 6 + .../example/integration_test/camera_test.dart | 136 +++++++++++++++++- .../camera/camera_web/lib/src/camera.dart | 3 +- .../camera_web/lib/src/pkg_web_tweaks.dart | 36 ++++- packages/camera/camera_web/pubspec.yaml | 2 +- 5 files changed, 178 insertions(+), 5 deletions(-) diff --git a/packages/camera/camera_web/CHANGELOG.md b/packages/camera/camera_web/CHANGELOG.md index a5d9cefa755d..786380923b14 100644 --- a/packages/camera/camera_web/CHANGELOG.md +++ b/packages/camera/camera_web/CHANGELOG.md @@ -1,3 +1,9 @@ +## 0.3.5+6 + +* Fixes a `TypeError` in `setFlashMode` and `takePicture` caused by browsers reporting the + `torch` capability as a `boolean` instead of the `boolean` sequence the Image Capture + specification describes. + ## 0.3.5+5 * Removes invalid @JS annotation from extension type constructors. diff --git a/packages/camera/camera_web/example/integration_test/camera_test.dart b/packages/camera/camera_web/example/integration_test/camera_test.dart index f03ba1993d48..5027aef8c124 100644 --- a/packages/camera/camera_web/example/integration_test/camera_test.dart +++ b/packages/camera/camera_web/example/integration_test/camera_test.dart @@ -12,6 +12,7 @@ import 'package:camera_platform_interface/camera_platform_interface.dart'; // ignore_for_file: implementation_imports import 'package:camera_web/src/camera.dart'; import 'package:camera_web/src/types/types.dart'; +import 'package:flutter/foundation.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:integration_test/integration_test.dart'; import 'package:mockito/mockito.dart'; @@ -262,7 +263,7 @@ void main() { videoElement = getVideoElementWithBlankStream(const Size(100, 100))..muted = true; mockVideoTrack.getCapabilities = () { - return MediaTrackCapabilities(torch: [true.toJS].toJS); + return capabilitiesWithTorch(true.toJS); }.toJS; }); @@ -443,6 +444,125 @@ void main() { expect(capturedConstraints[0].torch.dartify(), false); }); + // Regression tests for https://github.com/flutter/flutter/issues/191384. + group('reads the torch capability', () { + late List warnings; + late DebugPrintCallback originalDebugPrint; + + setUp(() { + warnings = []; + originalDebugPrint = debugPrint; + debugPrint = (String? message, {int? wrapWidth}) { + if (message != null) { + warnings.add(message); + } + }; + }); + + tearDown(() { + debugPrint = originalDebugPrint; + }); + + final torchCapabilities = { + 'reported as a bare true by Chromium and WebKit': (true.toJS, true), + 'reported as a bare false by Chromium and WebKit': (false.toJS, false), + 'reported as a sequence by a browser following the specification': ( + [false.toJS, true.toJS].toJS, + true, + ), + 'reported as a sequence holding only true': ([true.toJS].toJS, true), + 'reported as a sequence holding only false': ([false.toJS].toJS, false), + 'reported as an empty sequence': ([].toJS, false), + 'not reported by the browser at all': (null, false), + }; + + for (final MapEntry testCase in torchCapabilities.entries) { + final (JSAny? capability, bool canEnableTorch) = testCase.value; + + testWidgets(testCase.key, (WidgetTester tester) async { + mockMediaDevices.getSupportedConstraints = () { + return MediaTrackSupportedConstraints(torch: true); + }.toJS; + + mockVideoTrack.getCapabilities = () { + return capabilitiesWithTorch(capability); + }.toJS; + + final camera = Camera(textureId: textureId, cameraService: cameraService) + ..window = window + ..stream = videoStream; + + final capturedConstraints = []; + mockVideoTrack.applyConstraints = ([MediaTrackConstraints? constraints]) { + if (constraints != null) { + capturedConstraints.add(constraints); + } + return Future.value().toJS; + }.toJS; + + if (canEnableTorch) { + camera.setFlashMode(FlashMode.torch); + + expect(capturedConstraints.length, 1); + expect(capturedConstraints[0].torch.dartify(), true); + } else { + expect( + () => camera.setFlashMode(FlashMode.torch), + throwsA( + isA() + .having((CameraWebException e) => e.cameraId, 'cameraId', textureId) + .having( + (CameraWebException e) => e.code, + 'code', + CameraErrorCode.torchModeNotSupported, + ), + ), + ); + expect(capturedConstraints, isEmpty); + } + + expect(warnings, isEmpty, reason: 'a recognized shape must not warn'); + }); + } + + final unrecognizedCapabilities = { + 'a value that is not a boolean': 'yes'.toJS, + 'a sequence holding something other than booleans': ['yes'.toJS].toJS, + }; + + for (final MapEntry testCase in unrecognizedCapabilities.entries) { + testWidgets('warns while debugging when the browser reports ' + '${testCase.key}', (WidgetTester tester) async { + mockMediaDevices.getSupportedConstraints = () { + return MediaTrackSupportedConstraints(torch: true); + }.toJS; + + mockVideoTrack.getCapabilities = () { + return capabilitiesWithTorch(testCase.value); + }.toJS; + + final camera = Camera(textureId: textureId, cameraService: cameraService) + ..window = window + ..stream = videoStream; + + expect( + () => camera.setFlashMode(FlashMode.torch), + throwsA( + isA().having( + (CameraWebException e) => e.code, + 'code', + CameraErrorCode.torchModeNotSupported, + ), + ), + ); + + expect(warnings, hasLength(1)); + expect(warnings.single, contains('torch')); + expect(warnings.single, contains('github.com/flutter/flutter/issues')); + }); + } + }); + group('throws a CameraWebException', () { testWidgets('with torchModeNotSupported error ' 'when the torch mode is not supported ' @@ -1275,3 +1395,17 @@ void main() { }); }); } + +/// Builds a [MediaTrackCapabilities] reporting [torch] as its torch +/// capability, or omitting the capability entirely when [torch] is null. +/// +/// Browser engines disagree on the shape of this value, so it cannot be built +/// with the typed `MediaTrackCapabilities` constructor from `package:web`, +/// which only accepts the `sequence` the specification describes. +MediaTrackCapabilities capabilitiesWithTorch(JSAny? torch) { + final capabilities = JSObject(); + if (torch != null) { + capabilities.setProperty('torch'.toJS, torch); + } + return capabilities as MediaTrackCapabilities; +} diff --git a/packages/camera/camera_web/lib/src/camera.dart b/packages/camera/camera_web/lib/src/camera.dart index 242a59d2b41c..479e285eb3df 100644 --- a/packages/camera/camera_web/lib/src/camera.dart +++ b/packages/camera/camera_web/lib/src/camera.dart @@ -333,8 +333,7 @@ class Camera { if (videoTracks.isNotEmpty) { final web.MediaStreamTrack defaultVideoTrack = videoTracks.first; - final bool canEnableTorchMode = - defaultVideoTrack.getCapabilities().torchNullable?.toDart.first.toDart ?? false; + final bool canEnableTorchMode = defaultVideoTrack.getCapabilities().canEnableTorch; if (canEnableTorchMode) { defaultVideoTrack.applyWebTweakConstraints( diff --git a/packages/camera/camera_web/lib/src/pkg_web_tweaks.dart b/packages/camera/camera_web/lib/src/pkg_web_tweaks.dart index a279e0059942..cb79d204ef2d 100644 --- a/packages/camera/camera_web/lib/src/pkg_web_tweaks.dart +++ b/packages/camera/camera_web/lib/src/pkg_web_tweaks.dart @@ -6,6 +6,7 @@ import 'dart:js_interop'; +import 'package:flutter/foundation.dart'; import 'package:web/web.dart'; /// Adds missing fields to [Element]. @@ -28,8 +29,41 @@ extension NonStandardFieldsOnMediaTrackCapabilities on MediaTrackCapabilities { @JS('zoom') external WebTweakMediaSettingsRange? get zoomNullable; + /// The raw `torch` capability, as reported by the browser. + /// + /// Chromium and WebKit report a `boolean`, while the Image Capture + /// specification changed this to `sequence` in + /// https://github.com/w3c/mediacapture-image/pull/305. Typed as [JSAny] so + /// that either shape can be read; see [canEnableTorch]. @JS('torch') - external JSArray? get torchNullable; + external JSAny? get torchNullable; + + /// Whether the camera is able to turn its torch on. + bool get canEnableTorch { + final JSAny? torch = torchNullable; + if (torch == null) { + return false; + } + if (torch.isA()) { + return (torch as JSBoolean).toDart; + } + if (torch.isA>()) { + final List values = (torch as JSArray).toDart; + if (values.every((JSAny? value) => value.isA())) { + return values.any((JSAny? value) => (value! as JSBoolean).toDart); + } + } + assert(() { + debugPrint( + 'camera_web: ignoring the `torch` capability of this camera because ' + 'the browser reported it as neither a boolean nor a sequence of ' + 'booleans. Please report the browser and its version at ' + 'https://github.com/flutter/flutter/issues.', + ); + return true; + }()); + return false; + } @JS('facingMode') external JSArray? get facingModeNullable; diff --git a/packages/camera/camera_web/pubspec.yaml b/packages/camera/camera_web/pubspec.yaml index dadd95cb64b3..e2749256bd6e 100644 --- a/packages/camera/camera_web/pubspec.yaml +++ b/packages/camera/camera_web/pubspec.yaml @@ -2,7 +2,7 @@ name: camera_web description: A Flutter plugin for getting information about and controlling the camera on Web. repository: https://github.com/flutter/packages/tree/main/packages/camera/camera_web issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+camera%22 -version: 0.3.5+5 +version: 0.3.5+6 environment: sdk: ^3.10.0