[camera_web] Fix TypeError when reading the torch capability (#12647)
## What broke
`setFlashMode` and `takePicture` threw on any camera that has a flash.
The Image Capture specification declares `sequence<boolean> torch` in
`MediaTrackCapabilities`. `package:web` is generated from that specification, so
`torchNullable` was typed `JSArray<JSBoolean>?`.
No browser ships that shape. Chromium and WebKit both declare a bare `boolean torch`.
Reading the property threw before the torch could be applied. The error depends on
the compiler:
| Compiler | Error |
| --- | --- |
| DDC | `TypeError: true: type 'bool' is not a subtype of type 'JSArray<Object?>?'` |
| dart2js | `NoSuchMethodError: method not found: 'gq'` |
| dart2wasm | `ArgumentError: Invalid argument(s)` |
The same line held two more defects:
- `.first` gave the wrong answer for `[false, true]`. A specification-shaped sequence
lists the values the source accepts, so that means the torch can be turned on.
- `.first` threw `StateError` on an empty sequence.
## The fix
Read `torch` as `JSAny?` and add `canEnableTorch`, which accepts either shape and
returns false for anything else.
A sequence is answered with `any` instead of `first`, and is only trusted when every
element is a boolean. Anything unrecognised raises `torchModeNotSupported` instead of
crashing, and prints a debug-only warning so the shape can be reported.
## Test matrix
| Browser reports | Before | After |
| --- | --- | --- |
| `true` | TypeError | torch on |
| `false` | TypeError | torchModeNotSupported |
| `[false, true]` | wrong answer | torch on |
| `[true]` | torch on | torch on |
| `[false]` | torchModeNotSupported | torchModeNotSupported |
| `[]` | StateError | torchModeNotSupported |
| key absent | torchModeNotSupported | torchModeNotSupported |
| `'yes'` | TypeError | torchModeNotSupported + warning |
| `['yes']` | TypeError | torchModeNotSupported + warning |
Nine cases are covered by integration tests. Six of them fail without this change.
## Devices tested
All nine shapes were run through the real plugin code on each of these. All passed.
| Device | Browser | Result |
| --- | --- | --- |
| Moto G56 5G, Android 16 | Chrome 151 | 9 / 9 |
| macOS 26.3 | Safari 26.3 | 9 / 9 |
| iPhone 17 Pro simulator, iOS 26.1 | Safari | 9 / 9 |
| macOS 26.3 | Chrome 151, dart2js and dart2wasm | 9 / 9 |
On the Moto G56 the rear camera reports `torch: true` as a bare boolean, and the flash
now turns on. The front camera does not report the key at all and raises
`torchModeNotSupported` as expected.
## What was added
- `canEnableTorch` on `NonStandardFieldsOnMediaTrackCapabilities`, which reads either shape.
- A debug-only `debugPrint` warning for a shape that is neither, so an unknown browser can
be reported rather than failing quietly.
- Nine integration tests covering every shape, plus a check that a recognised shape never warns.
- A CHANGELOG entry and a version bump to `0.3.5+6`.
## What was removed
- The `JSArray<JSBoolean>?` type on `torchNullable`, which no browser matches.
- The `?.toDart.first.toDart ?? false` chain in `_setTorchMode`.
## Issues fixed by this PR
Fixes https://github.com/flutter/flutter/issues/191384
## Pre-Review Checklist
🤖 Generated with [Claude Code](https://claude.com/claude-code)
diff --git a/packages/camera/camera_web/CHANGELOG.md b/packages/camera/camera_web/CHANGELOG.md
index a5d9cef..7863809 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 f03ba19..5027aef 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 @@
// 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 @@
videoElement = getVideoElementWithBlankStream(const Size(100, 100))..muted = true;
mockVideoTrack.getCapabilities = () {
- return MediaTrackCapabilities(torch: <JSBoolean>[true.toJS].toJS);
+ return capabilitiesWithTorch(true.toJS);
}.toJS;
});
@@ -443,6 +444,125 @@
expect(capturedConstraints[0].torch.dartify(), false);
});
+ // Regression tests for https://github.com/flutter/flutter/issues/191384.
+ group('reads the torch capability', () {
+ late List<String> warnings;
+ late DebugPrintCallback originalDebugPrint;
+
+ setUp(() {
+ warnings = <String>[];
+ originalDebugPrint = debugPrint;
+ debugPrint = (String? message, {int? wrapWidth}) {
+ if (message != null) {
+ warnings.add(message);
+ }
+ };
+ });
+
+ tearDown(() {
+ debugPrint = originalDebugPrint;
+ });
+
+ final torchCapabilities = <String, (JSAny?, bool)>{
+ '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': (
+ <JSBoolean>[false.toJS, true.toJS].toJS,
+ true,
+ ),
+ 'reported as a sequence holding only true': (<JSBoolean>[true.toJS].toJS, true),
+ 'reported as a sequence holding only false': (<JSBoolean>[false.toJS].toJS, false),
+ 'reported as an empty sequence': (<JSBoolean>[].toJS, false),
+ 'not reported by the browser at all': (null, false),
+ };
+
+ for (final MapEntry<String, (JSAny?, bool)> 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 = <MediaTrackConstraints>[];
+ mockVideoTrack.applyConstraints = ([MediaTrackConstraints? constraints]) {
+ if (constraints != null) {
+ capturedConstraints.add(constraints);
+ }
+ return Future<JSAny?>.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<CameraWebException>()
+ .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 = <String, JSAny>{
+ 'a value that is not a boolean': 'yes'.toJS,
+ 'a sequence holding something other than booleans': <JSAny>['yes'.toJS].toJS,
+ };
+
+ for (final MapEntry<String, JSAny> 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<CameraWebException>().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 @@
});
});
}
+
+/// 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<boolean>` 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 242a59d..479e285 100644
--- a/packages/camera/camera_web/lib/src/camera.dart
+++ b/packages/camera/camera_web/lib/src/camera.dart
@@ -333,8 +333,7 @@
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 a279e00..cb79d20 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 @@
@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<boolean>` 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<JSBoolean>? 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<JSBoolean>()) {
+ return (torch as JSBoolean).toDart;
+ }
+ if (torch.isA<JSArray<JSAny?>>()) {
+ final List<JSAny?> values = (torch as JSArray<JSAny?>).toDart;
+ if (values.every((JSAny? value) => value.isA<JSBoolean>())) {
+ 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<JSString>? get facingModeNullable;
diff --git a/packages/camera/camera_web/pubspec.yaml b/packages/camera/camera_web/pubspec.yaml
index dadd95c..e274925 100644
--- a/packages/camera/camera_web/pubspec.yaml
+++ b/packages/camera/camera_web/pubspec.yaml
@@ -2,7 +2,7 @@
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