import 'dart:async'; import 'dart:convert'; import 'package:cryptography_plus/cryptography_plus.dart' show Hkdf, Hmac, Mac, SecretBox, SecretKey, Xchacha20; import 'package:firebase_messaging/firebase_messaging.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:hashlib/random.dart'; import 'package:qr_flutter/qr_flutter.dart'; import 'package:share_plus/share_plus.dart'; import 'package:twonly/core/bridge/wrapper.dart' show RustUtils; import 'package:twonly/locator.dart'; import 'package:twonly/src/constants/keyvalue.keys.dart'; import 'package:twonly/src/model/json/onboarding_state.model.dart'; import 'package:twonly/src/model/protobuf/client/generated/passwordless_recovery.pb.dart'; import 'package:twonly/src/services/backup.service.dart'; import 'package:twonly/src/services/passwordless_recovery.service.dart'; import 'package:twonly/src/utils/keyvalue.dart'; import 'package:twonly/src/utils/log.dart'; import 'package:twonly/src/utils/misc.dart'; import 'package:twonly/src/visual/components/avatar_icon.comp.dart'; import 'package:twonly/src/visual/components/snackbar.dart'; import 'package:twonly/src/visual/elements/contact_chip.element.dart'; import 'package:twonly/src/visual/elements/my_button.element.dart'; import 'package:twonly/src/visual/elements/my_input.element.dart'; import 'package:twonly/src/visual/views/onboarding/components/animated_bell_icon.comp.dart'; import 'package:twonly/src/visual/views/onboarding/recovery_progress.view.dart'; class RecoverPasswordless extends StatefulWidget { const RecoverPasswordless({this.initialEmailToken, super.key}); final String? initialEmailToken; @override State createState() => _RecoverPasswordlessState(); } class _RecoverPasswordlessState extends State { bool _isLoading = true; bool _notificationsEnabled = false; String _shareUrl = ''; OnboardingState? _onboardingState; Timer? _pollTimer; SharedSecretData? _reconstructedSecret; bool _isReconstructing = false; String? _reconstructionError; bool _isRecovering = false; final TextEditingController _secondFactorController = TextEditingController(); StreamSubscription? _emailTokenSubscription; @override void initState() { super.initState(); final token = widget.initialEmailToken ?? PasswordlessRecoveryService.lastEmailToken; _secondFactorController.text = token ?? ''; _emailTokenSubscription = PasswordlessRecoveryService .onEmailTokenReceived .stream .listen((token) async { if (mounted) { setState(() { _secondFactorController.text = token; }); } }); _initAsync(token); } @override void dispose() { _emailTokenSubscription?.cancel(); _pollTimer?.cancel(); _secondFactorController.dispose(); super.dispose(); } Future _initAsync(String? initialToken) async { try { // 1. Load OnboardingState final state = await KeyValueStore.getModel( KeyValueKeys.onboardingState, ); if (initialToken != null && !state.emailRecoveryRequested) { state.emailRecoveryRequested = true; await KeyValueStore.update( key: KeyValueKeys.onboardingState, update: (s) => s.emailRecoveryRequested = true, ); } // 2. Generate fields if they are missing if (state.notificationId == null) { state ..notificationId = uuid.v4() ..downloadAuthToken = getRandomUint8List(32) ..encryptionKey = getRandomUint8List(32); await KeyValueStore.update( key: KeyValueKeys.onboardingState, update: (s) { s ..notificationId = state.notificationId ..downloadAuthToken = state.downloadAuthToken ..encryptionKey = state.encryptionKey; }, ); } // 3. Construct URL final keyBytes = state.encryptionKey!; final base64Key = base64Url.encode(keyBytes).replaceAll('=', ''); _shareUrl = 'https://me.twonly.eu/r/#${state.notificationId}/$base64Key'; if (!kReleaseMode) Log.info(_shareUrl); // 4. Check FCM Notification Settings final settings = await FirebaseMessaging.instance .getNotificationSettings(); _notificationsEnabled = settings.authorizationStatus == AuthorizationStatus.authorized || settings.authorizationStatus == AuthorizationStatus.provisional; // 5. Register with the server if not registered yet if (!state.serverRegistered) { await _registerPasswordlessNotification(state); } _onboardingState = state; // 6. If already registered, do an immediate check and start the poll timer if (state.serverRegistered) _startPollTimer(); _checkAndReconstruct(); } catch (e) { Log.warn('Error during passwordless recovery initialization: $e'); } finally { if (mounted) setState(() => _isLoading = false); } } void _startPollTimer() { if (_pollTimer != null) return; _pollTimer = Timer.periodic(const Duration(seconds: 10), (_) { _pollForMessages(); }); unawaited(_pollForMessages()); } Future _pollForMessages() async { if (_onboardingState == null) return; final didUpdate = await PasswordlessRecoveryService.checkAndStorePasswordlessMessages( _onboardingState!, ); // _onboardingState was changed in checkAndStorePasswordlessMessages if (didUpdate && mounted) { setState(() => ()); _checkAndReconstruct(); } } void _checkAndReconstruct() { final state = _onboardingState; if (state != null) { final shares = state.receivedShares; if (shares.isNotEmpty) { final threshold = shares.first.threshold; if (shares.length >= threshold && _reconstructedSecret == null && !_isReconstructing) { unawaited(_reconstructSecret()); } } } } Future _reconstructSecret() async { final state = _onboardingState; if (state == null) return; final shares = state.receivedShares; if (shares.isEmpty) return; final threshold = shares.first.threshold; if (shares.length < threshold) return; setState(() { _isReconstructing = true; _reconstructionError = null; }); try { final shareBytesList = shares .map((s) => Uint8List.fromList(s.sharedSecretDataBytes)) .toList(); final secretBytes = await RustUtils.recoverSecret( shares: shareBytesList, threshold: threshold, ); final sharedSecretData = SharedSecretData.fromBuffer(secretBytes); if (mounted) { setState(() { _reconstructedSecret = sharedSecretData; }); } } catch (e) { Log.error('Failed to reconstruct secret: $e'); if (mounted) { setState(() { _reconstructionError = e.toString(); }); } } finally { if (mounted) { setState(() { _isReconstructing = false; }); } } } Future _recoverNow(List shares) async { final reconstructed = _reconstructedSecret; if (reconstructed == null) return; setState(() { _isRecovering = true; }); try { final userId = shares.first.myUserId; Uint8List? serverKey; if (!reconstructed.hasEmailHint() && reconstructed.hasServerKeyProtection()) { final pin = _secondFactorController.text.trim(); if (pin.isEmpty) { showSnackbar( context, context.lang.passwordlessRecoveryEnterPin, ); setState(() { _isRecovering = false; }); return; } // Calculate pinProtectionKey via Hkdf final hkdf = Hkdf(hmac: Hmac.sha256(), outputLength: 32); final pinKey = await hkdf.deriveKey( secretKey: SecretKey(reconstructed.serverKeyProtection), nonce: utf8.encode(pin), ); // Fetch serverKey final res = await rustApiResult( RustApi.getServerKeyForPasswordlessRecovery( userId: userId, serverKeyProtection: const [], pinUnlockToken: Uint8List.fromList(reconstructed.pinUnlockToken), pinProtectionKey: Uint8List.fromList(await pinKey.extractBytes()), ), ); if (res.isError) { if (mounted) { showSnackbar( context, context.lang.passwordlessRecoveryTestPinIncorrect, ); } setState(() { _isRecovering = false; }); return; } serverKey = Uint8List.fromList(res.value!); } else if (reconstructed.hasEmailHint()) { final state = _onboardingState; if (state == null) return; if (!state.emailRecoveryRequested) { // Stage 1: Send Email final email = _secondFactorController.text.trim(); if (email.isEmpty) { showSnackbar( context, context.lang.passwordlessRecoveryEnterEmail, ); setState(() { _isRecovering = false; }); return; } // Fetch serverKey (sends recovery email) final res = await rustApiResult( RustApi.getServerKeyForPasswordlessRecovery( userId: userId, email: email, serverKeyProtection: reconstructed.serverKeyProtection, ), ); if (res.isError) { if (mounted) { final isInternalError = res.error == ErrorCode.InternalError; showSnackbar( context, isInternalError ? context.lang.passwordlessRecoveryNetworkError : context.lang.passwordlessRecoveryInvalidEmail, ); } setState(() { _isRecovering = false; }); return; } // Success - server sent recovery email. Store in state state.emailRecoveryRequested = true; await KeyValueStore.update( key: KeyValueKeys.onboardingState, update: (s) => s.emailRecoveryRequested = true, ); _secondFactorController.clear(); if (mounted) { showSnackbar( context, context.lang.passwordlessRecoveryEmailSent, level: SnackbarLevel.success, ); } setState(() { _isRecovering = false; }); return; } else { // Stage 2: Token verification final token = _secondFactorController.text.trim(); if (token.isEmpty) { if (mounted) { showSnackbar( context, 'Please enter the recovery token from your email.', ); } setState(() { _isRecovering = false; }); return; } try { serverKey = base64Url.decode(base64Url.normalize(token)); } catch (e) { if (mounted) { showSnackbar( context, 'Invalid verification token format.', ); } setState(() { _isRecovering = false; }); return; } } } // Decrypt recoveryData using serverKey if present List recoveryDataBytes; if (serverKey != null) { final envelope = EncryptedEnvelope.fromBuffer( reconstructed.recoveryData, ); final secretBox = SecretBox( envelope.encryptedData, nonce: envelope.iv, mac: Mac(envelope.mac), ); final xchacha20 = Xchacha20.poly1305Aead(); recoveryDataBytes = await xchacha20.decrypt( secretBox, secretKey: SecretKey(serverKey), ); } else { recoveryDataBytes = reconstructed.recoveryData; } final recoveryData = RecoveryData.fromBuffer(recoveryDataBytes); if (!mounted) return; // Start full passwordless recovery. The progress view reports every // step and restarts the app once the recovery succeeded. await Navigator.of(context).push( MaterialPageRoute( builder: (_) => RecoveryProgressView( username: shares.first.myDisplayName, steps: const [ RecoveryProgress.restoringIdentity, RecoveryProgress.downloadingArchive, RecoveryProgress.extractingData, ], runRecovery: (onProgress) => BackupService.startPasswordlessBackupRecovery( recoveryData.userId.toInt(), shares.first.myDisplayName, Uint8List.fromList(recoveryData.keyManager), onProgress: onProgress, ), ), ), ); } catch (e) { Log.error('Failed to recover passwordless: $e'); if (mounted) { showSnackbar( context, '${context.lang.recoverErrorUnknown}: $e', ); } } finally { if (mounted) { setState(() { _isRecovering = false; }); } } } Future _registerPasswordlessNotification(OnboardingState state) async { final fcmToken = await FirebaseMessaging.instance.getToken(); if (!mounted) return; final res = await rustApiResult( RustApi.registerPasswordlessNotification( notificationId: state.notificationId!, downloadAuthToken: state.downloadAuthToken!, langCode: Localizations.localeOf(context).languageCode, googleFcm: fcmToken, ), ); if (res.isSuccess) { state.serverRegistered = true; await KeyValueStore.update( key: KeyValueKeys.onboardingState, update: (s) => s.serverRegistered = true, ); _startPollTimer(); } } Future _requestNotificationPermission() async { try { final settings = await FirebaseMessaging.instance.requestPermission(); if (settings.authorizationStatus == AuthorizationStatus.authorized || settings.authorizationStatus == AuthorizationStatus.provisional) { if (mounted) setState(() => _notificationsEnabled = true); final state = _onboardingState; if (state != null) { await _registerPasswordlessNotification(state); } } } catch (e) { Log.error('Error requesting notification permission: $e'); } } Widget _buildProgressSection( BuildContext context, bool isDark, List shares, ) { final first = shares.first; final threshold = first.threshold; final thresholdReached = shares.length >= threshold; return Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Center( child: Column( children: [ AvatarIcon( svg: first.myAvatarSvg != null ? RustApi.decodeAvatarSvg( avatarSvgCompressed: first.myAvatarSvg!, ) : null, fontSize: 60, ), const SizedBox(height: 12), Text( first.myDisplayName, style: Theme.of(context).textTheme.titleLarge?.copyWith( fontWeight: FontWeight.bold, ), textAlign: TextAlign.center, ), ], ), ), const SizedBox(height: 24), Row( mainAxisAlignment: MainAxisAlignment.center, children: [ Text( context.lang.recoverPasswordlessSharesReceived( shares.length, threshold, ), style: Theme.of(context).textTheme.titleMedium?.copyWith( fontWeight: FontWeight.w600, ), textAlign: TextAlign.center, ), ], ), const SizedBox(height: 12), ClipRRect( borderRadius: BorderRadius.circular(12), child: LinearProgressIndicator( value: shares.length / threshold, minHeight: 12, backgroundColor: isDark ? Colors.white.withValues(alpha: 0.1) : Colors.black.withValues(alpha: 0.08), valueColor: AlwaysStoppedAnimation( thresholdReached ? Colors.green : context.color.primary, ), ), ), const SizedBox(height: 24), Wrap( spacing: 8, runSpacing: 8, alignment: WrapAlignment.center, children: shares.map((share) { return ContactChip( username: share.trustedFriendDisplayName, avatarSvg: share.trustedFriendAvatarSvg, ); }).toList(), ), const SizedBox(height: 24), if (thresholdReached) ...[ if (_isReconstructing) const Center( child: Padding( padding: EdgeInsets.symmetric(vertical: 24), child: CircularProgressIndicator(), ), ) else if (_reconstructionError != null) Padding( padding: const EdgeInsets.symmetric(vertical: 16), child: Text( 'Reconstruction failed: $_reconstructionError', style: const TextStyle(color: Colors.red), textAlign: TextAlign.center, ), ) else if (_reconstructedSecret != null) ...[ if (_reconstructedSecret!.hasServerKeyProtection() && !_reconstructedSecret!.hasEmailHint()) ...[ MyInput( controller: _secondFactorController, hintText: context.lang.passwordlessRecoveryMethodPinHint, keyboardType: TextInputType.number, ), const SizedBox(height: 16), ] else if (_reconstructedSecret!.hasEmailHint()) ...[ if (_onboardingState != null && !_onboardingState!.emailRecoveryRequested) ...[ MyInput( controller: _secondFactorController, hintText: _reconstructedSecret!.emailHint, prefixIcon: const Icon(Icons.email_rounded), keyboardType: TextInputType.emailAddress, ), const SizedBox(height: 16), ] else ...[ MyInput( controller: _secondFactorController, hintText: 'Enter recovery token', prefixIcon: const Icon(Icons.key_rounded), ), const SizedBox(height: 16), ], ], MyButton( onPressed: _isRecovering ? null : () => _recoverNow(shares), child: _isRecovering ? const SizedBox( height: 24, width: 24, child: CircularProgressIndicator( strokeWidth: 2.5, color: Colors.black87, ), ) : Text( _reconstructedSecret!.hasEmailHint() && _onboardingState != null && !_onboardingState!.emailRecoveryRequested ? 'Send recovery email' : context.lang.recoverPasswordlessRecoverNowBtn, ), ), const SizedBox(height: 16), if (_reconstructedSecret!.hasEmailHint() && _onboardingState != null && _onboardingState!.emailRecoveryRequested) ...[ MyButton( onPressed: _isRecovering ? null : () async { final state = _onboardingState; if (state == null) return; setState(() { state.emailRecoveryRequested = false; _secondFactorController.clear(); }); await KeyValueStore.update( key: KeyValueKeys.onboardingState, update: (s) => s.emailRecoveryRequested = false, ); }, variant: MyButtonVariant.secondary, child: Text(context.lang.passwordlessRecoveryResendEmail), ), const SizedBox(height: 16), ], ], ], ], ); } @override Widget build(BuildContext context) { final isDark = isDarkMode(context); final shares = _onboardingState?.receivedShares ?? []; final hasShares = shares.isNotEmpty; final threshold = hasShares ? shares.first.threshold : 0; final thresholdReached = hasShares && shares.length >= threshold; if (_isLoading) { return Scaffold( appBar: AppBar( title: Text(context.lang.passwordlessRecoveryRecoverBtn), leading: IconButton( icon: const Icon(Icons.arrow_back_ios_new_rounded), color: isDark ? Colors.white70 : Colors.black54, iconSize: 20, onPressed: () => Navigator.of(context).pop(), ), ), body: const Center( child: CircularProgressIndicator(), ), ); } return Scaffold( appBar: AppBar( title: Text(context.lang.passwordlessRecoveryRecoverBtn), leading: IconButton( icon: const Icon(Icons.arrow_back_ios_new_rounded), color: isDark ? Colors.white70 : Colors.black54, iconSize: 20, onPressed: () => Navigator.of(context).pop(), ), ), body: SafeArea( child: SingleChildScrollView( padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16), child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ if (!hasShares) ...[ Text( context.lang.recoverPasswordlessExplanation, textAlign: TextAlign.center, style: const TextStyle(fontSize: 16), ), const SizedBox(height: 24), ], if (hasShares) _buildProgressSection(context, isDark, shares), if (!_notificationsEnabled && !hasShares) ...[ Container( decoration: BoxDecoration( color: isDark ? Colors.white.withValues(alpha: 0.05) : Colors.black.withValues(alpha: 0.04), borderRadius: BorderRadius.circular(16), border: Border.all( color: isDark ? Colors.white.withValues(alpha: 0.08) : Colors.black.withValues(alpha: 0.06), ), ), padding: const EdgeInsets.all(16), child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ const AnimatedBellIcon(), const SizedBox(width: 16), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( context .lang .recoverPasswordlessNotificationCardTitle, style: const TextStyle( fontWeight: FontWeight.bold, fontSize: 16, ), ), const SizedBox(height: 4), Text( context .lang .recoverPasswordlessNotificationCardSubtitle, style: const TextStyle(fontSize: 13), ), const SizedBox(height: 12), MyButton( variant: MyButtonVariant.secondaryDense, onPressed: _requestNotificationPermission, child: Text( context .lang .recoverPasswordlessNotificationCardBtn, ), ), ], ), ), ], ), ), const SizedBox(height: 32), ], if (!thresholdReached) ...[ if (!hasShares) Container( decoration: BoxDecoration( color: isDark ? Colors.white.withValues(alpha: 0.05) : Colors.black.withValues(alpha: 0.04), borderRadius: BorderRadius.circular(16), border: Border.all( color: isDark ? Colors.white.withValues(alpha: 0.08) : Colors.black.withValues(alpha: 0.06), ), ), padding: const EdgeInsets.all(16), child: Row( children: [ Icon( Icons.info_outline_rounded, color: isDark ? Colors.white70 : Colors.black54, size: 22, ), const SizedBox(width: 12), Expanded( child: Text( context.lang.recoverPasswordlessQrInstructions, style: const TextStyle(fontSize: 14, height: 1.4), ), ), ], ), ), if (!hasShares) const SizedBox(height: 20), Center( child: Container( padding: const EdgeInsets.all(8), decoration: BoxDecoration( color: Colors.white, borderRadius: BorderRadius.circular(24), boxShadow: [ BoxShadow( color: Colors.black.withValues( alpha: isDark ? 0.3 : 0.08, ), blurRadius: 20, offset: const Offset(0, 10), ), ], ), child: QrImageView.withQr( qr: QrCode.fromData( data: _shareUrl, errorCorrectLevel: QrErrorCorrectLevel.M, ), eyeStyle: const QrEyeStyle( color: Colors.black, borderRadius: 4, ), dataModuleStyle: const QrDataModuleStyle( color: Colors.black, borderRadius: 4, ), gapless: false, size: 200, ), ), ), const SizedBox(height: 32), MyInput( controller: TextEditingController(text: _shareUrl), readOnly: true, hintText: '', prefixIcon: const Icon(Icons.link_rounded), suffixIcon: Row( mainAxisSize: MainAxisSize.min, children: [ IconButton( icon: const Icon(Icons.copy_rounded), tooltip: context.lang.recoverPasswordlessCopyBtn, onPressed: () { Clipboard.setData( ClipboardData(text: _shareUrl), ); showSnackbar( context, context.lang.recoverPasswordlessCopiedSnackbar, level: SnackbarLevel.success, ); }, ), IconButton( icon: const Icon(Icons.share_rounded), tooltip: context.lang.recoverPasswordlessShareBtn, onPressed: () { final params = ShareParams( text: _shareUrl, ); SharePlus.instance.share(params); }, ), ], ), ), const SizedBox(height: 24), ], ], ), ), ), ); } }