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 <noreply@anthropic.com>
344 lines
11 KiB
Dart
344 lines
11 KiB
Dart
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<void> showVerificationSheet(
|
|
BuildContext context,
|
|
KeyVerification request,
|
|
) => showModalBottomSheet<void>(
|
|
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?>? _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<void> _scan() async {
|
|
final bytes = await scanVerificationCode(context);
|
|
if (bytes == null || !mounted) return;
|
|
await _run(
|
|
() => request.continueVerification(
|
|
EventTypes.Reciprocate,
|
|
qrDataRawBytes: bytes,
|
|
),
|
|
);
|
|
}
|
|
|
|
Future<void> _run(Future<void> 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.',
|
|
<Widget>[
|
|
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.',
|
|
<Widget>[
|
|
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.',
|
|
<Widget>[
|
|
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.',
|
|
<Widget>[
|
|
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.',
|
|
<Widget>[],
|
|
),
|
|
KeyVerificationState.waitingSas => (
|
|
'Waiting…',
|
|
'Waiting for $_who to confirm the emoji match.',
|
|
<Widget>[],
|
|
),
|
|
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.',
|
|
<Widget>[
|
|
FilledButton(
|
|
onPressed: () => Navigator.pop(context),
|
|
child: const Text('Done'),
|
|
),
|
|
],
|
|
),
|
|
KeyVerificationState.error => (
|
|
'Verification stopped',
|
|
_cancelText(request.canceledCode, request.canceledReason),
|
|
<Widget>[
|
|
FilledButton(
|
|
onPressed: () => Navigator.pop(context),
|
|
child: const Text('Close'),
|
|
),
|
|
],
|
|
),
|
|
_ => ('Verifying…', 'Waiting for $_who to accept.', <Widget>[]),
|
|
};
|
|
|
|
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<QRCode?>(
|
|
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,
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|