Files
Client/lib/main.dart
T

603 lines
24 KiB
Dart

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:hydrated_bloc/hydrated_bloc.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/session_manager.dart';
import 'share_intent/share_intent_listener.dart';
import 'state/app/modules/account/bloc/account_bloc.dart';
import 'state/app/modules/account/bloc/account_state.dart';
import 'state/app/modules/breaker/bloc/breaker_bloc.dart';
import 'state/app/modules/capabilities/bloc/capabilities_cubit.dart';
import 'state/app/modules/chat/bloc/chat_bloc.dart';
import 'state/app/modules/chat_list/bloc/chat_list_bloc.dart';
import 'state/app/modules/children/child_selection_cubit.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 'state/app/modules/timetable/primary/primary_timetable_scope.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/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/debug/cache_view.dart';
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();
Future<void> 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 {
final path = (await getTemporaryDirectory()).path;
HydratedBloc.storage = await buildHydratedStorageWithFallback(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);
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();
_syncMarianumConnectEndpoint(settingsCubit.state);
settingsCubit.stream.listen(_syncMarianumConnectEndpoint);
log('running app...');
runApp(
MultiBlocProvider(
providers: [
BlocProvider<SettingsCubit>.value(value: settingsCubit),
BlocProvider<AccountBloc>(
create: (_) => AccountBloc(initialStatus: _initialAccountStatus()),
),
BlocProvider<BreakerBloc>(create: (_) => BreakerBloc()),
BlocProvider<CapabilitiesCubit>(create: (_) => CapabilitiesCubit()),
BlocProvider<NextcloudCapabilitiesCubit>(
create: (_) => NextcloudCapabilitiesCubit(),
),
BlocProvider<ChatListBloc>(create: (_) => ChatListBloc()),
BlocProvider<ChatBloc>(
create: (ctx) => ChatBloc(chatListBloc: ctx.read<ChatListBloc>()),
),
BlocProvider<ChildSelectionCubit>(create: (_) => ChildSelectionCubit()),
BlocProvider<ParentLettersBloc>(create: (_) => ParentLettersBloc()),
],
child: const PrimaryTimetableScope(child: 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() {
final session = SessionManager();
if (session.isSignedIn) return AccountStatus.loggedIn;
return session.isLoaded ? AccountStatus.loggedOut : AccountStatus.undefined;
}
class Main extends StatefulWidget {
const Main({super.key});
static PersistentTabController bottomNavigator = PersistentTabController(
initialIndex: 0,
);
@override
State<Main> createState() => _MainState();
}
class _MainState extends State<Main> {
bool _showPostLoginSplash = false;
bool _appMounted = true;
@override
void initState() {
super.initState();
Jiffy.setLocale('de');
SessionManager().unauthorizedSignal.addListener(_onUnauthorized);
SessionManager().waitForLoad().then((session) {
if (!mounted) return;
final accountBloc = context.read<AccountBloc>();
accountBloc.setStatus(
session != null ? AccountStatus.loggedIn : AccountStatus.loggedOut,
);
if (session != null) {
_scheduleSessionValidation(accountBloc);
// Cold start while already logged in: the account status doesn't
// change, so the loggedIn listener below never fires.
_onSessionActive();
}
});
}
/// Pulls the capability flags of the active account, then registers push
/// right away instead of deferring it to the next app start.
void _onSessionActive() {
final settingsCubit = context.read<SettingsCubit>();
final capabilitiesCubit = context.read<CapabilitiesCubit>();
unawaited(
capabilitiesCubit.load().then((_) {
if (!mounted) return;
_syncPush(settingsCubit, capabilitiesCubit);
_promptGuardianNotifications();
}),
);
unawaited(context.read<NextcloudCapabilitiesCubit>().load());
}
/// 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<ChatListBloc>().refresh(silent: true));
unawaited(context.read<ParentLettersBloc>().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);
super.dispose();
}
void _onUnauthorized() {
if (!mounted) return;
final accountBloc = context.read<AccountBloc>();
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 wipes the local session and flips the
/// account bloc to `loggedOut` (sending the user to the login screen).
void _scheduleSessionValidation(AccountBloc accountBloc) {
unawaited(
SessionValidator.probeStored(
onInvalidated: () async {
if (!mounted) return;
accountBloc.setStatus(AccountStatus.loggedOut);
},
),
);
}
@override
Widget build(BuildContext context) => Directionality(
textDirection: TextDirection.ltr,
child: BlocBuilder<SettingsCubit, Settings>(
builder: (context, settings) {
final devToolsSettings = settings.devToolsSettings;
// 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 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: BlocConsumer<AccountBloc, AccountState>(
listenWhen: (previous, current) =>
previous.status != current.status,
listener: (context, accountState) {
if (accountState.status == AccountStatus.loggedIn) {
_onSessionActive();
_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 breakerBloc = context.read<BreakerBloc>();
final capabilitiesCubit = context.read<CapabilitiesCubit>();
final childSelectionCubit = context
.read<ChildSelectionCubit>();
final chatListBloc = context.read<ChatListBloc>();
final parentLettersBloc = context.read<ParentLettersBloc>();
final chatBloc = context.read<ChatBloc>();
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,
childSelectionCubit: childSelectionCubit,
chatListBloc: chatListBloc,
parentLettersBloc: parentLettersBloc,
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);
_promptGuardianNotifications();
},
),
],
);
case AccountStatus.loggedOut:
return const Login();
case AccountStatus.undefined:
return const AccountLoadingScreen();
}
},
),
),
),
),
);
},
),
);
}
Future<void> _wipeUserState({
required SettingsCubit settingsCubit,
required ChildSelectionCubit childSelectionCubit,
required ChatListBloc chatListBloc,
required ParentLettersBloc parentLettersBloc,
required ChatBloc chatBloc,
required BreakerBloc breakerBloc,
required CapabilitiesCubit capabilitiesCubit,
required NextcloudCapabilitiesCubit nextcloudCapabilitiesCubit,
}) async {
try {
// Reset user-data blocs whose tree is no longer mounted after the
// home swap. We do NOT touch SettingsCubit here — its outer BlocBuilder
// wraps MaterialApp, so emit'ing a fresh state would tear down the
// freshly-mounted Login tree and leave the user with a blank screen
// (the MaterialApp.builder backdrop) until the next interaction.
// The timetable bloc is not reset here: PrimaryTimetableScope replaces it
// on the status change, and HydratedBloc.storage.clear() below drops the
// cached plans of every subject.
childSelectionCubit.reset();
capabilitiesCubit.reset();
nextcloudCapabilitiesCubit.reset();
await Future.wait([
chatListBloc.reset(),
parentLettersBloc.reset(),
chatBloc.reset(),
breakerBloc.reset(),
]);
final prefs = await SharedPreferences.getInstance();
await prefs.clear();
await HydratedBloc.storage.clear();
await const CacheView().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('User state wipe failed: $e', stackTrace: s);
}
}