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 <noreply@anthropic.com>
This commit is contained in:
1 parent
879e22141b
commit
0fc8ee809d
4 files changed
+242
-4
No files matched your search
@@ -3,6 +3,9 @@
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
|
||||
<uses-permission android:name="android.permission.VIBRATE" />
|
||||
<!-- Scanning a QR code to verify a session or a person. -->
|
||||
<uses-permission android:name="android.permission.CAMERA" />
|
||||
<uses-feature android:name="android.hardware.camera" android:required="false" />
|
||||
|
||||
<application
|
||||
android:label="Warp"
|
||||
|
||||
@@ -48,6 +48,9 @@ Future<Client> createClient({bool startSync = true}) async {
|
||||
verificationMethods: {
|
||||
KeyVerificationMethod.emoji,
|
||||
KeyVerificationMethod.numbers,
|
||||
KeyVerificationMethod.qrShow,
|
||||
KeyVerificationMethod.qrScan,
|
||||
KeyVerificationMethod.reciprocate,
|
||||
},
|
||||
supportedLoginTypes: {AuthenticationTypes.password},
|
||||
nativeImplementations: NativeImplementationsIsolate(
|
||||
|
||||
@@ -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<Uint8List?> scanVerificationCode(BuildContext context) =>
|
||||
Navigator.of(context).push<Uint8List>(
|
||||
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),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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<void> 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?>? _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<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 {
|
||||
@@ -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.',
|
||||
<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.',
|
||||
@@ -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<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),
|
||||
|
||||
Reference in new issue
Block a user