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