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. /// /// 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, ) => showModalBottomSheet( context: context, isScrollControlled: true, isDismissible: false, builder: (_) => _VerificationSheet(request), ); class _VerificationSheet extends StatefulWidget { const _VerificationSheet(this.request); final KeyVerification request; @override State<_VerificationSheet> createState() => _VerificationSheetState(); } class _VerificationSheetState extends State<_VerificationSheet> { KeyVerification get request => widget.request; bool _busy = false; @override void initState() { super.initState(); request.onUpdate = _onUpdate; _onUpdate(); } @override void dispose() { request.onUpdate = null; if (!request.isDone) request.cancel('m.user'); 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) { 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 { await action(); } finally { if (mounted) setState(() => _busy = false); } } bool get _isSelf => request.userId == request.client.userID; String get _who { if (_isSelf) return 'your other session'; final user = request.room?.unsafeGetUserFromMemoryOrFallback( request.userId, ); return user?.calcDisplayname() ?? request.userId; } @override Widget build(BuildContext context) { final c = context.warp; final text = Theme.of(context).textTheme; final (title, body, actions) = switch (request.state) { KeyVerificationState.askAccept => ( _isSelf ? 'Verify this session?' : 'Verification request', _isSelf ? 'Another of your sessions wants to verify this one. Accept, ' 'then compare the emoji on both screens.' : '$_who wants to verify you. Accept, then compare the emoji ' 'on both screens.', [ OutlinedButton( onPressed: _busy ? null : () => _run(request.rejectVerification), child: const Text('Decline'), ), FilledButton( onPressed: _busy ? null : () => _run(request.acceptVerification), child: const Text('Accept'), ), ], ), KeyVerificationState.askSas => ( 'Compare emoji', 'Check that these appear, in the same order, on $_who\'s screen.', [ OutlinedButton( onPressed: _busy ? null : () => _run(request.rejectSas), child: const Text("They don't match"), ), FilledButton( onPressed: _busy ? null : () => _run(request.acceptSas), child: const Text('They match'), ), ], ), 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.', [], ), KeyVerificationState.done => ( 'Verified', _isSelf ? 'This session is verified. It can read your encrypted ' 'messages, and the people you talk to will trust it.' : '$_who is verified. Messages from their verified sessions ' 'are trusted.', [ FilledButton( onPressed: () => Navigator.pop(context), child: const Text('Done'), ), ], ), KeyVerificationState.error => ( 'Verification stopped', _cancelText(request.canceledCode, request.canceledReason), [ FilledButton( onPressed: () => Navigator.pop(context), child: const Text('Close'), ), ], ), _ => ('Verifying…', 'Waiting for $_who to accept.', []), }; return SafeArea( child: Padding( padding: const EdgeInsets.fromLTRB(24, 0, 24, 24), child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, children: [ 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), ], if (actions.isEmpty && request.state != KeyVerificationState.done) ...[ const SizedBox(height: 24), const Center(child: CircularProgressIndicator()), const SizedBox(height: 16), TextButton( onPressed: () => Navigator.pop(context), child: const Text('Cancel'), ), ], if (actions.isNotEmpty) ...[ const SizedBox(height: 24), Row( children: [ for (final (i, a) in actions.indexed) ...[ if (i > 0) const SizedBox(width: 12), Expanded(child: a), ], ], ), ], ], ), ), ); } static String _cancelText(String? code, String? reason) => switch (code) { 'm.user' => 'The verification was canceled.', 'm.timeout' => 'The verification took too long.', 'm.mismatched_sas' || 'm.key_mismatch' => "The emoji didn't match. Nobody was verified.", 'm.accepted' => 'Another of your sessions answered the request.', _ => reason ?? 'Something went wrong.', }; } class _SasView extends StatelessWidget { const _SasView(this.request); final KeyVerification request; @override Widget build(BuildContext context) { final text = Theme.of(context).textTheme; if (!request.sasTypes.contains('emoji')) { return Text( request.sasNumbers.join(' '), textAlign: TextAlign.center, style: text.headlineMedium?.copyWith(fontFamily: fontMono), ); } return Wrap( alignment: WrapAlignment.center, spacing: 8, runSpacing: 16, children: [ for (final emoji in request.sasEmojis) SizedBox( width: 72, child: Column( children: [ Text(emoji.emoji, style: const TextStyle(fontSize: 36)), const SizedBox(height: 4), Text( emoji.name, textAlign: TextAlign.center, style: text.labelMedium, ), ], ), ), ], ); } }