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
+198 -170
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/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();
}
},
),
),
),