Seamless recovery for iOS reinstallations

This commit is contained in:
otsmr 2026-05-12 22:55:56 +02:00
parent 7634177191
commit 4d39eb0bf4
16 changed files with 594 additions and 48 deletions

View file

@ -2,6 +2,7 @@
## 0.2.11 ## 0.2.11
- New: Seamless recovery for iOS reinstallations
- Improved: Redesigned snackbar notifications - Improved: Redesigned snackbar notifications
- Improved: New backup mechanism to allow larger backup files - Improved: New backup mechanism to allow larger backup files
- Improved: Move keys into a centralized Rust-owned structure stored in secure storage - Improved: Move keys into a centralized Rust-owned structure stored in secure storage

View file

@ -15,14 +15,20 @@ import 'package:twonly/src/visual/themes/dark.dart';
import 'package:twonly/src/visual/themes/light.dart'; import 'package:twonly/src/visual/themes/light.dart';
import 'package:twonly/src/visual/views/critical_error.view.dart'; import 'package:twonly/src/visual/views/critical_error.view.dart';
import 'package:twonly/src/visual/views/home.view.dart'; import 'package:twonly/src/visual/views/home.view.dart';
import 'package:twonly/src/visual/views/recovery.view.dart';
import 'package:twonly/src/visual/views/onboarding/onboarding.view.dart'; import 'package:twonly/src/visual/views/onboarding/onboarding.view.dart';
import 'package:twonly/src/visual/views/onboarding/register.view.dart'; import 'package:twonly/src/visual/views/onboarding/register.view.dart';
import 'package:twonly/src/visual/views/onboarding/setup.view.dart'; import 'package:twonly/src/visual/views/onboarding/setup.view.dart';
import 'package:twonly/src/visual/views/unlock_twonly.view.dart'; import 'package:twonly/src/visual/views/unlock_twonly.view.dart';
class App extends StatefulWidget { class App extends StatefulWidget {
const App({required this.storageError, super.key}); const App({
required this.storageError,
required this.recoveryPossible,
super.key,
});
final bool storageError; final bool storageError;
final bool recoveryPossible;
@override @override
State<App> createState() => _AppState(); State<App> createState() => _AppState();
} }
@ -88,6 +94,19 @@ class _AppState extends State<App> with WidgetsBindingObserver {
); );
} }
if (widget.recoveryPossible) {
return MaterialApp(
localizationsDelegates: localizationsDelegates,
debugShowCheckedModeBanner: false,
supportedLocales: supportedLocales,
title: 'twonly',
theme: lightTheme,
darkTheme: darkTheme,
themeMode: context.read<SettingsChangeProvider>().themeMode,
home: const RecoveryView(),
);
}
return MaterialApp.router( return MaterialApp.router(
routerConfig: routerProvider, routerConfig: routerProvider,
localizationsDelegates: localizationsDelegates, localizationsDelegates: localizationsDelegates,

View file

@ -17,6 +17,9 @@ class RustKeyManager {
.api .api
.crateBridgeWrapperKeyManagerRustKeyManagerGetSignalIdentity(); .crateBridgeWrapperKeyManagerRustKeyManagerGetSignalIdentity();
static Future<PlatformInt64?> getUserId() => RustLib.instance.api
.crateBridgeWrapperKeyManagerRustKeyManagerGetUserId();
static Future<void> importSignalIdentity({ static Future<void> importSignalIdentity({
required List<int> identityKeyPairStructure, required List<int> identityKeyPairStructure,
required PlatformInt64 registrationId, required PlatformInt64 registrationId,
@ -40,6 +43,9 @@ class RustKeyManager {
.api .api
.crateBridgeWrapperKeyManagerRustKeyManagerLoadSignedPrekeys(); .crateBridgeWrapperKeyManagerRustKeyManagerLoadSignedPrekeys();
static Future<void> removeKeyManager() => RustLib.instance.api
.crateBridgeWrapperKeyManagerRustKeyManagerRemoveKeyManager();
static Future<void> removeSignedPrekey({ static Future<void> removeSignedPrekey({
required PlatformInt64 signedPreKeyId, required PlatformInt64 signedPreKeyId,
}) => RustLib.instance.api }) => RustLib.instance.api
@ -47,6 +53,11 @@ class RustKeyManager {
signedPreKeyId: signedPreKeyId, signedPreKeyId: signedPreKeyId,
); );
static Future<void> setUserId({required PlatformInt64 userId}) => RustLib
.instance
.api
.crateBridgeWrapperKeyManagerRustKeyManagerSetUserId(userId: userId);
static Future<void> storeSignedPrekey({ static Future<void> storeSignedPrekey({
required PlatformInt64 signedPreKeyId, required PlatformInt64 signedPreKeyId,
required List<int> record, required List<int> record,

View file

@ -74,7 +74,7 @@ class RustLib extends BaseEntrypoint<RustLibApi, RustLibApiImpl, RustLibWire> {
String get codegenVersion => '2.12.0'; String get codegenVersion => '2.12.0';
@override @override
int get rustContentHash => 1215442517; int get rustContentHash => -1867463121;
static const kDefaultExternalLibraryLoaderConfig = static const kDefaultExternalLibraryLoaderConfig =
ExternalLibraryLoaderConfig( ExternalLibraryLoaderConfig(
@ -200,6 +200,8 @@ abstract class RustLibApi extends BaseApi {
Future<(Uint8List, PlatformInt64)> Future<(Uint8List, PlatformInt64)>
crateBridgeWrapperKeyManagerRustKeyManagerGetSignalIdentity(); crateBridgeWrapperKeyManagerRustKeyManagerGetSignalIdentity();
Future<PlatformInt64?> crateBridgeWrapperKeyManagerRustKeyManagerGetUserId();
Future<void> crateBridgeWrapperKeyManagerRustKeyManagerImportSignalIdentity({ Future<void> crateBridgeWrapperKeyManagerRustKeyManagerImportSignalIdentity({
required List<int> identityKeyPairStructure, required List<int> identityKeyPairStructure,
required PlatformInt64 registrationId, required PlatformInt64 registrationId,
@ -214,10 +216,16 @@ abstract class RustLibApi extends BaseApi {
Future<Map<PlatformInt64, Uint8List>> Future<Map<PlatformInt64, Uint8List>>
crateBridgeWrapperKeyManagerRustKeyManagerLoadSignedPrekeys(); crateBridgeWrapperKeyManagerRustKeyManagerLoadSignedPrekeys();
Future<void> crateBridgeWrapperKeyManagerRustKeyManagerRemoveKeyManager();
Future<void> crateBridgeWrapperKeyManagerRustKeyManagerRemoveSignedPrekey({ Future<void> crateBridgeWrapperKeyManagerRustKeyManagerRemoveSignedPrekey({
required PlatformInt64 signedPreKeyId, required PlatformInt64 signedPreKeyId,
}); });
Future<void> crateBridgeWrapperKeyManagerRustKeyManagerSetUserId({
required PlatformInt64 userId,
});
Future<void> crateBridgeWrapperKeyManagerRustKeyManagerStoreSignedPrekey({ Future<void> crateBridgeWrapperKeyManagerRustKeyManagerStoreSignedPrekey({
required PlatformInt64 signedPreKeyId, required PlatformInt64 signedPreKeyId,
required List<int> record, required List<int> record,
@ -1035,6 +1043,38 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
argNames: [], argNames: [],
); );
@override
Future<PlatformInt64?> crateBridgeWrapperKeyManagerRustKeyManagerGetUserId() {
return handler.executeNormal(
NormalTask(
callFfi: (port_) {
final serializer = SseSerializer(generalizedFrbRustBinding);
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
funcId: 20,
port: port_,
);
},
codec: SseCodec(
decodeSuccessData: sse_decode_opt_box_autoadd_i_64,
decodeErrorData: sse_decode_AnyhowException,
),
constMeta:
kCrateBridgeWrapperKeyManagerRustKeyManagerGetUserIdConstMeta,
argValues: [],
apiImpl: this,
),
);
}
TaskConstMeta
get kCrateBridgeWrapperKeyManagerRustKeyManagerGetUserIdConstMeta =>
const TaskConstMeta(
debugName: "rust_key_manager_get_user_id",
argNames: [],
);
@override @override
Future<void> crateBridgeWrapperKeyManagerRustKeyManagerImportSignalIdentity({ Future<void> crateBridgeWrapperKeyManagerRustKeyManagerImportSignalIdentity({
required List<int> identityKeyPairStructure, required List<int> identityKeyPairStructure,
@ -1054,7 +1094,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi( pdeCallFfi(
generalizedFrbRustBinding, generalizedFrbRustBinding,
serializer, serializer,
funcId: 20, funcId: 21,
port: port_, port: port_,
); );
}, },
@ -1098,7 +1138,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi( pdeCallFfi(
generalizedFrbRustBinding, generalizedFrbRustBinding,
serializer, serializer,
funcId: 21, funcId: 22,
port: port_, port: port_,
); );
}, },
@ -1131,7 +1171,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi( pdeCallFfi(
generalizedFrbRustBinding, generalizedFrbRustBinding,
serializer, serializer,
funcId: 22, funcId: 23,
port: port_, port: port_,
); );
}, },
@ -1154,6 +1194,38 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
argNames: [], argNames: [],
); );
@override
Future<void> crateBridgeWrapperKeyManagerRustKeyManagerRemoveKeyManager() {
return handler.executeNormal(
NormalTask(
callFfi: (port_) {
final serializer = SseSerializer(generalizedFrbRustBinding);
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
funcId: 24,
port: port_,
);
},
codec: SseCodec(
decodeSuccessData: sse_decode_unit,
decodeErrorData: sse_decode_AnyhowException,
),
constMeta:
kCrateBridgeWrapperKeyManagerRustKeyManagerRemoveKeyManagerConstMeta,
argValues: [],
apiImpl: this,
),
);
}
TaskConstMeta
get kCrateBridgeWrapperKeyManagerRustKeyManagerRemoveKeyManagerConstMeta =>
const TaskConstMeta(
debugName: "rust_key_manager_remove_key_manager",
argNames: [],
);
@override @override
Future<void> crateBridgeWrapperKeyManagerRustKeyManagerRemoveSignedPrekey({ Future<void> crateBridgeWrapperKeyManagerRustKeyManagerRemoveSignedPrekey({
required PlatformInt64 signedPreKeyId, required PlatformInt64 signedPreKeyId,
@ -1166,7 +1238,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi( pdeCallFfi(
generalizedFrbRustBinding, generalizedFrbRustBinding,
serializer, serializer,
funcId: 23, funcId: 25,
port: port_, port: port_,
); );
}, },
@ -1189,6 +1261,41 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
argNames: ["signedPreKeyId"], argNames: ["signedPreKeyId"],
); );
@override
Future<void> crateBridgeWrapperKeyManagerRustKeyManagerSetUserId({
required PlatformInt64 userId,
}) {
return handler.executeNormal(
NormalTask(
callFfi: (port_) {
final serializer = SseSerializer(generalizedFrbRustBinding);
sse_encode_i_64(userId, serializer);
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
funcId: 26,
port: port_,
);
},
codec: SseCodec(
decodeSuccessData: sse_decode_unit,
decodeErrorData: sse_decode_AnyhowException,
),
constMeta:
kCrateBridgeWrapperKeyManagerRustKeyManagerSetUserIdConstMeta,
argValues: [userId],
apiImpl: this,
),
);
}
TaskConstMeta
get kCrateBridgeWrapperKeyManagerRustKeyManagerSetUserIdConstMeta =>
const TaskConstMeta(
debugName: "rust_key_manager_set_user_id",
argNames: ["userId"],
);
@override @override
Future<void> crateBridgeWrapperKeyManagerRustKeyManagerStoreSignedPrekey({ Future<void> crateBridgeWrapperKeyManagerRustKeyManagerStoreSignedPrekey({
required PlatformInt64 signedPreKeyId, required PlatformInt64 signedPreKeyId,
@ -1203,7 +1310,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi( pdeCallFfi(
generalizedFrbRustBinding, generalizedFrbRustBinding,
serializer, serializer,
funcId: 24, funcId: 27,
port: port_, port: port_,
); );
}, },

View file

@ -1,7 +1,5 @@
import 'dart:async'; import 'dart:async';
import 'dart:convert'; import 'dart:convert';
import 'dart:io';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:mutex/mutex.dart'; import 'package:mutex/mutex.dart';
@ -90,6 +88,8 @@ void main() async {
var userExists = false; var userExists = false;
var recoveryPossible = false;
if (!storageError) { if (!storageError) {
try { try {
userExists = await userService.tryInit(); userExists = await userService.tryInit();
@ -99,12 +99,14 @@ void main() async {
} }
} }
if (Platform.isIOS && userExists) { if (!userExists && !storageError) {
final dbFile = File('${AppEnvironment.supportDir}/twonly.sqlite'); try {
if (!dbFile.existsSync()) { final userId = await RustKeyManager.getUserId();
Log.error('[twonly] IOS: App was removed and then reinstalled again...'); if (userId != null) {
await SecureStorage.instance.deleteAll(); recoveryPossible = true;
userExists = false; }
} catch (e) {
Log.error('Could not check KeyManager userId for iOS recovery: $e');
} }
} }
@ -152,7 +154,10 @@ void main() async {
ChangeNotifierProvider(create: (_) => ImageEditorProvider()), ChangeNotifierProvider(create: (_) => ImageEditorProvider()),
ChangeNotifierProvider(create: (_) => PurchasesProvider()), ChangeNotifierProvider(create: (_) => PurchasesProvider()),
], ],
child: App(storageError: storageError), child: App(
storageError: storageError,
recoveryPossible: recoveryPossible,
),
), ),
); );
} }

View file

@ -3079,6 +3079,42 @@ abstract class AppLocalizations {
/// In en, this message translates to: /// In en, this message translates to:
/// **'Click here to open the app again'** /// **'Click here to open the app again'**
String get recoverSuccessBody; String get recoverSuccessBody;
/// No description provided for @iosRecoveryWelcomeBack.
///
/// In en, this message translates to:
/// **'Welcome Back'**
String get iosRecoveryWelcomeBack;
/// No description provided for @iosRecoveryPrompt.
///
/// In en, this message translates to:
/// **'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?'**
String get iosRecoveryPrompt;
/// No description provided for @iosRecoveryNoBackupFound.
///
/// In en, this message translates to:
/// **'No backup archive could be retrieved from the server for this device.\n\nError: {error}\n\nPlease proceed to register a new twonly account.'**
String iosRecoveryNoBackupFound(Object error);
/// No description provided for @registerNewAccount.
///
/// In en, this message translates to:
/// **'Register New Account'**
String get registerNewAccount;
/// No description provided for @tryRestoreAgain.
///
/// In en, this message translates to:
/// **'Try Restore Again'**
String get tryRestoreAgain;
/// No description provided for @registeringNewAccount.
///
/// In en, this message translates to:
/// **'Registering new account'**
String get registeringNewAccount;
} }
class _AppLocalizationsDelegate class _AppLocalizationsDelegate

View file

@ -1736,4 +1736,25 @@ 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 iosRecoveryWelcomeBack => 'Willkommen zurück';
@override
String get 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?';
@override
String iosRecoveryNoBackupFound(Object error) {
return '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.';
}
@override
String get registerNewAccount => 'Neues Konto registrieren';
@override
String get tryRestoreAgain => 'Wiederherstellung erneut versuchen';
@override
String get registeringNewAccount => 'Neues Konto wird registriert';
} }

View file

@ -1721,4 +1721,25 @@ 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 iosRecoveryWelcomeBack => 'Welcome Back';
@override
String get 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?';
@override
String iosRecoveryNoBackupFound(Object error) {
return 'No backup archive could be retrieved from the server for this device.\n\nError: $error\n\nPlease proceed to register a new twonly account.';
}
@override
String get registerNewAccount => 'Register New Account';
@override
String get tryRestoreAgain => 'Try Restore Again';
@override
String get registeringNewAccount => 'Registering new account';
} }

@ -1 +1 @@
Subproject commit 65bf6a4d161bfa0cd2db698446c58b4cd03db92c Subproject commit 75b97e912f2e72a8e2a5da65e8ad12f0d1091855

View file

@ -7,6 +7,7 @@ import 'package:clock/clock.dart' as clock;
import 'package:http/http.dart' as http; import 'package:http/http.dart' as http;
import 'package:mutex/mutex.dart'; import 'package:mutex/mutex.dart';
import 'package:twonly/core/bridge/wrapper/backup.dart'; import 'package:twonly/core/bridge/wrapper/backup.dart';
import 'package:twonly/core/bridge/wrapper/key_manager.dart';
import 'package:twonly/globals.dart'; import 'package:twonly/globals.dart';
import 'package:twonly/locator.dart'; import 'package:twonly/locator.dart';
import 'package:twonly/src/constants/keyvalue.keys.dart'; import 'package:twonly/src/constants/keyvalue.keys.dart';
@ -102,6 +103,11 @@ class BackupService {
backup.identityLastSuccessFull!.isBefore( backup.identityLastSuccessFull!.isBefore(
lastWeek.subtract(const Duration(days: 1)), lastWeek.subtract(const Duration(days: 1)),
))) { ))) {
final backupId = await RustBackupIdentity.getBackupId();
if (backupId == null) {
Log.error('No backup password was set by the user.');
backup.identityState = LastBackupUploadState.failed;
} else {
Log.info('Performing a identity backup.'); Log.info('Performing a identity backup.');
final encryptedBackup = final encryptedBackup =
await RustBackupIdentity.getIdentityBackupBytes(); await RustBackupIdentity.getIdentityBackupBytes();
@ -114,11 +120,6 @@ class BackupService {
'Identity backup has a size of ${backupTempFile.statSync().size}.', 'Identity backup has a size of ${backupTempFile.statSync().size}.',
); );
final backupId = await RustBackupIdentity.getBackupId();
if (backupId == null) {
Log.error('Got empty backup id.');
backup.identityState = LastBackupUploadState.failed;
} else {
final task = UploadTask.fromFile( final task = UploadTask.fromFile(
taskId: 'backup_identity', taskId: 'backup_identity',
httpRequestMethod: 'PUT', httpRequestMethod: 'PUT',
@ -290,6 +291,19 @@ class BackupService {
}); });
} }
static Future<RecoveryError?> tryToReinstallTheArchive() async {
final userId = await RustKeyManager.getUserId();
if (userId == null) return null;
final state = BackupRecovery(
username: '',
userId: userId,
password: '',
)..state = BackupRecoveryState.archiveBackupStarted;
await KeyValueStore.put(KeyValueKeys.backupRecoveryState, state.toJson());
return _nextBackupStage();
}
static Future<RecoveryError?> startFullBackupRecovery( static Future<RecoveryError?> startFullBackupRecovery(
String username, String username,
String password, String password,

View file

@ -2,6 +2,7 @@ import 'dart:async';
import 'dart:convert'; import 'dart:convert';
import 'package:mutex/mutex.dart'; import 'package:mutex/mutex.dart';
import 'package:twonly/core/bridge/wrapper/key_manager.dart';
import 'package:twonly/locator.dart'; import 'package:twonly/locator.dart';
import 'package:twonly/src/constants/secure_storage.keys.dart'; import 'package:twonly/src/constants/secure_storage.keys.dart';
import 'package:twonly/src/model/json/userdata.model.dart'; import 'package:twonly/src/model/json/userdata.model.dart';
@ -30,7 +31,9 @@ class UserService {
// 1. Try to load from KeyValueStore (user.json) // 1. Try to load from KeyValueStore (user.json)
final userDataMap = await KeyValueStore.get('user'); final userDataMap = await KeyValueStore.get('user');
if (userDataMap != null) { if (userDataMap != null) {
return UserData.fromJson(userDataMap); final userData = UserData.fromJson(userDataMap);
await RustKeyManager.setUserId(userId: userData.userId);
return userData;
} }
// 2. If not found, try to load from SecureStorage (Migration path) // 2. If not found, try to load from SecureStorage (Migration path)
@ -58,6 +61,11 @@ class UserService {
static Future<void> _migrateFromSecureStorage(UserData userData) async { static Future<void> _migrateFromSecureStorage(UserData userData) async {
// Currently empty migration logic as requested, but we MUST store the data // Currently empty migration logic as requested, but we MUST store the data
await KeyValueStore.put('user', userData.toJson()); await KeyValueStore.put('user', userData.toJson());
try {
await RustKeyManager.setUserId(userId: userData.userId);
} catch (e) {
Log.error('Could not set userId in RustKeyManager during migration: $e');
}
// Optional: Log migration // Optional: Log migration
Log.info('Migrated user data from SecureStorage to KeyValueStore'); Log.info('Migrated user data from SecureStorage to KeyValueStore');
@ -87,6 +95,11 @@ class UserService {
static Future<void> save(UserData user) async { static Future<void> save(UserData user) async {
await KeyValueStore.put('user', user.toJson()); await KeyValueStore.put('user', user.toJson());
try {
await RustKeyManager.setUserId(userId: user.userId);
} catch (e) {
Log.error('Could not set userId in RustKeyManager during save: $e');
}
await userService.tryInit(); await userService.tryInit();
} }

View file

@ -0,0 +1,178 @@
import 'package:flutter/material.dart';
import 'package:font_awesome_flutter/font_awesome_flutter.dart';
import 'package:restart_app/restart_app.dart';
import 'package:twonly/core/bridge/wrapper/key_manager.dart';
import 'package:twonly/locator.dart';
import 'package:twonly/src/services/backup.service.dart';
import 'package:twonly/src/utils/log.dart';
import 'package:twonly/src/utils/misc.dart';
import 'package:twonly/src/utils/storage.dart';
class RecoveryView extends StatefulWidget {
const RecoveryView({super.key});
@override
State<RecoveryView> createState() => _RecoveryViewState();
}
class _RecoveryViewState extends State<RecoveryView> {
bool _isLoading = false;
String? _errorMessage;
bool _showRegisterNewPrompt = false;
Future<void> _startRestore() async {
setState(() {
_isLoading = true;
_errorMessage = null;
});
final error = await BackupService.tryToReinstallTheArchive();
if (!mounted) return;
if (error != null) {
String msg;
switch (error) {
case RecoveryError.noInternet:
msg = context.lang.recoverErrorNoInternet;
case RecoveryError.usernameNotValid:
msg = context.lang.recoverErrorUsernameNotValid;
case RecoveryError.passwordInvalid:
msg = context.lang.recoverErrorPasswordInvalid;
case RecoveryError.tryAgainLater:
msg = context.lang.recoverErrorTryAgainLater;
case RecoveryError.unkownError:
msg = context.lang.recoverErrorUnknown;
}
setState(() {
_isLoading = false;
_errorMessage = msg;
_showRegisterNewPrompt = true;
});
return;
}
final userExists = await userService.tryInit();
if (userExists && mounted) {
await Restart.restartApp(
notificationTitle: context.lang.recoverSuccessTitle,
notificationBody: context.lang.recoverSuccessBody,
forceKill: true,
);
} else {
setState(() {
_isLoading = false;
_errorMessage = context.lang.recoverErrorUnknown;
_showRegisterNewPrompt = true;
});
}
}
Future<void> _registerNewAccount() async {
try {
await RustKeyManager.removeKeyManager();
} catch (e) {
Log.error('Could not remove KeyManager during account reset: $e');
}
await deleteLocalUserData();
if (!mounted) return;
await Restart.restartApp(
notificationTitle: 'twonly',
notificationBody: context.lang.registeringNewAccount,
forceKill: true,
);
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(context.lang.twonlySafeRecoverTitle),
),
body: Center(
child: Padding(
padding: const EdgeInsets.all(24),
child: ListView(
children: [
const SizedBox(height: 100),
Center(
child: FaIcon(
FontAwesomeIcons.cloudArrowDown,
size: 80,
color: context.color.primary,
),
),
const SizedBox(height: 24),
Text(
context.lang.iosRecoveryWelcomeBack,
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
fontWeight: FontWeight.bold,
),
textAlign: TextAlign.center,
),
const SizedBox(height: 16),
Text(
_showRegisterNewPrompt
? context.lang.iosRecoveryNoBackupFound(
_errorMessage ?? '',
)
: context.lang.iosRecoveryPrompt,
textAlign: TextAlign.center,
style: const TextStyle(fontSize: 15),
),
const SizedBox(height: 32),
if (!_showRegisterNewPrompt) ...[
FilledButton.icon(
onPressed: _isLoading ? null : _startRestore,
icon: _isLoading
? const SizedBox(
height: 16,
width: 16,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.restore_rounded),
style: FilledButton.styleFrom(
padding: const EdgeInsets.symmetric(
horizontal: 32,
vertical: 12,
),
),
label: Text(
context.lang.twonlySafeRecoverBtn,
style: const TextStyle(fontSize: 16),
),
),
const SizedBox(height: 16),
TextButton(
onPressed: _isLoading ? null : _registerNewAccount,
child: Text(context.lang.registerNewAccount),
),
] else ...[
FilledButton.icon(
onPressed: _registerNewAccount,
icon: const Icon(Icons.person_add_rounded),
style: FilledButton.styleFrom(
backgroundColor: Colors.redAccent,
padding: const EdgeInsets.symmetric(
horizontal: 32,
vertical: 12,
),
),
label: Text(
context.lang.registerNewAccount,
style: const TextStyle(fontSize: 16),
),
),
const SizedBox(height: 16),
OutlinedButton.icon(
onPressed: _startRestore,
icon: const Icon(Icons.refresh_rounded),
label: Text(context.lang.tryRestoreAgain),
),
],
],
),
),
),
);
}
}

View file

@ -12,6 +12,19 @@ impl RustKeyManager {
Ok(key_manager.main_key.get_login_token().to_vec()) Ok(key_manager.main_key.get_login_token().to_vec())
} }
pub async fn get_user_id() -> Result<Option<i64>> {
let key_manager = get_twonly_flutter()?.key_manager.lock().await;
Ok(key_manager.user_id)
}
pub async fn set_user_id(user_id: i64) -> Result<()> {
let ctx = get_twonly_flutter()?;
let mut key_manager = ctx.key_manager.lock().await;
key_manager.user_id = Some(user_id);
key_manager.store_to_keychain(&ctx.secure_storage)?;
Ok(())
}
pub async fn import_signal_identity( pub async fn import_signal_identity(
identity_key_pair_structure: Vec<u8>, identity_key_pair_structure: Vec<u8>,
registration_id: i64, registration_id: i64,
@ -89,4 +102,10 @@ impl RustKeyManager {
Err(TwonlyError::SignalIdentityNotFound) Err(TwonlyError::SignalIdentityNotFound)
} }
} }
pub async fn remove_key_manager() -> Result<()> {
let ctx = get_twonly_flutter()?;
crate::keys::KeyManager::remove_from_keychain(&ctx.secure_storage)?;
Ok(())
}
} }

View file

@ -38,7 +38,7 @@ flutter_rust_bridge::frb_generated_boilerplate!(
default_rust_auto_opaque = RustAutoOpaqueMoi, default_rust_auto_opaque = RustAutoOpaqueMoi,
); );
pub(crate) const FLUTTER_RUST_BRIDGE_CODEGEN_VERSION: &str = "2.12.0"; pub(crate) const FLUTTER_RUST_BRIDGE_CODEGEN_VERSION: &str = "2.12.0";
pub(crate) const FLUTTER_RUST_BRIDGE_CODEGEN_CONTENT_HASH: i32 = 1215442517; pub(crate) const FLUTTER_RUST_BRIDGE_CODEGEN_CONTENT_HASH: i32 = -1867463121;
// Section: executor // Section: executor
@ -424,6 +424,43 @@ fn wire__crate__bridge__wrapper__key_manager__rust_key_manager_get_signal_identi
})().await) })().await)
} }) } })
} }
fn wire__crate__bridge__wrapper__key_manager__rust_key_manager_get_user_id_impl(
port_: flutter_rust_bridge::for_generated::MessagePort,
ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr,
rust_vec_len_: i32,
data_len_: i32,
) {
FLUTTER_RUST_BRIDGE_HANDLER.wrap_async::<flutter_rust_bridge::for_generated::SseCodec, _, _, _>(
flutter_rust_bridge::for_generated::TaskInfo {
debug_name: "rust_key_manager_get_user_id",
port: Some(port_),
mode: flutter_rust_bridge::for_generated::FfiCallMode::Normal,
},
move || {
let message = unsafe {
flutter_rust_bridge::for_generated::Dart2RustMessageSse::from_wire(
ptr_,
rust_vec_len_,
data_len_,
)
};
let mut deserializer =
flutter_rust_bridge::for_generated::SseDeserializer::new(message);
deserializer.end();
move |context| async move {
transform_result_sse::<_, flutter_rust_bridge::for_generated::anyhow::Error>(
(move || async move {
let output_ok =
crate::bridge::wrapper::key_manager::RustKeyManager::get_user_id()
.await?;
Ok(output_ok)
})()
.await,
)
}
},
)
}
fn wire__crate__bridge__wrapper__key_manager__rust_key_manager_import_signal_identity_impl( fn wire__crate__bridge__wrapper__key_manager__rust_key_manager_import_signal_identity_impl(
port_: flutter_rust_bridge::for_generated::MessagePort, port_: flutter_rust_bridge::for_generated::MessagePort,
ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr,
@ -471,6 +508,21 @@ fn wire__crate__bridge__wrapper__key_manager__rust_key_manager_load_signed_preke
})().await) })().await)
} }) } })
} }
fn wire__crate__bridge__wrapper__key_manager__rust_key_manager_remove_key_manager_impl(
port_: flutter_rust_bridge::for_generated::MessagePort,
ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr,
rust_vec_len_: i32,
data_len_: i32,
) {
FLUTTER_RUST_BRIDGE_HANDLER.wrap_async::<flutter_rust_bridge::for_generated::SseCodec,_,_,_>(flutter_rust_bridge::for_generated::TaskInfo{ debug_name: "rust_key_manager_remove_key_manager", port: Some(port_), mode: flutter_rust_bridge::for_generated::FfiCallMode::Normal }, move || {
let message = unsafe { flutter_rust_bridge::for_generated::Dart2RustMessageSse::from_wire(ptr_, rust_vec_len_, data_len_) };
let mut deserializer = flutter_rust_bridge::for_generated::SseDeserializer::new(message);
deserializer.end(); move |context| async move {
transform_result_sse::<_, flutter_rust_bridge::for_generated::anyhow::Error>((move || async move {
let output_ok = crate::bridge::wrapper::key_manager::RustKeyManager::remove_key_manager().await?; Ok(output_ok)
})().await)
} })
}
fn wire__crate__bridge__wrapper__key_manager__rust_key_manager_remove_signed_prekey_impl( fn wire__crate__bridge__wrapper__key_manager__rust_key_manager_remove_signed_prekey_impl(
port_: flutter_rust_bridge::for_generated::MessagePort, port_: flutter_rust_bridge::for_generated::MessagePort,
ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr,
@ -486,6 +538,46 @@ fn wire__crate__bridge__wrapper__key_manager__rust_key_manager_remove_signed_pre
})().await) })().await)
} }) } })
} }
fn wire__crate__bridge__wrapper__key_manager__rust_key_manager_set_user_id_impl(
port_: flutter_rust_bridge::for_generated::MessagePort,
ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr,
rust_vec_len_: i32,
data_len_: i32,
) {
FLUTTER_RUST_BRIDGE_HANDLER.wrap_async::<flutter_rust_bridge::for_generated::SseCodec, _, _, _>(
flutter_rust_bridge::for_generated::TaskInfo {
debug_name: "rust_key_manager_set_user_id",
port: Some(port_),
mode: flutter_rust_bridge::for_generated::FfiCallMode::Normal,
},
move || {
let message = unsafe {
flutter_rust_bridge::for_generated::Dart2RustMessageSse::from_wire(
ptr_,
rust_vec_len_,
data_len_,
)
};
let mut deserializer =
flutter_rust_bridge::for_generated::SseDeserializer::new(message);
let api_user_id = <i64>::sse_decode(&mut deserializer);
deserializer.end();
move |context| async move {
transform_result_sse::<_, flutter_rust_bridge::for_generated::anyhow::Error>(
(move || async move {
let output_ok =
crate::bridge::wrapper::key_manager::RustKeyManager::set_user_id(
api_user_id,
)
.await?;
Ok(output_ok)
})()
.await,
)
}
},
)
}
fn wire__crate__bridge__wrapper__key_manager__rust_key_manager_store_signed_prekey_impl( fn wire__crate__bridge__wrapper__key_manager__rust_key_manager_store_signed_prekey_impl(
port_: flutter_rust_bridge::for_generated::MessagePort, port_: flutter_rust_bridge::for_generated::MessagePort,
ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr,
@ -1327,11 +1419,14 @@ fn pde_ffi_dispatcher_primary_impl(
17 => wire__crate__bridge__wrapper__backup__rust_backup_identity_set_backup_password_keys_impl(port, ptr, rust_vec_len, data_len), 17 => wire__crate__bridge__wrapper__backup__rust_backup_identity_set_backup_password_keys_impl(port, ptr, rust_vec_len, data_len),
18 => wire__crate__bridge__wrapper__key_manager__rust_key_manager_get_login_token_impl(port, ptr, rust_vec_len, data_len), 18 => wire__crate__bridge__wrapper__key_manager__rust_key_manager_get_login_token_impl(port, ptr, rust_vec_len, data_len),
19 => wire__crate__bridge__wrapper__key_manager__rust_key_manager_get_signal_identity_impl(port, ptr, rust_vec_len, data_len), 19 => wire__crate__bridge__wrapper__key_manager__rust_key_manager_get_signal_identity_impl(port, ptr, rust_vec_len, data_len),
20 => wire__crate__bridge__wrapper__key_manager__rust_key_manager_import_signal_identity_impl(port, ptr, rust_vec_len, data_len), 20 => wire__crate__bridge__wrapper__key_manager__rust_key_manager_get_user_id_impl(port, ptr, rust_vec_len, data_len),
21 => wire__crate__bridge__wrapper__key_manager__rust_key_manager_load_signed_prekey_impl(port, ptr, rust_vec_len, data_len), 21 => wire__crate__bridge__wrapper__key_manager__rust_key_manager_import_signal_identity_impl(port, ptr, rust_vec_len, data_len),
22 => wire__crate__bridge__wrapper__key_manager__rust_key_manager_load_signed_prekeys_impl(port, ptr, rust_vec_len, data_len), 22 => wire__crate__bridge__wrapper__key_manager__rust_key_manager_load_signed_prekey_impl(port, ptr, rust_vec_len, data_len),
23 => wire__crate__bridge__wrapper__key_manager__rust_key_manager_remove_signed_prekey_impl(port, ptr, rust_vec_len, data_len), 23 => wire__crate__bridge__wrapper__key_manager__rust_key_manager_load_signed_prekeys_impl(port, ptr, rust_vec_len, data_len),
24 => wire__crate__bridge__wrapper__key_manager__rust_key_manager_store_signed_prekey_impl(port, ptr, rust_vec_len, data_len), 24 => wire__crate__bridge__wrapper__key_manager__rust_key_manager_remove_key_manager_impl(port, ptr, rust_vec_len, data_len),
25 => wire__crate__bridge__wrapper__key_manager__rust_key_manager_remove_signed_prekey_impl(port, ptr, rust_vec_len, data_len),
26 => wire__crate__bridge__wrapper__key_manager__rust_key_manager_set_user_id_impl(port, ptr, rust_vec_len, data_len),
27 => wire__crate__bridge__wrapper__key_manager__rust_key_manager_store_signed_prekey_impl(port, ptr, rust_vec_len, data_len),
_ => unreachable!(), _ => unreachable!(),
} }
} }

View file

@ -53,4 +53,10 @@ impl KeyManager {
Ok(()) Ok(())
} }
/// Removes the KeyManager from the secure keychain/local storage.
pub fn remove_from_keychain(storage: &SecureStorage) -> Result<()> {
storage.delete(KEY_MANAGER_ID)?;
Ok(())
}
} }

View file

@ -92,15 +92,15 @@ impl SecureStorage {
/// Deletes the secret associated with the given key from the secure keyring. /// Deletes the secret associated with the given key from the secure keyring.
/// ///
/// If the key does not exist, this function returns `Ok(())` (idempotent). /// If the key does not exist, this function returns `Ok(())` (idempotent).
// pub fn delete(&self, key: &str) -> Result<(), String> { pub fn delete(&self, key: &str) -> Result<(), String> {
// let entry = self.get_entry(key)?; let entry = self.get_entry(key)?;
// match entry.delete_credential() { match entry.delete_credential() {
// Ok(()) => Ok(()), Ok(()) => Ok(()),
// Err(KeyringError::NoEntry) => Ok(()), Err(KeyringError::NoEntry) => Ok(()),
// Err(e) => Err(format!("Failed to delete secret from keyring: {}", e)), Err(e) => Err(format!("Failed to delete secret from keyring: {}", e)),
// } }
// } }
/// Helper to create a keyring entry with the appropriate platform modifiers. /// Helper to create a keyring entry with the appropriate platform modifiers.
fn get_entry(&self, key: &str) -> Result<Entry, String> { fn get_entry(&self, key: &str) -> Result<Entry, String> {
@ -142,10 +142,10 @@ mod tests {
assert_eq!(read_val, Some(secret.to_string())); assert_eq!(read_val, Some(secret.to_string()));
// 3. Delete the secret // 3. Delete the secret
// storage.delete(key).expect("Failed to delete secret"); storage.delete(key).expect("Failed to delete secret");
// 4. Verify the secret is gone // 4. Verify the secret is gone
// let after_delete = storage.read(key).expect("Failed to read after delete"); let after_delete = storage.read(key).expect("Failed to read after delete");
// assert_eq!(after_delete, None); assert_eq!(after_delete, None);
} }
} }