fixed possible lockups on app start

This commit is contained in:
2026-09-11 12:04:32 +02:00
parent 43dfc52bc7
commit 3734d7ff2c
7 changed files with 452 additions and 173 deletions
+115 -87
View File
@@ -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()
.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; _startupStep(
}), 'ca certificates',
Future(() async { () => 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; 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});
@@ -374,7 +414,8 @@ class _MainState extends State<Main> {
// 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
.read<CapabilitiesCubit>();
unawaited( unawaited(
capabilitiesCubit.load().then((_) { capabilitiesCubit.load().then((_) {
if (!mounted) return; if (!mounted) return;
@@ -428,7 +469,8 @@ class _MainState extends State<Main> {
chatBloc: chatBloc, chatBloc: chatBloc,
breakerBloc: breakerBloc, breakerBloc: breakerBloc,
capabilitiesCubit: capabilitiesCubit, capabilitiesCubit: capabilitiesCubit,
nextcloudCapabilitiesCubit: nextcloudCapabilitiesCubit, nextcloudCapabilitiesCubit:
nextcloudCapabilitiesCubit,
), ),
); );
}); });
@@ -444,30 +486,16 @@ class _MainState extends State<Main> {
if (_showPostLoginSplash) if (_showPostLoginSplash)
PostLoginSplash( PostLoginSplash(
key: const ValueKey('post-login-splash'), key: const ValueKey('post-login-splash'),
onComplete: () => onComplete: () => setState(
setState(() => _showPostLoginSplash = false), () => _showPostLoginSplash = false,
),
), ),
], ],
); );
case AccountStatus.loggedOut: case AccountStatus.loggedOut:
return const Login(); return const Login();
case AccountStatus.undefined: case AccountStatus.undefined:
return Scaffold( return const AccountLoadingScreen();
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),
),
],
),
),
);
} }
}, },
), ),
+59 -3
View File
@@ -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 {}
}
+11
View File
@@ -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'),
),
],
],
),
),
),
);
}
+21
View File
@@ -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);
});
}
+35
View File
@@ -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),
);
});
});
}