Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
69 changes: 69 additions & 0 deletions lib/utilities/qr_image_decoder.dart
Original file line number Diff line number Diff line change
@@ -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;
}
36 changes: 2 additions & 34 deletions lib/widgets/desktop/qr_code_scanner_dialog.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -386,39 +386,7 @@ class _QrCodeScannerDialogState extends State<QrCodeScannerDialog> {
}
}

Future<String?> _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<String?> _scanImage(img.Image image) async => decodeQrFromImage(image);

Widget _buildCameraView(BuildContext context) {
if (Platform.isWindows && _isCameraOpen) {
Expand Down
98 changes: 98 additions & 0 deletions test/utilities/qr_image_decoder_test.dart
Original file line number Diff line number Diff line change
@@ -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 = <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 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);
});
}
Loading