feat: Phase 3 — E2EE calls, Jitsi conferencing, help tab, web security model
- LiveKit call E2EE: CallE2EEManager exchanges encryption keys via Matrix to-device events (m.rtc.encryption_keys) for interop with Element X - Olm bootstrapped in index.html before Flutter init; main.dart logs result - Encrypted messages shown with lock icon and informative fallback text - Profile screen: key restore dialog + security setup (cross-signing/backup) - Jitsi feature: welcome screen (public, no login), conference tab, full-screen embed via JitsiMeetExternalAPI, JitsiLink parser for all common link formats - Help tab: expandable cards for encryption, video calls, account management - Web security model: no session persistence — device ID only across visits - Media auth: MSC3916 authenticated endpoint for avatars (Synapse 1.120+) - Router: welcome route as public landing page; jitsi route as public - Manifest/index.html: M8Chat branding, dark theme colours Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
213
lib/features/profile/presentation/key_restore_dialog.dart
Normal file
213
lib/features/profile/presentation/key_restore_dialog.dart
Normal file
@@ -0,0 +1,213 @@
|
||||
// Version: 1.0.0 | Created: 2026-04-11
|
||||
// Dialog for entering a recovery key to restore encrypted message keys
|
||||
// from the server-side key backup (SSSS / Secure Secret Storage).
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:matrix/encryption/ssss.dart';
|
||||
import 'package:matrix/matrix.dart';
|
||||
|
||||
/// Shows a dialog that asks the user for their recovery key, unlocks SSSS,
|
||||
/// and restores all Megolm session keys from the server backup.
|
||||
///
|
||||
/// Returns `true` if keys were restored successfully, `false` otherwise.
|
||||
Future<bool> showKeyRestoreDialog(BuildContext context, Client client) async {
|
||||
final result = await showDialog<bool>(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
builder: (_) => _KeyRestoreDialog(client: client),
|
||||
);
|
||||
return result ?? false;
|
||||
}
|
||||
|
||||
class _KeyRestoreDialog extends StatefulWidget {
|
||||
const _KeyRestoreDialog({required this.client});
|
||||
final Client client;
|
||||
|
||||
@override
|
||||
State<_KeyRestoreDialog> createState() => _KeyRestoreDialogState();
|
||||
}
|
||||
|
||||
enum _RestorePhase { input, unlocking, downloading, done, error }
|
||||
|
||||
class _KeyRestoreDialogState extends State<_KeyRestoreDialog> {
|
||||
final _controller = TextEditingController();
|
||||
_RestorePhase _phase = _RestorePhase.input;
|
||||
String? _error;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _restore() async {
|
||||
final key = _controller.text.trim();
|
||||
if (key.isEmpty) return;
|
||||
|
||||
final enc = widget.client.encryption;
|
||||
if (enc == null) {
|
||||
setState(() {
|
||||
_phase = _RestorePhase.error;
|
||||
_error = 'Encryption is not available.';
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Phase 1: Unlock SSSS with the recovery key
|
||||
setState(() => _phase = _RestorePhase.unlocking);
|
||||
try {
|
||||
final ssssKey = enc.ssss.open();
|
||||
await ssssKey.unlock(keyOrPassphrase: key);
|
||||
debugPrint('[KeyRestore] SSSS unlocked successfully');
|
||||
} on InvalidPassphraseException {
|
||||
setState(() {
|
||||
_phase = _RestorePhase.error;
|
||||
_error = 'Invalid recovery key. Please check and try again.';
|
||||
});
|
||||
return;
|
||||
} on Exception catch (e) {
|
||||
setState(() {
|
||||
_phase = _RestorePhase.error;
|
||||
_error = 'Could not unlock: ${e.toString().split('\n').first}';
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Phase 2: Download and decrypt all keys from backup
|
||||
setState(() => _phase = _RestorePhase.downloading);
|
||||
try {
|
||||
await enc.keyManager.loadAllKeys();
|
||||
debugPrint('[KeyRestore] All keys loaded from backup');
|
||||
setState(() => _phase = _RestorePhase.done);
|
||||
} on MatrixException catch (e) {
|
||||
if (e.errcode == 'M_NOT_FOUND') {
|
||||
setState(() {
|
||||
_phase = _RestorePhase.error;
|
||||
_error = 'No key backup found on the server for this account.';
|
||||
});
|
||||
} else {
|
||||
setState(() {
|
||||
_phase = _RestorePhase.error;
|
||||
_error = 'Server error: ${e.errorMessage}';
|
||||
});
|
||||
}
|
||||
} on Exception catch (e) {
|
||||
setState(() {
|
||||
_phase = _RestorePhase.error;
|
||||
_error = 'Failed to download keys: ${e.toString().split('\n').first}';
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AlertDialog(
|
||||
title: Text(switch (_phase) {
|
||||
_RestorePhase.input => 'Restore Message Keys',
|
||||
_RestorePhase.unlocking => 'Unlocking...',
|
||||
_RestorePhase.downloading => 'Downloading Keys...',
|
||||
_RestorePhase.done => 'Keys Restored',
|
||||
_RestorePhase.error => 'Restore Failed',
|
||||
}),
|
||||
content: switch (_phase) {
|
||||
_RestorePhase.input => Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text(
|
||||
'Enter your recovery key to decrypt old messages. '
|
||||
'This is the key you were given when you set up '
|
||||
'encryption in Element.',
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextField(
|
||||
controller: _controller,
|
||||
decoration: const InputDecoration(
|
||||
hintText: 'EsTc oRgW rqHN...',
|
||||
labelText: 'Recovery key',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
maxLines: 3,
|
||||
textInputAction: TextInputAction.done,
|
||||
onSubmitted: (_) => _restore(),
|
||||
),
|
||||
],
|
||||
),
|
||||
_RestorePhase.unlocking => const _ProgressContent(
|
||||
message: 'Verifying recovery key...',
|
||||
),
|
||||
_RestorePhase.downloading => const _ProgressContent(
|
||||
message: 'Downloading and decrypting message keys...\n'
|
||||
'This may take a moment.',
|
||||
),
|
||||
_RestorePhase.done => const Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(Icons.check_circle, color: Colors.green, size: 48),
|
||||
SizedBox(height: 16),
|
||||
Text(
|
||||
'Message keys have been restored. '
|
||||
'Go back to your rooms — previously encrypted '
|
||||
'messages should now be readable.',
|
||||
),
|
||||
],
|
||||
),
|
||||
_RestorePhase.error => Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Icon(Icons.error_outline, color: Colors.red, size: 48),
|
||||
const SizedBox(height: 16),
|
||||
Text(_error ?? 'An unknown error occurred.'),
|
||||
],
|
||||
),
|
||||
},
|
||||
actions: switch (_phase) {
|
||||
_RestorePhase.input => [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(false),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
ElevatedButton(
|
||||
onPressed: _restore,
|
||||
child: const Text('Restore'),
|
||||
),
|
||||
],
|
||||
_RestorePhase.unlocking || _RestorePhase.downloading => [],
|
||||
_RestorePhase.done => [
|
||||
ElevatedButton(
|
||||
onPressed: () => Navigator.of(context).pop(true),
|
||||
child: const Text('Done'),
|
||||
),
|
||||
],
|
||||
_RestorePhase.error => [
|
||||
TextButton(
|
||||
onPressed: () =>
|
||||
setState(() => _phase = _RestorePhase.input),
|
||||
child: const Text('Try again'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(false),
|
||||
child: const Text('Close'),
|
||||
),
|
||||
],
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ProgressContent extends StatelessWidget {
|
||||
const _ProgressContent({required this.message});
|
||||
final String message;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const CircularProgressIndicator(),
|
||||
const SizedBox(height: 16),
|
||||
Text(message, textAlign: TextAlign.center),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
// Version: 1.1.0 | Created: 2026-04-01 | Updated: 2026-04-02
|
||||
// Version: 1.4.0 | Created: 2026-04-01 | Updated: 2026-04-11
|
||||
// Profile screen. Shows current user info and logout button.
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
@@ -10,6 +10,8 @@ import '../../../core/config/app_config.dart';
|
||||
import '../../../core/network/matrix_client.dart';
|
||||
import '../../../shared/utils/matrix_id.dart';
|
||||
import '../../../shared/widgets/matrix_avatar.dart';
|
||||
import 'key_restore_dialog.dart';
|
||||
import 'security_setup_dialog.dart';
|
||||
|
||||
class ProfileScreen extends ConsumerWidget {
|
||||
const ProfileScreen({super.key, this.embedded = false});
|
||||
@@ -85,14 +87,57 @@ class ProfileScreen extends ConsumerWidget {
|
||||
ListTile(
|
||||
leading: const Icon(Icons.lock_outline),
|
||||
title: const Text('End-to-end encryption'),
|
||||
subtitle: const Text('Setup coming in Phase 3'),
|
||||
subtitle: Text(
|
||||
client.encryptionEnabled
|
||||
? 'Active — messages are encrypted'
|
||||
: 'Not available in this browser',
|
||||
),
|
||||
enabled: false,
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.verified_user_outlined),
|
||||
title: const Text('Verify devices'),
|
||||
subtitle: const Text('Cross-signing setup — coming in Phase 2'),
|
||||
enabled: false,
|
||||
leading: const Icon(Icons.key_outlined),
|
||||
title: const Text('Restore message keys'),
|
||||
subtitle: const Text(
|
||||
'Enter your recovery key to decrypt old messages',
|
||||
),
|
||||
enabled: client.encryptionEnabled,
|
||||
onTap: client.encryptionEnabled
|
||||
? () async {
|
||||
final restored =
|
||||
await showKeyRestoreDialog(context, client);
|
||||
if (restored && context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Keys restored — reopen rooms '
|
||||
'to see decrypted messages.'),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
: null,
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.shield_outlined),
|
||||
title: const Text('Security setup'),
|
||||
subtitle: Text(
|
||||
client.encryption?.crossSigning.enabled == true
|
||||
? 'Cross-signing active — device verified'
|
||||
: 'Set up cross-signing and key backup',
|
||||
),
|
||||
enabled: client.encryptionEnabled,
|
||||
onTap: client.encryptionEnabled
|
||||
? () async {
|
||||
final completed =
|
||||
await showSecuritySetupDialog(context, client);
|
||||
if (completed && context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Security setup complete.'),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
: null,
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.password_outlined),
|
||||
|
||||
416
lib/features/profile/presentation/security_setup_dialog.dart
Normal file
416
lib/features/profile/presentation/security_setup_dialog.dart
Normal file
@@ -0,0 +1,416 @@
|
||||
// Version: 1.0.0 | Created: 2026-04-11
|
||||
// Security setup dialog: drives the Matrix SDK Bootstrap to set up
|
||||
// SSSS, cross-signing, and online key backup in one flow.
|
||||
// For new users: creates everything, shows recovery key.
|
||||
// For existing users: unlocks with recovery key, restores message keys.
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:matrix/encryption/ssss.dart';
|
||||
import 'package:matrix/encryption/utils/bootstrap.dart';
|
||||
import 'package:matrix/matrix.dart';
|
||||
|
||||
/// Shows the security setup dialog. Returns true if setup completed.
|
||||
Future<bool> showSecuritySetupDialog(
|
||||
BuildContext context, Client client) async {
|
||||
final result = await showDialog<bool>(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
builder: (_) => _SecuritySetupDialog(client: client),
|
||||
);
|
||||
return result ?? false;
|
||||
}
|
||||
|
||||
class _SecuritySetupDialog extends StatefulWidget {
|
||||
const _SecuritySetupDialog({required this.client});
|
||||
final Client client;
|
||||
|
||||
@override
|
||||
State<_SecuritySetupDialog> createState() => _SecuritySetupDialogState();
|
||||
}
|
||||
|
||||
enum _Phase {
|
||||
checking,
|
||||
needsRecoveryKey,
|
||||
working,
|
||||
showRecoveryKey,
|
||||
restoring,
|
||||
done,
|
||||
error,
|
||||
}
|
||||
|
||||
class _SecuritySetupDialogState extends State<_SecuritySetupDialog> {
|
||||
final _keyController = TextEditingController();
|
||||
_Phase _phase = _Phase.checking;
|
||||
String? _error;
|
||||
String? _recoveryKey;
|
||||
String _statusMessage = 'Checking security setup...';
|
||||
late Bootstrap _bootstrap;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_start();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_keyController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _start() async {
|
||||
final enc = widget.client.encryption;
|
||||
if (enc == null) {
|
||||
_setError('Encryption is not available in this browser.');
|
||||
return;
|
||||
}
|
||||
|
||||
_bootstrap = enc.bootstrap(onUpdate: (_) {});
|
||||
|
||||
try {
|
||||
await _driveBootstrap();
|
||||
} catch (e) {
|
||||
_setError(e.toString().split('\n').first);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _driveBootstrap() async {
|
||||
while (_bootstrap.state != BootstrapState.done &&
|
||||
_bootstrap.state != BootstrapState.error) {
|
||||
debugPrint('[Security] Bootstrap state: ${_bootstrap.state}');
|
||||
|
||||
switch (_bootstrap.state) {
|
||||
case BootstrapState.loading:
|
||||
setState(() {
|
||||
_phase = _Phase.working;
|
||||
_statusMessage = 'Setting up...';
|
||||
});
|
||||
// Wait for the bootstrap to advance via onUpdate
|
||||
await Future.delayed(const Duration(milliseconds: 200));
|
||||
|
||||
case BootstrapState.askWipeSsss:
|
||||
// Existing SSSS found — don't wipe, use it
|
||||
setState(() {
|
||||
_phase = _Phase.working;
|
||||
_statusMessage = 'Found existing security setup...';
|
||||
});
|
||||
_bootstrap.wipeSsss(false);
|
||||
|
||||
case BootstrapState.askUseExistingSsss:
|
||||
// Use the existing SSSS
|
||||
_bootstrap.useExistingSsss(true);
|
||||
|
||||
case BootstrapState.openExistingSsss:
|
||||
// Need the recovery key from the user
|
||||
if (!_bootstrap.newSsssKey!.isUnlocked) {
|
||||
setState(() => _phase = _Phase.needsRecoveryKey);
|
||||
return; // Wait for user input
|
||||
}
|
||||
// Already unlocked, continue
|
||||
setState(() {
|
||||
_phase = _Phase.working;
|
||||
_statusMessage = 'Verifying security keys...';
|
||||
});
|
||||
await _bootstrap.openExistingSsss();
|
||||
|
||||
case BootstrapState.askBadSsss:
|
||||
// Ignore bad secrets and continue
|
||||
_bootstrap.ignoreBadSecrets(true);
|
||||
|
||||
case BootstrapState.askUnlockSsss:
|
||||
// Old keys need unlocking — this is a migration scenario
|
||||
if (_bootstrap.oldSsssKeys != null) {
|
||||
for (final key in _bootstrap.oldSsssKeys!.values) {
|
||||
if (!key.isUnlocked) {
|
||||
setState(() => _phase = _Phase.needsRecoveryKey);
|
||||
return; // Wait for user input
|
||||
}
|
||||
}
|
||||
}
|
||||
_bootstrap.unlockedSsss();
|
||||
|
||||
case BootstrapState.askNewSsss:
|
||||
// Create new SSSS (no passphrase — recovery key only)
|
||||
setState(() {
|
||||
_phase = _Phase.working;
|
||||
_statusMessage = 'Creating security keys...';
|
||||
});
|
||||
await _bootstrap.newSsss();
|
||||
|
||||
case BootstrapState.askSetupCrossSigning:
|
||||
setState(() => _statusMessage = 'Setting up cross-signing...');
|
||||
await _bootstrap.askSetupCrossSigning(
|
||||
setupMasterKey: true,
|
||||
setupSelfSigningKey: true,
|
||||
setupUserSigningKey: true,
|
||||
);
|
||||
|
||||
case BootstrapState.askWipeCrossSigning:
|
||||
// Don't wipe existing cross-signing
|
||||
await _bootstrap.wipeCrossSigning(false);
|
||||
|
||||
case BootstrapState.askSetupOnlineKeyBackup:
|
||||
setState(() => _statusMessage = 'Creating key backup...');
|
||||
await _bootstrap.askSetupOnlineKeyBackup(true);
|
||||
|
||||
case BootstrapState.askWipeOnlineKeyBackup:
|
||||
// Don't wipe existing backup
|
||||
_bootstrap.wipeOnlineKeyBackup(false);
|
||||
|
||||
case BootstrapState.done:
|
||||
case BootstrapState.error:
|
||||
break; // Exit loop
|
||||
}
|
||||
}
|
||||
|
||||
if (_bootstrap.state == BootstrapState.error) {
|
||||
_setError('Setup failed. Please try again.');
|
||||
return;
|
||||
}
|
||||
|
||||
// Success — show recovery key if we created new SSSS
|
||||
_recoveryKey = _bootstrap.newSsssKey?.recoveryKey;
|
||||
|
||||
if (_recoveryKey != null && _phase != _Phase.needsRecoveryKey) {
|
||||
// New setup — show the recovery key
|
||||
setState(() => _phase = _Phase.showRecoveryKey);
|
||||
} else {
|
||||
// Existing setup — restore keys from backup
|
||||
await _restoreKeys();
|
||||
}
|
||||
}
|
||||
|
||||
/// Called when the user enters their recovery key for existing SSSS.
|
||||
Future<void> _unlockWithRecoveryKey() async {
|
||||
final key = _keyController.text.trim();
|
||||
if (key.isEmpty) return;
|
||||
|
||||
setState(() {
|
||||
_phase = _Phase.working;
|
||||
_statusMessage = 'Verifying recovery key...';
|
||||
});
|
||||
|
||||
try {
|
||||
// Unlock whichever SSSS key is waiting
|
||||
if (_bootstrap.newSsssKey != null && !_bootstrap.newSsssKey!.isUnlocked) {
|
||||
await _bootstrap.newSsssKey!.unlock(keyOrPassphrase: key);
|
||||
}
|
||||
if (_bootstrap.oldSsssKeys != null) {
|
||||
for (final ssssKey in _bootstrap.oldSsssKeys!.values) {
|
||||
if (!ssssKey.isUnlocked) {
|
||||
await ssssKey.unlock(keyOrPassphrase: key);
|
||||
}
|
||||
}
|
||||
}
|
||||
} on InvalidPassphraseException {
|
||||
_setError('Invalid recovery key. Please check and try again.');
|
||||
return;
|
||||
} on Exception catch (e) {
|
||||
_setError('Could not unlock: ${e.toString().split('\n').first}');
|
||||
return;
|
||||
}
|
||||
|
||||
// Continue the bootstrap
|
||||
try {
|
||||
await _driveBootstrap();
|
||||
} catch (e) {
|
||||
_setError(e.toString().split('\n').first);
|
||||
}
|
||||
}
|
||||
|
||||
/// Download all keys from the online key backup after SSSS is unlocked.
|
||||
Future<void> _restoreKeys() async {
|
||||
setState(() {
|
||||
_phase = _Phase.restoring;
|
||||
_statusMessage = 'Downloading message keys from backup...';
|
||||
});
|
||||
|
||||
try {
|
||||
await widget.client.encryption?.keyManager.loadAllKeys();
|
||||
debugPrint('[Security] All keys loaded from backup');
|
||||
} on MatrixException catch (e) {
|
||||
if (e.errcode != 'M_NOT_FOUND') {
|
||||
debugPrint('[Security] Key restore error: ${e.errorMessage}');
|
||||
}
|
||||
// M_NOT_FOUND just means no backup exists yet — not an error
|
||||
} on Exception catch (e) {
|
||||
debugPrint('[Security] Key restore error: $e');
|
||||
}
|
||||
|
||||
setState(() => _phase = _Phase.done);
|
||||
}
|
||||
|
||||
void _setError(String message) {
|
||||
setState(() {
|
||||
_phase = _Phase.error;
|
||||
_error = message;
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AlertDialog(
|
||||
title: Text(switch (_phase) {
|
||||
_Phase.checking => 'Security Setup',
|
||||
_Phase.needsRecoveryKey => 'Enter Recovery Key',
|
||||
_Phase.working || _Phase.restoring => 'Setting Up...',
|
||||
_Phase.showRecoveryKey => 'Save Your Recovery Key',
|
||||
_Phase.done => 'Security Setup Complete',
|
||||
_Phase.error => 'Setup Error',
|
||||
}),
|
||||
content: SizedBox(
|
||||
width: 400,
|
||||
child: switch (_phase) {
|
||||
_Phase.checking || _Phase.working || _Phase.restoring => Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const CircularProgressIndicator(),
|
||||
const SizedBox(height: 16),
|
||||
Text(_statusMessage, textAlign: TextAlign.center),
|
||||
],
|
||||
),
|
||||
_Phase.needsRecoveryKey => Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text(
|
||||
'Enter your recovery key to unlock your encrypted '
|
||||
'messages. This is the key shown when you first set '
|
||||
'up encryption in Element.',
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextField(
|
||||
controller: _keyController,
|
||||
decoration: const InputDecoration(
|
||||
hintText: 'EsTc oRgW rqHN...',
|
||||
labelText: 'Recovery key or passphrase',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
maxLines: 3,
|
||||
onSubmitted: (_) => _unlockWithRecoveryKey(),
|
||||
),
|
||||
],
|
||||
),
|
||||
_Phase.showRecoveryKey => Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text(
|
||||
'Your recovery key has been created. Save it somewhere '
|
||||
'safe — you will need it to restore your messages on '
|
||||
'new devices.',
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context)
|
||||
.colorScheme
|
||||
.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(
|
||||
color: Theme.of(context).colorScheme.outline,
|
||||
),
|
||||
),
|
||||
child: SelectableText(
|
||||
_recoveryKey ?? '',
|
||||
style: const TextStyle(
|
||||
fontFamily: 'monospace',
|
||||
fontSize: 14,
|
||||
letterSpacing: 1.2,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
children: [
|
||||
const Icon(Icons.warning_amber, size: 16),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'If you lose this key, you will not be able to '
|
||||
'read your encrypted messages on new devices.',
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
_Phase.done => const Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(Icons.verified, color: Colors.green, size: 48),
|
||||
SizedBox(height: 16),
|
||||
Text(
|
||||
'Security is set up. Cross-signing is active and '
|
||||
'your message keys are backed up. Previously '
|
||||
'encrypted messages should now be readable.',
|
||||
),
|
||||
],
|
||||
),
|
||||
_Phase.error => Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Icon(Icons.error_outline, color: Colors.red, size: 48),
|
||||
const SizedBox(height: 16),
|
||||
Text(_error ?? 'An unknown error occurred.'),
|
||||
],
|
||||
),
|
||||
},
|
||||
),
|
||||
actions: switch (_phase) {
|
||||
_Phase.checking || _Phase.working || _Phase.restoring => [],
|
||||
_Phase.needsRecoveryKey => [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(false),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
ElevatedButton(
|
||||
onPressed: _unlockWithRecoveryKey,
|
||||
child: const Text('Unlock'),
|
||||
),
|
||||
],
|
||||
_Phase.showRecoveryKey => [
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
Clipboard.setData(
|
||||
ClipboardData(text: _recoveryKey ?? ''));
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Recovery key copied to clipboard')),
|
||||
);
|
||||
},
|
||||
child: const Text('Copy'),
|
||||
),
|
||||
ElevatedButton(
|
||||
onPressed: () async {
|
||||
await _restoreKeys();
|
||||
},
|
||||
child: const Text('I saved it — continue'),
|
||||
),
|
||||
],
|
||||
_Phase.done => [
|
||||
ElevatedButton(
|
||||
onPressed: () => Navigator.of(context).pop(true),
|
||||
child: const Text('Done'),
|
||||
),
|
||||
],
|
||||
_Phase.error => [
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
setState(() => _phase = _Phase.needsRecoveryKey);
|
||||
},
|
||||
child: const Text('Try again'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(false),
|
||||
child: const Text('Close'),
|
||||
),
|
||||
],
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user