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/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 'package:shared_preferences/shared_preferences.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 'auth_link/guardian_link_listener.dart'; import 'background/widget_background_task.dart'; import 'firebase_options.dart'; import 'notification/notification_service.dart'; import 'push/notification_permission_prompt.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/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/parent_letters/bloc/parent_letters_bloc.dart'; import 'state/app/modules/settings/bloc/settings_cubit.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/debouncer.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/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(); // 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), _startupStep('guardian link', GuardianLinkListener.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!'); // 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'), ), ); // Wire up the home-screen widget bridge before runApp so any widget render // triggered during startup hits initialised native storage. await _startupStep('widget sync', WidgetSync.ensureInitialized); 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 primary 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
{ bool _showPostLoginSplash = false; bool _appMounted = true; @override void initState() { super.initState(); Jiffy.setLocale('de'); SessionManager().unauthorizedSignal.addListener(_onUnauthorized); GuardianLinkListener.pending.addListener(_onGuardianLink); 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); unawaited(_onGuardianLink()); }); } /// 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((_) { if (!mounted) return; _syncPush(settingsCubit, capabilitiesCubit); _promptGuardianNotifications(); }), ); 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'); } /// Waits for the post-login splash so the dialog never covers it; the /// splash's completion calls this again. void _promptGuardianNotifications() { if (_showPostLoginSplash) return; final overlayContext = AppRoutes.overlayContext; if (overlayContext == null) return; unawaited(maybePromptGuardianLoginNotifications(overlayContext)); } /// Warms the chat list and files root in the background so the first screen /// render hits populated data. The timetable needs no warm-up: /// PrimaryTimetableScope creates a freshly loading bloc per account. void _prefetchBaseData(BuildContext context) { unawaited(context.read().refresh(silent: true)); 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); } }), ); } @override void dispose() { SessionManager().unauthorizedSignal.removeListener(_onUnauthorized); GuardianLinkListener.pending.removeListener(_onGuardianLink); super.dispose(); } /// A guardian login link tapped while signed in belongs to an "add account" /// flow (the app may have been killed meanwhile): reopen the login for it. Future _onGuardianLink() async { if (GuardianLinkListener.pending.value == null || !mounted) return; final accountBloc = context.read(); if (accountBloc.state.status != AccountStatus.loggedIn) return; await SessionLifecycle.beginAddAccount(); accountBloc.setStatus(AccountStatus.addingAccount); } void _onUnauthorized() { if (!mounted) return; final accountBloc = context.read(); if (accountBloc.state.status != AccountStatus.loggedIn) return; Debouncer.throttle( 'sessionUnauthorized', const Duration(seconds: 30), () => _scheduleSessionValidation(accountBloc), ); } /// 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); }); } if (accountState.status == AccountStatus.addingAccount) { // Routes pushed on the root navigator (Settings) would otherwise keep // covering the login screen that replaces the home route. AppRoutes.rootNavigatorKey.currentState?.popUntil( (route) => route.isFirst, ); } 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(); // Deferred until the account-scoped tree is torn down. WidgetsBinding.instance.addPostFrameCallback( (_) => unawaited(_wipeDeviceState()), ); } String? _scopeAccountId; @override Widget build(BuildContext context) => Directionality( textDirection: TextDirection.ltr, child: BlocBuilder( builder: (context, settings) { // 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( settings.notificationSettings.enabled, ), ); return BlocConsumer( listenWhen: (previous, current) => previous.status != current.status || previous.accountId != current.accountId, listener: _onAccountChanged, buildWhen: (previous, current) => previous.accountId != current.accountId, builder: (context, account) { if (account.accountId != _scopeAccountId) { _scopeAccountId = account.accountId; // 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(account.accountId), onActive: _onSessionActive, child: _buildApp(settings), ); }, ); }, ), ); Widget _buildApp(Settings settings) { final devToolsSettings = settings.devToolsSettings; return MaterialApp( showPerformanceOverlay: devToolsSettings.showPerformanceOverlay, checkerboardOffscreenLayers: devToolsSettings.checkerboardOffscreenLayers, checkerboardRasterCacheImages: devToolsSettings.checkerboardRasterCacheImages, 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: [ AppRoutes.chatRouteObserver, DownloadRouteObserver(), ], localizationsDelegates: const [ ...GlobalMaterialLocalizations.delegates, GlobalWidgetsLocalizations.delegate, ], supportedLocales: const [Locale('de'), Locale('en')], locale: const Locale('de'), title: 'Marianum Fulda', themeMode: settings.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) => 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()), ), 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); _promptGuardianNotifications(); }, ), ], ); case AccountStatus.loggedOut: return const Login(); case AccountStatus.addingAccount: return const Login(addingAccount: true); case AccountStatus.undefined: return const AccountLoadingScreen(); } }, ), ), ), ), ); } } /// Wipes what outlives accounts once the last one signed out. Account data /// itself is removed by [SessionLifecycle.signOut]. Future _wipeDeviceState() async { try { final prefs = await SharedPreferences.getInstance(); await prefs.clear(); // The chat background image lives outside HydratedStorage, so clear it too // (best-effort) to avoid orphaning the previous user's wallpaper. final backgroundImage = File(AppPaths.chatBackgroundImage); if (backgroundImage.existsSync()) backgroundImage.deleteSync(); // Stop the periodic widget refresh job so the background isolate doesn't // wake up every 30 minutes only to write `loggedIn=false`. Re-registers // on the next successful login. await WidgetBackgroundTask.cancelAll(); await WidgetSync.clear(); await WidgetSync.triggerUpdate(); } catch (e, s) { log('Device state wipe failed: $e', stackTrace: s); } }