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/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<App> with WidgetsBindingObserver {
bool wasPaused = false;
Object? _storageError;
@override
void initState() {
@ -42,11 +44,20 @@ class _AppState extends State<App> with WidgetsBindingObserver {
}
Future<void> initAsync() async {
final user = await getUser();
if (user != null && mounted) {
context.read<PurchasesProvider>().updatePlan(
planFromString(user.subscriptionPlan),
);
try {
final user = await getUser();
if (user != null && mounted) {
context.read<PurchasesProvider>().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<App> with WidgetsBindingObserver {
return ListenableBuilder(
listenable: context.watch<SettingsChangeProvider>(),
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<SettingsChangeProvider>().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<AppMainWidget> {
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<AppMainWidget> {
}
Future<void> 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<AppMainWidget> {
return Center(child: Container());
}
if (_storageError != null) {
return const CriticalErrorView();
}
late Widget child;
if (_isUserCreated) {

View file

@ -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<void> buy(PurchasableProduct product) async {

View file

@ -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<void> 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<void> updateThemeMode(ThemeMode? newThemeMode) async {

View file

@ -416,23 +416,22 @@ class ApiService {
return res;
}
Future<bool> tryAuthenticateWithToken(int userId) async {
Future<bool> 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');

View file

@ -90,7 +90,11 @@ Future<void> 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;

View file

@ -79,18 +79,22 @@ Future<void> 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();

View file

@ -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<TwonlyDB>(this);
ColorScheme get color => Theme.of(this).colorScheme;
Future<dynamic> navPush(Widget route) async {
return Navigator.push(

View file

@ -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<FeedbackIconButtonComp> {
}
Future<void> initAsync() async {
final user = await getUser();
if (user == null || !mounted) return;
if (!mounted) return;
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(_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;

View file

@ -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<BackupRecoveryView> {
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),

View file

@ -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) {

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/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<NotificationView> {
);
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;
}
}