display critical error instead of removing app data

This commit is contained in:
otsmr 2026-04-25 01:55:46 +02:00
parent db9d9022fd
commit 583368505d
13 changed files with 221 additions and 112 deletions

View file

@ -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/components/app_outdated.comp.dart';
import 'package:twonly/src/visual/themes/dark.dart'; 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/home.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/onboarding.view.dart';
import 'package:twonly/src/visual/views/onboarding/register.view.dart'; import 'package:twonly/src/visual/views/onboarding/register.view.dart';
@ -31,6 +32,7 @@ class App extends StatefulWidget {
class _AppState extends State<App> with WidgetsBindingObserver { class _AppState extends State<App> with WidgetsBindingObserver {
bool wasPaused = false; bool wasPaused = false;
Object? _storageError;
@override @override
void initState() { void initState() {
@ -42,12 +44,21 @@ class _AppState extends State<App> with WidgetsBindingObserver {
} }
Future<void> initAsync() async { Future<void> initAsync() async {
try {
final user = await getUser(); final user = await getUser();
if (user != null && mounted) { if (user != null && mounted) {
context.read<PurchasesProvider>().updatePlan( context.read<PurchasesProvider>().updatePlan(
planFromString(user.subscriptionPlan), planFromString(user.subscriptionPlan),
); );
} }
} catch (e) {
Log.error('Storage error in App.initAsync: $e');
if (mounted) {
setState(() {
_storageError = e;
});
}
}
await apiService.connect(); await apiService.connect();
await apiService.listenToNetworkChanges(); await apiService.listenToNetworkChanges();
} }
@ -78,20 +89,38 @@ class _AppState extends State<App> with WidgetsBindingObserver {
return ListenableBuilder( return ListenableBuilder(
listenable: context.watch<SettingsChangeProvider>(), listenable: context.watch<SettingsChangeProvider>(),
builder: (context, child) { builder: (context, child) {
return MaterialApp.router( const localizationsDelegates = [
routerConfig: routerProvider,
scaffoldMessengerKey: AppGlobalKeys.scaffoldMessengerKey,
localizationsDelegates: const [
AppLocalizations.delegate, AppLocalizations.delegate,
GlobalMaterialLocalizations.delegate, GlobalMaterialLocalizations.delegate,
GlobalWidgetsLocalizations.delegate, GlobalWidgetsLocalizations.delegate,
GlobalCupertinoLocalizations.delegate, GlobalCupertinoLocalizations.delegate,
], ];
debugShowCheckedModeBanner: false,
supportedLocales: const [ const supportedLocales = [
Locale('en', ''), Locale('en', ''),
Locale('de', ''), 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<SettingsChangeProvider>().themeMode,
home: const CriticalErrorView(),
);
}
return MaterialApp.router(
routerConfig: routerProvider,
scaffoldMessengerKey: AppGlobalKeys.scaffoldMessengerKey,
localizationsDelegates: localizationsDelegates,
debugShowCheckedModeBanner: false,
supportedLocales: supportedLocales,
title: 'twonly', title: 'twonly',
theme: lightTheme, theme: lightTheme,
darkTheme: darkTheme, darkTheme: darkTheme,
@ -116,6 +145,7 @@ class _AppMainWidgetState extends State<AppMainWidget> {
bool _isUserCreated = false; bool _isUserCreated = false;
bool _showOnboarding = true; bool _showOnboarding = true;
bool _isLoaded = false; bool _isLoaded = false;
Object? _storageError;
bool _skipBackup = kDebugMode; bool _skipBackup = kDebugMode;
bool _isTwonlyLocked = true; bool _isTwonlyLocked = true;
@ -128,6 +158,7 @@ class _AppMainWidgetState extends State<AppMainWidget> {
} }
Future<void> initAsync() async { Future<void> initAsync() async {
try {
_isUserCreated = await isUserCreated(); _isUserCreated = await isUserCreated();
if (_isUserCreated) { if (_isUserCreated) {
@ -149,6 +180,10 @@ class _AppMainWidgetState extends State<AppMainWidget> {
_proofOfWork = (null, disabled); _proofOfWork = (null, disabled);
} }
} }
} catch (e) {
Log.error('Storage error in AppMainWidget.initAsync: $e');
_storageError = e;
}
setState(() { setState(() {
_isLoaded = true; _isLoaded = true;
@ -161,6 +196,10 @@ class _AppMainWidgetState extends State<AppMainWidget> {
return Center(child: Container()); return Center(child: Container());
} }
if (_storageError != null) {
return const CriticalErrorView();
}
late Widget child; late Widget child;
if (_isUserCreated) { if (_isUserCreated) {

View file

@ -41,10 +41,14 @@ class PurchasesProvider with ChangeNotifier, DiagnosticableTreeMixin {
_planSub = apiService.onPlanUpdated.listen(updatePlan); _planSub = apiService.onPlanUpdated.listen(updatePlan);
_connSub = apiService.onConnectionStateUpdated.listen((_) async { _connSub = apiService.onConnectionStateUpdated.listen((_) async {
try {
final user = await getUser(); final user = await getUser();
if (user != null) { if (user != null) {
updatePlan(planFromString(user.subscriptionPlan)); updatePlan(planFromString(user.subscriptionPlan));
} }
} catch (e) {
Log.error(e);
}
}); });
loadPurchases(); loadPurchases();
@ -90,16 +94,22 @@ class PurchasesProvider with ChangeNotifier, DiagnosticableTreeMixin {
storeState = StoreState.available; storeState = StoreState.available;
notifyListeners(); notifyListeners();
try {
final user = await getUser(); final user = await getUser();
if (user != null && isPayingUser(planFromString(user.subscriptionPlan))) { if (user != null && isPayingUser(planFromString(user.subscriptionPlan))) {
Log.info('Started IPA timer for verification.'); Log.info('Started IPA timer for verification.');
globalForceIpaCheck = Timer(const Duration(seconds: 5), () async { globalForceIpaCheck = Timer(const Duration(seconds: 5), () async {
Log.info('Force Ipa check was not stopped. Requesting forced check...'); Log.info(
'Force Ipa check was not stopped. Requesting forced check...',
);
await apiService.forceIpaCheck(); await apiService.forceIpaCheck();
}); });
} }
await iapConnection.restorePurchases(); await iapConnection.restorePurchases();
} catch (e) {
Log.error(e);
}
} }
Future<void> buy(PurchasableProduct product) async { Future<void> buy(PurchasableProduct product) async {

View file

@ -1,6 +1,7 @@
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:twonly/src/services/user.service.dart'; import 'package:twonly/src/services/user.service.dart';
import 'package:twonly/src/utils/log.dart';
class SettingsChangeProvider with ChangeNotifier, DiagnosticableTreeMixin { class SettingsChangeProvider with ChangeNotifier, DiagnosticableTreeMixin {
late ThemeMode _themeMode; late ThemeMode _themeMode;
@ -8,8 +9,13 @@ class SettingsChangeProvider with ChangeNotifier, DiagnosticableTreeMixin {
ThemeMode get themeMode => _themeMode; ThemeMode get themeMode => _themeMode;
Future<void> loadSettings() async { Future<void> loadSettings() async {
try {
_themeMode = (await getUser())?.themeMode ?? ThemeMode.system; _themeMode = (await getUser())?.themeMode ?? ThemeMode.system;
notifyListeners(); notifyListeners();
} catch (e) {
_themeMode = ThemeMode.system;
Log.error(e);
}
} }
Future<void> updateThemeMode(ThemeMode? newThemeMode) async { Future<void> updateThemeMode(ThemeMode? newThemeMode) async {

View file

@ -416,23 +416,22 @@ class ApiService {
return res; return res;
} }
Future<bool> tryAuthenticateWithToken(int userId) async { Future<bool> tryAuthenticateWithToken() async {
final apiAuthToken = await SecureStorage.instance.read( final apiAuthToken = await SecureStorage.instance.read(
key: SecureStorageKeys.apiAuthToken, key: SecureStorageKeys.apiAuthToken,
); );
final user = await getUser();
if (apiAuthToken != null && user != null) { if (apiAuthToken != null) {
if (user.appVersion < 62) { if (userService.currentUser.appVersion < 62) {
Log.error( Log.error(
'DID NOT authenticate the user, as he still has the old version!', 'DID NOT authenticate the user, as he still has the old version!',
); );
return false; return false;
} }
final authenticate = Handshake_Authenticate() final authenticate = Handshake_Authenticate()
..userId = Int64(userId) ..userId = Int64(userService.currentUser.userId)
..appVersion = (await PackageInfo.fromPlatform()).version ..appVersion = (await PackageInfo.fromPlatform()).version
..deviceId = Int64(user.deviceId) ..deviceId = Int64(userService.currentUser.deviceId)
..inBackground = AppState.isInBackgroundTask ..inBackground = AppState.isInBackgroundTask
..authToken = base64Decode(apiAuthToken); ..authToken = base64Decode(apiAuthToken);
@ -474,7 +473,7 @@ class ApiService {
final userData = await getUser(); final userData = await getUser();
if (userData == null) return; if (userData == null) return;
if (await tryAuthenticateWithToken(userData.userId)) { if (await tryAuthenticateWithToken()) {
return; return;
} }
@ -522,7 +521,7 @@ class ApiService {
value: apiAuthTokenB64, value: apiAuthTokenB64,
); );
await tryAuthenticateWithToken(userData.userId); await tryAuthenticateWithToken();
}); });
} }
@ -797,11 +796,10 @@ class ApiService {
}); });
return ballance; return ballance;
} }
final user = await getUser(); if (userService.currentUser.lastPlanBallance != null && useCache) {
if (user != null && user.lastPlanBallance != null && useCache) {
try { try {
return Response_PlanBallance.fromJson( return Response_PlanBallance.fromJson(
user.lastPlanBallance!, userService.currentUser.lastPlanBallance!,
); );
} catch (e) { } catch (e) {
Log.error('from json: $e'); Log.error('from json: $e');

View file

@ -90,7 +90,11 @@ Future<void> handleBackupData(
final originalDatabase = File( final originalDatabase = File(
join(AppEnvironment.supportDir, 'twonly.sqlite'), join(AppEnvironment.supportDir, 'twonly.sqlite'),
); );
// in case there was only a secure storage error, do not replace the original database
if (!originalDatabase.existsSync()) {
await originalDatabase.writeAsBytes(backupContent.twonlyDatabase); await originalDatabase.writeAsBytes(backupContent.twonlyDatabase);
}
const storage = SecureStorage.instance; const storage = SecureStorage.instance;

View file

@ -79,6 +79,7 @@ Future<void> updateUser(
void Function(UserData userData) updateUser, void Function(UserData userData) updateUser,
) async { ) async {
await updateProtection.protect(() async { await updateProtection.protect(() async {
try {
final user = await getUser(); final user = await getUser();
if (user == null) return; if (user == null) return;
if (user.defaultShowTime == 999999) { if (user.defaultShowTime == 999999) {
@ -91,6 +92,9 @@ Future<void> updateUser(
value: jsonEncode(user), value: jsonEncode(user),
); );
userService.currentUser = user; userService.currentUser = user;
} catch (e) {
Log.error('Could not update the user: $e');
}
}); });
userService.triggerUserUpdate(); userService.triggerUserUpdate();

View file

@ -10,7 +10,6 @@ import 'package:gal/gal.dart';
import 'package:intl/intl.dart'; import 'package:intl/intl.dart';
import 'package:local_auth/local_auth.dart'; import 'package:local_auth/local_auth.dart';
import 'package:provider/provider.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/localization/generated/app_localizations.dart';
import 'package:twonly/src/model/protobuf/api/websocket/error.pb.dart'; import 'package:twonly/src/model/protobuf/api/websocket/error.pb.dart';
import 'package:twonly/src/providers/settings.provider.dart'; import 'package:twonly/src/providers/settings.provider.dart';
@ -19,7 +18,6 @@ import 'package:twonly/src/utils/misc.dart';
extension ShortCutsExtension on BuildContext { extension ShortCutsExtension on BuildContext {
AppLocalizations get lang => AppLocalizations.of(this)!; AppLocalizations get lang => AppLocalizations.of(this)!;
TwonlyDB get db => Provider.of<TwonlyDB>(this);
ColorScheme get color => Theme.of(this).colorScheme; ColorScheme get color => Theme.of(this).colorScheme;
Future<dynamic> navPush(Widget route) async { Future<dynamic> navPush(Widget route) async {
return Navigator.push( return Navigator.push(

View file

@ -3,8 +3,8 @@ import 'dart:async';
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:twonly/locator.dart';
import 'package:twonly/src/constants/routes.keys.dart'; import 'package:twonly/src/constants/routes.keys.dart';
import 'package:twonly/src/services/user.service.dart';
import 'package:twonly/src/utils/misc.dart'; import 'package:twonly/src/utils/misc.dart';
class FeedbackIconButtonComp extends StatefulWidget { class FeedbackIconButtonComp extends StatefulWidget {
@ -24,10 +24,9 @@ class _FeedbackIconButtonCompState extends State<FeedbackIconButtonComp> {
} }
Future<void> initAsync() async { Future<void> initAsync() async {
final user = await getUser(); if (!mounted) return;
if (user == null || !mounted) return;
setState(() { setState(() {
showFeedbackShortcut = user.showFeedbackShortcut; showFeedbackShortcut = userService.currentUser.showFeedbackShortcut;
}); });
} }

View file

@ -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'),
),
],
),
),
),
);
}
}

View file

@ -70,13 +70,6 @@ class HomeViewState extends State<HomeView> {
unawaited(_mainCameraController.selectCamera(0, true)); unawaited(_mainCameraController.selectCamera(0, true));
unawaited(_initAsync()); unawaited(_initAsync());
handleIntentUrl(
context,
Uri.parse(
'https://me.twonly.eu/qr/#EAAauAEIgLDN0Nm7oKh0EghoYWhoaGhoaBohBRZQ8w_zpm1v7SRTdc8GEOMAxuf1caGDlBa-v0ZiTw9qIiEF05juEs1c3yw0STiSwQR7lowDX5hBaxN4YFR0HhkopGIoudTO5wIyQFQRtU1aO7P7O5s2ekB1ppAost3iQQizwhFObjOLgHQnpwcnwEONXZzSADYqCeEoNcvyE45w0v21z1Imhozk3Q44oI0GQhA9U_chIJwwZ7J9fpeXODZF',
),
);
// Subscribe to all events (initial link and further) // Subscribe to all events (initial link and further)
_deepLinkSub = AppLinks().uriLinkStream.listen((uri) async { _deepLinkSub = AppLinks().uriLinkStream.listen((uri) async {
if (!mounted) return; if (!mounted) return;

View file

@ -1,14 +1,13 @@
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:restart_app/restart_app.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/model/json/userdata.model.dart';
import 'package:twonly/src/services/backup/restore.backup.dart'; import 'package:twonly/src/services/backup/restore.backup.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/utils/misc.dart';
import 'package:twonly/src/visual/components/alert.dialog.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/decorations/input_text.decoration.dart';
import 'package:twonly/src/visual/views/settings/backup/backup_server.view.dart';
class BackupRecoveryView extends StatefulWidget { class BackupRecoveryView extends StatefulWidget {
const BackupRecoveryView({super.key}); const BackupRecoveryView({super.key});
@ -140,9 +139,11 @@ class _BackupRecoveryViewState extends State<BackupRecoveryView> {
Center( Center(
child: OutlinedButton( child: OutlinedButton(
onPressed: () async { onPressed: () async {
backupServer = await context.push( backupServer =
Routes.settingsBackupServer, await context.navPush(
); const BackupServerView(),
)
as BackupServer?;
setState(() {}); setState(() {});
}, },
child: Text(context.lang.backupExpertSettings), child: Text(context.lang.backupExpertSettings),

View file

@ -264,15 +264,8 @@ $debugLogToken
final fullMessage = await _getFeedbackText(); final fullMessage = await _getFeedbackText();
if (!context.mounted || fullMessage == null) return; if (!context.mounted || fullMessage == null) return;
final feedbackSend = await Navigator.push( final feedbackSend = await context.navPush(
context, SubmitMessage(fullMessage: fullMessage),
MaterialPageRoute(
builder: (context) {
return SubmitMessage(
fullMessage: fullMessage,
);
},
),
); );
if (feedbackSend == true && context.mounted) { if (feedbackSend == true && context.mounted) {

View file

@ -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/model/protobuf/client/generated/push_notification.pb.dart';
import 'package:twonly/src/services/notifications/fcm.notifications.dart'; import 'package:twonly/src/services/notifications/fcm.notifications.dart';
import 'package:twonly/src/services/notifications/pushkeys.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/misc.dart';
import 'package:twonly/src/utils/secure_storage.dart'; import 'package:twonly/src/utils/secure_storage.dart';
import 'package:twonly/src/visual/components/alert.dialog.dart'; import 'package:twonly/src/visual/components/alert.dialog.dart';
@ -56,21 +55,18 @@ class _NotificationViewState extends State<NotificationView> {
); );
if (run) { if (run) {
final user = await getUser();
if (user != null) {
final pushData = await encryptPushNotification( final pushData = await encryptPushNotification(
user.userId, userService.currentUser.userId,
PushNotification( PushNotification(
messageId: uuid.v4(), messageId: uuid.v4(),
kind: PushKind.TEST_NOTIFICATION, kind: PushKind.TEST_NOTIFICATION,
), ),
); );
await apiService.sendTextMessage( await apiService.sendTextMessage(
user.userId, userService.currentUser.userId,
Uint8List(0), Uint8List(0),
pushData, pushData,
); );
}
_troubleshootingDidRun = true; _troubleshootingDidRun = true;
} }
} }