mirror of
https://github.com/twonlyapp/twonly-app.git
synced 2026-09-23 12:14:07 +00:00
add recovery view
This commit is contained in:
parent
9207a34994
commit
6da70abc0f
10 changed files with 585 additions and 95 deletions
|
|
@ -194,6 +194,18 @@ abstract class AppLocalizations {
|
|||
/// **'Send to'**
|
||||
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.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
|
|
@ -3356,6 +3368,48 @@ abstract class AppLocalizations {
|
|||
/// **'Click here to open the app again'**
|
||||
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.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
|
|
|
|||
|
|
@ -62,6 +62,13 @@ class AppLocalizationsDe extends AppLocalizations {
|
|||
@override
|
||||
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
|
||||
String get shareImageTitle => 'Teilen mit';
|
||||
|
||||
|
|
@ -1931,6 +1938,30 @@ class AppLocalizationsDe extends AppLocalizations {
|
|||
@override
|
||||
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
|
||||
String get iosRecoveryWelcomeBack => 'Willkommen zurück';
|
||||
|
||||
|
|
|
|||
|
|
@ -61,6 +61,13 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||
@override
|
||||
String get cameraPreviewSendTo => 'Send to';
|
||||
|
||||
@override
|
||||
String get cameraPermissionsBody =>
|
||||
'twonly needs access to the camera and microphone.';
|
||||
|
||||
@override
|
||||
String get cameraPermissionsRequestButton => 'Request permissions';
|
||||
|
||||
@override
|
||||
String get shareImageTitle => 'Share with';
|
||||
|
||||
|
|
@ -1917,6 +1924,29 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||
@override
|
||||
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
|
||||
String get iosRecoveryWelcomeBack => 'Welcome Back';
|
||||
|
||||
|
|
|
|||
|
|
@ -16,6 +16,8 @@
|
|||
"registerSubmitButton": "Jetzt registrieren!",
|
||||
"chatsTapToSend": "Klicke, um dein erstes Bild zu teilen.",
|
||||
"cameraPreviewSendTo": "Senden an",
|
||||
"cameraPermissionsBody": "twonly benötigt Zugriff auf die Kamera und das Mikrofon.",
|
||||
"cameraPermissionsRequestButton": "Berechtigungen anfragen",
|
||||
"shareImageTitle": "Teilen mit",
|
||||
"shareImageBestFriends": "Beste Freunde",
|
||||
"shareImagePinnedContacts": "Angeheftet",
|
||||
|
|
@ -681,6 +683,13 @@
|
|||
"recoverErrorUnknown": "Ein unbekannter Fehler ist aufgetreten. Bitte versuche es erneut.",
|
||||
"recoverSuccessTitle": "Backup erfolgreich wiederhergestellt.",
|
||||
"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",
|
||||
"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.",
|
||||
|
|
|
|||
|
|
@ -16,6 +16,8 @@
|
|||
"registerSubmitButton": "Register now!",
|
||||
"chatsTapToSend": "Click to send your first image",
|
||||
"cameraPreviewSendTo": "Send to",
|
||||
"cameraPermissionsBody": "twonly needs access to the camera and microphone.",
|
||||
"cameraPermissionsRequestButton": "Request permissions",
|
||||
"shareImageTitle": "Share with",
|
||||
"shareImageBestFriends": "Best friends",
|
||||
"shareImagePinnedContacts": "Pinnded",
|
||||
|
|
@ -691,6 +693,13 @@
|
|||
"recoverErrorUnknown": "An unknown error occurred. Please try again.",
|
||||
"recoverSuccessTitle": "Backup successfully recovered.",
|
||||
"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",
|
||||
"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.",
|
||||
|
|
|
|||
|
|
@ -264,12 +264,15 @@ class BackupService {
|
|||
return BackupRecovery.fromJson(stateJson);
|
||||
}
|
||||
|
||||
static Future<RecoveryError?> _nextBackupStage() async {
|
||||
static Future<RecoveryError?> _nextBackupStage({
|
||||
void Function(RecoveryProgress)? onProgress,
|
||||
}) async {
|
||||
return _protected.protect(() async {
|
||||
final recoveryData = await getBackupRecoveryData();
|
||||
if (recoveryData == null) return null;
|
||||
|
||||
if (recoveryData.state == BackupRecoveryState.identityBackupStarted) {
|
||||
onProgress?.call(RecoveryProgress.restoringIdentity);
|
||||
// First start to download the identity to restore the KeyManager
|
||||
final backupKeys = await RustBackupIdentity.getBackupPasswordKeys(
|
||||
userId: recoveryData.userId,
|
||||
|
|
@ -304,6 +307,7 @@ class BackupService {
|
|||
|
||||
if (recoveryData.state == BackupRecoveryState.archiveBackupStarted) {
|
||||
// The KeyManager was restored successfully, restore the archive now.
|
||||
onProgress?.call(RecoveryProgress.downloadingArchive);
|
||||
try {
|
||||
final downloadToken =
|
||||
await RustBackupArchive.getBackupDownloadToken();
|
||||
|
|
@ -326,6 +330,8 @@ class BackupService {
|
|||
return backupArchive.$2;
|
||||
}
|
||||
|
||||
onProgress?.call(RecoveryProgress.extractingData);
|
||||
|
||||
final archiveFile = File('${AppEnvironment.cacheDir}/archive.bin')
|
||||
..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();
|
||||
if (userId == null) return null;
|
||||
|
||||
|
|
@ -358,13 +366,15 @@ class BackupService {
|
|||
password: '',
|
||||
)..state = BackupRecoveryState.archiveBackupStarted;
|
||||
await KeyValueStore.put(KeyValueKeys.backupRecoveryState, state.toJson());
|
||||
return _nextBackupStage();
|
||||
return _nextBackupStage(onProgress: onProgress);
|
||||
}
|
||||
|
||||
static Future<RecoveryError?> startFullBackupRecovery(
|
||||
String username,
|
||||
String password,
|
||||
) async {
|
||||
String password, {
|
||||
void Function(RecoveryProgress)? onProgress,
|
||||
}) async {
|
||||
onProgress?.call(RecoveryProgress.resolvingAccount);
|
||||
late final int userId;
|
||||
try {
|
||||
userId = await RustApi.getUserIdFromUsername(username: username);
|
||||
|
|
@ -381,27 +391,29 @@ class BackupService {
|
|||
|
||||
await deleteLocalUserData(removeCredentials: true);
|
||||
await KeyValueStore.put(KeyValueKeys.backupRecoveryState, state.toJson());
|
||||
return _nextBackupStage();
|
||||
return _nextBackupStage(onProgress: onProgress);
|
||||
}
|
||||
|
||||
static Future<RecoveryError?> startPasswordlessBackupRecovery(
|
||||
int userId,
|
||||
String username,
|
||||
Uint8List keyManagerBytes,
|
||||
) async {
|
||||
Uint8List keyManagerBytes, {
|
||||
void Function(RecoveryProgress)? onProgress,
|
||||
}) async {
|
||||
final state = BackupRecovery(
|
||||
username: username,
|
||||
password: '',
|
||||
userId: userId,
|
||||
)..state = BackupRecoveryState.archiveBackupStarted;
|
||||
|
||||
onProgress?.call(RecoveryProgress.restoringIdentity);
|
||||
await deleteLocalUserData(removeCredentials: true);
|
||||
|
||||
// Import KeyManager keys into secure storage & in-memory key manager
|
||||
await RustKeyManager.importSerialized(serializedBytes: keyManagerBytes);
|
||||
|
||||
await KeyValueStore.put(KeyValueKeys.backupRecoveryState, state.toJson());
|
||||
return _nextBackupStage();
|
||||
return _nextBackupStage(onProgress: onProgress);
|
||||
}
|
||||
|
||||
static Future<(Uint8List?, RecoveryError?)> _downloadBackup(
|
||||
|
|
@ -441,3 +453,12 @@ enum RecoveryError {
|
|||
noInternet,
|
||||
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,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,6 +4,8 @@ import 'package:flutter/material.dart';
|
|||
import 'package:permission_handler/permission_handler.dart';
|
||||
import 'package:twonly/src/services/user.service.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 {
|
||||
const PermissionHandlerView({
|
||||
|
|
@ -124,32 +126,44 @@ class PermissionHandlerViewState extends State<PermissionHandlerView>
|
|||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
body: Center(
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(100),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const Text(
|
||||
'twonly needs access to the camera and microphone.',
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(
|
||||
left: 100,
|
||||
right: 100,
|
||||
top: 100,
|
||||
bottom: 50,
|
||||
),
|
||||
child: Text(
|
||||
context.lang.cameraPermissionsBody,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 50),
|
||||
FilledButton.icon(
|
||||
label: const Text('Request permissions'),
|
||||
icon: const Icon(Icons.perm_camera_mic),
|
||||
onPressed: () async {
|
||||
try {
|
||||
await permissionServices();
|
||||
if (await checkPermissions()) {
|
||||
_handleSuccess();
|
||||
}
|
||||
} catch (e) {
|
||||
Log.error(e);
|
||||
),
|
||||
MyButton(
|
||||
variant: MyButtonVariant.primaryMiddle,
|
||||
onPressed: () async {
|
||||
try {
|
||||
await permissionServices();
|
||||
if (await checkPermissions()) {
|
||||
_handleSuccess();
|
||||
}
|
||||
},
|
||||
} 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),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,17 +1,16 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:font_awesome_flutter/font_awesome_flutter.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/routes.keys.dart';
|
||||
import 'package:twonly/src/model/json/onboarding_state.model.dart';
|
||||
import 'package:twonly/src/services/backup.service.dart';
|
||||
import 'package:twonly/src/utils/keyvalue.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_input.element.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';
|
||||
|
||||
class BackupRecoveryView extends StatefulWidget {
|
||||
|
|
@ -23,7 +22,6 @@ class BackupRecoveryView extends StatefulWidget {
|
|||
|
||||
class _BackupRecoveryViewState extends State<BackupRecoveryView> {
|
||||
bool obscureText = true;
|
||||
bool isLoading = false;
|
||||
final TextEditingController usernameCtrl = TextEditingController();
|
||||
final TextEditingController passwordCtrl = TextEditingController();
|
||||
|
||||
|
|
@ -35,32 +33,27 @@ class _BackupRecoveryViewState extends State<BackupRecoveryView> {
|
|||
}
|
||||
|
||||
Future<void> _recoverTwonlySafe() async {
|
||||
setState(() {
|
||||
isLoading = true;
|
||||
});
|
||||
final username = usernameCtrl.text;
|
||||
final password = passwordCtrl.text;
|
||||
|
||||
final error = await BackupService.startFullBackupRecovery(
|
||||
usernameCtrl.text,
|
||||
passwordCtrl.text,
|
||||
await Navigator.of(context).push(
|
||||
MaterialPageRoute<void>(
|
||||
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
|
||||
|
|
@ -159,19 +152,8 @@ class _BackupRecoveryViewState extends State<BackupRecoveryView> {
|
|||
),
|
||||
const SizedBox(height: 32),
|
||||
MyButton(
|
||||
onPressed: (!isLoading) ? _recoverTwonlySafe : null,
|
||||
child: isLoading
|
||||
? const SizedBox(
|
||||
height: 24,
|
||||
width: 24,
|
||||
child: CircularProgressIndicator.adaptive(
|
||||
valueColor: AlwaysStoppedAnimation(
|
||||
Colors.white,
|
||||
),
|
||||
strokeWidth: 3,
|
||||
),
|
||||
)
|
||||
: Text(context.lang.twonlySafeRecoverBtn),
|
||||
onPressed: _recoverTwonlySafe,
|
||||
child: Text(context.lang.twonlySafeRecoverBtn),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
MyButton(
|
||||
|
|
|
|||
|
|
@ -9,7 +9,6 @@ import 'package:flutter/material.dart';
|
|||
import 'package:flutter/services.dart';
|
||||
import 'package:hashlib/random.dart';
|
||||
import 'package:qr_flutter/qr_flutter.dart';
|
||||
import 'package:restart_app/restart_app.dart';
|
||||
import 'package:share_plus/share_plus.dart';
|
||||
import 'package:twonly/core/bridge/wrapper.dart' show RustUtils;
|
||||
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_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});
|
||||
|
|
@ -398,31 +398,28 @@ class _RecoverPasswordlessState extends State<RecoverPasswordless> {
|
|||
|
||||
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 (error != null) {
|
||||
showSnackbar(
|
||||
context,
|
||||
error.toLocalizedString(context),
|
||||
);
|
||||
setState(() {
|
||||
_isRecovering = false;
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Successful! Restart the app to apply restored keymanager/archive database
|
||||
await Restart.restartApp(
|
||||
notificationTitle: context.lang.recoverSuccessTitle,
|
||||
notificationBody: context.lang.recoverSuccessBody,
|
||||
forceKill: true,
|
||||
// Start full passwordless recovery. The progress view reports every
|
||||
// step and restarts the app once the recovery succeeded.
|
||||
await Navigator.of(context).push(
|
||||
MaterialPageRoute<void>(
|
||||
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');
|
||||
|
|
|
|||
343
lib/src/visual/views/onboarding/recovery_progress.view.dart
Normal file
343
lib/src/visual/views/onboarding/recovery_progress.view.dart
Normal 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),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue