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
16 changes: 10 additions & 6 deletions .github/workflows/flutter_ci.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,11 @@ name: '[CI]'

on:
pull_request:
# Also run on direct pushes to main. Without this, anything pushed straight
# to main skips CI entirely - which is how the encrypt 5.0.3 IV regression
# shipped despite the existing round-trip tests catching it.
push:
branches: [main]
workflow_dispatch:

jobs:
Expand All @@ -13,20 +18,19 @@ jobs:

- uses: subosito/flutter-action@v2
with:
flutter-version: "3.10.0"
flutter-version: "3.44.2"
channel: 'stable'
cache: true

- uses: dart-lang/setup-dart@v1
with:
sdk: 3.0.0


- name: Install dependencies
run: flutter pub get

# NOTE: `dart format --fix` was removed in newer Dart SDKs; formatting
# is written in place by default. Applying lint fixes is now a separate
# command (`dart fix --apply`).
- name: Verify formatting
run: dart format . --fix
run: dart format .

- name: Analyze project source
run: flutter analyze . --fatal-infos --no-pub
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/flutter_pub_cd.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ jobs:

- uses: subosito/flutter-action@v2
with:
flutter-version: "3.7.0"
flutter-version: "3.44.2"
channel: 'stable'
cache: true

Expand Down
18 changes: 18 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,23 @@
# qr_plus changelog

## 1.4.0

- fix: snowden mode could not decrypt its own output. `encrypt` 5.0.3 changed
`IV.fromLength(16)` to return RANDOM bytes instead of zeros, so the renderer
and the reader each generated a different IV and every code failed to decrypt
with "Invalid or corrupted pad block". Now uses `IV.allZerosOfLength(16)`,
which restores the previous behaviour and keeps the wire format
byte-identical to 1.3.0 and earlier, so older clients still interoperate.
- feat: added `onError` to `QrPlusReader`, reporting a `QrPlusReadError` when a
code is detected but cannot be decoded. Previously such failures were
swallowed silently, making an undecryptable code indistinguishable from the
camera seeing nothing at all.
- test: added crypto regression tests pinning the wire format, and fixed a TTL
test that asserted nothing because it used a mode without a TTL.
- ci: run on pushes to `main`, not just pull requests. The 1.3.0 dependency
update was pushed straight to `main` and skipped CI entirely, which is why
the existing round-trip tests never flagged the regression.

## 1.3.0

- fix: fix issues with snowden mode
Expand Down
2 changes: 1 addition & 1 deletion example/pubspec.lock
Original file line number Diff line number Diff line change
Expand Up @@ -542,7 +542,7 @@ packages:
path: ".."
relative: true
source: path
version: "1.3.0"
version: "1.4.0"
screen_capture_event:
dependency: transitive
description:
Expand Down
2 changes: 1 addition & 1 deletion lib/qr_plus.dart
Original file line number Diff line number Diff line change
Expand Up @@ -6,4 +6,4 @@ export 'src/feature/reader/controller/controller.dart';
export 'src/feature/reader/view/view.dart' show QrPlusReader;
export 'src/feature/renderer/view/view.dart' show QrPlusRenderer;
export 'src/model/model.dart' show QrPlusMode;
export 'src/utility/utility.dart' show QrPlusAuthenticity;
export 'src/utility/utility.dart' show QrPlusAuthenticity, QrPlusReadError;
21 changes: 20 additions & 1 deletion lib/src/feature/reader/cubit/qr_plus_reader_cubit.dart
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ class QrPlusReaderCubit extends Cubit<QrPlusReaderState> {
required this.mode,
required this.onData,
required this.ntpRepository,
this.onReadError,
this.allowDuplicates = false,
}) : super(const QrPlusReaderState());

Expand All @@ -25,6 +26,11 @@ class QrPlusReaderCubit extends Cubit<QrPlusReaderState> {
List<QrPlusAuthenticity> authenticity,
) onData;

/// Called when a QR code was detected but could not be turned into usable
/// data. Without this the failure is invisible: the reader ignores the code
/// and the user sees a camera that never reacts.
final void Function(QrPlusReadError error)? onReadError;

/// Whether to call [onData] on duplicate detections or not.
final bool allowDuplicates;

Expand All @@ -42,7 +48,14 @@ class QrPlusReaderCubit extends Cubit<QrPlusReaderState> {
/// [id] being null means the data is [UnknownQrPlusData], which we won't process.
final uid = data.maybeUid;

if (uid == null) return;
if (uid == null) {
/// The code was readable as a QR code but its contents could not be
/// decrypted or parsed. In snowden mode this usually means the reader and
/// the renderer disagree on the encryption key.
onReadError?.call(QrPlusReadError.unreadable);

return;
}

final cachedData = state.cache[uid];

Expand Down Expand Up @@ -82,6 +95,12 @@ class QrPlusReaderCubit extends Cubit<QrPlusReaderState> {
requiredMode: mode,
);

if (isWhole && !valid) {
/// All crumbs arrived but they were produced with a different mode than
/// this reader is configured with.
onReadError?.call(QrPlusReadError.modeMismatch);
}

if (valid) {
/// Converts the crumbs into a string
final dataList = [...newCrumbs]..sort(
Expand Down
10 changes: 10 additions & 0 deletions lib/src/feature/reader/view/qr_plus_reader.dart
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ class QrPlusReader extends StatefulWidget {
required this.onData,
this.mode = const QrPlusMode.plain(),
super.key,
this.onError,
this.controller,
this.fit = BoxFit.cover,
@Deprecated('Use DetectionSpeed on QrPlusReaderController instead')
Expand Down Expand Up @@ -42,6 +43,14 @@ class QrPlusReader extends StatefulWidget {
List<QrPlusAuthenticity> authenticity,
) onData;

/// Called when a QR code was detected but could not be turned into usable
/// data, with the reason why. See [QrPlusReadError].
///
/// Handling this is strongly recommended: without it a decryption or parsing
/// failure is completely silent, and an unreadable code is indistinguishable
/// from the camera seeing nothing at all.
final void Function(QrPlusReadError error)? onError;

/// Handles how the widget should fit the screen.
final BoxFit fit;

Expand Down Expand Up @@ -83,6 +92,7 @@ class _QrPlusReaderState extends State<QrPlusReader> {
mode: widget.mode,
ntpRepository: _ntpRepository,
onData: widget.onData,
onReadError: widget.onError,
),
child: BlocBuilder<QrPlusReaderCubit, QrPlusReaderState>(
buildWhen: (_, __) => false,
Expand Down
11 changes: 9 additions & 2 deletions lib/src/model/src/qr_plus_data_crumb.dart
Original file line number Diff line number Diff line change
Expand Up @@ -185,10 +185,17 @@ class QrPlusDataCrumb with _$QrPlusDataCrumb {
return null;
}

// NOTE: must be IV.allZerosOfLength, NOT IV.fromLength. As of encrypt
// 5.0.3 IV.fromLength returns RANDOM bytes rather than zeros, so the
// renderer and the reader each generate a different IV and decryption
// fails with "Invalid or corrupted pad block". allZerosOfLength also keeps
// the wire format byte-identical to encrypt 5.0.1, so older clients still
// interop. Covered by test/model/qr_plus_crypto_regression_test.dart.
try {
final encrypted = Encrypted.from64(data);
final encrypter = Encrypter(AES(Key.fromUtf8(encryptionKey)));
final decrypted = encrypter.decrypt(encrypted, iv: IV.fromLength(16));
final decrypted =
encrypter.decrypt(encrypted, iv: IV.allZerosOfLength(16));

return tryParseJson(decrypted);
} catch (e) {
Expand All @@ -210,7 +217,7 @@ class QrPlusDataCrumb with _$QrPlusDataCrumb {

final encrypted = encrypter.encrypt(
jsonEncode(toJson()),
iv: IV.fromLength(16),
iv: IV.allZerosOfLength(16),
);

return encrypted.base64;
Expand Down
26 changes: 26 additions & 0 deletions lib/src/utility/src/qr_plus_read_error.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
/// {@template qr_plus_read_error}
/// Describes why a scanned QR code could not be turned into usable data.
///
/// Without this, a failure to decode is indistinguishable from the camera
/// simply not seeing anything: the reader silently ignores the code and the
/// user is left staring at a viewfinder that never reacts. Surfacing the reason
/// lets the app tell the user what is wrong, and makes misconfiguration
/// (notably a mismatched encryption key) obvious instead of invisible.
/// {@endtemplate}
enum QrPlusReadError {
/// {@macro qr_plus_read_error}
///
/// The QR code was read, but its contents could not be decrypted or parsed.
///
/// In snowden mode this almost always means the reader and the renderer
/// disagree on the encryption key, or the code was not produced by
/// package:qr_plus at all.
unreadable,

/// {@macro qr_plus_read_error}
///
/// The data was decoded, but it was created with a different QrPlusMode
/// than the reader is configured with. The renderer and reader must use the
/// same mode.
modeMismatch,
}
1 change: 1 addition & 0 deletions lib/src/utility/utility.dart
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
export 'src/connectivity_result_extension.dart';
export 'src/qr_plus_authenticity.dart';
export 'src/qr_plus_read_error.dart';
export 'src/screen_recorder_status.dart';
export 'src/string_extension.dart';
2 changes: 1 addition & 1 deletion pubspec.yaml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
name: qr_plus
description: Advanced all-in-one qr code package with support for safe qr codes.
version: 1.3.0

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should this be a patch as 1.3.0 is broken? So 1.3.1?

@JTorkk JTorkk Aug 24, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added the onError callback so I think 1.4.0 is more suitable. Probably should have been its own pr though.

version: 1.4.0
homepage: https://mankeli.co
repository: https://github.com/Mankeli-Software/qr_plus
issue_tracker: https://github.com/Mankeli-Software/qr_plus/issues
Expand Down
15 changes: 10 additions & 5 deletions test/feature/reader/controller/qr_plus_reader_controller_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,18 @@ void main() {
TestWidgetsFlutterBinding.ensureInitialized();
group('QrPlusReaderController', () {
test(
'barcodes will return empty stream '
'barcodes forwards the underlying scanner stream '
'always',
() {
expect(
QrPlusReaderController().barcodes,
const Stream<BarcodeCapture>.empty(),
);
// This used to return const Stream.empty(). 44e26b0 ("fixes issues
// where reader is too slow") deliberately changed it to forward
// super.barcodes, but this test was never updated and had been failing
// ever since. The getter stays deprecated because the values it emits
// are raw, still-encoded scanner data: consumers want onData instead.
final controller = QrPlusReaderController();

expect(controller.barcodes, isA<Stream<BarcodeCapture>>());
expect(controller.barcodes.isBroadcast, isTrue);
},
);
});
Expand Down
130 changes: 130 additions & 0 deletions test/feature/reader/cubit/qr_plus_reader_error_test.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
// Tests for the onReadError path.
//
// Before this existed, a QR code that could not be decrypted was discarded in
// silence: no callback, no log, nothing. That made a total crypto failure look
// identical to "the camera isn't seeing the code", which is exactly what made
// the encrypt 5.0.3 IV regression so slow to diagnose.

import 'package:bloc_test/bloc_test.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:mocktail/mocktail.dart';
import 'package:qr_plus/qr_plus.dart';
import 'package:qr_plus/src/feature/reader/cubit/cubit.dart';
import 'package:qr_plus/src/model/model.dart';
import 'package:qr_plus/src/repository/repository.dart';

class DataCallback {
void call(String s, List<QrPlusAuthenticity> a) {}
}

class ErrorCallback {
void call(QrPlusReadError error) {}
}

class MockDataCallback extends Mock implements DataCallback {}

class MockErrorCallback extends Mock implements ErrorCallback {}

class MockNtpRepository extends Mock implements NtpRepository {}

void main() {
late DataCallback onData;
late ErrorCallback onReadError;
late NtpRepository ntpRepository;

const mode = QrPlusMode.snowden(
encryptionKey: 'abcdefghijklmnopqrstuvwx',
ttl: Duration(minutes: 5),
crumbs: 2,
);

setUp(() {
registerFallbackValue(QrPlusReadError.unreadable);
onData = MockDataCallback();
onReadError = MockErrorCallback();
ntpRepository = MockNtpRepository();

when(() => ntpRepository.now).thenReturn(DateTime(2026));
when(() => onReadError.call(any<QrPlusReadError>())).thenAnswer((_) {});
});

group('QrPlusReaderCubit onReadError', () {
blocTest<QrPlusReaderCubit, QrPlusReaderState>(
'is called with unreadable '
'when the code cannot be decrypted',
build: () => QrPlusReaderCubit(
mode: mode,
onData: onData.call,
onReadError: onReadError.call,
ntpRepository: ntpRepository,
),
act: (cubit) => cubit.onRawData('not-a-qr-plus-code'),
verify: (_) => verify(
() => onReadError.call(QrPlusReadError.unreadable),
).called(1),
);

blocTest<QrPlusReaderCubit, QrPlusReaderState>(
'is called with unreadable '
'when the code was encrypted with a different key',
build: () => QrPlusReaderCubit(
mode: mode,
onData: onData.call,
onReadError: onReadError.call,
ntpRepository: ntpRepository,
),
act: (cubit) {
final foreign = QrPlusDataCrumb.authentic(
uid: 'uid',
data: 'data',
mode: const QrPlusMode.snowden(
encryptionKey: 'XXXXXXXXXXXXXXXXXXXXXXXX',
),
timestamp: DateTime.utc(2026),
index: 0,
crumbs: 2,
).toQrString();

return cubit.onRawData(foreign);
},
verify: (_) => verify(
() => onReadError.call(QrPlusReadError.unreadable),
).called(1),
);

blocTest<QrPlusReaderCubit, QrPlusReaderState>(
'is not called '
'when the code decrypts successfully',
build: () => QrPlusReaderCubit(
mode: mode,
onData: onData.call,
onReadError: onReadError.call,
ntpRepository: ntpRepository,
),
act: (cubit) {
final valid = QrPlusDataCrumb.authentic(
uid: 'uid',
data: 'data',
mode: mode,
timestamp: DateTime.utc(2026),
index: 0,
crumbs: 2,
).toQrString();

return cubit.onRawData(valid);
},
verify: (_) => verifyNever(() => onReadError.call(any())),
);

blocTest<QrPlusReaderCubit, QrPlusReaderState>(
'does not throw '
'when no onReadError handler is provided',
build: () => QrPlusReaderCubit(
mode: mode,
onData: onData.call,
ntpRepository: ntpRepository,
),
act: (cubit) => cubit.onRawData('not-a-qr-plus-code'),
);
});
}
Loading
Loading