Merge pull request #1489 from cypherstack/fix/qr-camera-frame-scaling
What changed, and why it matters
This commit fixes the desktop QR code scanner so it can read codes from high-resolution Windows webcams without freezing. It also stops the Windows camera plugin from secretly saving a snapshot to your Pictures folder every time it scans. The changes are framed by the developers as a performance and privacy fix, not as a security vulnerability.
No immediate security response is required. Treat this as a routine bug-fix/performance merge. If auditing, verify that the Windows preview-capture path no longer leaves any temporary files on disk and that the camera permission model is unchanged.
Security signals we found
Windows QR scanning no longer writes a JPEG to the user's Pictures folder on every scan (privacy leak removed)
Camera preview frames are now captured in memory and scaled before QR decoding
Added blank/repeated-frame detection to detect stalled camera previews
Added error handling and user-visible messages for camera initialization and runtime failures
camera_windows dependency upgraded from a custom fork to official pub.dev release
New unit and widget tests cover frame flipping, blank detection, fingerprinting, and QR decoding across image formats
Evidence from the diff
The patch refactors desktop QR scanning: it scales large camera frames down to 1280 px before decoding, switches from GlobalHistogramBinarizer to HybridBinarizer with a fallback, handles mirrored Windows preview frames correctly, and replaces takePicture() on Windows with in-memory preview capture. It also upgrades camera_windows from a CypherStack Git fork to the official 0.3.0+1 package. The code comments explicitly note that the previous Windows path used takePicture(), which ‘always saves a JPEG to the user’s Pictures folder’—a privacy leak. The commit title and message, however, describe the work as fixing frame scaling and a frozen preview.
Changed components
lib/widgets/desktop/qr_code_scanner_dialog.dartlib/utilities/qr_image_decoder.dartlib/utilities/camera_preview_frame.dartpubspec.lock / camera_windows dependencyscripts/app_config/templates/pubspec.template.yamlInspect captured patch +1020 / −104
### lib/utilities/camera_preview_frame.dart
@@ -0,0 +1,94 @@
+import 'dart:typed_data';
+
+import 'package:image/image.dart' as img;
+
+/// Why a frame captured from a camera preview can't be scanned.
+enum BlankPreviewFrame {
+ /// Nothing was drawn: the preview texture has no camera frame yet, or the
+ /// camera has been closed.
+ transparent,
+
+ /// Every visible pixel is black or close to it, as with a closed privacy
+ /// shutter.
+ black,
+}
+
+// Sensor noise keeps a covered webcam from being exactly 0.
+const _blackLevel = 16;
+
+/// Returns why [rgba] can't be scanned, or null if it has picture content.
+///
+/// [rgba] is tightly packed 8-bit RGBA, as from
+/// `ui.Image.toByteData(format: ui.ImageByteFormat.rawRgba)`.
+BlankPreviewFrame? blankPreviewFrame(Uint8List rgba) {
+ var anyVisible = false;
+ for (var i = 0; i + 3 < rgba.length; i += 4) {
+ if (rgba[i + 3] == 0) continue;
+ anyVisible = true;
+ if (rgba[i] > _blackLevel ||
+ rgba[i + 1] > _blackLevel ||
+ rgba[i + 2] > _blackLevel) {
+ return null;
+ }
+ }
+ return anyVisible ? BlankPreviewFrame.black : BlankPreviewFrame.transparent;
+}
+
+/// Builds an image from an RGBA capture of a horizontally mirrored camera
+/// preview, flipped back so a QR code in it reads the right way round.
+///
+/// [rgba] is tightly packed 8-bit RGBA of [width] x [height] pixels.
+img.Image imageFromMirroredPreviewRgba(
+ Uint8List rgba, {
+ required int width,
+ required int height,
+}) {
+ if (width <= 0 || height <= 0) {
+ throw ArgumentError("Invalid frame size ${width}x$height");
+ }
+ final byteLength = width * height * 4;
+ if (rgba.length < byteLength) {
+ throw ArgumentError.value(
+ rgba.length,
+ "rgba",
+ "Too short for a ${width}x$height RGBA frame",
+ );
+ }
+
+ // sublist copies into a fresh buffer, so the 32-bit view is aligned even
+ // if the caller's bytes start at an odd offset.
+ final src = rgba.sublist(0, byteLength).buffer.asUint32List();
+ final flipped = Uint32List(width * height);
+ for (var y = 0; y < height; y++) {
+ final row = y * width;
+ final last = row + width - 1;
+ for (var x = 0; x < width; x++) {
+ flipped[row + x] = src[last - x];
+ }
+ }
+
+ return img.Image.fromBytes(
+ width: width,
+ height: height,
+ bytes: flipped.buffer,
+ numChannels: 4,
+ order: img.ChannelOrder.rgba,
+ );
+}
+
+/// A 64-bit FNV-1a hash of [rgba], for telling a new camera frame from a
+/// repeat of the last one.
+///
+/// Frames from a live sensor never repeat byte for byte, so two captures
+/// with the same fingerprint mean the preview has stopped updating. This is
+/// a full pass over the buffer; an indexed loop keeps it to a few
+/// milliseconds for a 1280 px frame.
+int previewFrameFingerprint(Uint8List rgba) {
+ var hash = 0xcbf29ce484222325;
+ final length = rgba.length;
+ for (var i = 0; i < length; i++) {
+ hash ^= rgba[i];
+ hash *= 0x100000001b3;
+ }
+ return hash;
+}
### lib/utilities/qr_image_decoder.dart
@@ -0,0 +1,89 @@
+import 'dart:math' as math;
+import 'dart:typed_data';
+
+import 'package:image/image.dart' as img;
+import 'package:zxing2/qrcode.dart';
+
+/// Decodes a QR code from encoded image bytes (PNG, JPEG, or anything else
+/// the image package can read). Returns null if the bytes can't be decoded as
+/// an image or no QR code is found.
+String? decodeQrFromImageBytes(Uint8List bytes) {
+ final img.Image? image;
+ try {
+ image = img.decodeImage(bytes);
+ } catch (_) {
+ return null;
+ }
+ return image == null ? null : decodeQrFromImage(image);
+}
+
+/// Decodes a QR code from [image], whatever its bit depth, channel count or
+/// palette. Returns null if no QR code is found.
+///
+/// If [maxDimension] is set, larger images are scaled down to fit it first.
+/// Some webcams hand back full sensor-size photos (3264x2448 from a USB
+/// webcam on Windows), which took 2-3 s per frame to scan on an older laptop.
+String? decodeQrFromImage(img.Image image, {int? maxDimension}) {
+ if (maxDimension != null &&
+ math.max(image.width, image.height) > maxDimension) {
+ image = image.width >= image.height
+ ? img.copyResize(
+ image,
+ width: maxDimension,
+ interpolation: img.Interpolation.average,
+ )
+ : img.copyResize(
+ image,
+ height: maxDimension,
+ interpolation: img.Interpolation.average,
+ );
+ }
+
+ final LuminanceSource source;
+ try {
+ source = RGBLuminanceSource(image.width, image.height, _argbPixels(image));
+ } catch (_) {
+ return null;
+ }
+
+ // HybridBinarizer thresholds locally and copes with uneven lighting in a
+ // webcam frame far better. Keep the global one as a fallback.
+ for (final Binarizer binarizer in [
+ HybridBinarizer(source),
+ GlobalHistogramBinarizer(source),
+ ]) {
+ try {
+ final result = QRCodeReader().decode(BinaryBitmap(binarizer));
+ if (result.text.isNotEmpty) {
+ return result.text;
+ }
+ } catch (_) {
+ // No QR code found with this binarizer.
+ }
+ }
+ return null;
+}
+
+/// Packs [image] into the 0xAARRGGBB ints RGBLuminanceSource expects.
+///
+/// convert() keeps the source format unless told otherwise, so a 1-bit PNG
+/// (Python qrcode's default) stays bit-packed and a 16-bit one keeps 2 bytes
+/// per channel. Force 8-bit RGBA, which also resolves palettes. Transparent
+/// pixels are composited onto white so a dark-on-transparent code still has
+/// a light background.
+Int32List _argbPixels(img.Image image) {
+ final rgba = image.convert(format: img.Format.uint8, numChannels: 4);
+ final bytes = rgba.getBytes(order: img.ChannelOrder.rgba);
+ final pixels = Int32List(image.width * image.height);
+ for (var i = 0, j = 0; i < pixels.length; i++, j += 4) {
+ final a = bytes[j + 3];
+ int r = bytes[j], g = bytes[j + 1], b = bytes[j + 2];
+ if (a != 255) {
+ r = (r * a + 255 * (255 - a)) ~/ 255;
+ g = (g * a + 255 * (255 - a)) ~/ 255;
+ b = (b * a + 255 * (255 - a)) ~/ 255;
+ }
+ pixels[i] = 0xFF000000 | (r << 16) | (g << 8) | b;
+ }
+ return pixels;
+}
### lib/widgets/desktop/qr_code_scanner_dialog.dart
@@ -1,6 +1,9 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
+import 'dart:math';
+import 'dart:typed_data';
+import 'dart:ui' as ui;
import 'package:camera_linux/camera_linux.dart';
import 'package:camera_macos/camera_macos_arguments.dart';
@@ -10,12 +13,15 @@ import 'package:camera_platform_interface/camera_platform_interface.dart';
import 'package:camera_windows/camera_windows.dart';
import 'package:file_picker/file_picker.dart';
import 'package:flutter/material.dart';
+import 'package:flutter/rendering.dart';
import 'package:image/image.dart' as img;
-import 'package:zxing2/qrcode.dart';
import '../../notifications/show_flush_bar.dart';
+import '../../themes/stack_colors.dart';
import '../../utilities/assets.dart';
+import '../../utilities/camera_preview_frame.dart';
import '../../utilities/logger.dart';
+import '../../utilities/qr_image_decoder.dart';
import '../../utilities/text_styles.dart';
import 'desktop_dialog.dart';
import 'desktop_dialog_close_button.dart';
@@ -30,16 +36,40 @@ class QrCodeScannerDialog extends StatefulWidget {
}
class _QrCodeScannerDialogState extends State<QrCodeScannerDialog> {
- final CameraLinux? _cameraLinuxPlugin =
- Platform.isLinux ? CameraLinux() : null;
- final CameraWindows? _cameraWindowsPlugin =
- Platform.isWindows ? CameraWindows() : null;
+ // Created in _initializeCamera: the constructor loads libcamera_linux.so
+ // and throws if it can't.
+ CameraLinux? _cameraLinuxPlugin;
+ final CameraWindows? _cameraWindowsPlugin = Platform.isWindows
+ ? CameraWindows()
+ : null;
bool _isCameraOpen = false;
Image? _image;
bool _isScanning = false;
int _cameraId = -1;
String? _macOSDeviceId;
final int _imageDelayInMs = Platform.isLinux ? 500 : 250;
+ int _consecutiveFailures = 0;
+ // Time since the last usable frame, to notice a camera that opened but
+ // stopped (or never started) returning images.
+ final Stopwatch _sinceLastFrame = Stopwatch();
+ static const _frameTimeout = Duration(seconds: 10);
+ String? _cameraError;
+ String? _initError;
+
+ // On Windows we scan what the preview shows rather than calling
+ // takePicture(), which always saves a JPEG to the user's Pictures folder.
+ final GlobalKey _windowsPreviewKey = GlobalKey();
+ Size? _windowsPreviewSize;
+ bool _loggedBlankWindowsFrame = false;
+ bool _hasWindowsFrame = false;
+ // camera_windows keeps showing the last frame after the capture engine
+ // fails, and toImage() keeps returning it. The fingerprint tells a new
+ // frame from a repeat; the error event tells us the camera is gone at once.
+ int? _lastWindowsFrameFingerprint;
+ bool _loggedRepeatedWindowsFrame = false;
+ bool _windowsCameraLost = false;
+ StreamSubscription<CameraErrorEvent>? _windowsErrorSub;
+ bool _cameraStopped = false;
@override
void initState() {
@@ -52,37 +82,111 @@ class _QrCodeScannerDialogState extends State<QrCodeScannerDialog> {
_isCameraOpen = true;
});
unawaited(_captureAndScanImage());
+ } else if (mounted) {
+ setState(() {
+ _cameraError =
+ "Unable to open the camera"
+ "${_initError == null ? "" : " ($_initError)"}. You can "
+ "still use \"Select file\" to scan a QR code image.";
+ });
}
});
});
}
Future<bool> _initializeCamera() async {
try {
- if (Platform.isLinux && _cameraLinuxPlugin != null) {
- await _cameraLinuxPlugin.initializeCamera();
+ if (Platform.isLinux) {
+ final plugin = _cameraLinuxPlugin = CameraLinux();
+ // This only starts a capture thread. If OpenCV can't open a camera
+ // the thread just exits, and captureImage() keeps returning nothing,
+ // so wait for a first frame before calling the camera open.
+ await plugin.initializeCamera();
+ if (!await _waitForFirstLinuxFrame(plugin)) {
+ plugin.stopCamera();
+ if (!mounted) return false;
+ throw TimeoutException("No image from the camera", _frameTimeout);
+ }
Logging.instance.d("Linux Camera initialized");
} else if (Platform.isWindows && _cameraWindowsPlugin != null) {
- final List<CameraDescription> cameras =
- await _cameraWindowsPlugin.availableCameras();
+ final List<CameraDescription> cameras = await _cameraWindowsPlugin
+ .availableCameras();
if (cameras.isEmpty) {
throw CameraException('No cameras available', 'No cameras found.');
}
- final CameraDescription camera = cameras[0]; // Could be user-selected.
- _cameraId = await _cameraWindowsPlugin.createCameraWithSettings(
- camera,
- const MediaSettings(
- resolutionPreset: ResolutionPreset.low,
- fps: 4,
- videoBitrate: 200000,
- enableAudio: false,
- ),
+ Logging.instance.d(
+ "Windows cameras: ${cameras.map((c) => c.name).toList()}",
);
- await _cameraWindowsPlugin.initializeCamera(_cameraId);
- // await _cameraWindowsPlugin!.onCameraInitialized(_cameraId).first;
- // TODO [prio=low]: Make this work. ^^^
+ // Try anything that looks like an IR camera last.
+ final irName = RegExp(r'\bIR\b|infrared', caseSensitive: false);
+ final ordered = [
+ ...cameras.where((c) => !irName.hasMatch(c.name)),
+ ...cameras.where((c) => irName.hasMatch(c.name)),
+ ];
+
+ // initializeCamera() starts the preview, and camera_windows picks the
+ // preview mode as the largest one with height <= the preset cap
+ // (low = 240, medium = 480, max = none) at >= 15 fps. If no mode fits,
+ // it fails with "Failed to initialize video preview". We scan the
+ // preview, so this also sets the scan resolution. fps and
+ // videoBitrate only apply to video recording, so leave them out.
+ CameraException? lastError;
+ bool opened = false;
+ for (final camera in ordered) {
+ for (final preset in [
+ ResolutionPreset.medium,
+ ResolutionPreset.max,
+ ]) {
+ if (!mounted) return false;
+ try {
+ _cameraId = await _cameraWindowsPlugin.createCameraWithSettings(
+ camera,
+ MediaSettings(resolutionPreset: preset, enableAudio: false),
+ );
+ // The plugin reports the preview size through this event,
+ // which it adds before initializeCamera() returns.
+ final initialized = _cameraWindowsPlugin
+ .onCameraInitialized(_cameraId)
+ .first;
+ await _cameraWindowsPlugin.initializeCamera(_cameraId);
+ final event = await initialized;
+ _windowsPreviewSize = Size(
+ event.previewWidth,
+ event.previewHeight,
+ );
+ opened = true;
+ break;
+ } on CameraException catch (e) {
+ lastError = e;
+ Logging.instance.w(
+ "Windows camera ${camera.name} failed with $preset",
+ error: e,
+ );
+ if (_cameraId >= 0) {
+ await _cameraWindowsPlugin.dispose(_cameraId);
+ _cameraId = -1;
+ }
+ }
+ }
+ if (opened) break;
+ }
+ if (!opened) {
+ throw lastError ??
+ CameraException('camera_error', 'No usable camera found.');
+ }
+ if (!mounted) {
+ // Closed while the camera was opening; PopScope already ran.
+ await _cameraWindowsPlugin.dispose(_cameraId);
+ return false;
+ }
+ // The plugin's only closing event comes from our own dispose(), so
+ // the error event is the one that matters.
+ _windowsErrorSub = _cameraWindowsPlugin
+ .onCameraError(_cameraId)
+ .listen((e) => _onWindowsCameraLost(e.description));
Logging.instance.d(
- "Windows Camera initialized with ID: $_cameraId",
+ "Windows Camera initialized with ID: $_cameraId, "
+ "preview $_windowsPreviewSize",
);
} else if (Platform.isMacOS) {
final List<CameraMacOSDevice> videoDevices = await CameraMacOS.instance
@@ -91,16 +195,20 @@ class _QrCodeScannerDialogState extends State<QrCodeScannerDialog> {
throw Exception('No cameras available');
}
_macOSDeviceId = videoDevices.first.deviceId;
- await CameraMacOS.instance
- .initialize(cameraMacOSMode: CameraMacOSMode.photo);
-
- Logging.instance.d(
- "macOS Camera initialized with ID: $_macOSDeviceId",
+ await CameraMacOS.instance.initialize(
+ cameraMacOSMode: CameraMacOSMode.photo,
);
+
+ Logging.instance.d("macOS Camera initialized with ID: $_macOSDeviceId");
}
return true;
} catch (e, s) {
+ _initError = switch (e) {
+ CameraException() => e.description ?? e.code,
+ TimeoutException() => e.message ?? "$e",
+ _ => "$e",
+ };
Logging.instance.e(
"Failed to initialize camera",
error: e,
@@ -110,14 +218,79 @@ class _QrCodeScannerDialogState extends State<QrCodeScannerDialog> {
}
}
+ /// Polls [plugin] until it returns a frame. Returns false on timeout or if
+ /// the dialog closes first.
+ Future<bool> _waitForFirstLinuxFrame(CameraLinux plugin) async {
+ final stopwatch = Stopwatch()..start();
+ Object? lastError;
+ while (mounted && stopwatch.elapsed < _frameTimeout) {
+ try {
+ if ((await plugin.captureImage()).isNotEmpty) {
+ return true;
+ }
+ } catch (e) {
+ // captureImage() can throw on the null pointer it gets when there is
+ // no frame yet.
+ lastError = e;
+ }
+ await Future<void>.delayed(const Duration(milliseconds: 250));
+ }
+ if (lastError != null) {
+ Logging.instance.w("No frame from Linux camera", error: lastError);
+ }
+ return false;
+ }
+
+ /// Shows an error once no new frame has arrived for [_frameTimeout].
+ void _checkFrameTimeout() {
+ if (_sinceLastFrame.elapsed >= _frameTimeout &&
+ _cameraError == null &&
+ mounted) {
+ setState(() {
+ _cameraError =
+ "The camera preview has stopped updating. You can still "
+ "use \"Select file\" to scan a QR code image.";
+ });
+ }
+ }
+
+ /// The plugin reported a capture engine error. Its preview keeps the last
+ /// frame and it never restarts the stream, so stop scanning and say so now
+ /// rather than waiting for the frame timeout.
+ void _onWindowsCameraLost(String description) {
+ // The engine can report one failure several times, and an event already
+ // queued can still arrive after the dialog started closing.
+ if (_windowsCameraLost || !_isScanning) return;
+ _windowsCameraLost = true;
+ _isScanning = false;
+ Logging.instance.w("Windows camera error: $description");
+ // FormatMessage text ends in ".\r\n", and unknown errors come through
+ // empty.
+ final detail = description.trim().replaceFirst(RegExp(r"\.$"), "");
+ if (!mounted) return;
+ setState(() {
+ _cameraError =
+ "The camera stopped working"
+ "${detail.isEmpty ? "" : " ($detail)"}. Close and reopen the "
+ "scanner to try again, or use \"Select file\" to scan a QR code "
+ "image.";
+ });
+ }
+
Future<void> _stopCamera() async {
_isScanning = false;
+ // Runs from PopScope and from dispose(); the second call has nothing to
+ // do.
+ if (_cameraStopped) return;
+ _cameraStopped = true;
try {
if (Platform.isLinux && _cameraLinuxPlugin != null) {
- _cameraLinuxPlugin.stopCamera();
+ _cameraLinuxPlugin!.stopCamera();
Logging.instance.d("Linux Camera stopped");
} else if (Platform.isWindows && _cameraWindowsPlugin != null) {
+ await _windowsErrorSub?.cancel();
+ _windowsErrorSub = null;
// if (_cameraId >= 0) {
await _cameraWindowsPlugin.dispose(_cameraId);
Logging.instance.d("Windows Camera stopped with ID: $_cameraId");
@@ -135,69 +308,170 @@ class _QrCodeScannerDialogState extends State<QrCodeScannerDialog> {
// }
}
} catch (e, s) {
- Logging.instance.e(
- "Failed to stop camera",
- error: e,
- stackTrace: s,
+ Logging.instance.e("Failed to stop camera", error: e, stackTrace: s);
+ }
+ }
+
+ @override
+ void dispose() {
+ // PopScope normally gets here first; this covers a route removed without
+ // a pop.
+ unawaited(_stopCamera());
+ super.dispose();
+ }
+
+ /// Grabs the frame the Windows preview is showing, in memory. Returns null
+ /// when there is nothing to scan yet, or nothing new since the last call.
+ Future<img.Image?> _captureWindowsPreviewFrame() async {
+ final boundary = _windowsPreviewKey.currentContext?.findRenderObject();
+ // Not laid out or painted yet, e.g. just after the camera opened.
+ // debugNeedsPaint is always false in release builds, where toImage()
+ // would throw instead and the capture loop retries after a delay.
+ if (boundary is! RenderRepaintBoundary ||
+ !boundary.hasSize ||
+ boundary.size.isEmpty ||
+ boundary.debugNeedsPaint) {
+ return null;
+ }
+
+ // toImage() redraws the texture at whatever scale we ask for, so match
+ // the camera's own resolution instead of the on-screen size.
+ final preview = _windowsPreviewSize;
+ double pixelRatio = 1;
+ if (preview != null && preview.width > 0 && preview.height > 0) {
+ final fit = min(
+ 1.0,
+ _cameraFrameMaxDimension / max(preview.width, preview.height),
);
+ // toImage() rounds each side up, and the laid-out box is rarely exactly
+ // the camera's aspect ratio, so take the smaller ratio and stay a hair
+ // under it. Otherwise a 640x480 camera can come back as 640x481.
+ pixelRatio =
+ min(
+ preview.width * fit / boundary.size.width,
+ preview.height * fit / boundary.size.height,
+ ) *
+ (1 - 1e-9);
+ }
+
+ final ui.Image frame = await boundary.toImage(pixelRatio: pixelRatio);
+ final int width = frame.width;
+ final int height = frame.height;
+ final ByteData? bytes;
+ try {
+ bytes = await frame.toByteData(format: ui.ImageByteFormat.rawRgba);
+ } finally {
+ frame.dispose();
}
+ // The dialog closed while we were waiting on the raster thread.
+ if (!_isScanning || !mounted || bytes == null) return null;
+
+ final rgba = bytes.buffer.asUint8List(
+ bytes.offsetInBytes,
+ bytes.lengthInBytes,
+ );
+ final blank = blankPreviewFrame(rgba);
+ if (blank != null) {
+ if (!_loggedBlankWindowsFrame) {
+ _loggedBlankWindowsFrame = true;
+ Logging.instance.w(
+ "Windows camera preview frame is ${blank.name}, "
+ "waiting for a usable one",
+ );
+ }
+ return null;
+ }
+ _loggedBlankWindowsFrame = false;
+
+ // A repeat of the last capture means the preview has stopped updating,
+ // so treat it like no frame and let the frame timeout notice.
+ final fingerprint = previewFrameFingerprint(rgba);
+ if (fingerprint == _lastWindowsFrameFingerprint) {
+ if (!_loggedRepeatedWindowsFrame) {
+ _loggedRepeatedWindowsFrame = true;
+ Logging.instance.w(
+ "Windows camera preview frame has not changed, waiting for a new one",
+ );
+ }
+ return null;
+ }
+ _lastWindowsFrameFingerprint = fingerprint;
+ _loggedRepeatedWindowsFrame = false;
+
+ // camera_windows mirrors the preview, so flip it back for zxing.
+ return imageFromMirroredPreviewRgba(rgba, width: width, height: height);
}
Future<void> _captureAndScanImage() async {
_isScanning = true;
+ _sinceLastFrame.start();
while (_isScanning) {
try {
String? base64Image;
+ img.Image? windowsFrame;
if (Platform.isLinux && _cameraLinuxPlugin != null) {
- base64Image = await _cameraLinuxPlugin.captureImage();
+ base64Image = await _cameraLinuxPlugin!.captureImage();
} else if (Platform.isWindows) {
- final XFile xfile =
- await _cameraWindowsPlugin!.takePicture(_cameraId);
- final bytes = await xfile.readAsBytes();
- base64Image = base64Encode(bytes);
- // We could use a Uint8List to optimize for Windows and macOS.
+ windowsFrame = await _captureWindowsPreviewFrame();
} else if (Platform.isMacOS) {
final macOSimg = await CameraMacOS.instance.takePicture();
if (macOSimg == null) {
Logging.instance.w("Failed to capture image");
await Future<void>.delayed(Duration(milliseconds: _imageDelayInMs));
+ _checkFrameTimeout();
continue;
}
final img.Image? image = img.decodeImage(macOSimg.bytes!);
if (image == null) {
Logging.instance.w("Failed to capture image");
await Future<void>.delayed(Duration(milliseconds: _imageDelayInMs));
+ _checkFrameTimeout();
continue;
}
base64Image = base64Encode(img.encodePng(image));
}
- if (base64Image == null || base64Image.isEmpty) {
+ if (windowsFrame == null &&
+ (base64Image == null || base64Image.isEmpty)) {
// Logging.instance
// .log("Failed to capture image", level: LogLevel.Error);
// Spammy.
await Future<void>.delayed(Duration(milliseconds: _imageDelayInMs));
+ _checkFrameTimeout();
continue;
}
- final img.Image? image = img.decodeImage(base64Decode(base64Image));
+ final img.Image? image =
+ windowsFrame ?? img.decodeImage(base64Decode(base64Image!));
// TODO [prio=low]: Optimize this process. Docs say:
// > WARNING Since this will check the image data against all known
// > decoders, it is much slower than using an explicit decoder
if (image == null) {
Logging.instance.w("Failed to decode image");
await Future<void>.delayed(Duration(milliseconds: _imageDelayInMs));
+ _checkFrameTimeout();
continue;
}
+ _consecutiveFailures = 0;
+ _sinceLastFrame.reset();
if (mounted) {
setState(() {
- _image = Image.memory(
- base64Decode(base64Image!),
- fit: BoxFit.cover,
- );
+ _cameraError = null;
+ // Windows already shows the live preview.
+ if (windowsFrame == null) {
+ _image = Image.memory(
+ base64Decode(base64Image!),
+ fit: BoxFit.cover,
+ );
+ } else {
+ _hasWindowsFrame = true;
+ }
});
}
- final String? scanResult = await _scanImage(image);
+ final String? scanResult = await _scanImage(
+ image,
+ maxDimension: _cameraFrameMaxDimension,
+ );
if (scanResult != null && scanResult.isNotEmpty) {
await _stopCamera();
@@ -214,43 +488,85 @@ class _QrCodeScannerDialogState extends State<QrCodeScannerDialog> {
}
await Future<void>.delayed(Duration(milliseconds: _imageDelayInMs));
- } catch (e) {
- // Logging.instance.log("Failed to capture and scan image", error: e, stackTrace: s,);
- // Spammy.
-
- // if (mounted) {
- // widget.onSnackbar(
- // "Error capturing or scanning the image. Please try again.");
- // }
+ } catch (e, s) {
+ _consecutiveFailures++;
+ // Log the first failure and every 20th after it, so a capture that
+ // fails on every iteration shows up in the logs without flooding them.
+ if (_consecutiveFailures == 1 || _consecutiveFailures % 20 == 0) {
+ Logging.instance.e(
+ "Failed to capture image ($_consecutiveFailures in a row)",
+ error: e,
+ stackTrace: s,
+ );
+ }
+ _checkFrameTimeout();
+ // Without this a failing capture spins the loop with no delay.
+ await Future<void>.delayed(Duration(milliseconds: _imageDelayInMs));
}
}
}
- Future<String?> _scanImage(img.Image image) async {
- try {
- final LuminanceSource source = RGBLuminanceSource(
- image.width,
- image.height,
- image
- .convert(numChannels: 4)
- .getBytes(order: img.ChannelOrder.abgr)
- .buffer
- .asInt32List(),
- );
- final BinaryBitmap bitmap =
- BinaryBitmap(GlobalHistogramBinarizer(source));
+ // Camera frames bigger than this are scaled down before scanning, and
+ // Windows preview captures are taken at no more than this. Files picked
+ // with "Select file" are scanned at full size.
+ static const _cameraFrameMaxDimension = 1280;
- final QRCodeReader reader = QRCodeReader();
- final qrDecode = reader.decode(bitmap);
- if (qrDecode.text.isEmpty) {
- return null;
- }
- return qrDecode.text;
- } catch (e) {
- // Logging.instance.log("Failed to decode QR code", error: e, stackTrace: s,);
- // Spammy.
- return null;
+ Future<String?> _scanImage(img.Image image, {int? maxDimension}) async =>
+ decodeQrFromImage(image, maxDimension: maxDimension);
+
+ Widget _buildCameraView(BuildContext context) {
+ if (Platform.isWindows && _isCameraOpen) {
+ return _buildWindowsPreview(context);
}
+ if (_cameraError != null) {
+ return _buildCameraError(context);
+ }
+ if (_isCameraOpen && _image != null) {
+ return _image!;
+ }
+ return const Center(child: CircularProgressIndicator());
+ }
+
+ Widget _buildCameraError(BuildContext context) {
+ return Center(
+ child: Padding(
+ padding: const EdgeInsets.symmetric(horizontal: 32),
+ child: Text(
+ _cameraError!,
+ textAlign: TextAlign.center,
+ style: STextStyles.desktopTextSmall(context),
+ ),
+ ),
+ );
+ }
+
+ Widget _buildWindowsPreview(BuildContext context) {
+ final size = _windowsPreviewSize;
+ return Stack(
+ fit: StackFit.expand,
+ children: [
+ Center(
+ child: AspectRatio(
+ aspectRatio: size != null && size.width > 0 && size.height > 0
+ ? size.width / size.height
+ : 4 / 3,
+ child: RepaintBoundary(
+ key: _windowsPreviewKey,
+ child: _cameraWindowsPlugin!.buildPreview(_cameraId),
+ ),
+ ),
+ ),
+ // Cover the preview rather than replace it, so it keeps painting and
+ // scanning picks up again once frames come back.
+ if (_cameraError != null)
+ ColoredBox(
+ color: Theme.of(context).extension<StackColors>()!.popupBG,
+ child: _buildCameraError(context),
+ )
+ else if (!_hasWindowsFrame)
+ const Center(child: CircularProgressIndicator()),
+ ],
+ );
}
@override
@@ -278,18 +594,7 @@ class _QrCodeScannerDialogState extends State<QrCodeScannerDialog> {
const DesktopDialogCloseButton(),
],
),
- Expanded(
- child: _isCameraOpen
- ? _image != null
- ? _image!
- : const Center(
- child: CircularProgressIndicator(),
- )
- : const Center(
- child:
- CircularProgressIndicator(), // Show progress indicator immediately
- ),
- ),
+ Expanded(child: _buildCameraView(context)),
Padding(
padding: const EdgeInsets.all(16),
child: Row(
@@ -330,8 +635,9 @@ class _QrCodeScannerDialogState extends State<QrCodeScannerDialog> {
}
try {
- final img.Image? image =
- img.decodeImage(File(filePath).readAsBytesSync());
+ final img.Image? image = img.decodeImage(
+ File(filePath).readAsBytesSync(),
+ );
if (image == null) {
await showFloatingFlushBar(
type: FlushBarType.info,
@@ -364,8 +670,7 @@ class _QrCodeScannerDialogState extends State<QrCodeScannerDialog> {
if (context.mounted) {
await showFloatingFlushBar(
type: FlushBarType.info,
- message:
- "Error processing the image. Please try again.",
+ message: "Error processing the image. Please try again.",
iconAsset: Assets.svg.file,
context: context,
);
### pubspec.lock
@@ -244,19 +244,18 @@ packages:
dependency: "direct main"
description:
name: camera_platform_interface
- sha256: "98cfc9357e04bad617671b4c1f78a597f25f08003089dd94050709ae54effc63"
+ sha256: b3083b183ef1daa408d5f0c98c8c7ac0325b490695fb4557ff4210fcf04c5984
url: "https://pub.dev"
source: hosted
- version: "2.12.0"
+ version: "2.15.0"
camera_windows:
dependency: "direct main"
description:
- path: "packages/camera/camera_windows"
- ref: HEAD
- resolved-ref: "9bfbfd643ba4e6865ec34124e42a1cc502c400c0"
- url: "https://github.com/cypherstack/packages.git"
- source: git
- version: "0.2.4"
+ name: camera_windows
+ sha256: "05c322203f66fa68953378398542865606543f294395fbbb055b4ea8f089e39b"
+ url: "https://pub.dev"
+ source: hosted
+ version: "0.3.0+1"
cbor:
dependency: "direct main"
description:
### scripts/app_config/templates/pubspec.template.yaml
@@ -245,10 +245,7 @@ dependencies:
url: https://github.com/cypherstack/camera-linux
ref: ecb412474c5d240347b04ac1eb9f019802ff7034
zxing2: ^0.2.3
- camera_windows:
- git: # TODO [prio=low]: Revert to official after https://github.com/flutter/packages/pull/7067.
- url: https://github.com/cypherstack/packages.git
- path: packages/camera/camera_windows
+ camera_windows: ^0.3.0+1
camera_platform_interface: ^2.8.0
camera_macos: ^0.1.1
blockchain_utils: ^3.3.0
### test/utilities/camera_preview_capture_test.dart
@@ -0,0 +1,150 @@
+import 'dart:ui' as ui;
+
+import 'package:flutter/material.dart';
+import 'package:flutter/rendering.dart';
+import 'package:flutter_test/flutter_test.dart';
+import 'package:image/image.dart' as img;
+import 'package:stackwallet/utilities/camera_preview_frame.dart';
+import 'package:zxing2/qrcode.dart';
+
+const _text = "bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq";
+
+/// Paints a QR code with a red marker in its top-left corner, so a capture
+/// shows whether it was flipped.
+class _QrPainter extends CustomPainter {
+ _QrPainter(this.modules);
+
+ /// True for dark modules, row by row.
+ final List<List<bool>> modules;
+
+ @override
+ void paint(Canvas canvas, Size size) {
+ canvas.drawRect(Offset.zero & size, Paint()..color = Colors.white);
+ const border = 4;
+ final module = size.width / (modules.length + 2 * border);
+ final black = Paint()
+ ..color = Colors.black
+ // Hairline seams between anti-aliased modules throw off zxing.
+ ..isAntiAlias = false;
+ for (var y = 0; y < modules.length; y++) {
+ for (var x = 0; x < modules[y].length; x++) {
+ if (modules[y][x]) {
+ canvas.drawRect(
+ Rect.fromLTWH(
+ (x + border) * module,
+ (y + border) * module,
+ module,
+ module,
+ ),
+ black,
+ );
+ }
+ }
+ }
+ canvas.drawRect(
+ Rect.fromLTWH(0, 0, module * 2, module * 2),
+ Paint()..color = const Color(0xFFFF0000),
+ );
+ }
+
+ @override
+ bool shouldRepaint(_QrPainter oldDelegate) => false;
+}
+
+void main() {
+ testWidgets("reads a QR code back from a mirrored preview capture", (
+ tester,
+ ) async {
+ final matrix = Encoder.encode(_text, ErrorCorrectionLevel.m).matrix!;
+ final modules = [
+ for (var y = 0; y < matrix.height; y++)
+ [for (var x = 0; x < matrix.width; x++) matrix.get(x, y) == 1],
+ ];
+ // Whole logical pixels per module.
+ final side = (modules.length + 8) * 4.0;
+ final key = GlobalKey();
+ await tester.pumpWidget(
+ Center(
+ child: RepaintBoundary(
+ key: key,
+ child: SizedBox(
+ width: side,
+ height: side,
+ // Mirrored like the camera_windows preview.
+ child: Transform.flip(
+ flipX: true,
+ child: CustomPaint(painter: _QrPainter(modules)),
+ ),
+ ),
+ ),
+ ),
+ );
+
+ final boundary =
+ key.currentContext!.findRenderObject()! as RenderRepaintBoundary;
+ final (width, height, rgba) = (await tester.runAsync(() async {
+ final frame = await boundary.toImage(pixelRatio: 2);
+ try {
+ final bytes = await frame.toByteData(
+ format: ui.ImageByteFormat.rawRgba,
+ );
+ return (frame.width, frame.height, bytes!.buffer.asUint8List());
+ } finally {
+ frame.dispose();
+ }
+ }))!;
+
+ expect((width, height), (side * 2, side * 2));
+ expect(blankPreviewFrame(rgba), isNull);
+
+ final image = imageFromMirroredPreviewRgba(
+ rgba,
+ width: width,
+ height: height,
+ );
+
+ // The marker is back in the top-left once flipped.
+ final marker = image.getPixel(4, 4);
+ expect([marker.r, marker.g, marker.b], [255, 0, 0]);
+ final opposite = image.getPixel(width - 5, 4);
+ expect([opposite.r, opposite.g, opposite.b], [255, 255, 255]);
+
+ final source = RGBLuminanceSource(
+ width,
+ height,
+ image.getBytes(order: img.ChannelOrder.abgr).buffer.asInt32List(),
+ );
+ final result = QRCodeReader().decode(BinaryBitmap(HybridBinarizer(source)));
+ expect(result.text, _text);
+ });
+
+ testWidgets("sees a capture with nothing painted as transparent", (
+ tester,
+ ) async {
+ final key = GlobalKey();
+ await tester.pumpWidget(
+ Center(
+ child: RepaintBoundary(
+ key: key,
+ child: const SizedBox(width: 50, height: 40),
+ ),
+ ),
+ );
+
+ final boundary =
+ key.currentContext!.findRenderObject()! as RenderRepaintBoundary;
+ final rgba = (await tester.runAsync(() async {
+ final frame = await boundary.toImage();
+ try {
+ final bytes = await frame.toByteData(
+ format: ui.ImageByteFormat.rawRgba,
+ );
+ return bytes!.buffer.asUint8List();
+ } finally {
+ frame.dispose();
+ }
+ }))!;
+
+ expect(blankPreviewFrame(rgba), BlankPreviewFrame.transparent);
+ });
+}
### test/utilities/camera_preview_frame_test.dart
@@ -0,0 +1,169 @@
+import 'dart:typed_data';
+
+import 'package:flutter_test/flutter_test.dart';
+import 'package:image/image.dart' as img;
+import 'package:stackwallet/utilities/camera_preview_frame.dart';
+
+/// Packs one RGBA pixel per entry, in row order.
+Uint8List _rgba(List<List<int>> pixels) =>
+ Uint8List.fromList([for (final p in pixels) ...p]);
+
+void main() {
+ group("imageFromMirroredPreviewRgba", () {
+ test("flips each row back", () {
+ // 3x2, every pixel distinct.
+ final rgba = _rgba([
+ [1, 2, 3, 255], [4, 5, 6, 255], [7, 8, 9, 255], //
+ [10, 11, 12, 255], [13, 14, 15, 255], [16, 17, 18, 255],
+ ]);
+
+ final image = imageFromMirroredPreviewRgba(rgba, width: 3, height: 2);
+
+ expect(image.width, 3);
+ expect(image.height, 2);
+ expect(image.numChannels, 4);
+ List<num> rgbaAt(int x, int y) {
+ final p = image.getPixel(x, y);
+ return [p.r, p.g, p.b, p.a];
+ }
+
+ expect(rgbaAt(0, 0), [7, 8, 9, 255]);
+ expect(rgbaAt(1, 0), [4, 5, 6, 255]);
+ expect(rgbaAt(2, 0), [1, 2, 3, 255]);
+ expect(rgbaAt(0, 1), [16, 17, 18, 255]);
+ expect(rgbaAt(2, 1), [10, 11, 12, 255]);
+ });
+
+ test("undoes a mirrored capture of a whole image", () {
+ final original = img.Image(width: 40, height: 30, numChannels: 4);
+ for (final p in original) {
+ p
+ ..r = (p.x * 6) % 256
+ ..g = (p.y * 8) % 256
+ ..b = (p.x + p.y) % 256
+ ..a = 255;
+ }
+ final mirrored = img.flipHorizontal(original.clone());
+ final rgba = mirrored.getBytes(order: img.ChannelOrder.rgba);
+
+ final image = imageFromMirroredPreviewRgba(rgba, width: 40, height: 30);
+
+ expect(
+ image.getBytes(order: img.ChannelOrder.rgba),
+ original.getBytes(order: img.ChannelOrder.rgba),
+ );
+ });
+
+ test("accepts bytes at an unaligned offset", () {
+ final backing = Uint8List(1 + 2 * 4);
+ backing.setAll(1, [1, 2, 3, 255, 4, 5, 6, 255]);
+ final view = Uint8List.view(backing.buffer, 1, 8);
+
+ final image = imageFromMirroredPreviewRgba(view, width: 2, height: 1);
+
+ expect(image.getPixel(0, 0).r, 4);
+ expect(image.getPixel(1, 0).r, 1);
+ });
+
+ test("rejects a buffer that is too short", () {
+ expect(
+ () => imageFromMirroredPreviewRgba(Uint8List(15), width: 2, height: 2),
+ throwsArgumentError,
+ );
+ expect(
+ () => imageFromMirroredPreviewRgba(Uint8List(0), width: 0, height: 0),
+ throwsArgumentError,
+ );
+ });
+ });
+
+ group("blankPreviewFrame", () {
+ test("reports a frame with nothing drawn as transparent", () {
+ expect(
+ blankPreviewFrame(Uint8List(4 * 16)),
+ BlankPreviewFrame.transparent,
+ );
+ expect(blankPreviewFrame(Uint8List(0)), BlankPreviewFrame.transparent);
+ });
+
+ test("reports black and near-black frames as black", () {
+ expect(
+ blankPreviewFrame(_rgba(List.filled(16, [0, 0, 0, 255]))),
+ BlankPreviewFrame.black,
+ );
+ expect(
+ blankPreviewFrame(
+ _rgba([
+ [3, 9, 1, 255],
+ [16, 12, 0, 255],
+ [0, 0, 0, 0],
+ ]),
+ ),
+ BlankPreviewFrame.black,
+ );
+ });
+
+ test("accepts a frame with any picture content", () {
+ expect(
+ blankPreviewFrame(
+ _rgba([
+ [0, 0, 0, 0],
+ [0, 0, 0, 255],
+ [0, 0, 200, 255],
+ ]),
+ ),
+ isNull,
+ );
+ });
+
+ test("ignores color in fully transparent pixels", () {
+ expect(
+ blankPreviewFrame(
+ _rgba([
+ [255, 255, 255, 0],
+ [0, 0, 0, 0],
+ ]),
+ ),
+ BlankPreviewFrame.transparent,
+ );
+ });
+ });
+
+ group("previewFrameFingerprint", () {
+ test("is the same for identical frames", () {
+ final a = Uint8List.fromList(
+ List.generate(4 * 64, (i) => (i * 7) & 0xff),
+ );
+ final b = Uint8List.fromList(a);
+ expect(previewFrameFingerprint(a), previewFrameFingerprint(b));
+ });
+
+ test("changes when one byte changes", () {
+ final a = Uint8List.fromList(
+ List.generate(4 * 64, (i) => (i * 7) & 0xff),
+ );
+ final b = Uint8List.fromList(a);
+ b[b.length ~/ 2] ^= 1;
+ expect(previewFrameFingerprint(a), isNot(previewFrameFingerprint(b)));
+ });
+
+ test("changes when a pixel is moved", () {
+ // Same bytes, different order: a plain sum would miss this.
+ final a = _rgba([
+ [1, 2, 3, 255],
+ [4, 5, 6, 255],
+ ]);
+ final b = _rgba([
+ [4, 5, 6, 255],
+ [1, 2, 3, 255],
+ ]);
+ expect(previewFrameFingerprint(a), isNot(previewFrameFingerprint(b)));
+ });
+
+ test("distinguishes frames of different lengths", () {
+ final a = Uint8List(16);
+ final b = Uint8List(20);
+ expect(previewFrameFingerprint(a), isNot(previewFrameFingerprint(b)));
+ });
+ });
+}
### test/utilities/qr_image_decoder_test.dart
@@ -0,0 +1,113 @@
+import 'dart:typed_data';
+
+import 'package:flutter_test/flutter_test.dart';
+import 'package:image/image.dart' as img;
+import 'package:stackwallet/utilities/qr_image_decoder.dart';
+import 'package:zxing2/qrcode.dart';
+
+const _address = "bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq";
+
+/// Renders [text] as a black-on-white QR code in 8-bit RGB.
+img.Image _qrImage(String text, {int scale = 8, int border = 4}) {
+ final matrix = Encoder.encode(text, ErrorCorrectionLevel.m).matrix!;
+ final size = (matrix.width + 2 * border) * scale;
+ final image = img.Image(width: size, height: size);
+ img.fill(image, color: img.ColorRgb8(255, 255, 255));
+ for (var y = 0; y < matrix.height; y++) {
+ for (var x = 0; x < matrix.width; x++) {
+ if (matrix.get(x, y) == 1) {
+ img.fillRect(
+ image,
+ x1: (x + border) * scale,
+ y1: (y + border) * scale,
+ x2: (x + border + 1) * scale - 1,
+ y2: (y + border + 1) * scale - 1,
+ color: img.ColorRgb8(0, 0, 0),
+ );
+ }
+ }
+ }
+ return image;
+}
+
+Uint8List _png(img.Image image) => Uint8List.fromList(img.encodePng(image));
+
+void main() {
+ final rgb = _qrImage(_address);
+
+ final variants = <String, img.Image>{
+ "1-bit grayscale": rgb.convert(format: img.Format.uint1, numChannels: 1),
+ "1-bit palette": rgb.convert(
+ format: img.Format.uint1,
+ numChannels: 1,
+ withPalette: true,
+ ),
+ "8-bit grayscale": rgb.convert(numChannels: 1),
+ "8-bit palette": img.quantize(rgb, numberOfColors: 2),
+ "16-bit grayscale": rgb.convert(format: img.Format.uint16, numChannels: 1),
+ "8-bit RGB": rgb,
+ "8-bit RGBA": rgb.convert(numChannels: 4),
+ };
+
+ for (final entry in variants.entries) {
+ test("decodes a ${entry.key} PNG", () {
+ final bytes = _png(entry.value);
+ final decoded = img.decodePng(bytes)!;
+ expect(decoded.width, rgb.width);
+ expect(decodeQrFromImageBytes(bytes), _address);
+ });
+ }
+
+ test("decodes a 1-bit PNG with a 1-bit format after decoding", () {
+ final decoded = img.decodePng(
+ _png(rgb.convert(format: img.Format.uint1, numChannels: 1)),
+ )!;
+ // This is the case the old code got wrong: convert(numChannels: 4) kept
+ // the 1-bit format and handed bit-packed bytes to RGBLuminanceSource.
+ expect(decoded.format, img.Format.uint1);
+ expect(decodeQrFromImage(decoded), _address);
+ });
+
+ test("decodes a large camera-sized frame scaled down to maxDimension", () {
+ // A QR code held up to a 3264x2448 webcam, filling about a third of the
+ // frame's height.
+ final frame = img.Image(width: 3264, height: 2448);
+ img.fill(frame, color: img.ColorRgb8(200, 200, 200));
+ final code = _qrImage(_address, scale: 18);
+ img.compositeImage(frame, code, dstX: 1200, dstY: 700);
+ final jpeg = img.decodeJpg(img.encodeJpg(frame, quality: 85))!;
+ expect(decodeQrFromImage(jpeg, maxDimension: 1280), _address);
+ });
+
+ test("leaves images within maxDimension alone", () {
+ expect(decodeQrFromImage(rgb, maxDimension: 1280), _address);
+ });
+
+ test("decodes a JPEG", () {
+ final bytes = Uint8List.fromList(img.encodeJpg(rgb, quality: 85));
+ expect(decodeQrFromImageBytes(bytes), _address);
+ });
+
+ test("decodes a black-on-transparent RGBA PNG", () {
+ final image = img.Image(
+ width: rgb.width,
+ height: rgb.height,
+ numChannels: 4,
+ );
+ for (final p in rgb) {
+ final dark = p.r < 128;
+ image.setPixelRgba(p.x, p.y, 0, 0, 0, dark ? 255 : 0);
+ }
+ expect(decodeQrFromImageBytes(_png(image)), _address);
+ });
+
+ test("returns null for an image without a QR code", () {
+ final blank = img.Image(width: 64, height: 64);
+ img.fill(blank, color: img.ColorRgb8(255, 255, 255));
+ expect(decodeQrFromImageBytes(_png(blank)), isNull);
+ });
+
+ test("returns null for bytes that are not an image", () {
+ expect(decodeQrFromImageBytes(Uint8List.fromList([1, 2, 3, 4])), isNull);
+ });
+}Why this scored 27/100
Community notes
Notes can correct, qualify, or add evidence to the AI analysis. Every note shown here has been validated by a human moderator.
The AI analysis stands alone for now. Submit a note if you can add evidence or important context.