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/settings/bloc/settings_cubit.dart';
|
||||
import 'state/app/modules/timetable/bloc/timetable_bloc.dart';
|
||||
import 'storage/hydrated_storage_bootstrap.dart';
|
||||
import 'storage/settings.dart';
|
||||
import 'theming/dark_app_theme.dart';
|
||||
import 'theming/light_app_theme.dart';
|
||||
import 'utils/app_paths.dart';
|
||||
import 'utils/downloads/download_manager.dart';
|
||||
import 'view/login/account_loading_screen.dart';
|
||||
import 'view/login/login.dart';
|
||||
import 'view/login/post_login_splash.dart';
|
||||
import 'widget/app_progress_indicator.dart';
|
||||
import 'widget/avatar_disk_cache.dart';
|
||||
import 'widget/breaker/breaker.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_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 {
|
||||
log('MarianumMobile started');
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
// Before any initialisation so startup failures reach the backend too.
|
||||
_installErrorHandlers();
|
||||
|
||||
void addCertificateAsTrusted(ByteData certificate) => SecurityContext
|
||||
.defaultContext
|
||||
.setTrustedCertificatesBytes(certificate.buffer.asUint8List());
|
||||
Future<void> trustCertificate(String asset) => PlatformAssetBundle()
|
||||
.load(asset)
|
||||
.then(
|
||||
(certificate) => SecurityContext.defaultContext
|
||||
.setTrustedCertificatesBytes(certificate.buffer.asUint8List()),
|
||||
);
|
||||
|
||||
final initialisationTasks = [
|
||||
Firebase.initializeApp(options: DefaultFirebaseOptions.currentPlatform)
|
||||
.then<void>((_) {})
|
||||
.onError((error, _) => log('Error initializing Firebase: $error')),
|
||||
PlatformAssetBundle()
|
||||
.load('assets/ca/lets-encrypt-r3.pem')
|
||||
.then(addCertificateAsTrusted),
|
||||
PlatformAssetBundle()
|
||||
.load('assets/ca/lets-encrypt-r10.pem')
|
||||
.then(addCertificateAsTrusted),
|
||||
PlatformAssetBundle()
|
||||
.load('assets/ca/lets-encrypt-r13.pem')
|
||||
.then(addCertificateAsTrusted),
|
||||
Future(() async {
|
||||
final storage = await HydratedStorage.build(
|
||||
storageDirectory: HydratedStorageDirectory(
|
||||
(await getTemporaryDirectory()).path,
|
||||
),
|
||||
);
|
||||
HydratedBloc.storage = storage;
|
||||
}),
|
||||
Future(() async {
|
||||
_startupStep(
|
||||
'firebase',
|
||||
() => Firebase.initializeApp(
|
||||
options: DefaultFirebaseOptions.currentPlatform,
|
||||
),
|
||||
),
|
||||
_startupStep(
|
||||
'ca certificates',
|
||||
() => Future.wait([
|
||||
trustCertificate('assets/ca/lets-encrypt-r3.pem'),
|
||||
trustCertificate('assets/ca/lets-encrypt-r10.pem'),
|
||||
trustCertificate('assets/ca/lets-encrypt-r13.pem'),
|
||||
]),
|
||||
),
|
||||
_startupStep('hydrated storage', () async {
|
||||
final path = (await getTemporaryDirectory()).path;
|
||||
HydratedBloc.storage = await buildHydratedStorageWithFallback(path);
|
||||
}, timeout: null),
|
||||
_startupStep('documents dir', () async {
|
||||
AppPaths.documentsDir = (await getApplicationDocumentsDirectory()).path;
|
||||
}),
|
||||
AccountData().waitForPopulation(),
|
||||
ShareIntentListener.instance.initialize(),
|
||||
// The keychain may still be locked right after device unlock; AccountData
|
||||
// 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...');
|
||||
@@ -101,8 +169,11 @@ Future<void> main() async {
|
||||
// Local notifications: init the plugin (with tap/action callbacks) and the
|
||||
// Android channels, then register the FCM background isolate handler that
|
||||
// decrypts and renders Nextcloud pushes while the app is not in foreground.
|
||||
await NotificationService().initializeNotifications();
|
||||
await PushRenderer.ensureChannels();
|
||||
await _startupStep(
|
||||
'notifications',
|
||||
NotificationService().initializeNotifications,
|
||||
);
|
||||
await _startupStep('notification channels', PushRenderer.ensureChannels);
|
||||
FirebaseMessaging.onBackgroundMessage(pushOnBackgroundMessage);
|
||||
|
||||
// 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
|
||||
// triggered during startup hits initialised native storage.
|
||||
await WidgetSync.ensureInitialized();
|
||||
await _startupStep('widget sync', WidgetSync.ensureInitialized);
|
||||
unawaited(
|
||||
WidgetBackgroundTask.initialize().onError(
|
||||
(e, _) => log('Workmanager init failed: $e'),
|
||||
@@ -157,50 +228,13 @@ Future<void> main() async {
|
||||
// placeholder flash.
|
||||
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...');
|
||||
runApp(
|
||||
MultiBlocProvider(
|
||||
providers: [
|
||||
BlocProvider<SettingsCubit>(create: (_) => SettingsCubit()),
|
||||
BlocProvider<AccountBloc>(
|
||||
create: (_) => AccountBloc(
|
||||
initialStatus: AccountData().isPopulated()
|
||||
? AccountStatus.loggedIn
|
||||
: AccountStatus.loggedOut,
|
||||
),
|
||||
create: (_) => AccountBloc(initialStatus: _initialAccountStatus()),
|
||||
),
|
||||
BlocProvider<BreakerBloc>(create: (_) => BreakerBloc()),
|
||||
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 {
|
||||
const Main({super.key});
|
||||
|
||||
@@ -365,111 +405,99 @@ class _MainState extends State<Main> {
|
||||
child: LoaderOverlay(
|
||||
child: Breaker(
|
||||
breaker: BreakerArea.global,
|
||||
child: BlocConsumer<AccountBloc, AccountState>(
|
||||
listenWhen: (previous, current) =>
|
||||
previous.status != current.status,
|
||||
listener: (context, accountState) {
|
||||
// Fresh login (loggedOut -> loggedIn): pull capability flags
|
||||
// for the newly authenticated user, then register push right
|
||||
// away instead of deferring it to the next app start.
|
||||
if (accountState.status == AccountStatus.loggedIn) {
|
||||
final settingsCubit = context.read<SettingsCubit>();
|
||||
final capabilitiesCubit = context.read<CapabilitiesCubit>();
|
||||
unawaited(
|
||||
capabilitiesCubit.load().then((_) {
|
||||
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),
|
||||
),
|
||||
],
|
||||
child: BlocConsumer<AccountBloc, AccountState>(
|
||||
listenWhen: (previous, current) =>
|
||||
previous.status != current.status,
|
||||
listener: (context, accountState) {
|
||||
// Fresh login (loggedOut -> loggedIn): pull capability flags
|
||||
// for the newly authenticated user, then register push right
|
||||
// away instead of deferring it to the next app start.
|
||||
if (accountState.status == AccountStatus.loggedIn) {
|
||||
final settingsCubit = context.read<SettingsCubit>();
|
||||
final capabilitiesCubit = context
|
||||
.read<CapabilitiesCubit>();
|
||||
unawaited(
|
||||
capabilitiesCubit.load().then((_) {
|
||||
if (!mounted) return;
|
||||
_syncPush(settingsCubit, capabilitiesCubit);
|
||||
}),
|
||||
);
|
||||
case AccountStatus.loggedOut:
|
||||
return const Login();
|
||||
case AccountStatus.undefined:
|
||||
return Scaffold(
|
||||
backgroundColor: LightAppTheme.marianumRed,
|
||||
body: const Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
AppProgressIndicator.large(color: Colors.white),
|
||||
SizedBox(height: 16),
|
||||
Text(
|
||||
'Konto wird geladen…',
|
||||
style: TextStyle(color: Colors.white),
|
||||
),
|
||||
],
|
||||
),
|
||||
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:
|
||||
return const Login();
|
||||
case AccountStatus.undefined:
|
||||
return const AccountLoadingScreen();
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:developer';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:crypto/crypto.dart';
|
||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
import '../push/push_secure_storage.dart';
|
||||
import '../utils/exponential_backoff.dart';
|
||||
|
||||
class AccountData {
|
||||
static const _usernameField = 'username';
|
||||
@@ -26,7 +29,22 @@ class AccountData {
|
||||
// Keeps isPopulated()/getPassword() valid; demo mode never uses a real one.
|
||||
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();
|
||||
Completer<void> _populated = Completer();
|
||||
@@ -34,7 +52,7 @@ class AccountData {
|
||||
factory AccountData() => _instance;
|
||||
|
||||
AccountData._construct() {
|
||||
_migrateAndLoad();
|
||||
unawaited(_loadWithRetry());
|
||||
}
|
||||
|
||||
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 {
|
||||
await _migrateFromLegacyStorage();
|
||||
await _migrateKeychainAccessibility();
|
||||
_username = await _secureStorage.read(key: _usernameField);
|
||||
_password = await _secureStorage.read(key: _passwordField);
|
||||
_isDemo = (await _secureStorage.read(key: _demoField)) == 'true';
|
||||
_usesLoginFlow = (await _secureStorage.read(key: _loginFlowField)) == 'true';
|
||||
_usesLoginFlow =
|
||||
(await _secureStorage.read(key: _loginFlowField)) == 'true';
|
||||
try {
|
||||
_appPassword = await pushSecureStorage.read(key: _appPasswordField);
|
||||
_appPasswordTalk = await pushSecureStorage.read(
|
||||
@@ -210,11 +252,25 @@ class AccountData {
|
||||
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 {
|
||||
await _populated.future;
|
||||
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;
|
||||
|
||||
/// 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