add recovery view

This commit is contained in:
otsmr 2026-09-04 13:42:44 +02:00
parent 9207a34994
commit 6da70abc0f
10 changed files with 585 additions and 95 deletions

View file

@ -194,6 +194,18 @@ abstract class AppLocalizations {
/// **'Send to'** /// **'Send to'**
String get cameraPreviewSendTo; String get cameraPreviewSendTo;
/// No description provided for @cameraPermissionsBody.
///
/// In en, this message translates to:
/// **'twonly needs access to the camera and microphone.'**
String get cameraPermissionsBody;
/// No description provided for @cameraPermissionsRequestButton.
///
/// In en, this message translates to:
/// **'Request permissions'**
String get cameraPermissionsRequestButton;
/// No description provided for @shareImageTitle. /// No description provided for @shareImageTitle.
/// ///
/// In en, this message translates to: /// In en, this message translates to:
@ -3356,6 +3368,48 @@ abstract class AppLocalizations {
/// **'Click here to open the app again'** /// **'Click here to open the app again'**
String get recoverSuccessBody; String get recoverSuccessBody;
/// No description provided for @recoverProgressTitle.
///
/// In en, this message translates to:
/// **'Restoring your backup'**
String get recoverProgressTitle;
/// No description provided for @recoverProgressSubtitle.
///
/// In en, this message translates to:
/// **'This might take a moment, please keep the app open.'**
String get recoverProgressSubtitle;
/// No description provided for @recoverProgressResolvingAccount.
///
/// In en, this message translates to:
/// **'Verifying account'**
String get recoverProgressResolvingAccount;
/// No description provided for @recoverProgressRestoringIdentity.
///
/// In en, this message translates to:
/// **'Restoring your identity'**
String get recoverProgressRestoringIdentity;
/// No description provided for @recoverProgressDownloadingArchive.
///
/// In en, this message translates to:
/// **'Downloading and decrypting messages and contacts'**
String get recoverProgressDownloadingArchive;
/// No description provided for @recoverProgressExtractingData.
///
/// In en, this message translates to:
/// **'Extracting data'**
String get recoverProgressExtractingData;
/// No description provided for @recoverProgressFinishing.
///
/// In en, this message translates to:
/// **'Finishing up'**
String get recoverProgressFinishing;
/// No description provided for @iosRecoveryWelcomeBack. /// No description provided for @iosRecoveryWelcomeBack.
/// ///
/// In en, this message translates to: /// In en, this message translates to:

View file

@ -62,6 +62,13 @@ class AppLocalizationsDe extends AppLocalizations {
@override @override
String get cameraPreviewSendTo => 'Senden an'; String get cameraPreviewSendTo => 'Senden an';
@override
String get cameraPermissionsBody =>
'twonly benötigt Zugriff auf die Kamera und das Mikrofon.';
@override
String get cameraPermissionsRequestButton => 'Berechtigungen anfragen';
@override @override
String get shareImageTitle => 'Teilen mit'; String get shareImageTitle => 'Teilen mit';
@ -1931,6 +1938,30 @@ class AppLocalizationsDe extends AppLocalizations {
@override @override
String get recoverSuccessBody => 'Klicke hier, um die App wieder zu öffnen'; String get recoverSuccessBody => 'Klicke hier, um die App wieder zu öffnen';
@override
String get recoverProgressTitle => 'Backup wird wiederhergestellt';
@override
String get recoverProgressSubtitle =>
'Dies kann einen Moment dauern, bitte lasse die App geöffnet.';
@override
String get recoverProgressResolvingAccount => 'Konto wird überprüft';
@override
String get recoverProgressRestoringIdentity =>
'Identität wird wiederhergestellt';
@override
String get recoverProgressDownloadingArchive =>
'Nachrichten und Kontakte werden heruntergeladen und entschlüsselt';
@override
String get recoverProgressExtractingData => 'Daten werden extrahiert';
@override
String get recoverProgressFinishing => 'Wird abgeschlossen';
@override @override
String get iosRecoveryWelcomeBack => 'Willkommen zurück'; String get iosRecoveryWelcomeBack => 'Willkommen zurück';

View file

@ -61,6 +61,13 @@ class AppLocalizationsEn extends AppLocalizations {
@override @override
String get cameraPreviewSendTo => 'Send to'; String get cameraPreviewSendTo => 'Send to';
@override
String get cameraPermissionsBody =>
'twonly needs access to the camera and microphone.';
@override
String get cameraPermissionsRequestButton => 'Request permissions';
@override @override
String get shareImageTitle => 'Share with'; String get shareImageTitle => 'Share with';
@ -1917,6 +1924,29 @@ class AppLocalizationsEn extends AppLocalizations {
@override @override
String get recoverSuccessBody => 'Click here to open the app again'; String get recoverSuccessBody => 'Click here to open the app again';
@override
String get recoverProgressTitle => 'Restoring your backup';
@override
String get recoverProgressSubtitle =>
'This might take a moment, please keep the app open.';
@override
String get recoverProgressResolvingAccount => 'Verifying account';
@override
String get recoverProgressRestoringIdentity => 'Restoring your identity';
@override
String get recoverProgressDownloadingArchive =>
'Downloading and decrypting messages and contacts';
@override
String get recoverProgressExtractingData => 'Extracting data';
@override
String get recoverProgressFinishing => 'Finishing up';
@override @override
String get iosRecoveryWelcomeBack => 'Welcome Back'; String get iosRecoveryWelcomeBack => 'Welcome Back';

View file

@ -16,6 +16,8 @@
"registerSubmitButton": "Jetzt registrieren!", "registerSubmitButton": "Jetzt registrieren!",
"chatsTapToSend": "Klicke, um dein erstes Bild zu teilen.", "chatsTapToSend": "Klicke, um dein erstes Bild zu teilen.",
"cameraPreviewSendTo": "Senden an", "cameraPreviewSendTo": "Senden an",
"cameraPermissionsBody": "twonly benötigt Zugriff auf die Kamera und das Mikrofon.",
"cameraPermissionsRequestButton": "Berechtigungen anfragen",
"shareImageTitle": "Teilen mit", "shareImageTitle": "Teilen mit",
"shareImageBestFriends": "Beste Freunde", "shareImageBestFriends": "Beste Freunde",
"shareImagePinnedContacts": "Angeheftet", "shareImagePinnedContacts": "Angeheftet",
@ -681,6 +683,13 @@
"recoverErrorUnknown": "Ein unbekannter Fehler ist aufgetreten. Bitte versuche es erneut.", "recoverErrorUnknown": "Ein unbekannter Fehler ist aufgetreten. Bitte versuche es erneut.",
"recoverSuccessTitle": "Backup erfolgreich wiederhergestellt.", "recoverSuccessTitle": "Backup erfolgreich wiederhergestellt.",
"recoverSuccessBody": "Klicke hier, um die App wieder zu öffnen", "recoverSuccessBody": "Klicke hier, um die App wieder zu öffnen",
"recoverProgressTitle": "Backup wird wiederhergestellt",
"recoverProgressSubtitle": "Dies kann einen Moment dauern, bitte lasse die App geöffnet.",
"recoverProgressResolvingAccount": "Konto wird überprüft",
"recoverProgressRestoringIdentity": "Identität wird wiederhergestellt",
"recoverProgressDownloadingArchive": "Nachrichten und Kontakte werden heruntergeladen und entschlüsselt",
"recoverProgressExtractingData": "Daten werden extrahiert",
"recoverProgressFinishing": "Wird abgeschlossen",
"iosRecoveryWelcomeBack": "Willkommen zurück", "iosRecoveryWelcomeBack": "Willkommen zurück",
"iosRecoveryPrompt": "Wir haben eine zuvor gesicherte twonly-Identität auf diesem Gerät erkannt. Möchtest du deine Kontakte, Nachrichten und Einstellungen automatisch aus deinem Cloud-Archiv herunterladen und wiederherstellen?", "iosRecoveryPrompt": "Wir haben eine zuvor gesicherte twonly-Identität auf diesem Gerät erkannt. Möchtest du deine Kontakte, Nachrichten und Einstellungen automatisch aus deinem Cloud-Archiv herunterladen und wiederherstellen?",
"iosRecoveryNoBackupFound": "Für dieses Gerät konnte kein Backup-Archiv vom Server abgerufen werden.\n\nFehler: {error}\n\nBitte fahre mit der Registrierung eines neuen twonly-Kontos fort.", "iosRecoveryNoBackupFound": "Für dieses Gerät konnte kein Backup-Archiv vom Server abgerufen werden.\n\nFehler: {error}\n\nBitte fahre mit der Registrierung eines neuen twonly-Kontos fort.",

View file

@ -16,6 +16,8 @@
"registerSubmitButton": "Register now!", "registerSubmitButton": "Register now!",
"chatsTapToSend": "Click to send your first image", "chatsTapToSend": "Click to send your first image",
"cameraPreviewSendTo": "Send to", "cameraPreviewSendTo": "Send to",
"cameraPermissionsBody": "twonly needs access to the camera and microphone.",
"cameraPermissionsRequestButton": "Request permissions",
"shareImageTitle": "Share with", "shareImageTitle": "Share with",
"shareImageBestFriends": "Best friends", "shareImageBestFriends": "Best friends",
"shareImagePinnedContacts": "Pinnded", "shareImagePinnedContacts": "Pinnded",
@ -691,6 +693,13 @@
"recoverErrorUnknown": "An unknown error occurred. Please try again.", "recoverErrorUnknown": "An unknown error occurred. Please try again.",
"recoverSuccessTitle": "Backup successfully recovered.", "recoverSuccessTitle": "Backup successfully recovered.",
"recoverSuccessBody": "Click here to open the app again", "recoverSuccessBody": "Click here to open the app again",
"recoverProgressTitle": "Restoring your backup",
"recoverProgressSubtitle": "This might take a moment, please keep the app open.",
"recoverProgressResolvingAccount": "Verifying account",
"recoverProgressRestoringIdentity": "Restoring your identity",
"recoverProgressDownloadingArchive": "Downloading and decrypting messages and contacts",
"recoverProgressExtractingData": "Extracting data",
"recoverProgressFinishing": "Finishing up",
"iosRecoveryWelcomeBack": "Welcome Back", "iosRecoveryWelcomeBack": "Welcome Back",
"iosRecoveryPrompt": "We detected a previously secured twonly identity on this device. Would you like to automatically download and restore your contacts, messages, and settings from your cloud archive?", "iosRecoveryPrompt": "We detected a previously secured twonly identity on this device. Would you like to automatically download and restore your contacts, messages, and settings from your cloud archive?",
"iosRecoveryNoBackupFound": "No backup archive could be retrieved from the server for this device.\n\nError: {error}\n\nPlease proceed to register a new twonly account.", "iosRecoveryNoBackupFound": "No backup archive could be retrieved from the server for this device.\n\nError: {error}\n\nPlease proceed to register a new twonly account.",

View file

@ -264,12 +264,15 @@ class BackupService {
return BackupRecovery.fromJson(stateJson); return BackupRecovery.fromJson(stateJson);
} }
static Future<RecoveryError?> _nextBackupStage() async { static Future<RecoveryError?> _nextBackupStage({
void Function(RecoveryProgress)? onProgress,
}) async {
return _protected.protect(() async { return _protected.protect(() async {
final recoveryData = await getBackupRecoveryData(); final recoveryData = await getBackupRecoveryData();
if (recoveryData == null) return null; if (recoveryData == null) return null;
if (recoveryData.state == BackupRecoveryState.identityBackupStarted) { if (recoveryData.state == BackupRecoveryState.identityBackupStarted) {
onProgress?.call(RecoveryProgress.restoringIdentity);
// First start to download the identity to restore the KeyManager // First start to download the identity to restore the KeyManager
final backupKeys = await RustBackupIdentity.getBackupPasswordKeys( final backupKeys = await RustBackupIdentity.getBackupPasswordKeys(
userId: recoveryData.userId, userId: recoveryData.userId,
@ -304,6 +307,7 @@ class BackupService {
if (recoveryData.state == BackupRecoveryState.archiveBackupStarted) { if (recoveryData.state == BackupRecoveryState.archiveBackupStarted) {
// The KeyManager was restored successfully, restore the archive now. // The KeyManager was restored successfully, restore the archive now.
onProgress?.call(RecoveryProgress.downloadingArchive);
try { try {
final downloadToken = final downloadToken =
await RustBackupArchive.getBackupDownloadToken(); await RustBackupArchive.getBackupDownloadToken();
@ -326,6 +330,8 @@ class BackupService {
return backupArchive.$2; return backupArchive.$2;
} }
onProgress?.call(RecoveryProgress.extractingData);
final archiveFile = File('${AppEnvironment.cacheDir}/archive.bin') final archiveFile = File('${AppEnvironment.cacheDir}/archive.bin')
..writeAsBytesSync(backupArchive.$1!); ..writeAsBytesSync(backupArchive.$1!);
@ -348,7 +354,9 @@ class BackupService {
}); });
} }
static Future<RecoveryError?> tryToReinstallTheArchive() async { static Future<RecoveryError?> tryToReinstallTheArchive({
void Function(RecoveryProgress)? onProgress,
}) async {
final userId = await RustKeyManager.getUserId(); final userId = await RustKeyManager.getUserId();
if (userId == null) return null; if (userId == null) return null;
@ -358,13 +366,15 @@ class BackupService {
password: '', password: '',
)..state = BackupRecoveryState.archiveBackupStarted; )..state = BackupRecoveryState.archiveBackupStarted;
await KeyValueStore.put(KeyValueKeys.backupRecoveryState, state.toJson()); await KeyValueStore.put(KeyValueKeys.backupRecoveryState, state.toJson());
return _nextBackupStage(); return _nextBackupStage(onProgress: onProgress);
} }
static Future<RecoveryError?> startFullBackupRecovery( static Future<RecoveryError?> startFullBackupRecovery(
String username, String username,
String password, String password, {
) async { void Function(RecoveryProgress)? onProgress,
}) async {
onProgress?.call(RecoveryProgress.resolvingAccount);
late final int userId; late final int userId;
try { try {
userId = await RustApi.getUserIdFromUsername(username: username); userId = await RustApi.getUserIdFromUsername(username: username);
@ -381,27 +391,29 @@ class BackupService {
await deleteLocalUserData(removeCredentials: true); await deleteLocalUserData(removeCredentials: true);
await KeyValueStore.put(KeyValueKeys.backupRecoveryState, state.toJson()); await KeyValueStore.put(KeyValueKeys.backupRecoveryState, state.toJson());
return _nextBackupStage(); return _nextBackupStage(onProgress: onProgress);
} }
static Future<RecoveryError?> startPasswordlessBackupRecovery( static Future<RecoveryError?> startPasswordlessBackupRecovery(
int userId, int userId,
String username, String username,
Uint8List keyManagerBytes, Uint8List keyManagerBytes, {
) async { void Function(RecoveryProgress)? onProgress,
}) async {
final state = BackupRecovery( final state = BackupRecovery(
username: username, username: username,
password: '', password: '',
userId: userId, userId: userId,
)..state = BackupRecoveryState.archiveBackupStarted; )..state = BackupRecoveryState.archiveBackupStarted;
onProgress?.call(RecoveryProgress.restoringIdentity);
await deleteLocalUserData(removeCredentials: true); await deleteLocalUserData(removeCredentials: true);
// Import KeyManager keys into secure storage & in-memory key manager // Import KeyManager keys into secure storage & in-memory key manager
await RustKeyManager.importSerialized(serializedBytes: keyManagerBytes); await RustKeyManager.importSerialized(serializedBytes: keyManagerBytes);
await KeyValueStore.put(KeyValueKeys.backupRecoveryState, state.toJson()); await KeyValueStore.put(KeyValueKeys.backupRecoveryState, state.toJson());
return _nextBackupStage(); return _nextBackupStage(onProgress: onProgress);
} }
static Future<(Uint8List?, RecoveryError?)> _downloadBackup( static Future<(Uint8List?, RecoveryError?)> _downloadBackup(
@ -441,3 +453,12 @@ enum RecoveryError {
noInternet, noInternet,
unkownError, unkownError,
} }
/// Granular steps of a backup recovery, reported via the `onProgress`
/// callback so the UI can show the user what is currently happening.
enum RecoveryProgress {
resolvingAccount,
restoringIdentity,
downloadingArchive,
extractingData,
}

View file

@ -4,6 +4,8 @@ import 'package:flutter/material.dart';
import 'package:permission_handler/permission_handler.dart'; import 'package:permission_handler/permission_handler.dart';
import 'package:twonly/src/services/user.service.dart'; import 'package:twonly/src/services/user.service.dart';
import 'package:twonly/src/utils/log.dart'; import 'package:twonly/src/utils/log.dart';
import 'package:twonly/src/utils/misc.dart';
import 'package:twonly/src/visual/elements/my_button.element.dart';
class PermissionHandlerView extends StatefulWidget { class PermissionHandlerView extends StatefulWidget {
const PermissionHandlerView({ const PermissionHandlerView({
@ -124,32 +126,44 @@ class PermissionHandlerViewState extends State<PermissionHandlerView>
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( return Scaffold(
body: Center( body: Center(
child: Container( child: Column(
padding: const EdgeInsets.all(100), mainAxisAlignment: MainAxisAlignment.center,
child: Column( children: [
mainAxisAlignment: MainAxisAlignment.center, Padding(
children: [ padding: const EdgeInsets.only(
const Text( left: 100,
'twonly needs access to the camera and microphone.', right: 100,
top: 100,
bottom: 50,
),
child: Text(
context.lang.cameraPermissionsBody,
textAlign: TextAlign.center, textAlign: TextAlign.center,
), ),
const SizedBox(height: 50), ),
FilledButton.icon( MyButton(
label: const Text('Request permissions'), variant: MyButtonVariant.primaryMiddle,
icon: const Icon(Icons.perm_camera_mic), onPressed: () async {
onPressed: () async { try {
try { await permissionServices();
await permissionServices(); if (await checkPermissions()) {
if (await checkPermissions()) { _handleSuccess();
_handleSuccess();
}
} catch (e) {
Log.error(e);
} }
}, } catch (e) {
Log.error(e);
}
},
child: Row(
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(Icons.perm_camera_mic, size: 18),
const SizedBox(width: 8),
Text(context.lang.cameraPermissionsRequestButton),
],
), ),
], ),
), ],
), ),
), ),
); );

View file

@ -1,17 +1,16 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:font_awesome_flutter/font_awesome_flutter.dart'; import 'package:font_awesome_flutter/font_awesome_flutter.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import 'package:restart_app/restart_app.dart';
import 'package:twonly/src/constants/keyvalue.keys.dart'; import 'package:twonly/src/constants/keyvalue.keys.dart';
import 'package:twonly/src/constants/routes.keys.dart'; import 'package:twonly/src/constants/routes.keys.dart';
import 'package:twonly/src/model/json/onboarding_state.model.dart'; import 'package:twonly/src/model/json/onboarding_state.model.dart';
import 'package:twonly/src/services/backup.service.dart'; import 'package:twonly/src/services/backup.service.dart';
import 'package:twonly/src/utils/keyvalue.dart'; import 'package:twonly/src/utils/keyvalue.dart';
import 'package:twonly/src/utils/misc.dart'; import 'package:twonly/src/utils/misc.dart';
import 'package:twonly/src/visual/components/snackbar.dart';
import 'package:twonly/src/visual/elements/my_button.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/elements/my_input.element.dart';
import 'package:twonly/src/visual/views/onboarding/components/link_logo_animation.dart'; import 'package:twonly/src/visual/views/onboarding/components/link_logo_animation.dart';
import 'package:twonly/src/visual/views/onboarding/recovery_progress.view.dart';
import 'package:twonly/src/visual/views/settings/backup/components/backup_setup.comp.dart'; import 'package:twonly/src/visual/views/settings/backup/components/backup_setup.comp.dart';
class BackupRecoveryView extends StatefulWidget { class BackupRecoveryView extends StatefulWidget {
@ -23,7 +22,6 @@ class BackupRecoveryView extends StatefulWidget {
class _BackupRecoveryViewState extends State<BackupRecoveryView> { class _BackupRecoveryViewState extends State<BackupRecoveryView> {
bool obscureText = true; bool obscureText = true;
bool isLoading = false;
final TextEditingController usernameCtrl = TextEditingController(); final TextEditingController usernameCtrl = TextEditingController();
final TextEditingController passwordCtrl = TextEditingController(); final TextEditingController passwordCtrl = TextEditingController();
@ -35,32 +33,27 @@ class _BackupRecoveryViewState extends State<BackupRecoveryView> {
} }
Future<void> _recoverTwonlySafe() async { Future<void> _recoverTwonlySafe() async {
setState(() { final username = usernameCtrl.text;
isLoading = true; final password = passwordCtrl.text;
});
final error = await BackupService.startFullBackupRecovery( await Navigator.of(context).push(
usernameCtrl.text, MaterialPageRoute<void>(
passwordCtrl.text, builder: (_) => RecoveryProgressView(
username: username,
steps: const [
RecoveryProgress.resolvingAccount,
RecoveryProgress.restoringIdentity,
RecoveryProgress.downloadingArchive,
RecoveryProgress.extractingData,
],
runRecovery: (onProgress) => BackupService.startFullBackupRecovery(
username,
password,
onProgress: onProgress,
),
),
),
); );
if (!mounted) return;
if (error != null) {
setState(() {
isLoading = false;
});
return showSnackbar(context, error.toLocalizedString(context));
}
await Restart.restartApp(
notificationTitle: context.lang.recoverSuccessTitle,
notificationBody: context.lang.recoverSuccessBody,
forceKill: true,
);
setState(() {
isLoading = false;
});
} }
@override @override
@ -159,19 +152,8 @@ class _BackupRecoveryViewState extends State<BackupRecoveryView> {
), ),
const SizedBox(height: 32), const SizedBox(height: 32),
MyButton( MyButton(
onPressed: (!isLoading) ? _recoverTwonlySafe : null, onPressed: _recoverTwonlySafe,
child: isLoading child: Text(context.lang.twonlySafeRecoverBtn),
? const SizedBox(
height: 24,
width: 24,
child: CircularProgressIndicator.adaptive(
valueColor: AlwaysStoppedAnimation(
Colors.white,
),
strokeWidth: 3,
),
)
: Text(context.lang.twonlySafeRecoverBtn),
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
MyButton( MyButton(

View file

@ -9,7 +9,6 @@ import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:hashlib/random.dart'; import 'package:hashlib/random.dart';
import 'package:qr_flutter/qr_flutter.dart'; import 'package:qr_flutter/qr_flutter.dart';
import 'package:restart_app/restart_app.dart';
import 'package:share_plus/share_plus.dart'; import 'package:share_plus/share_plus.dart';
import 'package:twonly/core/bridge/wrapper.dart' show RustUtils; import 'package:twonly/core/bridge/wrapper.dart' show RustUtils;
import 'package:twonly/locator.dart'; import 'package:twonly/locator.dart';
@ -27,6 +26,7 @@ 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_button.element.dart';
import 'package:twonly/src/visual/elements/my_input.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/components/animated_bell_icon.comp.dart';
import 'package:twonly/src/visual/views/onboarding/recovery_progress.view.dart';
class RecoverPasswordless extends StatefulWidget { class RecoverPasswordless extends StatefulWidget {
const RecoverPasswordless({this.initialEmailToken, super.key}); const RecoverPasswordless({this.initialEmailToken, super.key});
@ -398,31 +398,28 @@ class _RecoverPasswordlessState extends State<RecoverPasswordless> {
final recoveryData = RecoveryData.fromBuffer(recoveryDataBytes); final recoveryData = RecoveryData.fromBuffer(recoveryDataBytes);
// Start full passwordless recovery
final error = await BackupService.startPasswordlessBackupRecovery(
recoveryData.userId.toInt(),
shares.first.myDisplayName,
Uint8List.fromList(recoveryData.keyManager),
);
if (!mounted) return; if (!mounted) return;
if (error != null) { // Start full passwordless recovery. The progress view reports every
showSnackbar( // step and restarts the app once the recovery succeeded.
context, await Navigator.of(context).push(
error.toLocalizedString(context), MaterialPageRoute<void>(
); builder: (_) => RecoveryProgressView(
setState(() { username: shares.first.myDisplayName,
_isRecovering = false; steps: const [
}); RecoveryProgress.restoringIdentity,
return; RecoveryProgress.downloadingArchive,
} RecoveryProgress.extractingData,
],
// Successful! Restart the app to apply restored keymanager/archive database runRecovery: (onProgress) =>
await Restart.restartApp( BackupService.startPasswordlessBackupRecovery(
notificationTitle: context.lang.recoverSuccessTitle, recoveryData.userId.toInt(),
notificationBody: context.lang.recoverSuccessBody, shares.first.myDisplayName,
forceKill: true, Uint8List.fromList(recoveryData.keyManager),
onProgress: onProgress,
),
),
),
); );
} catch (e) { } catch (e) {
Log.error('Failed to recover passwordless: $e'); Log.error('Failed to recover passwordless: $e');

View file

@ -0,0 +1,343 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:restart_app/restart_app.dart';
import 'package:twonly/src/services/backup.service.dart';
import 'package:twonly/src/utils/misc.dart';
import 'package:twonly/src/visual/elements/my_button.element.dart';
import 'package:twonly/src/visual/views/onboarding/components/link_logo_animation.dart';
/// Runs the actual recovery and reports every step it enters.
typedef RecoveryRunner =
Future<RecoveryError?> Function(void Function(RecoveryProgress) onProgress);
enum _StepStatus { pending, active, done, error }
/// Shows the user what the recovery is currently doing. Used by both the
/// password based and the passwordless recovery. On success the app is
/// restarted so the restored key manager and archive database are loaded.
class RecoveryProgressView extends StatefulWidget {
const RecoveryProgressView({
required this.steps,
required this.runRecovery,
this.username,
super.key,
});
/// The steps the recovery will report, in the order they will happen.
final List<RecoveryProgress> steps;
final RecoveryRunner runRecovery;
/// Username or display name of the account being restored.
final String? username;
@override
State<RecoveryProgressView> createState() => _RecoveryProgressViewState();
}
class _RecoveryProgressViewState extends State<RecoveryProgressView> {
/// Index into [RecoveryProgressView.steps], -1 until the first step starts.
int _currentIndex = -1;
bool _isFinishing = false;
RecoveryError? _error;
/// The last step is a UI only step shown while the app restarts.
int get _stepCount => widget.steps.length + 1;
bool get _hasUsername =>
widget.username != null && widget.username!.isNotEmpty;
/// The username belongs under the account check, or under the first step
/// when the recovery does not look up the account at all.
int get _usernameStepIndex {
final index = widget.steps.indexOf(RecoveryProgress.resolvingAccount);
return index >= 0 ? index : 0;
}
@override
void initState() {
super.initState();
unawaited(_startRecovery());
}
Future<void> _startRecovery() async {
setState(() {
_currentIndex = -1;
_isFinishing = false;
_error = null;
});
final error = await widget.runRecovery((progress) {
final index = widget.steps.indexOf(progress);
if (mounted && index >= 0) {
setState(() => _currentIndex = index);
}
});
if (!mounted) return;
if (error != null) {
setState(() => _error = error);
return;
}
setState(() => _isFinishing = true);
await Restart.restartApp(
notificationTitle: context.lang.recoverSuccessTitle,
notificationBody: context.lang.recoverSuccessBody,
forceKill: true,
);
}
_StepStatus _statusFor(int index) {
if (_error != null) {
if (index < _currentIndex) return _StepStatus.done;
if (index == _currentIndex) return _StepStatus.error;
return _StepStatus.pending;
}
if (_isFinishing) {
return index < widget.steps.length
? _StepStatus.done
: _StepStatus.active;
}
if (index < _currentIndex) return _StepStatus.done;
if (index == _currentIndex) return _StepStatus.active;
return _StepStatus.pending;
}
String _labelFor(BuildContext context, int index) {
if (index >= widget.steps.length) {
return context.lang.recoverProgressFinishing;
}
switch (widget.steps[index]) {
case RecoveryProgress.resolvingAccount:
return context.lang.recoverProgressResolvingAccount;
case RecoveryProgress.restoringIdentity:
return context.lang.recoverProgressRestoringIdentity;
case RecoveryProgress.downloadingArchive:
return context.lang.recoverProgressDownloadingArchive;
case RecoveryProgress.extractingData:
return context.lang.recoverProgressExtractingData;
}
}
Widget _buildStatusIcon(BuildContext context, _StepStatus status) {
final isDark = isDarkMode(context);
switch (status) {
case _StepStatus.done:
return const Icon(
Icons.check_circle_rounded,
color: Colors.green,
size: 24,
);
case _StepStatus.active:
return SizedBox(
width: 24,
height: 24,
child: Padding(
padding: const EdgeInsets.all(3),
child: CircularProgressIndicator(
strokeWidth: 2.5,
color: context.color.primary,
),
),
);
case _StepStatus.error:
return const Icon(
Icons.error_rounded,
color: Colors.redAccent,
size: 24,
);
case _StepStatus.pending:
return Icon(
Icons.circle_outlined,
size: 24,
color: isDark ? Colors.white24 : Colors.black26,
);
}
}
TextStyle _labelStyle(BuildContext context, _StepStatus status) {
final isDark = isDarkMode(context);
const base = TextStyle(fontSize: 15, height: 1.3);
switch (status) {
case _StepStatus.active:
return base.copyWith(
fontWeight: FontWeight.w600,
color: isDark ? Colors.white : Colors.black87,
);
case _StepStatus.error:
return base.copyWith(
fontWeight: FontWeight.w600,
color: Colors.redAccent,
);
case _StepStatus.done:
return base.copyWith(color: isDark ? Colors.white60 : Colors.black54);
case _StepStatus.pending:
return base.copyWith(color: isDark ? Colors.white30 : Colors.black38);
}
}
Widget _buildStepRow(BuildContext context, int index) {
final isDark = isDarkMode(context);
final isLast = index == _stepCount - 1;
final status = _statusFor(index);
return IntrinsicHeight(
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Column(
children: [
_buildStatusIcon(context, status),
if (!isLast)
Expanded(
child: Container(
width: 2,
margin: const EdgeInsets.symmetric(vertical: 4),
color: status == _StepStatus.done
? Colors.green.withValues(alpha: 0.4)
: (isDark ? Colors.white12 : Colors.black12),
),
),
],
),
const SizedBox(width: 14),
Expanded(
child: Padding(
padding: EdgeInsets.only(bottom: isLast ? 0 : 24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
_labelFor(context, index),
style: _labelStyle(context, status),
),
if (index == _usernameStepIndex && _hasUsername) ...[
const SizedBox(height: 2),
Text(
widget.username!,
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w600,
color: context.color.primary,
),
),
],
],
),
),
),
],
),
);
}
@override
Widget build(BuildContext context) {
final isDark = isDarkMode(context);
final titleColor = isDark ? Colors.white : Colors.black87;
final hasFailed = _error != null;
return PopScope(
// While the recovery is running the local data is already replaced,
// so the user should not be able to leave in between.
canPop: hasFailed,
child: Scaffold(
backgroundColor: Theme.of(context).scaffoldBackgroundColor,
body: SafeArea(
child: SingleChildScrollView(
padding: const EdgeInsets.symmetric(horizontal: 24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
children: [
IconButton(
onPressed: hasFailed
? () => Navigator.of(context).pop()
: null,
icon: const Icon(Icons.arrow_back_ios_new_rounded),
color: isDark ? Colors.white70 : Colors.black54,
iconSize: 20,
),
],
),
const SizedBox(height: 12),
Center(
child: Padding(
padding: const EdgeInsets.all(12),
child: LinkLogoAnimation(
size: 90,
color: isDark ? Colors.white : Colors.black,
),
),
),
const SizedBox(height: 12),
Text(
context.lang.recoverProgressTitle,
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 22,
fontWeight: FontWeight.w800,
color: titleColor,
letterSpacing: -0.5,
),
),
const SizedBox(height: 8),
Text(
context.lang.recoverProgressSubtitle,
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 13,
color: isDark ? Colors.white54 : Colors.black45,
),
),
const SizedBox(height: 32),
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.fromLTRB(16, 20, 16, 20),
child: Column(
children: [
for (var i = 0; i < _stepCount; i++)
_buildStepRow(context, i),
],
),
),
if (hasFailed) ...[
const SizedBox(height: 24),
Text(
_error!.toLocalizedString(context),
textAlign: TextAlign.center,
style: const TextStyle(
color: Colors.redAccent,
fontSize: 14,
),
),
const SizedBox(height: 20),
MyButton(
onPressed: _startRecovery,
child: Text(context.lang.tryRestoreAgain),
),
],
const SizedBox(height: 40),
],
),
),
),
),
);
}
}