Files
Client/lib/main.dart
T

597 lines
22 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/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<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();
SessionManager().markUiEngine();
// 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 {
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<SettingsCubit>.value(value: settingsCubit),
BlocProvider<AccountBloc>(
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<Main> createState() => _MainState();
}
class _MainState extends State<Main> {
final List<NavigatorObserver> _navigatorObservers = [
AppRoutes.chatRouteObserver,
DownloadRouteObserver(),
];
bool _showPostLoginSplash = false;
bool _appMounted = true;
late AccountStatus _lastStatus;
@override
void initState() {
super.initState();
Jiffy.setLocale('de');
_lastStatus = context.read<AccountBloc>().state.status;
SessionManager().waitForLoad().then((session) async {
if (!mounted) return;
final accountBloc = context.read<AccountBloc>();
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<SettingsCubit>();
final capabilitiesCubit = scopeContext.read<CapabilitiesCubit>();
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<NextcloudCapabilitiesCubit>().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<TimetableBloc>().refresh();
unawaited(context.read<ChatListBloc>().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<SettingsCubit, Settings, _RootSettings>(
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<AccountBloc, AccountState>(
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<NavigatorState>();
}
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<AccountBloc, AccountState>(
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,
});