Files
Warp/apps/mobile/lib/widgets/verification_sheet.dart
T
tajniak81andClaude Opus 5.5 3356089c13 Android app (apps/mobile) with Flutter
apps/mobile is a Matrix client for Android on the famedly Matrix Dart SDK,
with vodozemac (compiled from Rust during the build) for end-to-end
encryption. It speaks the plain Client-Server API and uses the web
client's design tokens, fonts and symbol.

- Sign in by server name (.well-known), homeserver address or full user ID;
  sign up with the dummy and registration token stages. Sessions survive
  restarts: SQLite in private storage, credentials also in the Keystore,
  both excluded from backups and device transfers.
- Room list with unread and mention counts, invites, search and filters.
  Timeline with grouping, day separators, notices, history on scroll,
  links, images and files (authenticated media). Sending text, photos,
  camera shots and files, with Sending/Sent/Read/Not sent and Retry.
  Typing, receipts, deleting. New rooms and DMs, encrypted by default;
  joining by address; room info with members, invite, rename, topic,
  notification level, leave.
- Encryption: one-step setup behind a recovery key, verifying a new session
  with the recovery key (restoring the backup) or another session, emoji
  verification of people and sessions, reset. Undecryptable messages say
  why. Session list with verify and sign out.
- Notifications while running, and push through UnifiedPush (no Firebase):
  the distributor's endpoint becomes an event_id_only pusher, the gateway
  is discovered, and a push starts the app headless to fetch, decrypt and
  show the event.

Make targets: mobile (release APKs per ABI into bin/), mobile-run,
mobile-test. Kotlin incremental compilation is off (Windows, project and
pub cache on different drives), and Tink's JVM artifact is excluded in
favour of tink-android.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 22:45:06 +02:00

235 lines
7.2 KiB
Dart

import 'package:flutter/material.dart';
import 'package:matrix/encryption.dart';
import 'package:matrix/matrix.dart';
import '../theme.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.
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();
}
void _onUpdate() {
// Steps that need no decision from the user.
if (request.state == KeyVerificationState.askChoice &&
request.possibleMethods.contains(EventTypes.Sas)) {
request.continueVerification(EventTypes.Sas);
} else if (request.state == KeyVerificationState.askSSSS) {
request.openSSSS(skip: true);
}
if (mounted) setState(() {});
}
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.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.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,
),
],
),
),
],
);
}
}