From 583368505d7130c260bd5057a84edc911eed68c5 Mon Sep 17 00:00:00 2001 From: otsmr Date: Sat, 25 Apr 2026 01:55:46 +0200 Subject: [PATCH] display critical error instead of removing app data --- lib/app.dart | 103 ++++++++++++------ lib/src/providers/purchases.provider.dart | 34 ++++-- lib/src/providers/settings.provider.dart | 10 +- lib/src/services/api.service.dart | 20 ++-- lib/src/services/backup/restore.backup.dart | 6 +- lib/src/services/user.service.dart | 26 +++-- lib/src/utils/misc.dart | 2 - .../feedback_btn.comp.dart | 7 +- lib/src/visual/views/critical_error.view.dart | 68 ++++++++++++ lib/src/visual/views/home.view.dart | 7 -- .../visual/views/onboarding/recover.view.dart | 11 +- .../views/settings/help/contact_us.view.dart | 11 +- .../views/settings/notification.view.dart | 28 ++--- 13 files changed, 221 insertions(+), 112 deletions(-) create mode 100644 lib/src/visual/views/critical_error.view.dart diff --git a/lib/app.dart b/lib/app.dart index 556759d9..a6e78d59 100644 --- a/lib/app.dart +++ b/lib/app.dart @@ -17,6 +17,7 @@ import 'package:twonly/src/utils/pow.dart'; import 'package:twonly/src/visual/components/app_outdated.comp.dart'; import 'package:twonly/src/visual/themes/dark.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/home.view.dart'; import 'package:twonly/src/visual/views/onboarding/onboarding.view.dart'; import 'package:twonly/src/visual/views/onboarding/register.view.dart'; @@ -31,6 +32,7 @@ class App extends StatefulWidget { class _AppState extends State with WidgetsBindingObserver { bool wasPaused = false; + Object? _storageError; @override void initState() { @@ -42,11 +44,20 @@ class _AppState extends State with WidgetsBindingObserver { } Future initAsync() async { - final user = await getUser(); - if (user != null && mounted) { - context.read().updatePlan( - planFromString(user.subscriptionPlan), - ); + try { + final user = await getUser(); + if (user != null && mounted) { + context.read().updatePlan( + planFromString(user.subscriptionPlan), + ); + } + } catch (e) { + Log.error('Storage error in App.initAsync: $e'); + if (mounted) { + setState(() { + _storageError = e; + }); + } } await apiService.connect(); await apiService.listenToNetworkChanges(); @@ -78,20 +89,38 @@ class _AppState extends State with WidgetsBindingObserver { return ListenableBuilder( listenable: context.watch(), builder: (context, child) { + const localizationsDelegates = [ + AppLocalizations.delegate, + GlobalMaterialLocalizations.delegate, + GlobalWidgetsLocalizations.delegate, + GlobalCupertinoLocalizations.delegate, + ]; + + const supportedLocales = [ + Locale('en', ''), + Locale('de', ''), + ]; + + if (_storageError != null) { + return MaterialApp( + scaffoldMessengerKey: AppGlobalKeys.scaffoldMessengerKey, + localizationsDelegates: localizationsDelegates, + debugShowCheckedModeBanner: false, + supportedLocales: supportedLocales, + title: 'twonly', + theme: lightTheme, + darkTheme: darkTheme, + themeMode: context.watch().themeMode, + home: const CriticalErrorView(), + ); + } + return MaterialApp.router( routerConfig: routerProvider, scaffoldMessengerKey: AppGlobalKeys.scaffoldMessengerKey, - localizationsDelegates: const [ - AppLocalizations.delegate, - GlobalMaterialLocalizations.delegate, - GlobalWidgetsLocalizations.delegate, - GlobalCupertinoLocalizations.delegate, - ], + localizationsDelegates: localizationsDelegates, debugShowCheckedModeBanner: false, - supportedLocales: const [ - Locale('en', ''), - Locale('de', ''), - ], + supportedLocales: supportedLocales, title: 'twonly', theme: lightTheme, darkTheme: darkTheme, @@ -116,6 +145,7 @@ class _AppMainWidgetState extends State { bool _isUserCreated = false; bool _showOnboarding = true; bool _isLoaded = false; + Object? _storageError; bool _skipBackup = kDebugMode; bool _isTwonlyLocked = true; @@ -128,26 +158,31 @@ class _AppMainWidgetState extends State { } Future initAsync() async { - _isUserCreated = await isUserCreated(); + try { + _isUserCreated = await isUserCreated(); - if (_isUserCreated) { - if (_isTwonlyLocked) { - // do not change in case twonly was already unlocked at some point - _isTwonlyLocked = userService.currentUser.screenLockEnabled; - } - } else { - // This means the user is in the onboarding screen, so start with the Proof of Work. - - final (proof, disabled) = await apiService.getProofOfWork(); - if (proof != null) { - Log.info('Starting with proof of work calculation.'); - _proofOfWork = ( - calculatePoW(proof.prefix, proof.difficulty.toInt()), - false, - ); + if (_isUserCreated) { + if (_isTwonlyLocked) { + // do not change in case twonly was already unlocked at some point + _isTwonlyLocked = userService.currentUser.screenLockEnabled; + } } else { - _proofOfWork = (null, disabled); + // This means the user is in the onboarding screen, so start with the Proof of Work. + + final (proof, disabled) = await apiService.getProofOfWork(); + if (proof != null) { + Log.info('Starting with proof of work calculation.'); + _proofOfWork = ( + calculatePoW(proof.prefix, proof.difficulty.toInt()), + false, + ); + } else { + _proofOfWork = (null, disabled); + } } + } catch (e) { + Log.error('Storage error in AppMainWidget.initAsync: $e'); + _storageError = e; } setState(() { @@ -161,6 +196,10 @@ class _AppMainWidgetState extends State { return Center(child: Container()); } + if (_storageError != null) { + return const CriticalErrorView(); + } + late Widget child; if (_isUserCreated) { diff --git a/lib/src/providers/purchases.provider.dart b/lib/src/providers/purchases.provider.dart index 96eaa777..6c839ac5 100644 --- a/lib/src/providers/purchases.provider.dart +++ b/lib/src/providers/purchases.provider.dart @@ -41,9 +41,13 @@ class PurchasesProvider with ChangeNotifier, DiagnosticableTreeMixin { _planSub = apiService.onPlanUpdated.listen(updatePlan); _connSub = apiService.onConnectionStateUpdated.listen((_) async { - final user = await getUser(); - if (user != null) { - updatePlan(planFromString(user.subscriptionPlan)); + try { + final user = await getUser(); + if (user != null) { + updatePlan(planFromString(user.subscriptionPlan)); + } + } catch (e) { + Log.error(e); } }); @@ -90,16 +94,22 @@ class PurchasesProvider with ChangeNotifier, DiagnosticableTreeMixin { storeState = StoreState.available; notifyListeners(); - final user = await getUser(); - if (user != null && isPayingUser(planFromString(user.subscriptionPlan))) { - Log.info('Started IPA timer for verification.'); - globalForceIpaCheck = Timer(const Duration(seconds: 5), () async { - Log.info('Force Ipa check was not stopped. Requesting forced check...'); - await apiService.forceIpaCheck(); - }); - } + try { + final user = await getUser(); + if (user != null && isPayingUser(planFromString(user.subscriptionPlan))) { + Log.info('Started IPA timer for verification.'); + globalForceIpaCheck = Timer(const Duration(seconds: 5), () async { + Log.info( + 'Force Ipa check was not stopped. Requesting forced check...', + ); + await apiService.forceIpaCheck(); + }); + } - await iapConnection.restorePurchases(); + await iapConnection.restorePurchases(); + } catch (e) { + Log.error(e); + } } Future buy(PurchasableProduct product) async { diff --git a/lib/src/providers/settings.provider.dart b/lib/src/providers/settings.provider.dart index fb859bb6..e25645a0 100644 --- a/lib/src/providers/settings.provider.dart +++ b/lib/src/providers/settings.provider.dart @@ -1,6 +1,7 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:twonly/src/services/user.service.dart'; +import 'package:twonly/src/utils/log.dart'; class SettingsChangeProvider with ChangeNotifier, DiagnosticableTreeMixin { late ThemeMode _themeMode; @@ -8,8 +9,13 @@ class SettingsChangeProvider with ChangeNotifier, DiagnosticableTreeMixin { ThemeMode get themeMode => _themeMode; Future loadSettings() async { - _themeMode = (await getUser())?.themeMode ?? ThemeMode.system; - notifyListeners(); + try { + _themeMode = (await getUser())?.themeMode ?? ThemeMode.system; + notifyListeners(); + } catch (e) { + _themeMode = ThemeMode.system; + Log.error(e); + } } Future updateThemeMode(ThemeMode? newThemeMode) async { diff --git a/lib/src/services/api.service.dart b/lib/src/services/api.service.dart index eb84969a..16d32b50 100644 --- a/lib/src/services/api.service.dart +++ b/lib/src/services/api.service.dart @@ -416,23 +416,22 @@ class ApiService { return res; } - Future tryAuthenticateWithToken(int userId) async { + Future tryAuthenticateWithToken() async { final apiAuthToken = await SecureStorage.instance.read( key: SecureStorageKeys.apiAuthToken, ); - final user = await getUser(); - if (apiAuthToken != null && user != null) { - if (user.appVersion < 62) { + if (apiAuthToken != null) { + if (userService.currentUser.appVersion < 62) { Log.error( 'DID NOT authenticate the user, as he still has the old version!', ); return false; } final authenticate = Handshake_Authenticate() - ..userId = Int64(userId) + ..userId = Int64(userService.currentUser.userId) ..appVersion = (await PackageInfo.fromPlatform()).version - ..deviceId = Int64(user.deviceId) + ..deviceId = Int64(userService.currentUser.deviceId) ..inBackground = AppState.isInBackgroundTask ..authToken = base64Decode(apiAuthToken); @@ -474,7 +473,7 @@ class ApiService { final userData = await getUser(); if (userData == null) return; - if (await tryAuthenticateWithToken(userData.userId)) { + if (await tryAuthenticateWithToken()) { return; } @@ -522,7 +521,7 @@ class ApiService { value: apiAuthTokenB64, ); - await tryAuthenticateWithToken(userData.userId); + await tryAuthenticateWithToken(); }); } @@ -797,11 +796,10 @@ class ApiService { }); return ballance; } - final user = await getUser(); - if (user != null && user.lastPlanBallance != null && useCache) { + if (userService.currentUser.lastPlanBallance != null && useCache) { try { return Response_PlanBallance.fromJson( - user.lastPlanBallance!, + userService.currentUser.lastPlanBallance!, ); } catch (e) { Log.error('from json: $e'); diff --git a/lib/src/services/backup/restore.backup.dart b/lib/src/services/backup/restore.backup.dart index f2721e0e..716fff0a 100644 --- a/lib/src/services/backup/restore.backup.dart +++ b/lib/src/services/backup/restore.backup.dart @@ -90,7 +90,11 @@ Future handleBackupData( final originalDatabase = File( join(AppEnvironment.supportDir, 'twonly.sqlite'), ); - await originalDatabase.writeAsBytes(backupContent.twonlyDatabase); + + // in case there was only a secure storage error, do not replace the original database + if (!originalDatabase.existsSync()) { + await originalDatabase.writeAsBytes(backupContent.twonlyDatabase); + } const storage = SecureStorage.instance; diff --git a/lib/src/services/user.service.dart b/lib/src/services/user.service.dart index 8ea64c4c..c412f602 100644 --- a/lib/src/services/user.service.dart +++ b/lib/src/services/user.service.dart @@ -79,18 +79,22 @@ Future updateUser( void Function(UserData userData) updateUser, ) async { await updateProtection.protect(() async { - final user = await getUser(); - if (user == null) return; - if (user.defaultShowTime == 999999) { - // This was the old version for infinity -> change it to null - user.defaultShowTime = null; + try { + final user = await getUser(); + if (user == null) return; + if (user.defaultShowTime == 999999) { + // This was the old version for infinity -> change it to null + user.defaultShowTime = null; + } + updateUser(user); + await const FlutterSecureStorage().write( + key: SecureStorageKeys.userData, + value: jsonEncode(user), + ); + userService.currentUser = user; + } catch (e) { + Log.error('Could not update the user: $e'); } - updateUser(user); - await const FlutterSecureStorage().write( - key: SecureStorageKeys.userData, - value: jsonEncode(user), - ); - userService.currentUser = user; }); userService.triggerUserUpdate(); diff --git a/lib/src/utils/misc.dart b/lib/src/utils/misc.dart index 370ca3f4..e4157995 100644 --- a/lib/src/utils/misc.dart +++ b/lib/src/utils/misc.dart @@ -10,7 +10,6 @@ import 'package:gal/gal.dart'; import 'package:intl/intl.dart'; import 'package:local_auth/local_auth.dart'; import 'package:provider/provider.dart'; -import 'package:twonly/src/database/twonly.db.dart'; import 'package:twonly/src/localization/generated/app_localizations.dart'; import 'package:twonly/src/model/protobuf/api/websocket/error.pb.dart'; import 'package:twonly/src/providers/settings.provider.dart'; @@ -19,7 +18,6 @@ import 'package:twonly/src/utils/misc.dart'; extension ShortCutsExtension on BuildContext { AppLocalizations get lang => AppLocalizations.of(this)!; - TwonlyDB get db => Provider.of(this); ColorScheme get color => Theme.of(this).colorScheme; Future navPush(Widget route) async { return Navigator.push( diff --git a/lib/src/visual/views/chats/chat_list_components/feedback_btn.comp.dart b/lib/src/visual/views/chats/chat_list_components/feedback_btn.comp.dart index 6c3734f5..63bfd309 100644 --- a/lib/src/visual/views/chats/chat_list_components/feedback_btn.comp.dart +++ b/lib/src/visual/views/chats/chat_list_components/feedback_btn.comp.dart @@ -3,8 +3,8 @@ import 'dart:async'; import 'package:flutter/material.dart'; import 'package:font_awesome_flutter/font_awesome_flutter.dart'; import 'package:go_router/go_router.dart'; +import 'package:twonly/locator.dart'; import 'package:twonly/src/constants/routes.keys.dart'; -import 'package:twonly/src/services/user.service.dart'; import 'package:twonly/src/utils/misc.dart'; class FeedbackIconButtonComp extends StatefulWidget { @@ -24,10 +24,9 @@ class _FeedbackIconButtonCompState extends State { } Future initAsync() async { - final user = await getUser(); - if (user == null || !mounted) return; + if (!mounted) return; setState(() { - showFeedbackShortcut = user.showFeedbackShortcut; + showFeedbackShortcut = userService.currentUser.showFeedbackShortcut; }); } diff --git a/lib/src/visual/views/critical_error.view.dart b/lib/src/visual/views/critical_error.view.dart new file mode 100644 index 00000000..c39a7108 --- /dev/null +++ b/lib/src/visual/views/critical_error.view.dart @@ -0,0 +1,68 @@ +import 'package:flutter/material.dart'; +import 'package:font_awesome_flutter/font_awesome_flutter.dart'; +import 'package:restart_app/restart_app.dart'; +import 'package:twonly/src/utils/misc.dart'; +import 'package:twonly/src/visual/views/onboarding/recover.view.dart'; +import 'package:twonly/src/visual/views/settings/help/contact_us.view.dart'; + +class CriticalErrorView extends StatelessWidget { + const CriticalErrorView({super.key}); + + @override + Widget build(BuildContext context) { + return Scaffold( + body: Center( + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const FaIcon( + FontAwesomeIcons.triangleExclamation, + size: 80, + color: Colors.redAccent, + ), + const SizedBox(height: 24), + Text( + 'Critical Error', + style: Theme.of(context).textTheme.headlineSmall?.copyWith( + fontWeight: FontWeight.bold, + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: 16), + const Text( + 'Please try restarting twonly. If the error persists, please contact our support and upload your debug log so we can troubleshoot the issue.\n\nYou can restore your account using the button below. If you have forgotten your password, you will need to reinstall twonly and then register with a new account.', + textAlign: TextAlign.center, + ), + const SizedBox(height: 32), + FilledButton.icon( + onPressed: () async { + await Restart.restartApp( + notificationTitle: 'App restarted', + notificationBody: 'Click here to open the app again', + forceKill: true, + ); + }, + icon: const Icon(Icons.refresh), + label: const Text('Try Again'), + ), + const SizedBox(height: 12), + OutlinedButton.icon( + onPressed: () async { + await context.navPush(const BackupRecoveryView()); + }, + icon: const Icon(Icons.backup_rounded), + label: const Text('Recovery from backup'), + ), + TextButton( + onPressed: () => context.navPush(const ContactUsView()), + child: const Text('Contact Support'), + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/src/visual/views/home.view.dart b/lib/src/visual/views/home.view.dart index 0db147ca..73a6fbf1 100644 --- a/lib/src/visual/views/home.view.dart +++ b/lib/src/visual/views/home.view.dart @@ -70,13 +70,6 @@ class HomeViewState extends State { unawaited(_mainCameraController.selectCamera(0, true)); unawaited(_initAsync()); - handleIntentUrl( - context, - Uri.parse( - 'https://me.twonly.eu/qr/#EAAauAEIgLDN0Nm7oKh0EghoYWhoaGhoaBohBRZQ8w_zpm1v7SRTdc8GEOMAxuf1caGDlBa-v0ZiTw9qIiEF05juEs1c3yw0STiSwQR7lowDX5hBaxN4YFR0HhkopGIoudTO5wIyQFQRtU1aO7P7O5s2ekB1ppAost3iQQizwhFObjOLgHQnpwcnwEONXZzSADYqCeEoNcvyE45w0v21z1Imhozk3Q44oI0GQhA9U_chIJwwZ7J9fpeXODZF', - ), - ); - // Subscribe to all events (initial link and further) _deepLinkSub = AppLinks().uriLinkStream.listen((uri) async { if (!mounted) return; diff --git a/lib/src/visual/views/onboarding/recover.view.dart b/lib/src/visual/views/onboarding/recover.view.dart index b45aecf7..6599fe45 100644 --- a/lib/src/visual/views/onboarding/recover.view.dart +++ b/lib/src/visual/views/onboarding/recover.view.dart @@ -1,14 +1,13 @@ 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/routes.keys.dart'; import 'package:twonly/src/model/json/userdata.model.dart'; import 'package:twonly/src/services/backup/restore.backup.dart'; import 'package:twonly/src/utils/log.dart'; import 'package:twonly/src/utils/misc.dart'; import 'package:twonly/src/visual/components/alert.dialog.dart'; import 'package:twonly/src/visual/decorations/input_text.decoration.dart'; +import 'package:twonly/src/visual/views/settings/backup/backup_server.view.dart'; class BackupRecoveryView extends StatefulWidget { const BackupRecoveryView({super.key}); @@ -140,9 +139,11 @@ class _BackupRecoveryViewState extends State { Center( child: OutlinedButton( onPressed: () async { - backupServer = await context.push( - Routes.settingsBackupServer, - ); + backupServer = + await context.navPush( + const BackupServerView(), + ) + as BackupServer?; setState(() {}); }, child: Text(context.lang.backupExpertSettings), diff --git a/lib/src/visual/views/settings/help/contact_us.view.dart b/lib/src/visual/views/settings/help/contact_us.view.dart index 2d91c099..7bcbde94 100644 --- a/lib/src/visual/views/settings/help/contact_us.view.dart +++ b/lib/src/visual/views/settings/help/contact_us.view.dart @@ -264,15 +264,8 @@ $debugLogToken final fullMessage = await _getFeedbackText(); if (!context.mounted || fullMessage == null) return; - final feedbackSend = await Navigator.push( - context, - MaterialPageRoute( - builder: (context) { - return SubmitMessage( - fullMessage: fullMessage, - ); - }, - ), + final feedbackSend = await context.navPush( + SubmitMessage(fullMessage: fullMessage), ); if (feedbackSend == true && context.mounted) { diff --git a/lib/src/visual/views/settings/notification.view.dart b/lib/src/visual/views/settings/notification.view.dart index 26a3b591..4c33d9ee 100644 --- a/lib/src/visual/views/settings/notification.view.dart +++ b/lib/src/visual/views/settings/notification.view.dart @@ -9,7 +9,6 @@ import 'package:twonly/src/constants/secure_storage.keys.dart'; import 'package:twonly/src/model/protobuf/client/generated/push_notification.pb.dart'; import 'package:twonly/src/services/notifications/fcm.notifications.dart'; import 'package:twonly/src/services/notifications/pushkeys.notifications.dart'; -import 'package:twonly/src/services/user.service.dart'; import 'package:twonly/src/utils/misc.dart'; import 'package:twonly/src/utils/secure_storage.dart'; import 'package:twonly/src/visual/components/alert.dialog.dart'; @@ -56,21 +55,18 @@ class _NotificationViewState extends State { ); if (run) { - final user = await getUser(); - if (user != null) { - final pushData = await encryptPushNotification( - user.userId, - PushNotification( - messageId: uuid.v4(), - kind: PushKind.TEST_NOTIFICATION, - ), - ); - await apiService.sendTextMessage( - user.userId, - Uint8List(0), - pushData, - ); - } + final pushData = await encryptPushNotification( + userService.currentUser.userId, + PushNotification( + messageId: uuid.v4(), + kind: PushKind.TEST_NOTIFICATION, + ), + ); + await apiService.sendTextMessage( + userService.currentUser.userId, + Uint8List(0), + pushData, + ); _troubleshootingDidRun = true; } }