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 53b21bb9c6..ba6b056c0b 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'; @@ -30,16 +36,31 @@ 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; int _cameraId = -1; String? _macOSDeviceId; final int _imageDelayInMs = Platform.isLinux ? 500 : 250; + int _consecutiveFailures = 0; + 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() { @@ -52,6 +73,13 @@ 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."; + }); } }); }); @@ -63,26 +91,79 @@ 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.'); } - 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; + } 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 @@ -91,16 +172,16 @@ 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; } catch (e, s) { + _initError = e is CameraException ? e.description ?? e.code : "$e"; Logging.instance.e( "Failed to initialize camera", error: e, @@ -135,12 +216,75 @@ 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); + } + } + + /// 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 { @@ -148,14 +292,11 @@ class _QrCodeScannerDialogState extends State { 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 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) { @@ -171,14 +312,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 @@ -188,12 +331,19 @@ class _QrCodeScannerDialogState extends State { continue; } + _consecutiveFailures = 0; 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; + } }); } @@ -214,21 +364,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 +398,81 @@ 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; + } + + 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 @@ -278,18 +500,7 @@ class _QrCodeScannerDialogState extends State { 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 +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, @@ -364,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/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 diff --git a/test/utilities/camera_preview_capture_test.dart b/test/utilities/camera_preview_capture_test.dart new file mode 100644 index 0000000000..0af854e677 --- /dev/null +++ b/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> 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, + ); + }); + }); +}