import 'dart:async'; import 'dart:developer'; import 'dart:io'; import 'dart:ui'; import 'package:firebase_core/firebase_core.dart'; import 'package:firebase_messaging/firebase_messaging.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/scheduler.dart' show timeDilation; import 'package:flutter/services.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_localizations/flutter_localizations.dart'; import 'package:jiffy/jiffy.dart'; import 'package:loader_overlay/loader_overlay.dart'; import 'package:path_provider/path_provider.dart'; import 'package:persistent_bottom_nav_bar_v2/persistent_bottom_nav_bar_v2.dart'; import 'api/marianumcloud/webdav/queries/list_files/list_files_cache.dart'; import 'api/marianumconnect/auth/session_validator.dart'; import 'api/marianumconnect/marianumconnect_endpoint.dart'; import 'api/marianumconnect/queries/get_breakers/get_breakers_response.dart'; import 'api/marianumconnect/queries/report_client_error/client_error_reporter.dart'; import 'api/marianumconnect/queries/telemetry_heartbeat/telemetry_heartbeat.dart'; import 'app.dart'; import 'background/widget_background_task.dart'; import 'firebase_options.dart'; import 'model/session_wipe.dart'; import 'notification/notification_service.dart'; import 'push/push_message_handler.dart'; import 'push/push_registration.dart'; import 'push/push_registration_store.dart'; import 'push/push_renderer.dart'; import 'routing/app_routes.dart'; import 'session/account_codec.dart'; import 'session/session_lifecycle.dart'; import 'session/session_manager.dart'; import 'share_intent/share_intent_listener.dart'; import 'state/app/modules/account/account_scope.dart'; import 'state/app/modules/account/bloc/account_bloc.dart'; import 'state/app/modules/account/bloc/account_state.dart'; import 'state/app/modules/app_modules.dart'; import 'state/app/modules/capabilities/bloc/capabilities_cubit.dart'; 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/account_storage.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/avatar_disk_cache.dart'; import 'widget/breaker/breaker.dart'; import 'widget/debug/debug_stats_overlay.dart'; import 'widget/downloads/download_tray.dart'; import 'widget/emergency/emergency_notice_gate.dart'; import 'widget/info_dialog.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 _startupStep( String name, Future 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 main() async { log('MarianumMobile started'); WidgetsFlutterBinding.ensureInitialized(); SessionManager().markUiEngine(); // Before any initialisation so startup failures reach the backend too. _installErrorHandlers(); Future trustCertificate(String asset) => PlatformAssetBundle() .load(asset) .then( (certificate) => SecurityContext.defaultContext .setTrustedCertificatesBytes(certificate.buffer.asUint8List()), ); final initialisationTasks = [ _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 { await AccountStorage.init((await getTemporaryDirectory()).path); }, timeout: null), _startupStep('documents dir', () async { AppPaths.documentsDir = (await getApplicationDocumentsDirectory()).path; }), // The keychain may still be locked right after device unlock; the session // 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', SessionManager().waitForLoad, 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...'); await Future.wait(initialisationTasks); // Snapshot: the session may still finish loading later (see _MainState), // the bloc and the storage must agree on the account they start with. final initialAccount = SessionManager().isLoaded ? SessionManager().activeAccount : null; await _startupStep( 'account storage', () => AccountStorage.activate(initialAccount), timeout: null, ); log('app initialisation done!'); // Independent of the notification setup below, so it runs alongside it. final widgetSyncInit = _startupStep( 'widget sync', WidgetSync.ensureInitialized, ); // 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 _startupStep( 'notifications', NotificationService().initializeNotifications, ); await _startupStep('notification channels', PushRenderer.ensureChannels); FirebaseMessaging.onBackgroundMessage(pushOnBackgroundMessage); // Wire up the native background downloader (progress notifications + tap // handling) before the UI so a completion notification tapped during cold // start is captured and opened once the downloads tray mounts. unawaited( DownloadManager.instance.initialize().onError( (e, _) => log('DownloadManager init failed: $e'), ), ); // The home-screen widget bridge must be ready before runApp so any widget // render triggered during startup hits initialised native storage. await widgetSyncInit; unawaited( WidgetBackgroundTask.initialize().onError( (e, _) => log('Workmanager init failed: $e'), ), ); // Diagnostic log only. getToken() can fail transiently during cold start // (Android: "IOException: FCM Registration failed!" from Play Services, // iOS: apns-token-not-set until APNS registration completes), so tolerate // the failure instead of letting it surface as an uncaught async error that // gets reported to the telemetry backend as noise. unawaited( FirebaseMessaging.instance .getToken() .then( (token) => log('Firebase token: ${token ?? "Error: no Firebase token!"}'), ) .onError((e, _) => log('Firebase token unavailable: $e')), ); // Warm up the Nextcloud root listing in the background while the user is // still on the launch screen / other modules — the root endpoint is slow // on our instance, so kicking it off early means the Files page already // has data ready by the time the user navigates to it. No-op when a // cached payload is already present, so this does not undo the day-long // root cache TTL. if (SessionManager().hasNextcloud) { unawaited( ListFilesCache.prefetchRootListing().onError( (e, _) => log('Files root prefetch failed: $e'), ), ); } // Resolve the avatar cache directory ahead of the first avatar render so the // synchronous disk read hits and cold-start avatars appear without a blank // placeholder flash. AvatarDiskCache.instance.warmUp(); // Created eagerly so the endpoint is configured before anything below can // issue a request (the timetable bloc loads on creation). final settingsCubit = SettingsCubit(storage: AccountStorage.global); _syncMarianumConnectEndpoint(settingsCubit.state); settingsCubit.stream.listen(_syncMarianumConnectEndpoint); log('running app...'); runApp( MultiBlocProvider( providers: [ BlocProvider.value(value: settingsCubit), BlocProvider( create: (_) => AccountBloc( initialStatus: _initialAccountStatus(initialAccount), accountId: initialAccount?.id, ), ), ], child: const Main(), ), ); } String? _syncedMcBaseUrl; /// Keeps the MC dio singleton aligned with the selected endpoint (live / /// beta / custom), mirrored into WidgetSync so the background isolate /// refreshes against the same endpoint. Settings emit on every toggle; only /// an actual URL change is applied. void _syncMarianumConnectEndpoint(Settings settings) { final url = settings.devToolsSettings.resolveMarianumConnectBaseUrl(); if (url == _syncedMcBaseUrl) return; _syncedMcBaseUrl = url; MarianumConnectEndpoint.update(url); unawaited(WidgetSync.setMarianumConnectBaseUrl(url)); } AccountStatus _initialAccountStatus(AccountEntry? account) { if (account != null) return AccountStatus.loggedIn; return SessionManager().isLoaded ? AccountStatus.loggedOut : AccountStatus.undefined; } class Main extends StatefulWidget { const Main({super.key}); static PersistentTabController bottomNavigator = PersistentTabController( initialIndex: 0, ); @override State
createState() => _MainState(); } class _MainState extends State
{ final List _navigatorObservers = [ AppRoutes.chatRouteObserver, DownloadRouteObserver(), ]; bool _showPostLoginSplash = false; bool _appMounted = true; late AccountStatus _lastStatus; @override void initState() { super.initState(); Jiffy.setLocale('de'); _lastStatus = context.read().state.status; SessionManager().waitForLoad().then((session) async { if (!mounted) return; final accountBloc = context.read(); if (session == null) { accountBloc.setStatus(AccountStatus.loggedOut); return; } // Covers a session that finished loading after the startup snapshot; // otherwise the id is unchanged and nothing remounts. final account = SessionManager().activeAccount; await AccountStorage.activate(account); if (!mounted) return; accountBloc.activated(account?.id); _scheduleSessionValidation(accountBloc); }); } /// Runs whenever the account-scoped tree mounts for a signed-in account: /// pulls the capability flags, then registers push for this account. void _onSessionActive(BuildContext scopeContext) { final settingsCubit = scopeContext.read(); final capabilitiesCubit = scopeContext.read(); unawaited( capabilitiesCubit.load().then((_) { // Closed: the account was switched away from while loading. if (!mounted || capabilitiesCubit.isClosed) return; _syncPush(settingsCubit, capabilitiesCubit); _applyRoleDefaults(settingsCubit, capabilitiesCubit); }), ); unawaited(scopeContext.read().load()); unawaited(SessionLifecycle.refreshDisplayName()); _prefetchBaseData(scopeContext); WidgetsBinding.instance.addPostFrameCallback((_) => _showTakeoverNotice()); } /// A forced sign-out that handed over to another account never reaches the /// login screen, which normally explains it. void _showTakeoverNotice() { final notice = SessionLifecycle.signOutNotice.value; final overlayContext = AppRoutes.overlayContext; if (notice == null || overlayContext == null) return; SessionLifecycle.signOutNotice.value = null; InfoDialog.show(overlayContext, notice, title: 'Abgemeldet'); } /// Warms the core caches (timetable, chat list, files root) in the /// background so the first screen render hits populated data. void _prefetchBaseData(BuildContext context) { context.read().refresh(); unawaited(context.read().refresh(silent: true)); if (SessionManager().hasNextcloud) { unawaited(ListFilesCache.prefetchRootListing()); } } /// Registers/self-heals the push subscription whenever the backend advertises /// the capability — independent of the notification toggle, so a user with /// notifications off stays registered for silent sync pushes. Fire-and-forget. void _syncPush(SettingsCubit settings, CapabilitiesCubit capabilities) { final enabled = settings.val().notificationSettings.enabled; unawaited( PushRegistration.syncSubscription( capable: capabilities.canReceivePushNotifications, ).then((registered) { // The app-start heartbeat runs before this async registration // finishes, so it reports the pre-registration state. Re-emit once // the identifier is persisted so the new push status shows up this // session instead of only after the next launch. if (registered) { TelemetryHeartbeat.report(notificationsEnabled: enabled); } }), ); } /// userType is only known once capabilities are loaded. void _applyRoleDefaults( SettingsCubit settings, CapabilitiesCubit capabilities, ) { if (AppModule.applyTeacherDefaults( settings.val().modulesSettings, isTeacher: capabilities.isTeacher, )) { settings.val(write: true); } } /// Background credential check: a 401 means the password was rotated /// server-side, so the validator signs the account out and the app moves /// on to another stored account or the login screen. void _scheduleSessionValidation(AccountBloc accountBloc) { unawaited( SessionValidator.probeStored( onInvalidated: (nextAccountId) async { if (!mounted) return; accountBloc.activated(nextAccountId); }, ), ); } void _onAccountChanged(BuildContext context, AccountState accountState) { if (accountState.status == AccountStatus.loggedIn && accountState.freshLogin) { _showPostLoginSplash = true; _appMounted = false; WidgetsBinding.instance.addPostFrameCallback((_) { if (mounted) setState(() => _appMounted = true); }); } final wasLoggedIn = _lastStatus == AccountStatus.loggedIn; _lastStatus = accountState.status; // A cold start that merely resolves as signed out has nothing to wipe, // and a share waiting for the login must survive it. if (accountState.status != AccountStatus.loggedOut || !wasLoggedIn) return; // Deferred until the account-scoped tree is torn down. WidgetsBinding.instance.addPostFrameCallback( (_) => unawaited(SessionWipe.deviceLeft()), ); } /// The account-scoped tree only lives while its account is the one in use: /// adding another account parks it, so its blocs go as well. static Object _scopeOf(AccountState state) => ( state.accountId, state.status == AccountStatus.addingAccount, ); Object? _scope; @override Widget build(BuildContext context) => Directionality( textDirection: TextDirection.ltr, // Selects only what the root uses: Settings is mutated in place and // re-emitted as a fresh instance on every write, so a plain BlocBuilder // would rebuild MaterialApp (and every route) for unrelated settings. child: BlocSelector( selector: (settings) => ( appTheme: settings.appTheme, notificationsEnabled: settings.notificationSettings.enabled, showPerformanceOverlay: settings.devToolsSettings.showPerformanceOverlay, showSemanticsDebugger: settings.devToolsSettings.showSemanticsDebugger, showFrameStats: settings.devToolsSettings.showFrameStats, showMemoryStats: settings.devToolsSettings.showMemoryStats, slowAnimations: settings.devToolsSettings.slowAnimations, textScaleOverride: settings.devToolsSettings.textScaleOverride, ), builder: (context, root) { // Mirror the notification toggle into group-scoped storage so the FCM // background isolate and the iOS NSE can suppress rendering when off. unawaited( const PushRegistrationStore().setNotificationsEnabled( root.notificationsEnabled, ), ); timeDilation = root.slowAnimations ? 5.0 : 1.0; return BlocConsumer( listenWhen: (previous, current) => previous.status != current.status || previous.accountId != current.accountId, listener: _onAccountChanged, buildWhen: (previous, current) => _scopeOf(previous) != _scopeOf(current), builder: (context, account) { final scope = _scopeOf(account); if (scope != _scope) { _scope = scope; // The old navigator (and every route on it) goes with the old // account; a shared GlobalKey would carry it over instead. AppRoutes.rootNavigatorKey = GlobalKey(); } return AccountScope( key: ValueKey(scope), onActive: _onSessionActive, child: _buildApp(root), ); }, ); }, ), ); Widget _buildApp(_RootSettings root) => MaterialApp( showPerformanceOverlay: root.showPerformanceOverlay, showSemanticsDebugger: root.showSemanticsDebugger, debugShowCheckedModeBanner: false, navigatorKey: AppRoutes.rootNavigatorKey, // Used by ChatView.didPopNext to reclaim the global ChatBloc. // DownloadRouteObserver tracks full-page navigations so the downloads // chip only surfaces once the user leaves the screen they started on. navigatorObservers: _navigatorObservers, localizationsDelegates: const [ ...GlobalMaterialLocalizations.delegates, GlobalWidgetsLocalizations.delegate, ], supportedLocales: const [Locale('de'), Locale('en')], locale: const Locale('de'), title: 'Marianum Fulda', themeMode: root.appTheme, theme: LightAppTheme.theme, darkTheme: DarkAppTheme.theme, // Brand-colored backdrop behind every route. During the logout // home-swap and route pop animations the framework can briefly // expose the layer below the topmost Scaffold; without this // the dark Material default shows through and the user sees a // black flash. builder: (context, child) { Widget app = ColoredBox( color: LightAppTheme.marianumRed, // Downloads tray mounted ABOVE the navigator so its chip floats // over every route (folder views, chat, viewer are full-page // pushes that would otherwise cover it). child: DownloadTrayHost(child: child ?? const SizedBox.shrink()), ); final scale = root.textScaleOverride; if (scale > 0) { app = MediaQuery.withClampedTextScaling( minScaleFactor: scale, maxScaleFactor: scale, child: app, ); } return DebugStatsOverlay( showFrameStats: root.showFrameStats, showMemoryStats: root.showMemoryStats, child: app, ); }, home: EmergencyNoticeGate( child: LoaderOverlay( child: Breaker( breaker: BreakerArea.global, child: BlocBuilder( buildWhen: (previous, current) => previous.status != current.status, 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.addingAccount: return const Login(addingAccount: true); case AccountStatus.undefined: return const AccountLoadingScreen(); } }, ), ), ), ), ); } typedef _RootSettings = ({ ThemeMode appTheme, bool notificationsEnabled, bool showPerformanceOverlay, bool showSemanticsDebugger, bool showFrameStats, bool showMemoryStats, bool slowAnimations, double textScaleOverride, });