From ac0b8ae156fdc16320dc015a188107473ef338ea Mon Sep 17 00:00:00 2001 From: sneurlax Date: Fri, 30 Oct 2026 17:42:15 -0500 Subject: [PATCH 1/4] fix(desktop qr): open Windows cameras at a preset they support and show camera errors ResolutionPreset.low caps the camera_windows preview at 240 px, so webcams without a mode that small fail with "Failed to initialize video preview". Try medium then max on each camera (IR cameras last), delete the JPEG that takePicture leaves in Pictures, show init and capture failures instead of an endless spinner, and decode with HybridBinarizer before the global one. --- .../desktop/qr_code_scanner_dialog.dart | 174 ++++++++++++++---- 1 file changed, 134 insertions(+), 40 deletions(-) diff --git a/lib/widgets/desktop/qr_code_scanner_dialog.dart b/lib/widgets/desktop/qr_code_scanner_dialog.dart index 53b21bb9c6..0a0b40cd3b 100644 --- a/lib/widgets/desktop/qr_code_scanner_dialog.dart +++ b/lib/widgets/desktop/qr_code_scanner_dialog.dart @@ -40,6 +40,9 @@ class _QrCodeScannerDialogState extends State { int _cameraId = -1; String? _macOSDeviceId; final int _imageDelayInMs = Platform.isLinux ? 500 : 250; + int _consecutiveFailures = 0; + String? _cameraError; + String? _initError; @override void initState() { @@ -52,6 +55,12 @@ class _QrCodeScannerDialogState extends State { _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."; + }); } }); }); @@ -68,17 +77,62 @@ class _QrCodeScannerDialogState extends State { 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); + // 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". The cap only + // affects the preview; takePicture() always uses the largest mode. + // 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), + ); + await _cameraWindowsPlugin.initializeCamera(_cameraId); + 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; + } // await _cameraWindowsPlugin!.onCameraInitialized(_cameraId).first; // TODO [prio=low]: Make this work. ^^^ Logging.instance.d( @@ -101,6 +155,7 @@ class _QrCodeScannerDialogState extends State { return true; } catch (e, s) { + _initError = e is CameraException ? e.description ?? e.code : "$e"; Logging.instance.e( "Failed to initialize camera", error: e, @@ -153,7 +208,16 @@ class _QrCodeScannerDialogState extends State { } else if (Platform.isWindows) { final XFile xfile = await _cameraWindowsPlugin!.takePicture(_cameraId); - final bytes = await xfile.readAsBytes(); + final List bytes; + try { + bytes = await xfile.readAsBytes(); + } finally { + // camera_windows saves every capture as a JPEG in the user's + // Pictures folder and never removes it. + try { + await File(xfile.path).delete(); + } catch (_) {} + } base64Image = base64Encode(bytes); // We could use a Uint8List to optimize for Windows and macOS. } else if (Platform.isMacOS) { @@ -188,8 +252,10 @@ class _QrCodeScannerDialogState extends State { continue; } + _consecutiveFailures = 0; if (mounted) { setState(() { + _cameraError = null; _image = Image.memory( base64Decode(base64Image!), fit: BoxFit.cover, @@ -214,21 +280,32 @@ class _QrCodeScannerDialogState extends State { } await Future.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, + ); + } + if (_consecutiveFailures == 20 && mounted) { + setState(() { + _cameraError = "The camera is not returning images."; + }); + } + // Without this a failing capture spins the loop with no delay. + await Future.delayed(Duration(milliseconds: _imageDelayInMs)); } } } Future _scanImage(img.Image image) async { + final LuminanceSource source; try { - final LuminanceSource source = RGBLuminanceSource( + source = RGBLuminanceSource( image.width, image.height, image @@ -237,20 +314,26 @@ class _QrCodeScannerDialogState extends State { .buffer .asInt32List(), ); - final BinaryBitmap bitmap = - BinaryBitmap(GlobalHistogramBinarizer(source)); + } catch (_) { + return null; + } - final QRCodeReader reader = QRCodeReader(); - final qrDecode = reader.decode(bitmap); - if (qrDecode.text.isEmpty) { - 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 qrDecode = QRCodeReader().decode(BinaryBitmap(binarizer)); + if (qrDecode.text.isNotEmpty) { + return qrDecode.text; + } + } catch (_) { + // No QR code found with this binarizer. Spammy to log. } - return qrDecode.text; - } catch (e) { - // Logging.instance.log("Failed to decode QR code", error: e, stackTrace: s,); - // Spammy. - return null; } + return null; } @override @@ -279,16 +362,27 @@ class _QrCodeScannerDialogState extends State { ], ), Expanded( - child: _isCameraOpen - ? _image != null - ? _image! + child: _cameraError != null + ? Center( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 32), + child: Text( + _cameraError!, + textAlign: TextAlign.center, + style: STextStyles.desktopTextSmall(context), + ), + ), + ) + : _isCameraOpen + ? _image != null + ? _image! + : const Center( + child: CircularProgressIndicator(), + ) : const Center( - child: CircularProgressIndicator(), - ) - : const Center( - child: - CircularProgressIndicator(), // Show progress indicator immediately - ), + child: + CircularProgressIndicator(), // Show progress indicator immediately + ), ), Padding( padding: const EdgeInsets.all(16), From f9a8259b1d6ae57ff1da29d7bf7a4ca6c44f11af Mon Sep 17 00:00:00 2001 From: sneurlax Date: Fri, 30 Oct 2026 20:18:59 -0500 Subject: [PATCH 2/4] deps: use official camera_windows 0.3.0+1 from pub.dev Replace the cypherstack/packages image-streaming fork (0.2.4, from flutter/packages#7067, which upstream merged and later reverted). The app only polls takePicture on Windows and never streams images. 0.3.0+1 falls back to a physical stream when a multi-stream USB camera rejects the preferred-stream selectors, which fixes "Failed to initialize video preview" (flutter/flutter#140014). camera_platform_interface resolves to 2.15.0. --- pubspec.lock | 15 +++++++-------- .../app_config/templates/pubspec.template.yaml | 5 +---- 2 files changed, 8 insertions(+), 12 deletions(-) diff --git a/pubspec.lock b/pubspec.lock index fa22e5669a..85e7781182 100644 --- a/pubspec.lock +++ b/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: diff --git a/scripts/app_config/templates/pubspec.template.yaml b/scripts/app_config/templates/pubspec.template.yaml index 1a91a52483..d181892424 100644 --- a/scripts/app_config/templates/pubspec.template.yaml +++ b/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 From a04c78143ef86935ca8f79ac9b42f5c94bcffc77 Mon Sep 17 00:00:00 2001 From: sneurlax Date: Mon, 2 Nov 2026 14:52:36 -0600 Subject: [PATCH 3/4] fix(desktop qr): scan Windows camera frames from the preview in memory camera_windows' takePicture() always saves a JPEG in the user's Pictures folder. Deleting it afterwards still puts a QR code, which may hold a seed or private key, on disk, where OneDrive sync, file recovery or the indexer can pick it up. Show the live preview texture instead and capture it in memory with RepaintBoundary.toImage(), at the camera's own resolution capped at 1280 px. camera_windows mirrors its preview, so the capture is flipped back before decoding. Drop the capture cleanup code and its test. Errors are drawn over the preview rather than replacing it, so capture keeps running and the error clears when frames come back. Blank frames (nothing drawn yet, or a covered lens) are logged once and skipped with the usual delay, and a spinner shows until the first frame arrives. Tested on Windows 11 with a USB webcam under Impeller and Skia: preview captures are 640x480, two QR codes (one about 260 characters) scan in the add-node view, and nothing is written to Pictures. --- lib/utilities/camera_preview_frame.dart | 77 +++++++ .../desktop/qr_code_scanner_dialog.dart | 215 ++++++++++++++---- .../camera_preview_capture_test.dart | 143 ++++++++++++ test/utilities/camera_preview_frame_test.dart | 131 +++++++++++ 4 files changed, 516 insertions(+), 50 deletions(-) create mode 100644 lib/utilities/camera_preview_frame.dart create mode 100644 test/utilities/camera_preview_capture_test.dart create mode 100644 test/utilities/camera_preview_frame_test.dart diff --git a/lib/utilities/camera_preview_frame.dart b/lib/utilities/camera_preview_frame.dart new file mode 100644 index 0000000000..bdf83533b5 --- /dev/null +++ b/lib/utilities/camera_preview_frame.dart @@ -0,0 +1,77 @@ +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, + ); +} diff --git a/lib/widgets/desktop/qr_code_scanner_dialog.dart b/lib/widgets/desktop/qr_code_scanner_dialog.dart index 0a0b40cd3b..94d9e96919 100644 --- a/lib/widgets/desktop/qr_code_scanner_dialog.dart +++ b/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,11 +13,14 @@ 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/text_styles.dart'; import 'desktop_dialog.dart'; @@ -44,6 +50,16 @@ class _QrCodeScannerDialogState extends State { 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; + + // Bigger captures cost more to read back and scan without helping zxing. + static const _maxWindowsFrameDimension = 1280; + @override void initState() { super.initState(); @@ -90,10 +106,9 @@ class _QrCodeScannerDialogState extends State { // 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". The cap only - // affects the preview; takePicture() always uses the largest mode. - // fps and videoBitrate only apply to video recording, so leave them - // out. + // 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) { @@ -107,7 +122,14 @@ class _QrCodeScannerDialogState extends State { 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) { @@ -133,10 +155,9 @@ class _QrCodeScannerDialogState extends State { await _cameraWindowsPlugin.dispose(_cameraId); return false; } - // await _cameraWindowsPlugin!.onCameraInitialized(_cameraId).first; - // TODO [prio=low]: Make this work. ^^^ Logging.instance.d( - "Windows Camera initialized with ID: $_cameraId", + "Windows Camera initialized with ID: $_cameraId, " + "preview $_windowsPreviewSize", ); } else if (Platform.isMacOS) { final List videoDevices = await CameraMacOS.instance @@ -198,28 +219,82 @@ class _QrCodeScannerDialogState extends State { } } + /// Grabs the frame the Windows preview is showing, in memory. Returns null + /// when there is nothing to scan yet. + Future _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, + _maxWindowsFrameDimension / 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; + + // camera_windows mirrors the preview, so flip it back for zxing. + return imageFromMirroredPreviewRgba(rgba, width: width, height: height); + } + Future _captureAndScanImage() async { _isScanning = true; while (_isScanning) { try { String? base64Image; + img.Image? windowsFrame; if (Platform.isLinux && _cameraLinuxPlugin != null) { base64Image = await _cameraLinuxPlugin.captureImage(); } else if (Platform.isWindows) { - final XFile xfile = - await _cameraWindowsPlugin!.takePicture(_cameraId); - final List bytes; - try { - bytes = await xfile.readAsBytes(); - } finally { - // camera_windows saves every capture as a JPEG in the user's - // Pictures folder and never removes it. - try { - await File(xfile.path).delete(); - } catch (_) {} - } - 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) { @@ -235,14 +310,16 @@ class _QrCodeScannerDialogState extends State { } 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.delayed(Duration(milliseconds: _imageDelayInMs)); 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 @@ -256,10 +333,15 @@ class _QrCodeScannerDialogState extends State { if (mounted) { setState(() { _cameraError = null; - _image = Image.memory( - base64Decode(base64Image!), - fit: BoxFit.cover, - ); + // Windows already shows the live preview. + if (windowsFrame == null) { + _image = Image.memory( + base64Decode(base64Image!), + fit: BoxFit.cover, + ); + } else { + _hasWindowsFrame = true; + } }); } @@ -336,6 +418,61 @@ class _QrCodeScannerDialogState extends State { return null; } + 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()!.popupBG, + child: _buildCameraError(context), + ) + else if (!_hasWindowsFrame) + const Center(child: CircularProgressIndicator()), + ], + ); + } + @override Widget build(BuildContext context) { return PopScope( @@ -361,29 +498,7 @@ class _QrCodeScannerDialogState extends State { const DesktopDialogCloseButton(), ], ), - Expanded( - child: _cameraError != null - ? Center( - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 32), - child: Text( - _cameraError!, - textAlign: TextAlign.center, - style: STextStyles.desktopTextSmall(context), - ), - ), - ) - : _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( diff --git a/test/utilities/camera_preview_capture_test.dart b/test/utilities/camera_preview_capture_test.dart new file mode 100644 index 0000000000..45b22018e5 --- /dev/null +++ b/test/utilities/camera_preview_capture_test.dart @@ -0,0 +1,143 @@ +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> 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); + }); +} diff --git a/test/utilities/camera_preview_frame_test.dart b/test/utilities/camera_preview_frame_test.dart new file mode 100644 index 0000000000..90591ed333 --- /dev/null +++ b/test/utilities/camera_preview_frame_test.dart @@ -0,0 +1,131 @@ +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> 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 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, + ); + }); + }); +} From cdb5affcede1b937c3023957f5bdf6f2e6ce695d Mon Sep 17 00:00:00 2001 From: sneurlax Date: Tue, 3 Nov 2026 10:25:11 -0600 Subject: [PATCH 4/4] style: dart format --- .../desktop/qr_code_scanner_dialog.dart | 54 ++++++++++--------- .../camera_preview_capture_test.dart | 27 ++++++---- 2 files changed, 45 insertions(+), 36 deletions(-) diff --git a/lib/widgets/desktop/qr_code_scanner_dialog.dart b/lib/widgets/desktop/qr_code_scanner_dialog.dart index 94d9e96919..ba6b056c0b 100644 --- a/lib/widgets/desktop/qr_code_scanner_dialog.dart +++ b/lib/widgets/desktop/qr_code_scanner_dialog.dart @@ -36,10 +36,12 @@ class QrCodeScannerDialog extends StatefulWidget { } class _QrCodeScannerDialogState extends State { - final CameraLinux? _cameraLinuxPlugin = - Platform.isLinux ? CameraLinux() : null; - final CameraWindows? _cameraWindowsPlugin = - Platform.isWindows ? CameraWindows() : null; + final CameraLinux? _cameraLinuxPlugin = Platform.isLinux + ? CameraLinux() + : null; + final CameraWindows? _cameraWindowsPlugin = Platform.isWindows + ? CameraWindows() + : null; bool _isCameraOpen = false; Image? _image; bool _isScanning = false; @@ -73,7 +75,8 @@ class _QrCodeScannerDialogState extends State { unawaited(_captureAndScanImage()); } else if (mounted) { setState(() { - _cameraError = "Unable to open the camera" + _cameraError = + "Unable to open the camera" "${_initError == null ? "" : " ($_initError)"}. You can " "still use \"Select file\" to scan a QR code image."; }); @@ -88,8 +91,8 @@ class _QrCodeScannerDialogState extends State { await _cameraLinuxPlugin.initializeCamera(); Logging.instance.d("Linux Camera initialized"); } else if (Platform.isWindows && _cameraWindowsPlugin != null) { - final List cameras = - await _cameraWindowsPlugin.availableCameras(); + final List cameras = await _cameraWindowsPlugin + .availableCameras(); if (cameras.isEmpty) { throw CameraException('No cameras available', 'No cameras found.'); } @@ -124,12 +127,15 @@ class _QrCodeScannerDialogState extends State { ); // The plugin reports the preview size through this event, // which it adds before initializeCamera() returns. - final initialized = - _cameraWindowsPlugin.onCameraInitialized(_cameraId).first; + final initialized = _cameraWindowsPlugin + .onCameraInitialized(_cameraId) + .first; await _cameraWindowsPlugin.initializeCamera(_cameraId); final event = await initialized; - _windowsPreviewSize = - Size(event.previewWidth, event.previewHeight); + _windowsPreviewSize = Size( + event.previewWidth, + event.previewHeight, + ); opened = true; break; } on CameraException catch (e) { @@ -166,12 +172,11 @@ class _QrCodeScannerDialogState extends State { 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; @@ -211,11 +216,7 @@ class _QrCodeScannerDialogState extends State { // } } } 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); } } @@ -245,7 +246,8 @@ class _QrCodeScannerDialogState extends State { // 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( + pixelRatio = + min( preview.width * fit / boundary.size.width, preview.height * fit / boundary.size.height, ) * @@ -539,8 +541,9 @@ class _QrCodeScannerDialogState extends State { } 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, @@ -573,8 +576,7 @@ class _QrCodeScannerDialogState extends State { 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, ); diff --git a/test/utilities/camera_preview_capture_test.dart b/test/utilities/camera_preview_capture_test.dart index 45b22018e5..0af854e677 100644 --- a/test/utilities/camera_preview_capture_test.dart +++ b/test/utilities/camera_preview_capture_test.dart @@ -52,8 +52,9 @@ class _QrPainter extends CustomPainter { } void main() { - testWidgets("reads a QR code back from a mirrored preview capture", - (tester) async { + 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++) @@ -84,8 +85,9 @@ void main() { 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); + final bytes = await frame.toByteData( + format: ui.ImageByteFormat.rawRgba, + ); return (frame.width, frame.height, bytes!.buffer.asUint8List()); } finally { frame.dispose(); @@ -95,8 +97,11 @@ void main() { expect((width, height), (side * 2, side * 2)); expect(blankPreviewFrame(rgba), isNull); - final image = - imageFromMirroredPreviewRgba(rgba, width: width, height: height); + final image = imageFromMirroredPreviewRgba( + rgba, + width: width, + height: height, + ); // The marker is back in the top-left once flipped. final marker = image.getPixel(4, 4); @@ -113,8 +118,9 @@ void main() { expect(result.text, _text); }); - testWidgets("sees a capture with nothing painted as transparent", - (tester) async { + testWidgets("sees a capture with nothing painted as transparent", ( + tester, + ) async { final key = GlobalKey(); await tester.pumpWidget( Center( @@ -130,8 +136,9 @@ void main() { final rgba = (await tester.runAsync(() async { final frame = await boundary.toImage(); try { - final bytes = - await frame.toByteData(format: ui.ImageByteFormat.rawRgba); + final bytes = await frame.toByteData( + format: ui.ImageByteFormat.rawRgba, + ); return bytes!.buffer.asUint8List(); } finally { frame.dispose();