fixed possible lockups on app start
This commit is contained in:
+198
-170
@@ -43,14 +43,15 @@ import 'state/app/modules/chat_list/bloc/chat_list_bloc.dart';
|
|||||||
import 'state/app/modules/nextcloud_capabilities/bloc/nextcloud_capabilities_cubit.dart';
|
import 'state/app/modules/nextcloud_capabilities/bloc/nextcloud_capabilities_cubit.dart';
|
||||||
import 'state/app/modules/settings/bloc/settings_cubit.dart';
|
import 'state/app/modules/settings/bloc/settings_cubit.dart';
|
||||||
import 'state/app/modules/timetable/bloc/timetable_bloc.dart';
|
import 'state/app/modules/timetable/bloc/timetable_bloc.dart';
|
||||||
|
import 'storage/hydrated_storage_bootstrap.dart';
|
||||||
import 'storage/settings.dart';
|
import 'storage/settings.dart';
|
||||||
import 'theming/dark_app_theme.dart';
|
import 'theming/dark_app_theme.dart';
|
||||||
import 'theming/light_app_theme.dart';
|
import 'theming/light_app_theme.dart';
|
||||||
import 'utils/app_paths.dart';
|
import 'utils/app_paths.dart';
|
||||||
import 'utils/downloads/download_manager.dart';
|
import 'utils/downloads/download_manager.dart';
|
||||||
|
import 'view/login/account_loading_screen.dart';
|
||||||
import 'view/login/login.dart';
|
import 'view/login/login.dart';
|
||||||
import 'view/login/post_login_splash.dart';
|
import 'view/login/post_login_splash.dart';
|
||||||
import 'widget/app_progress_indicator.dart';
|
|
||||||
import 'widget/avatar_disk_cache.dart';
|
import 'widget/avatar_disk_cache.dart';
|
||||||
import 'widget/breaker/breaker.dart';
|
import 'widget/breaker/breaker.dart';
|
||||||
import 'widget/debug/cache_view.dart';
|
import 'widget/debug/cache_view.dart';
|
||||||
@@ -58,40 +59,107 @@ import 'widget/downloads/download_tray.dart';
|
|||||||
import 'widget/emergency/emergency_notice_gate.dart';
|
import 'widget/emergency/emergency_notice_gate.dart';
|
||||||
import 'widget_data/widget_sync.dart';
|
import 'widget_data/widget_sync.dart';
|
||||||
|
|
||||||
|
/// Runs one startup step with a time limit. Anything that throws or hangs
|
||||||
|
/// before `runApp` would otherwise leave the native launch screen up for
|
||||||
|
/// good, so failures are logged and reported and the app starts degraded.
|
||||||
|
Future<void> _startupStep(
|
||||||
|
String name,
|
||||||
|
Future<void> Function() step, {
|
||||||
|
Duration? timeout = const Duration(seconds: 10),
|
||||||
|
bool report = true,
|
||||||
|
}) async {
|
||||||
|
try {
|
||||||
|
final future = step();
|
||||||
|
await (timeout == null ? future : future.timeout(timeout));
|
||||||
|
} catch (e, s) {
|
||||||
|
log('Startup step "$name" failed: $e', stackTrace: s);
|
||||||
|
if (report) {
|
||||||
|
ClientErrorReporter.reportPlatformError('Startup step "$name": $e', s);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _installErrorHandlers() {
|
||||||
|
if (kReleaseMode) {
|
||||||
|
ErrorWidget.builder = (error) => Material(
|
||||||
|
color: Colors.white,
|
||||||
|
child: Center(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(24),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
const Icon(Icons.phonelink_erase_rounded, size: 40),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Text(error.toStringShort(), textAlign: TextAlign.center),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
FlutterError.onError = (details) {
|
||||||
|
log(
|
||||||
|
'Uncaught Flutter error: ${details.exception}',
|
||||||
|
stackTrace: details.stack,
|
||||||
|
);
|
||||||
|
ClientErrorReporter.reportFlutterError(details);
|
||||||
|
FlutterError.presentError(details);
|
||||||
|
};
|
||||||
|
PlatformDispatcher.instance.onError = (error, stack) {
|
||||||
|
log('Uncaught platform error: $error', stackTrace: stack);
|
||||||
|
ClientErrorReporter.reportPlatformError(error, stack);
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> main() async {
|
Future<void> main() async {
|
||||||
log('MarianumMobile started');
|
log('MarianumMobile started');
|
||||||
WidgetsFlutterBinding.ensureInitialized();
|
WidgetsFlutterBinding.ensureInitialized();
|
||||||
|
// Before any initialisation so startup failures reach the backend too.
|
||||||
|
_installErrorHandlers();
|
||||||
|
|
||||||
void addCertificateAsTrusted(ByteData certificate) => SecurityContext
|
Future<void> trustCertificate(String asset) => PlatformAssetBundle()
|
||||||
.defaultContext
|
.load(asset)
|
||||||
.setTrustedCertificatesBytes(certificate.buffer.asUint8List());
|
.then(
|
||||||
|
(certificate) => SecurityContext.defaultContext
|
||||||
|
.setTrustedCertificatesBytes(certificate.buffer.asUint8List()),
|
||||||
|
);
|
||||||
|
|
||||||
final initialisationTasks = [
|
final initialisationTasks = [
|
||||||
Firebase.initializeApp(options: DefaultFirebaseOptions.currentPlatform)
|
_startupStep(
|
||||||
.then<void>((_) {})
|
'firebase',
|
||||||
.onError((error, _) => log('Error initializing Firebase: $error')),
|
() => Firebase.initializeApp(
|
||||||
PlatformAssetBundle()
|
options: DefaultFirebaseOptions.currentPlatform,
|
||||||
.load('assets/ca/lets-encrypt-r3.pem')
|
),
|
||||||
.then(addCertificateAsTrusted),
|
),
|
||||||
PlatformAssetBundle()
|
_startupStep(
|
||||||
.load('assets/ca/lets-encrypt-r10.pem')
|
'ca certificates',
|
||||||
.then(addCertificateAsTrusted),
|
() => Future.wait([
|
||||||
PlatformAssetBundle()
|
trustCertificate('assets/ca/lets-encrypt-r3.pem'),
|
||||||
.load('assets/ca/lets-encrypt-r13.pem')
|
trustCertificate('assets/ca/lets-encrypt-r10.pem'),
|
||||||
.then(addCertificateAsTrusted),
|
trustCertificate('assets/ca/lets-encrypt-r13.pem'),
|
||||||
Future(() async {
|
]),
|
||||||
final storage = await HydratedStorage.build(
|
),
|
||||||
storageDirectory: HydratedStorageDirectory(
|
_startupStep('hydrated storage', () async {
|
||||||
(await getTemporaryDirectory()).path,
|
final path = (await getTemporaryDirectory()).path;
|
||||||
),
|
HydratedBloc.storage = await buildHydratedStorageWithFallback(path);
|
||||||
);
|
}, timeout: null),
|
||||||
HydratedBloc.storage = storage;
|
_startupStep('documents dir', () async {
|
||||||
}),
|
|
||||||
Future(() async {
|
|
||||||
AppPaths.documentsDir = (await getApplicationDocumentsDirectory()).path;
|
AppPaths.documentsDir = (await getApplicationDocumentsDirectory()).path;
|
||||||
}),
|
}),
|
||||||
AccountData().waitForPopulation(),
|
// The keychain may still be locked right after device unlock; AccountData
|
||||||
ShareIntentListener.instance.initialize(),
|
// keeps retrying, so on timeout the app starts on the loading screen and
|
||||||
|
// flips to the real state once the session is readable (see _MainState).
|
||||||
|
_startupStep(
|
||||||
|
'account data',
|
||||||
|
AccountData().waitForPopulation,
|
||||||
|
timeout: const Duration(seconds: 5),
|
||||||
|
// Expected on every background wake of a locked device; not an error.
|
||||||
|
report: false,
|
||||||
|
),
|
||||||
|
_startupStep('share intent', ShareIntentListener.instance.initialize),
|
||||||
];
|
];
|
||||||
|
|
||||||
log('starting app initialisation...');
|
log('starting app initialisation...');
|
||||||
@@ -101,8 +169,11 @@ Future<void> main() async {
|
|||||||
// Local notifications: init the plugin (with tap/action callbacks) and the
|
// Local notifications: init the plugin (with tap/action callbacks) and the
|
||||||
// Android channels, then register the FCM background isolate handler that
|
// Android channels, then register the FCM background isolate handler that
|
||||||
// decrypts and renders Nextcloud pushes while the app is not in foreground.
|
// decrypts and renders Nextcloud pushes while the app is not in foreground.
|
||||||
await NotificationService().initializeNotifications();
|
await _startupStep(
|
||||||
await PushRenderer.ensureChannels();
|
'notifications',
|
||||||
|
NotificationService().initializeNotifications,
|
||||||
|
);
|
||||||
|
await _startupStep('notification channels', PushRenderer.ensureChannels);
|
||||||
FirebaseMessaging.onBackgroundMessage(pushOnBackgroundMessage);
|
FirebaseMessaging.onBackgroundMessage(pushOnBackgroundMessage);
|
||||||
|
|
||||||
// Wire up the native background downloader (progress notifications + tap
|
// Wire up the native background downloader (progress notifications + tap
|
||||||
@@ -116,7 +187,7 @@ Future<void> main() async {
|
|||||||
|
|
||||||
// Wire up the home-screen widget bridge before runApp so any widget render
|
// Wire up the home-screen widget bridge before runApp so any widget render
|
||||||
// triggered during startup hits initialised native storage.
|
// triggered during startup hits initialised native storage.
|
||||||
await WidgetSync.ensureInitialized();
|
await _startupStep('widget sync', WidgetSync.ensureInitialized);
|
||||||
unawaited(
|
unawaited(
|
||||||
WidgetBackgroundTask.initialize().onError(
|
WidgetBackgroundTask.initialize().onError(
|
||||||
(e, _) => log('Workmanager init failed: $e'),
|
(e, _) => log('Workmanager init failed: $e'),
|
||||||
@@ -157,50 +228,13 @@ Future<void> main() async {
|
|||||||
// placeholder flash.
|
// placeholder flash.
|
||||||
AvatarDiskCache.instance.warmUp();
|
AvatarDiskCache.instance.warmUp();
|
||||||
|
|
||||||
if (kReleaseMode) {
|
|
||||||
ErrorWidget.builder = (error) => Material(
|
|
||||||
color: Colors.white,
|
|
||||||
child: Center(
|
|
||||||
child: Padding(
|
|
||||||
padding: const EdgeInsets.all(24),
|
|
||||||
child: Column(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
|
||||||
const Icon(Icons.phonelink_erase_rounded, size: 40),
|
|
||||||
const SizedBox(height: 12),
|
|
||||||
Text(error.toStringShort(), textAlign: TextAlign.center),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
FlutterError.onError = (details) {
|
|
||||||
log(
|
|
||||||
'Uncaught Flutter error: ${details.exception}',
|
|
||||||
stackTrace: details.stack,
|
|
||||||
);
|
|
||||||
ClientErrorReporter.reportFlutterError(details);
|
|
||||||
FlutterError.presentError(details);
|
|
||||||
};
|
|
||||||
PlatformDispatcher.instance.onError = (error, stack) {
|
|
||||||
log('Uncaught platform error: $error', stackTrace: stack);
|
|
||||||
ClientErrorReporter.reportPlatformError(error, stack);
|
|
||||||
return true;
|
|
||||||
};
|
|
||||||
|
|
||||||
log('running app...');
|
log('running app...');
|
||||||
runApp(
|
runApp(
|
||||||
MultiBlocProvider(
|
MultiBlocProvider(
|
||||||
providers: [
|
providers: [
|
||||||
BlocProvider<SettingsCubit>(create: (_) => SettingsCubit()),
|
BlocProvider<SettingsCubit>(create: (_) => SettingsCubit()),
|
||||||
BlocProvider<AccountBloc>(
|
BlocProvider<AccountBloc>(
|
||||||
create: (_) => AccountBloc(
|
create: (_) => AccountBloc(initialStatus: _initialAccountStatus()),
|
||||||
initialStatus: AccountData().isPopulated()
|
|
||||||
? AccountStatus.loggedIn
|
|
||||||
: AccountStatus.loggedOut,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
BlocProvider<BreakerBloc>(create: (_) => BreakerBloc()),
|
BlocProvider<BreakerBloc>(create: (_) => BreakerBloc()),
|
||||||
BlocProvider<CapabilitiesCubit>(create: (_) => CapabilitiesCubit()),
|
BlocProvider<CapabilitiesCubit>(create: (_) => CapabilitiesCubit()),
|
||||||
@@ -218,6 +252,12 @@ Future<void> main() async {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
AccountStatus _initialAccountStatus() {
|
||||||
|
final account = AccountData();
|
||||||
|
if (account.isPopulated()) return AccountStatus.loggedIn;
|
||||||
|
return account.isLoaded ? AccountStatus.loggedOut : AccountStatus.undefined;
|
||||||
|
}
|
||||||
|
|
||||||
class Main extends StatefulWidget {
|
class Main extends StatefulWidget {
|
||||||
const Main({super.key});
|
const Main({super.key});
|
||||||
|
|
||||||
@@ -365,111 +405,99 @@ class _MainState extends State<Main> {
|
|||||||
child: LoaderOverlay(
|
child: LoaderOverlay(
|
||||||
child: Breaker(
|
child: Breaker(
|
||||||
breaker: BreakerArea.global,
|
breaker: BreakerArea.global,
|
||||||
child: BlocConsumer<AccountBloc, AccountState>(
|
child: BlocConsumer<AccountBloc, AccountState>(
|
||||||
listenWhen: (previous, current) =>
|
listenWhen: (previous, current) =>
|
||||||
previous.status != current.status,
|
previous.status != current.status,
|
||||||
listener: (context, accountState) {
|
listener: (context, accountState) {
|
||||||
// Fresh login (loggedOut -> loggedIn): pull capability flags
|
// Fresh login (loggedOut -> loggedIn): pull capability flags
|
||||||
// for the newly authenticated user, then register push right
|
// for the newly authenticated user, then register push right
|
||||||
// away instead of deferring it to the next app start.
|
// away instead of deferring it to the next app start.
|
||||||
if (accountState.status == AccountStatus.loggedIn) {
|
if (accountState.status == AccountStatus.loggedIn) {
|
||||||
final settingsCubit = context.read<SettingsCubit>();
|
final settingsCubit = context.read<SettingsCubit>();
|
||||||
final capabilitiesCubit = context.read<CapabilitiesCubit>();
|
final capabilitiesCubit = context
|
||||||
unawaited(
|
.read<CapabilitiesCubit>();
|
||||||
capabilitiesCubit.load().then((_) {
|
unawaited(
|
||||||
if (!mounted) return;
|
capabilitiesCubit.load().then((_) {
|
||||||
_syncPush(settingsCubit, capabilitiesCubit);
|
if (!mounted) return;
|
||||||
}),
|
_syncPush(settingsCubit, capabilitiesCubit);
|
||||||
);
|
}),
|
||||||
unawaited(
|
|
||||||
context.read<NextcloudCapabilitiesCubit>().load(),
|
|
||||||
);
|
|
||||||
_showPostLoginSplash = true;
|
|
||||||
_appMounted = false;
|
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
||||||
if (mounted) setState(() => _appMounted = true);
|
|
||||||
});
|
|
||||||
_prefetchBaseData(context);
|
|
||||||
}
|
|
||||||
if (accountState.status != AccountStatus.loggedOut) return;
|
|
||||||
// A pending share would otherwise survive logout and be
|
|
||||||
// re-applied after re-login with file paths the OS may
|
|
||||||
// already have evicted from the cache.
|
|
||||||
ShareIntentListener.instance.clear();
|
|
||||||
// Routes pushed via AppRoutes (e.g. Settings) live on the
|
|
||||||
// root navigator and survive the home swap below, so they
|
|
||||||
// would still cover the Login screen after logout. Pop
|
|
||||||
// them here so the user immediately sees Login.
|
|
||||||
final navigator = Navigator.of(context);
|
|
||||||
if (navigator.canPop()) {
|
|
||||||
navigator.popUntil((route) => route.isFirst);
|
|
||||||
}
|
|
||||||
// Capture bloc references before the post-frame callback
|
|
||||||
// — by the time it runs the dialog/Settings context is
|
|
||||||
// gone but this listener context is still valid.
|
|
||||||
final settingsCubit = context.read<SettingsCubit>();
|
|
||||||
final timetableBloc = context.read<TimetableBloc>();
|
|
||||||
final chatListBloc = context.read<ChatListBloc>();
|
|
||||||
final chatBloc = context.read<ChatBloc>();
|
|
||||||
final breakerBloc = context.read<BreakerBloc>();
|
|
||||||
final capabilitiesCubit = context.read<CapabilitiesCubit>();
|
|
||||||
final nextcloudCapabilitiesCubit = context
|
|
||||||
.read<NextcloudCapabilitiesCubit>();
|
|
||||||
// Defer the actual wipe until after this frame so the
|
|
||||||
// App tree (TimetableBloc/ChatListBloc watchers etc.)
|
|
||||||
// is already torn down. Resetting blocs while App is
|
|
||||||
// still in front caused a black-frame race.
|
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
||||||
unawaited(
|
|
||||||
_wipeUserState(
|
|
||||||
settingsCubit: settingsCubit,
|
|
||||||
timetableBloc: timetableBloc,
|
|
||||||
chatListBloc: chatListBloc,
|
|
||||||
chatBloc: chatBloc,
|
|
||||||
breakerBloc: breakerBloc,
|
|
||||||
capabilitiesCubit: capabilitiesCubit,
|
|
||||||
nextcloudCapabilitiesCubit: nextcloudCapabilitiesCubit,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
},
|
|
||||||
builder: (context, accountState) {
|
|
||||||
switch (accountState.status) {
|
|
||||||
case AccountStatus.loggedIn:
|
|
||||||
return Stack(
|
|
||||||
fit: StackFit.expand,
|
|
||||||
children: [
|
|
||||||
if (_appMounted)
|
|
||||||
const App(key: ValueKey('app-shell')),
|
|
||||||
if (_showPostLoginSplash)
|
|
||||||
PostLoginSplash(
|
|
||||||
key: const ValueKey('post-login-splash'),
|
|
||||||
onComplete: () =>
|
|
||||||
setState(() => _showPostLoginSplash = false),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
);
|
||||||
case AccountStatus.loggedOut:
|
unawaited(
|
||||||
return const Login();
|
context.read<NextcloudCapabilitiesCubit>().load(),
|
||||||
case AccountStatus.undefined:
|
);
|
||||||
return Scaffold(
|
_showPostLoginSplash = true;
|
||||||
backgroundColor: LightAppTheme.marianumRed,
|
_appMounted = false;
|
||||||
body: const Center(
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
child: Column(
|
if (mounted) setState(() => _appMounted = true);
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
});
|
||||||
children: [
|
_prefetchBaseData(context);
|
||||||
AppProgressIndicator.large(color: Colors.white),
|
}
|
||||||
SizedBox(height: 16),
|
if (accountState.status != AccountStatus.loggedOut) return;
|
||||||
Text(
|
// A pending share would otherwise survive logout and be
|
||||||
'Konto wird geladen…',
|
// re-applied after re-login with file paths the OS may
|
||||||
style: TextStyle(color: Colors.white),
|
// already have evicted from the cache.
|
||||||
),
|
ShareIntentListener.instance.clear();
|
||||||
],
|
// Routes pushed via AppRoutes (e.g. Settings) live on the
|
||||||
),
|
// root navigator and survive the home swap below, so they
|
||||||
|
// would still cover the Login screen after logout. Pop
|
||||||
|
// them here so the user immediately sees Login.
|
||||||
|
final navigator = Navigator.of(context);
|
||||||
|
if (navigator.canPop()) {
|
||||||
|
navigator.popUntil((route) => route.isFirst);
|
||||||
|
}
|
||||||
|
// Capture bloc references before the post-frame callback
|
||||||
|
// — by the time it runs the dialog/Settings context is
|
||||||
|
// gone but this listener context is still valid.
|
||||||
|
final settingsCubit = context.read<SettingsCubit>();
|
||||||
|
final timetableBloc = context.read<TimetableBloc>();
|
||||||
|
final chatListBloc = context.read<ChatListBloc>();
|
||||||
|
final chatBloc = context.read<ChatBloc>();
|
||||||
|
final breakerBloc = context.read<BreakerBloc>();
|
||||||
|
final capabilitiesCubit = context.read<CapabilitiesCubit>();
|
||||||
|
final nextcloudCapabilitiesCubit = context
|
||||||
|
.read<NextcloudCapabilitiesCubit>();
|
||||||
|
// Defer the actual wipe until after this frame so the
|
||||||
|
// App tree (TimetableBloc/ChatListBloc watchers etc.)
|
||||||
|
// is already torn down. Resetting blocs while App is
|
||||||
|
// still in front caused a black-frame race.
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
unawaited(
|
||||||
|
_wipeUserState(
|
||||||
|
settingsCubit: settingsCubit,
|
||||||
|
timetableBloc: timetableBloc,
|
||||||
|
chatListBloc: chatListBloc,
|
||||||
|
chatBloc: chatBloc,
|
||||||
|
breakerBloc: breakerBloc,
|
||||||
|
capabilitiesCubit: capabilitiesCubit,
|
||||||
|
nextcloudCapabilitiesCubit:
|
||||||
|
nextcloudCapabilitiesCubit,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
});
|
||||||
},
|
},
|
||||||
|
builder: (context, accountState) {
|
||||||
|
switch (accountState.status) {
|
||||||
|
case AccountStatus.loggedIn:
|
||||||
|
return Stack(
|
||||||
|
fit: StackFit.expand,
|
||||||
|
children: [
|
||||||
|
if (_appMounted)
|
||||||
|
const App(key: ValueKey('app-shell')),
|
||||||
|
if (_showPostLoginSplash)
|
||||||
|
PostLoginSplash(
|
||||||
|
key: const ValueKey('post-login-splash'),
|
||||||
|
onComplete: () => setState(
|
||||||
|
() => _showPostLoginSplash = false,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
case AccountStatus.loggedOut:
|
||||||
|
return const Login();
|
||||||
|
case AccountStatus.undefined:
|
||||||
|
return const AccountLoadingScreen();
|
||||||
|
}
|
||||||
|
},
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -1,11 +1,14 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
|
import 'dart:developer';
|
||||||
|
import 'dart:io';
|
||||||
|
|
||||||
import 'package:crypto/crypto.dart';
|
import 'package:crypto/crypto.dart';
|
||||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||||
import 'package:shared_preferences/shared_preferences.dart';
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
|
||||||
import '../push/push_secure_storage.dart';
|
import '../push/push_secure_storage.dart';
|
||||||
|
import '../utils/exponential_backoff.dart';
|
||||||
|
|
||||||
class AccountData {
|
class AccountData {
|
||||||
static const _usernameField = 'username';
|
static const _usernameField = 'username';
|
||||||
@@ -26,7 +29,22 @@ class AccountData {
|
|||||||
// Keeps isPopulated()/getPassword() valid; demo mode never uses a real one.
|
// Keeps isPopulated()/getPassword() valid; demo mode never uses a real one.
|
||||||
static const _demoPasswordPlaceholder = 'demo';
|
static const _demoPasswordPlaceholder = 'demo';
|
||||||
|
|
||||||
static const FlutterSecureStorage _secureStorage = FlutterSecureStorage();
|
// `first_unlock` so a background launch on a locked device (silent push,
|
||||||
|
// BGAppRefresh) can still read the session. Items written by older versions
|
||||||
|
// carry the plugin default `unlocked` and are invisible to this instance
|
||||||
|
// until _migrateKeychainAccessibility moved them over.
|
||||||
|
static const FlutterSecureStorage _secureStorage = FlutterSecureStorage(
|
||||||
|
iOptions: IOSOptions(accessibility: KeychainAccessibility.first_unlock),
|
||||||
|
);
|
||||||
|
static const FlutterSecureStorage _legacySecureStorage = FlutterSecureStorage(
|
||||||
|
iOptions: IOSOptions(accessibility: KeychainAccessibility.unlocked),
|
||||||
|
);
|
||||||
|
static const List<String> _sessionFields = [
|
||||||
|
_usernameField,
|
||||||
|
_passwordField,
|
||||||
|
_demoField,
|
||||||
|
_loginFlowField,
|
||||||
|
];
|
||||||
|
|
||||||
static final AccountData _instance = AccountData._construct();
|
static final AccountData _instance = AccountData._construct();
|
||||||
Completer<void> _populated = Completer();
|
Completer<void> _populated = Completer();
|
||||||
@@ -34,7 +52,7 @@ class AccountData {
|
|||||||
factory AccountData() => _instance;
|
factory AccountData() => _instance;
|
||||||
|
|
||||||
AccountData._construct() {
|
AccountData._construct() {
|
||||||
_migrateAndLoad();
|
unawaited(_loadWithRetry());
|
||||||
}
|
}
|
||||||
|
|
||||||
String? _username;
|
String? _username;
|
||||||
@@ -175,12 +193,36 @@ class AccountData {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// iOS keychain reads fail while protected data is unavailable (app launch
|
||||||
|
/// racing the unlock, background wake on a locked device). Without a retry
|
||||||
|
/// the completer never resolved and the app stayed on the launch screen.
|
||||||
|
Future<void> _loadWithRetry() async {
|
||||||
|
for (var attempt = 1; !_populated.isCompleted; attempt++) {
|
||||||
|
try {
|
||||||
|
await _migrateAndLoad();
|
||||||
|
return;
|
||||||
|
} catch (e, s) {
|
||||||
|
log('AccountData load failed (attempt $attempt): $e', stackTrace: s);
|
||||||
|
await Future<void>.delayed(exponentialBackoff(attempt));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stops waiting for the stored session; the app then behaves as logged
|
||||||
|
/// out. The keychain entries stay untouched so a later start can still
|
||||||
|
/// restore the session.
|
||||||
|
void abandonLoad() {
|
||||||
|
if (!_populated.isCompleted) _populated.complete();
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _migrateAndLoad() async {
|
Future<void> _migrateAndLoad() async {
|
||||||
await _migrateFromLegacyStorage();
|
await _migrateFromLegacyStorage();
|
||||||
|
await _migrateKeychainAccessibility();
|
||||||
_username = await _secureStorage.read(key: _usernameField);
|
_username = await _secureStorage.read(key: _usernameField);
|
||||||
_password = await _secureStorage.read(key: _passwordField);
|
_password = await _secureStorage.read(key: _passwordField);
|
||||||
_isDemo = (await _secureStorage.read(key: _demoField)) == 'true';
|
_isDemo = (await _secureStorage.read(key: _demoField)) == 'true';
|
||||||
_usesLoginFlow = (await _secureStorage.read(key: _loginFlowField)) == 'true';
|
_usesLoginFlow =
|
||||||
|
(await _secureStorage.read(key: _loginFlowField)) == 'true';
|
||||||
try {
|
try {
|
||||||
_appPassword = await pushSecureStorage.read(key: _appPasswordField);
|
_appPassword = await pushSecureStorage.read(key: _appPasswordField);
|
||||||
_appPasswordTalk = await pushSecureStorage.read(
|
_appPasswordTalk = await pushSecureStorage.read(
|
||||||
@@ -210,11 +252,25 @@ class AccountData {
|
|||||||
await prefs.remove(_passwordField);
|
await prefs.remove(_passwordField);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _migrateKeychainAccessibility() async {
|
||||||
|
if (!Platform.isIOS) return;
|
||||||
|
for (final field in _sessionFields) {
|
||||||
|
final value = await _legacySecureStorage.read(key: field);
|
||||||
|
if (value == null) continue;
|
||||||
|
// Same account+service: the legacy item has to go before the re-add.
|
||||||
|
await _legacySecureStorage.delete(key: field);
|
||||||
|
await _secureStorage.write(key: field, value: value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Future<bool> waitForPopulation() async {
|
Future<bool> waitForPopulation() async {
|
||||||
await _populated.future;
|
await _populated.future;
|
||||||
return isPopulated();
|
return isPopulated();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// True once the stored session has been read (or given up on).
|
||||||
|
bool get isLoaded => _populated.isCompleted;
|
||||||
|
|
||||||
bool isPopulated() => _username != null && _password != null;
|
bool isPopulated() => _username != null && _password != null;
|
||||||
|
|
||||||
/// Returns the value for an HTTP `Authorization` header using HTTP Basic.
|
/// Returns the value for an HTTP `Authorization` header using HTTP Basic.
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import 'dart:developer';
|
||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:hydrated_bloc/hydrated_bloc.dart';
|
||||||
|
|
||||||
|
/// Opens the HydratedBloc box; a corrupt box is dropped and rebuilt, and if
|
||||||
|
/// even that fails the app runs on [InMemoryStorage] instead of never
|
||||||
|
/// reaching `runApp`.
|
||||||
|
Future<Storage> buildHydratedStorageWithFallback(String directoryPath) async {
|
||||||
|
final directory = HydratedStorageDirectory(directoryPath);
|
||||||
|
try {
|
||||||
|
return await HydratedStorage.build(storageDirectory: directory);
|
||||||
|
} catch (e, s) {
|
||||||
|
log('HydratedStorage open failed, rebuilding: $e', stackTrace: s);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await _deleteHydratedBox(directoryPath);
|
||||||
|
return await HydratedStorage.build(storageDirectory: directory);
|
||||||
|
} catch (e, s) {
|
||||||
|
log('HydratedStorage rebuild failed, using memory: $e', stackTrace: s);
|
||||||
|
return InMemoryStorage();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _deleteHydratedBox(String directoryPath) async {
|
||||||
|
final directory = Directory(directoryPath);
|
||||||
|
if (!directory.existsSync()) return;
|
||||||
|
await for (final entity in directory.list()) {
|
||||||
|
final name = entity.uri.pathSegments.lastWhere((s) => s.isNotEmpty);
|
||||||
|
if (entity is File && name.startsWith('hydrated_box')) {
|
||||||
|
await entity.delete();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Non-persistent [Storage]; state lives for the session only.
|
||||||
|
class InMemoryStorage implements Storage {
|
||||||
|
final Map<String, dynamic> _values = {};
|
||||||
|
|
||||||
|
@override
|
||||||
|
dynamic read(String key) => _values[key];
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> write(String key, dynamic value) async => _values[key] = value;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> delete(String key) async => _values.remove(key);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> clear() async => _values.clear();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> close() async {}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
/// Delay before retry number [attempt] (1-based): [base] doubled per attempt,
|
||||||
|
/// capped at [max].
|
||||||
|
Duration exponentialBackoff(
|
||||||
|
int attempt, {
|
||||||
|
Duration base = const Duration(milliseconds: 500),
|
||||||
|
Duration max = const Duration(seconds: 5),
|
||||||
|
}) {
|
||||||
|
final exponent = (attempt - 1).clamp(0, 30);
|
||||||
|
final millis = base.inMilliseconds * (1 << exponent);
|
||||||
|
return millis >= max.inMilliseconds ? max : Duration(milliseconds: millis);
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import '../../model/account_data.dart';
|
||||||
|
import '../../theming/light_app_theme.dart';
|
||||||
|
import '../../widget/app_progress_indicator.dart';
|
||||||
|
|
||||||
|
/// Shown while the stored session is still being read. Normally gone within
|
||||||
|
/// a second; after [hintDelay] it offers a way out so a keychain that keeps
|
||||||
|
/// failing can never trap the user on a spinner.
|
||||||
|
class AccountLoadingScreen extends StatefulWidget {
|
||||||
|
const AccountLoadingScreen({super.key});
|
||||||
|
|
||||||
|
static const Duration hintDelay = Duration(seconds: 15);
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<AccountLoadingScreen> createState() => _AccountLoadingScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _AccountLoadingScreenState extends State<AccountLoadingScreen> {
|
||||||
|
Timer? _hintTimer;
|
||||||
|
bool _showHint = false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_hintTimer = Timer(AccountLoadingScreen.hintDelay, () {
|
||||||
|
if (mounted) setState(() => _showHint = true);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_hintTimer?.cancel();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) => Scaffold(
|
||||||
|
backgroundColor: LightAppTheme.marianumRed,
|
||||||
|
body: Center(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 32),
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
const AppProgressIndicator.large(color: Colors.white),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
const Text(
|
||||||
|
'Konto wird geladen…',
|
||||||
|
style: TextStyle(color: Colors.white),
|
||||||
|
),
|
||||||
|
if (_showHint) ...[
|
||||||
|
const SizedBox(height: 32),
|
||||||
|
const Text(
|
||||||
|
'Das dauert länger als üblich. Die gespeicherte Anmeldung '
|
||||||
|
'konnte noch nicht gelesen werden.',
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: TextStyle(color: Colors.white70),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
TextButton(
|
||||||
|
onPressed: AccountData().abandonLoad,
|
||||||
|
style: TextButton.styleFrom(foregroundColor: Colors.white),
|
||||||
|
child: const Text('Zur Anmeldung'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:marianum_mobile/storage/hydrated_storage_bootstrap.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
test('InMemoryStorage round-trips, deletes and clears', () async {
|
||||||
|
final storage = InMemoryStorage();
|
||||||
|
expect(storage.read('a'), isNull);
|
||||||
|
|
||||||
|
await storage.write('a', {'x': 1});
|
||||||
|
await storage.write('b', 'two');
|
||||||
|
expect(storage.read('a'), {'x': 1});
|
||||||
|
expect(storage.read('b'), 'two');
|
||||||
|
|
||||||
|
await storage.delete('a');
|
||||||
|
expect(storage.read('a'), isNull);
|
||||||
|
expect(storage.read('b'), 'two');
|
||||||
|
|
||||||
|
await storage.clear();
|
||||||
|
expect(storage.read('b'), isNull);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:marianum_mobile/utils/exponential_backoff.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
group('exponentialBackoff', () {
|
||||||
|
test('doubles per attempt starting at base', () {
|
||||||
|
expect(exponentialBackoff(1), const Duration(milliseconds: 500));
|
||||||
|
expect(exponentialBackoff(2), const Duration(seconds: 1));
|
||||||
|
expect(exponentialBackoff(3), const Duration(seconds: 2));
|
||||||
|
expect(exponentialBackoff(4), const Duration(seconds: 4));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('caps at max', () {
|
||||||
|
expect(exponentialBackoff(5), const Duration(seconds: 5));
|
||||||
|
expect(exponentialBackoff(40), const Duration(seconds: 5));
|
||||||
|
expect(exponentialBackoff(1000), const Duration(seconds: 5));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('treats attempt 0 and negatives like the first attempt', () {
|
||||||
|
expect(exponentialBackoff(0), const Duration(milliseconds: 500));
|
||||||
|
expect(exponentialBackoff(-3), const Duration(milliseconds: 500));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('honours custom base and max', () {
|
||||||
|
expect(
|
||||||
|
exponentialBackoff(
|
||||||
|
3,
|
||||||
|
base: const Duration(seconds: 1),
|
||||||
|
max: const Duration(seconds: 3),
|
||||||
|
),
|
||||||
|
const Duration(seconds: 3),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user