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),