From 0fc8ee809d26468517cc343c98fcd5acde68d516 Mon Sep 17 00:00:00 2001 From: tajniak81 <13187254+tajniak81@users.noreply.github.com> Date: Sun, 18 Oct 2026 23:16:27 +0200 Subject: [PATCH] Mobile: QR-code verification When both sides support it, a verification offers QR codes first: this device shows its code (in a white quiet zone, whatever the theme) and can scan the other's with the camera (mobile_scanner; only codes that start with MATRIX count). The side whose code was scanned confirms the other saw it succeed. Compare emoji stays available, and is used directly when either side has no QR support. Adds the CAMERA permission (camera hardware not required). Co-Authored-By: Claude Opus 5.5 --- .../android/app/src/main/AndroidManifest.xml | 3 + apps/mobile/lib/matrix/client.dart | 3 + apps/mobile/lib/widgets/qr_scanner.dart | 123 ++++++++++++++++++ .../lib/widgets/verification_sheet.dart | 117 ++++++++++++++++- 4 files changed, 242 insertions(+), 4 deletions(-) create mode 100644 apps/mobile/lib/widgets/qr_scanner.dart diff --git a/apps/mobile/android/app/src/main/AndroidManifest.xml b/apps/mobile/android/app/src/main/AndroidManifest.xml index 58db8f6..76e6689 100644 --- a/apps/mobile/android/app/src/main/AndroidManifest.xml +++ b/apps/mobile/android/app/src/main/AndroidManifest.xml @@ -3,6 +3,9 @@ + + + createClient({bool startSync = true}) async { verificationMethods: { KeyVerificationMethod.emoji, KeyVerificationMethod.numbers, + KeyVerificationMethod.qrShow, + KeyVerificationMethod.qrScan, + KeyVerificationMethod.reciprocate, }, supportedLoginTypes: {AuthenticationTypes.password}, nativeImplementations: NativeImplementationsIsolate( diff --git a/apps/mobile/lib/widgets/qr_scanner.dart b/apps/mobile/lib/widgets/qr_scanner.dart new file mode 100644 index 0000000..b29ca44 --- /dev/null +++ b/apps/mobile/lib/widgets/qr_scanner.dart @@ -0,0 +1,123 @@ +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:flutter/material.dart'; +import 'package:mobile_scanner/mobile_scanner.dart'; + +/// Scans the other side's verification QR code with the camera and returns +/// its bytes. +/// +/// A verification code is binary, not text: it starts with "MATRIX", then +/// the version, the mode, the transaction and the keys. Codes that don't +/// start that way are ignored, so pointing the camera at something else +/// doesn't end the scan. +Future scanVerificationCode(BuildContext context) => + Navigator.of(context).push( + MaterialPageRoute( + fullscreenDialog: true, + builder: (_) => const _QrScanScreen(), + ), + ); + +final _prefix = ascii.encode('MATRIX'); + +bool _isVerificationCode(Uint8List bytes) { + if (bytes.length < _prefix.length) return false; + for (var i = 0; i < _prefix.length; i++) { + if (bytes[i] != _prefix[i]) return false; + } + return true; +} + +class _QrScanScreen extends StatefulWidget { + const _QrScanScreen(); + + @override + State<_QrScanScreen> createState() => _QrScanScreenState(); +} + +class _QrScanScreenState extends State<_QrScanScreen> { + final _controller = MobileScannerController( + formats: const [BarcodeFormat.qrCode], + ); + bool _done = false; + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + void _onDetect(BarcodeCapture capture) { + if (_done) return; + for (final barcode in capture.barcodes) { + final bytes = switch (barcode.rawDecodedBytes) { + DecodedBarcodeBytes(:final bytes) => bytes, + DecodedVisionBarcodeBytes(:final bytes) => bytes, + _ => null, + }; + if (bytes != null && _isVerificationCode(bytes)) { + _done = true; + Navigator.pop(context, bytes); + return; + } + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: Colors.black, + appBar: AppBar( + backgroundColor: Colors.black, + foregroundColor: Colors.white, + shape: const Border(), + title: const Text( + 'Scan their code', + style: TextStyle(color: Colors.white), + ), + ), + body: Stack( + children: [ + MobileScanner( + controller: _controller, + onDetect: _onDetect, + errorBuilder: (context, error) => Center( + child: Padding( + padding: const EdgeInsets.all(32), + child: Text( + error.errorCode == MobileScannerErrorCode.permissionDenied + ? 'Warp needs the camera to scan the code. Allow it in ' + 'the app settings, or compare emoji instead.' + : "The camera isn't available.", + textAlign: TextAlign.center, + style: const TextStyle(color: Colors.white), + ), + ), + ), + ), + Center( + child: Container( + width: 260, + height: 260, + decoration: BoxDecoration( + border: Border.all(color: Colors.white70, width: 3), + borderRadius: BorderRadius.circular(18), + ), + ), + ), + const Positioned( + left: 24, + right: 24, + bottom: 48, + child: Text( + "Point the camera at the code on the other device.", + textAlign: TextAlign.center, + style: TextStyle(color: Colors.white, fontSize: 15), + ), + ), + ], + ), + ); + } +} diff --git a/apps/mobile/lib/widgets/verification_sheet.dart b/apps/mobile/lib/widgets/verification_sheet.dart index b952452..0b40225 100644 --- a/apps/mobile/lib/widgets/verification_sheet.dart +++ b/apps/mobile/lib/widgets/verification_sheet.dart @@ -1,13 +1,19 @@ +import 'dart:typed_data'; + import 'package:flutter/material.dart'; +import 'package:qr_flutter/qr_flutter.dart'; import 'package:matrix/encryption.dart'; import 'package:matrix/matrix.dart'; import '../theme.dart'; +import 'qr_scanner.dart'; /// Runs a verification, incoming or one this session started, to the end: /// accept, compare emoji, done. Closing the sheet early cancels it. /// -/// Emoji (or numbers) only for now; scanning QR codes is a later addition. +/// When both sides can do QR codes, the sheet offers them first: show this +/// device's code, or scan the other's. Emoji (or numbers) are the fallback +/// and the only way when the other side has no camera or no QR support. Future showVerificationSheet( BuildContext context, KeyVerification request, @@ -44,17 +50,40 @@ class _VerificationSheetState extends State<_VerificationSheet> { super.dispose(); } + /// This device's QR code, made once both sides are ready. + Future? _qrCode; + + bool get _canShowQr => request.possibleMethods.contains(EventTypes.QRShow); + bool get _canScanQr => request.possibleMethods.contains(EventTypes.QRScan); + void _onUpdate() { // Steps that need no decision from the user. - if (request.state == KeyVerificationState.askChoice && - request.possibleMethods.contains(EventTypes.Sas)) { - request.continueVerification(EventTypes.Sas); + if (request.state == KeyVerificationState.askChoice) { + if (!_canShowQr && !_canScanQr) { + // No QR on one side: straight to emoji. + if (request.possibleMethods.contains(EventTypes.Sas)) { + request.continueVerification(EventTypes.Sas); + } + } else if (_canShowQr) { + _qrCode ??= request.generateQrCode(); + } } else if (request.state == KeyVerificationState.askSSSS) { request.openSSSS(skip: true); } if (mounted) setState(() {}); } + Future _scan() async { + final bytes = await scanVerificationCode(context); + if (bytes == null || !mounted) return; + await _run( + () => request.continueVerification( + EventTypes.Reciprocate, + qrDataRawBytes: bytes, + ), + ); + } + Future _run(Future Function() action) async { setState(() => _busy = true); try { @@ -111,6 +140,54 @@ class _VerificationSheetState extends State<_VerificationSheet> { ), ], ), + KeyVerificationState.askChoice when _canShowQr || _canScanQr => ( + 'Verify with a QR code', + _canShowQr && _canScanQr + ? 'Scan the code on $_who\'s screen, or let them scan this one.' + : _canShowQr + ? 'Let $_who scan this code.' + : 'Scan the code on $_who\'s screen.', + [ + if (request.possibleMethods.contains(EventTypes.Sas)) + OutlinedButton( + onPressed: _busy + ? null + : () => _run( + () => request.continueVerification(EventTypes.Sas), + ), + child: const Text('Compare emoji'), + ), + if (_canScanQr) + FilledButton.icon( + onPressed: _busy ? null : _scan, + icon: const Icon(Icons.qr_code_scanner), + label: const Text('Scan'), + ), + ], + ), + KeyVerificationState.confirmQRScan => ( + 'Did they scan it?', + 'Check that $_who\'s screen says the code was scanned.', + [ + OutlinedButton( + onPressed: _busy + ? null + : () => _run(() => request.cancel('m.user')), + child: const Text('No'), + ), + FilledButton( + onPressed: _busy + ? null + : () => _run(request.acceptQRScanConfirmation), + child: const Text('Yes'), + ), + ], + ), + KeyVerificationState.showQRSuccess => ( + 'Code scanned', + 'Waiting for $_who to confirm.', + [], + ), KeyVerificationState.waitingSas => ( 'Waiting…', 'Waiting for $_who to confirm the emoji match.', @@ -153,6 +230,38 @@ class _VerificationSheetState extends State<_VerificationSheet> { Text(title, style: text.titleLarge), const SizedBox(height: 8), Text(body, style: text.bodyMedium?.copyWith(color: c.inkMuted)), + if (request.state == KeyVerificationState.askChoice && + _qrCode != null) ...[ + const SizedBox(height: 24), + FutureBuilder( + future: _qrCode, + builder: (context, snapshot) { + final code = snapshot.data; + if (code == null) { + return const SizedBox( + height: 220, + child: Center(child: CircularProgressIndicator()), + ); + } + // A white quiet zone around the code, whatever the theme: + // scanners need the contrast. + return Center( + child: Container( + color: Colors.white, + padding: const EdgeInsets.all(12), + child: QrImageView.withQr( + qr: QrCode.fromUint8List( + data: Uint8List.fromList(code.qrDataRawBytes), + errorCorrectLevel: QrErrorCorrectLevel.L, + ), + size: 220, + backgroundColor: Colors.white, + ), + ), + ); + }, + ), + ], if (request.state == KeyVerificationState.askSas) ...[ const SizedBox(height: 24), _SasView(request),