diff --git a/lib/utilities/qr_image_decoder.dart b/lib/utilities/qr_image_decoder.dart new file mode 100644 index 000000000..be063ccdb --- /dev/null +++ b/lib/utilities/qr_image_decoder.dart @@ -0,0 +1,69 @@ +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. +String? decodeQrFromImage(img.Image image) { + 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; +} diff --git a/lib/widgets/desktop/qr_code_scanner_dialog.dart b/lib/widgets/desktop/qr_code_scanner_dialog.dart index ba6b056c0..54f9382b1 100644 --- a/lib/widgets/desktop/qr_code_scanner_dialog.dart +++ b/lib/widgets/desktop/qr_code_scanner_dialog.dart @@ -15,13 +15,13 @@ 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'; @@ -386,39 +386,7 @@ class _QrCodeScannerDialogState extends State { } } - Future _scanImage(img.Image image) async { - final LuminanceSource source; - try { - source = RGBLuminanceSource( - image.width, - image.height, - image - .convert(numChannels: 4) - .getBytes(order: img.ChannelOrder.abgr) - .buffer - .asInt32List(), - ); - } 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 qrDecode = QRCodeReader().decode(BinaryBitmap(binarizer)); - if (qrDecode.text.isNotEmpty) { - return qrDecode.text; - } - } catch (_) { - // No QR code found with this binarizer. Spammy to log. - } - } - return null; - } + Future _scanImage(img.Image image) async => decodeQrFromImage(image); Widget _buildCameraView(BuildContext context) { if (Platform.isWindows && _isCameraOpen) { diff --git a/test/utilities/qr_image_decoder_test.dart b/test/utilities/qr_image_decoder_test.dart new file mode 100644 index 000000000..0f38f18e1 --- /dev/null +++ b/test/utilities/qr_image_decoder_test.dart @@ -0,0 +1,98 @@ +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 = { + "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 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); + }); +}