import 'package:flutter/material.dart'; import 'package:matrix/encryption.dart'; import 'package:matrix/matrix.dart'; import 'package:pyramid/core/theme.dart'; /// Ob Cross-Signing/Key-Backup fehlt oder nicht entsperrt ist — wenn ja, würde /// ein Logout den Zugriff auf ältere verschlüsselte Nachrichten kosten. Future isKeyBackupMissing(Client client) async { final enc = client.encryption; if (enc == null) return true; if (!enc.crossSigning.enabled) return true; return !(await enc.keyManager.isCached().catchError((_) => false)); } Future showBootstrapDialog( BuildContext context, Client client, { bool force = false, }) async { if (client.encryption == null) return; if (!force) { final enc = client.encryption!; final cached = await enc.keyManager.isCached().catchError((_) => false); if (cached && enc.crossSigning.enabled) return; } if (!context.mounted) return; await showDialog( context: context, barrierDismissible: false, builder: (_) => _BootstrapDialog(client: client), ); } class _BootstrapDialog extends StatefulWidget { final Client client; const _BootstrapDialog({required this.client}); @override State<_BootstrapDialog> createState() => _BootstrapDialogState(); } class _BootstrapDialogState extends State<_BootstrapDialog> { Bootstrap? _bootstrap; final _keyCtrl = TextEditingController(); bool _keyLoading = false; String? _keyError; bool _wipe = false; @override void initState() { super.initState(); _start(); } @override void dispose() { _keyCtrl.dispose(); super.dispose(); } Future _start() async { final client = widget.client; await client.roomsLoading; await client.accountDataLoading; await client.userDeviceKeysLoading; while (client.prevBatch == null) { await client.onSyncStatus.stream.first; } if (!mounted) return; setState(() { _bootstrap = client.encryption!.bootstrap( onUpdate: (_) { if (mounted) setState(() {}); }, ); }); } void _autoAdvance(Bootstrap bs) { switch (bs.state) { case BootstrapState.askWipeSsss: WidgetsBinding.instance .addPostFrameCallback((_) => bs.wipeSsss(_wipe)); case BootstrapState.askBadSsss: WidgetsBinding.instance .addPostFrameCallback((_) => bs.ignoreBadSecrets(true)); case BootstrapState.askUseExistingSsss: WidgetsBinding.instance .addPostFrameCallback((_) => bs.useExistingSsss(!_wipe)); case BootstrapState.askUnlockSsss: // Handled by UI if oldSsssKeys exist, otherwise skip WidgetsBinding.instance .addPostFrameCallback((_) => bs.unlockedSsss()); case BootstrapState.askNewSsss: WidgetsBinding.instance.addPostFrameCallback((_) => bs.newSsss()); case BootstrapState.askWipeCrossSigning: WidgetsBinding.instance .addPostFrameCallback((_) => bs.wipeCrossSigning(_wipe)); case BootstrapState.askSetupCrossSigning: WidgetsBinding.instance.addPostFrameCallback( (_) => bs.askSetupCrossSigning( setupMasterKey: true, setupSelfSigningKey: true, setupUserSigningKey: true, ), ); case BootstrapState.askWipeOnlineKeyBackup: WidgetsBinding.instance .addPostFrameCallback((_) => bs.wipeOnlineKeyBackup(_wipe)); case BootstrapState.askSetupOnlineKeyBackup: WidgetsBinding.instance .addPostFrameCallback((_) => bs.askSetupOnlineKeyBackup(true)); default: break; } } Future _unlock() async { final bs = _bootstrap; if (bs == null || bs.newSsssKey == null) return; final key = _keyCtrl.text.trim(); if (key.isEmpty) { setState(() => _keyError = 'Bitte Wiederherstellungsschlüssel eingeben.'); return; } setState(() { _keyLoading = true; _keyError = null; }); try { await bs.newSsssKey!.unlock(keyOrPassphrase: key); Logs().d('[Bootstrap] SSSS key unlocked'); await bs.openExistingSsss(); Logs().d('[Bootstrap] SSSS opened, secrets cached'); if (bs.encryption.crossSigning.enabled) { await widget.client.encryption!.crossSigning.selfSign(recoveryKey: key); Logs().d('[Bootstrap] Cross-signing self-signed'); } } on InvalidPassphraseException catch (_) { setState( () => _keyError = 'Falscher Schlüssel. Bitte nochmal prüfen.', ); } on FormatException catch (_) { setState(() => _keyError = 'Ungültiges Format. Prüfe deinen Schlüssel.'); } catch (e) { setState(() => _keyError = e.toString().split('\n').first); } finally { if (mounted) setState(() => _keyLoading = false); } } Future _close({required bool success}) async { if (success) { // Download all megolm session keys from backup — bypasses // _requestedSessionIds deduplication so previously failed decryptions // get a second chance. widget.client.encryption?.keyManager .loadAllKeys() .catchError((e) => Logs().e('[Bootstrap] loadAllKeys failed', e)); } if (mounted) Navigator.of(context).pop(success); } @override Widget build(BuildContext context) { final pt = PyramidTheme.of(context); final bs = _bootstrap; // Waiting for bootstrap to initialise if (bs == null || bs.state == BootstrapState.loading) { return _Shell( pt: pt, title: 'Verschlüsselung einrichten', onClose: () => _close(success: false), child: const Center( child: Padding( padding: EdgeInsets.all(32), child: CircularProgressIndicator.adaptive(), ), ), ); } // User must enter recovery key if (bs.state == BootstrapState.openExistingSsss) { return _Shell( pt: pt, title: 'Nachrichten entschlüsseln', onClose: () => _close(success: false), child: _KeyInputBody( pt: pt, ctrl: _keyCtrl, loading: _keyLoading, error: _keyError, onUnlock: _unlock, onWipe: () { setState(() { _wipe = true; _start(); }); }, ), ); } // Success — all done if (bs.state == BootstrapState.done) { return _Shell( pt: pt, title: 'Einrichtung abgeschlossen', onClose: null, child: _DoneBody( pt: pt, onClose: () => _close(success: true), ), ); } // Error if (bs.state == BootstrapState.error) { return _Shell( pt: pt, title: 'Fehler', onClose: () => _close(success: false), child: _ErrorBody( pt: pt, onClose: () => _close(success: false), ), ); } // Auto-advance all other (non-interactive) states _autoAdvance(bs); return _Shell( pt: pt, title: 'Verschlüsselung einrichten…', onClose: () => _close(success: false), child: const Center( child: Padding( padding: EdgeInsets.all(32), child: CircularProgressIndicator.adaptive(), ), ), ); } } // ───────────────────────────────────────────────────────────────────────────── // Sub-widgets // ───────────────────────────────────────────────────────────────────────────── class _Shell extends StatelessWidget { final PyramidTheme pt; final String title; final VoidCallback? onClose; final Widget child; const _Shell({ required this.pt, required this.title, required this.onClose, required this.child, }); @override Widget build(BuildContext context) { return Dialog( backgroundColor: pt.bg1, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(pt.rBase + 4), ), child: ConstrainedBox( constraints: const BoxConstraints( maxWidth: 460, minWidth: 320, minHeight: 120, ), child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, children: [ // Header Padding( padding: const EdgeInsets.fromLTRB(20, 16, 12, 12), child: Row( children: [ Icon(Icons.shield_outlined, size: 18, color: pt.accent), const SizedBox(width: 8), Expanded( child: Text( title, style: TextStyle( color: pt.fg, fontSize: 15, fontWeight: FontWeight.w600, ), ), ), if (onClose != null) IconButton( icon: Icon(Icons.close_rounded, color: pt.fgDim, size: 16), onPressed: onClose, tooltip: 'Überspringen', padding: EdgeInsets.zero, constraints: const BoxConstraints(maxWidth: 28, maxHeight: 28), ), ], ), ), Divider(color: pt.border, height: 1), child, ], ), ), ); } } class _KeyInputBody extends StatelessWidget { final PyramidTheme pt; final TextEditingController ctrl; final bool loading; final String? error; final VoidCallback onUnlock; final VoidCallback onWipe; const _KeyInputBody({ required this.pt, required this.ctrl, required this.loading, required this.error, required this.onUnlock, required this.onWipe, }); @override Widget build(BuildContext context) { return Padding( padding: const EdgeInsets.fromLTRB(20, 16, 20, 20), child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( 'Gib deinen Wiederherstellungsschlüssel ein, um deine verschlüsselten Nachrichten lesbar zu machen.', style: TextStyle(color: pt.fgMuted, fontSize: 13, height: 1.5), ), const SizedBox(height: 16), TextField( controller: ctrl, readOnly: loading, autofocus: true, autocorrect: false, style: TextStyle( color: pt.fg, fontSize: 13, fontFamily: 'monospace', ), decoration: InputDecoration( hintText: 'EsXX XXXX XXXX XXXX XXXX XXXX XXXX XXXX', hintStyle: TextStyle(color: pt.fgDim, fontSize: 12), labelText: 'Wiederherstellungsschlüssel', labelStyle: TextStyle(color: pt.fgMuted, fontSize: 13), errorText: error, errorMaxLines: 2, filled: true, fillColor: pt.bg2, prefixIcon: Icon(Icons.vpn_key_outlined, color: pt.fgDim, size: 18), border: OutlineInputBorder( borderRadius: BorderRadius.circular(8), borderSide: BorderSide(color: pt.border), ), enabledBorder: OutlineInputBorder( borderRadius: BorderRadius.circular(8), borderSide: BorderSide(color: pt.border), ), focusedBorder: OutlineInputBorder( borderRadius: BorderRadius.circular(8), borderSide: BorderSide(color: pt.accent), ), isDense: true, contentPadding: const EdgeInsets.symmetric( horizontal: 12, vertical: 12, ), ), cursorColor: pt.accent, onSubmitted: (_) => onUnlock(), ), const SizedBox(height: 16), SizedBox( width: double.infinity, child: ElevatedButton.icon( style: ElevatedButton.styleFrom( backgroundColor: pt.accent, foregroundColor: pt.accentFg, padding: const EdgeInsets.symmetric(vertical: 12), shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(8), ), elevation: 0, ), onPressed: loading ? null : onUnlock, icon: loading ? SizedBox( width: 16, height: 16, child: CircularProgressIndicator( color: pt.accentFg, strokeWidth: 2, ), ) : const Icon(Icons.lock_open_outlined, size: 18), label: Text( loading ? 'Entschlüssele…' : 'Nachrichten entschlüsseln', ), ), ), const SizedBox(height: 12), // Divider + lost key option Row( children: [ Expanded(child: Divider(color: pt.border)), Padding( padding: const EdgeInsets.symmetric(horizontal: 12), child: Text('oder', style: TextStyle(color: pt.fgDim, fontSize: 12)), ), Expanded(child: Divider(color: pt.border)), ], ), const SizedBox(height: 12), SizedBox( width: double.infinity, child: OutlinedButton.icon( style: OutlinedButton.styleFrom( foregroundColor: PyramidColors.danger, side: BorderSide(color: PyramidColors.danger.withAlpha(100)), padding: const EdgeInsets.symmetric(vertical: 10), shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(8), ), ), onPressed: loading ? null : onWipe, icon: const Icon(Icons.delete_outlined, size: 16), label: const Text('Schlüssel verloren — neu einrichten', style: TextStyle(fontSize: 13)), ), ), ], ), ); } } class _DoneBody extends StatelessWidget { final PyramidTheme pt; final VoidCallback onClose; const _DoneBody({required this.pt, required this.onClose}); @override Widget build(BuildContext context) { return Padding( padding: const EdgeInsets.fromLTRB(20, 20, 20, 20), child: Column( mainAxisSize: MainAxisSize.min, children: [ Icon( Icons.check_circle_outline_rounded, color: PyramidColors.success, size: 56, ), const SizedBox(height: 12), Text( 'Alle Schlüssel wurden erfolgreich importiert.', textAlign: TextAlign.center, style: TextStyle(color: pt.fg, fontSize: 14, fontWeight: FontWeight.w500), ), const SizedBox(height: 6), Text( 'Deine verschlüsselten Nachrichten werden jetzt im Hintergrund entschlüsselt.', textAlign: TextAlign.center, style: TextStyle(color: pt.fgMuted, fontSize: 13, height: 1.5), ), const SizedBox(height: 20), SizedBox( width: double.infinity, child: ElevatedButton( style: ElevatedButton.styleFrom( backgroundColor: pt.accent, foregroundColor: pt.accentFg, padding: const EdgeInsets.symmetric(vertical: 12), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), elevation: 0, ), onPressed: onClose, child: const Text('Fertig'), ), ), ], ), ); } } class _ErrorBody extends StatelessWidget { final PyramidTheme pt; final VoidCallback onClose; const _ErrorBody({required this.pt, required this.onClose}); @override Widget build(BuildContext context) { return Padding( padding: const EdgeInsets.fromLTRB(20, 20, 20, 20), child: Column( mainAxisSize: MainAxisSize.min, children: [ Icon(Icons.error_outline_rounded, color: PyramidColors.danger, size: 48), const SizedBox(height: 12), Text( 'Etwas ist schiefgelaufen. Versuche es erneut.', textAlign: TextAlign.center, style: TextStyle(color: pt.fgMuted, fontSize: 13), ), const SizedBox(height: 20), SizedBox( width: double.infinity, child: OutlinedButton( style: OutlinedButton.styleFrom( foregroundColor: pt.fg, side: BorderSide(color: pt.border), padding: const EdgeInsets.symmetric(vertical: 12), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), ), onPressed: onClose, child: const Text('Schließen'), ), ), ], ), ); } }