Files
Warp/apps/mobile/lib/screens/encryption_screen.dart
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

375 lines
12 KiB
Dart

import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:matrix/encryption.dart';
import '../matrix/client.dart';
import '../matrix/encryption.dart';
import '../state.dart';
import '../theme.dart';
import '../widgets/verification_sheet.dart';
/// Encryption for the account and this session: set it up, verify this
/// session (with the recovery key or another session), or reset it.
class EncryptionScreen extends ConsumerStatefulWidget {
const EncryptionScreen({super.key});
@override
ConsumerState<EncryptionScreen> createState() => _EncryptionScreenState();
}
class _EncryptionScreenState extends ConsumerState<EncryptionScreen> {
bool _busy = false;
String? _error;
Future<void> _run(Future<void> Function() action) async {
setState(() {
_busy = true;
_error = null;
});
try {
await action();
} catch (e) {
if (mounted) setState(() => _error = describeError(e));
} finally {
if (mounted) setState(() => _busy = false);
}
}
Future<void> _setUp({required bool reset}) async {
final encryption = ref.read(clientProvider).encryption!;
if (reset) {
final ok = await showDialog<bool>(
context: context,
builder: (dialog) => AlertDialog(
title: const Text('Reset encryption?'),
content: const Text(
'Only do this if you have lost your recovery key and have no '
'other verified session.\n\nYou get a new recovery key. Messages '
'only the old keys could read stay unreadable, and the people '
"you've verified will need to verify you again.",
),
actions: [
TextButton(
onPressed: () => Navigator.pop(dialog, false),
child: const Text('Cancel'),
),
FilledButton(
style: FilledButton.styleFrom(
backgroundColor: context.warp.danger,
),
onPressed: () => Navigator.pop(dialog, true),
child: const Text('Reset'),
),
],
),
);
if (ok != true) return;
}
String? recoveryKey;
await _run(() async => recoveryKey = await setUpEncryption(encryption));
final key = recoveryKey;
if (key != null && mounted) {
await Navigator.of(context).push(
MaterialPageRoute(
fullscreenDialog: true,
builder: (_) => _RecoveryKeyScreen(recoveryKey: key),
),
);
if (mounted) setState(() {});
}
}
Future<void> _verifyWithKey() async {
final controller = TextEditingController();
final input = await showDialog<String>(
context: context,
builder: (dialog) => AlertDialog(
title: const Text('Enter your recovery key'),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'The key you saved when you set up encryption. It looks '
'like EsTc 1234 …',
),
const SizedBox(height: 16),
TextField(
controller: controller,
autofocus: true,
autocorrect: false,
enableSuggestions: false,
minLines: 2,
maxLines: 3,
style: const TextStyle(fontFamily: fontMono),
decoration: const InputDecoration(labelText: 'Recovery key'),
),
],
),
actions: [
TextButton(
onPressed: () => Navigator.pop(dialog),
child: const Text('Cancel'),
),
FilledButton(
onPressed: () => Navigator.pop(dialog, controller.text),
child: const Text('Verify'),
),
],
),
);
if (input == null || input.trim().isEmpty) return;
final encryption = ref.read(clientProvider).encryption!;
await _run(() async {
try {
await verifyWithRecoveryKey(encryption, input);
} on InvalidPassphraseException {
throw Exception("That recovery key isn't right.");
}
});
if (mounted && _error == null) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text(
'This session is verified. Older messages are being restored.',
),
),
);
}
}
Future<void> _verifyWithSession() async {
final client = ref.read(clientProvider);
await _run(() async {
final keys = client.userDeviceKeys[client.userID!];
if (keys == null) throw Exception('Your sessions are still loading.');
final request = await keys.startVerification();
if (mounted) await showVerificationSheet(context, request);
});
}
@override
Widget build(BuildContext context) {
ref.watch(syncProvider);
final client = ref.watch(clientProvider);
final encryption = client.encryption;
final c = context.warp;
final text = Theme.of(context).textTheme;
final List<Widget> body;
if (encryption == null) {
body = [
const _Para('End-to-end encryption is not available in this build.'),
];
} else if (!encryption.crossSigning.enabled) {
body = [
const _Para(
'Encryption keeps your messages readable only by the people in '
'the conversation. Set it up once and you get a recovery key: with '
'it, any new session you sign in to can verify itself and read '
'your older messages.',
),
const SizedBox(height: 8),
const _Para(
'You will be asked for your password. Keep the recovery key '
'somewhere safe, like a password manager.',
),
const SizedBox(height: 24),
FilledButton(
onPressed: _busy ? null : () => _setUp(reset: false),
child: const Text('Set up encryption'),
),
];
} else if (client.isUnknownSession) {
body = [
const _Para(
'Prove this session is you, so it can read your encrypted messages '
'and the people you talk to trust it.',
),
const SizedBox(height: 24),
FilledButton.icon(
onPressed: _busy ? null : _verifyWithSession,
icon: const Icon(Icons.devices_outlined),
label: const Text('Use another session'),
),
const SizedBox(height: 12),
OutlinedButton.icon(
onPressed: _busy ? null : _verifyWithKey,
icon: const Icon(Icons.key_outlined),
label: const Text('Use recovery key'),
),
const SizedBox(height: 32),
TextButton(
onPressed: _busy ? null : () => _setUp(reset: true),
style: TextButton.styleFrom(foregroundColor: c.danger),
child: const Text('Lost your recovery key? Reset encryption'),
),
];
} else {
body = [
_Status(
ok: true,
title: 'This session is verified',
detail: 'It can read your encrypted messages.',
),
_Status(
ok: encryption.keyManager.enabled,
title: encryption.keyManager.enabled
? 'Key backup is on'
: 'Key backup is off',
detail: encryption.keyManager.enabled
? 'Your message keys are backed up, encrypted with your '
'recovery key.'
: 'New sessions may not be able to read older messages.',
),
const SizedBox(height: 32),
OutlinedButton(
onPressed: _busy ? null : () => _setUp(reset: true),
style: OutlinedButton.styleFrom(foregroundColor: c.danger),
child: const Text('Reset encryption'),
),
];
}
return Scaffold(
appBar: AppBar(title: const Text('Encryption')),
body: ListView(
padding: const EdgeInsets.all(24),
children: [
if (_busy) ...[
const LinearProgressIndicator(minHeight: 2),
const SizedBox(height: 16),
],
if (_error != null) ...[
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: c.dangerSoft,
borderRadius: BorderRadius.circular(12),
),
child: Text(
_error!,
style: text.bodyMedium?.copyWith(color: c.danger),
),
),
const SizedBox(height: 16),
],
...body,
],
),
);
}
}
class _Para extends StatelessWidget {
const _Para(this.text);
final String text;
@override
Widget build(BuildContext context) =>
Text(text, style: Theme.of(context).textTheme.bodyLarge);
}
class _Status extends StatelessWidget {
const _Status({required this.ok, required this.title, required this.detail});
final bool ok;
final String title;
final String detail;
@override
Widget build(BuildContext context) {
final c = context.warp;
return ListTile(
contentPadding: EdgeInsets.zero,
leading: Icon(
ok ? Icons.check_circle_outline : Icons.error_outline,
color: ok ? c.tealText : c.warning,
),
title: Text(title),
subtitle: Text(detail),
);
}
}
/// The new recovery key, shown once. The user has to say they saved it.
class _RecoveryKeyScreen extends StatefulWidget {
const _RecoveryKeyScreen({required this.recoveryKey});
final String recoveryKey;
@override
State<_RecoveryKeyScreen> createState() => _RecoveryKeyScreenState();
}
class _RecoveryKeyScreenState extends State<_RecoveryKeyScreen> {
bool _saved = false;
@override
Widget build(BuildContext context) {
final c = context.warp;
final text = Theme.of(context).textTheme;
return PopScope(
canPop: _saved,
child: Scaffold(
appBar: AppBar(
title: const Text('Your recovery key'),
automaticallyImplyLeading: false,
),
body: ListView(
padding: const EdgeInsets.all(24),
children: [
Text(
'Save this key somewhere safe, like a password manager. It is '
'the only way to read your encrypted messages on a new session '
"if you aren't signed in anywhere else. It won't be shown again.",
style: text.bodyLarge,
),
const SizedBox(height: 24),
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: c.surfaceSunken,
border: Border.all(color: c.line),
borderRadius: BorderRadius.circular(12),
),
child: SelectableText(
widget.recoveryKey,
textAlign: TextAlign.center,
style: text.titleMedium?.copyWith(
fontFamily: fontMono,
letterSpacing: 1,
height: 1.6,
),
),
),
const SizedBox(height: 12),
OutlinedButton.icon(
onPressed: () {
Clipboard.setData(ClipboardData(text: widget.recoveryKey));
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Recovery key copied')),
);
},
icon: const Icon(Icons.copy),
label: const Text('Copy'),
),
const SizedBox(height: 24),
CheckboxListTile(
contentPadding: EdgeInsets.zero,
value: _saved,
onChanged: (v) => setState(() => _saved = v ?? false),
title: const Text("I've saved my recovery key"),
controlAffinity: ListTileControlAffinity.leading,
),
const SizedBox(height: 12),
FilledButton(
onPressed: _saved ? () => Navigator.pop(context) : null,
child: const Text('Done'),
),
],
),
),
);
}
}