import 'dart:async'; import 'dart:developer'; import 'dart:io'; import 'package:flutter/widgets.dart'; import 'package:workmanager/workmanager.dart'; import '../api/marianumconnect/marianumconnect_endpoint.dart'; import '../api/marianumconnect/queries/timetable_get_holidays/timetable_get_holidays.dart'; import '../api/marianumconnect/queries/timetable_get_holidays/timetable_get_holidays_response.dart'; import '../api/marianumconnect/queries/timetable_get_rooms/timetable_get_rooms.dart'; import '../api/marianumconnect/queries/timetable_get_rooms/timetable_get_rooms_response.dart'; import '../api/marianumconnect/queries/timetable_get_subjects/timetable_get_subjects.dart'; import '../api/marianumconnect/queries/timetable_get_subjects/timetable_get_subjects_response.dart'; import '../api/marianumconnect/queries/timetable_get_timegrid/timetable_get_timegrid.dart'; import '../api/marianumconnect/queries/timetable_get_timegrid/timetable_get_timegrid_response.dart'; import '../api/marianumconnect/queries/timetable_get_week/timetable_get_week.dart'; import '../api/mhsl/custom_timetable_event/get/get_custom_timetable_event.dart'; import '../api/mhsl/custom_timetable_event/get/get_custom_timetable_event_params.dart'; import '../api/mhsl/custom_timetable_event/get/get_custom_timetable_event_response.dart'; import '../model/account_data.dart'; import '../widget_data/widget_data_mapper.dart'; import '../widget_data/widget_publisher.dart'; import '../widget_data/widget_sync.dart'; /// Periodic widget refresh in a background Dart isolate. The Marianum-Connect /// dio singleton + bearer interceptor handle login/refresh transparently — /// we only need to pin the endpoint to whatever the user picked in the /// in-app settings before issuing calls. class WidgetBackgroundTask { static const String periodicTaskName = 'eu.mhsl.marianum.widget.refresh'; static const String oneOffTaskName = 'eu.mhsl.marianum.widget.refresh.once'; static const Duration periodicFrequency = Duration(minutes: 30); /// A snapshot younger than this is considered fresh enough — a second /// trigger within the window (push + periodic slot coinciding) is skipped. static const Duration refreshDebounce = Duration(minutes: 10); static Future initialize() async { await Workmanager().initialize(_callbackDispatcher); await Workmanager().registerPeriodicTask( periodicTaskName, periodicTaskName, frequency: periodicFrequency, // iOS ignores `frequency:` and instead uses initialDelay as the // BGAppRefresh earliestBeginDate on every (auto-)resubmission — // without it each completed run is immediately eligible again. initialDelay: Platform.isIOS ? periodicFrequency : Duration.zero, constraints: Constraints(networkType: NetworkType.connected), existingWorkPolicy: ExistingPeriodicWorkPolicy.keep, backoffPolicy: BackoffPolicy.linear, backoffPolicyDelay: const Duration(minutes: 5), ); } /// Single owner of the platform strategy for "refresh soon": Android /// enqueues a WorkManager one-off (retry + network constraint included), /// iOS runs inline — one-off Workmanager tasks there only execute /// in-process anyway, so the direct call is equivalent and skips the extra /// background engine. [inlineTimeout] bounds the inline path for callers /// with a hard budget (FCM handler). static Future requestImmediateRefresh({ bool force = true, Duration? inlineTimeout, }) async { if (Platform.isIOS) { final refresh = runRefreshNow(force: force); await (inlineTimeout == null ? refresh : refresh.timeout(inlineTimeout)); return; } await Workmanager().registerOneOffTask( '$oneOffTaskName-${DateTime.now().millisecondsSinceEpoch}', oneOffTaskName, constraints: Constraints(networkType: NetworkType.connected), existingWorkPolicy: ExistingWorkPolicy.append, ); } /// Shared refresh entry for the periodic worker, push triggers, and login. /// Throws on fetch failure so the worker path can signal a retry. static Future runRefreshNow({bool force = false}) async { await WidgetSync.ensureInitialized(); bool populated; try { // Bounded: a hanging keystore read must not stall the caller's budget // (FCM handler ~25s on iOS) forever. populated = await AccountData().waitForPopulation().timeout( const Duration(seconds: 10), ); } on TimeoutException { populated = false; } if (!populated) { // Deliberately does NOT flip the widget to logged-out: a failed or slow // keychain read (locked iOS device during the 06:00 silent push) is // indistinguishable from "never logged in" here, and blanking the // widget on a transient failure is worse than keeping the snapshot. // Logout/login manage the flag explicitly (WidgetSync.clear / login). log('[widget-refresh] credentials unavailable, skipping refresh'); return; } final fetchedAt = await WidgetSync.getFetchedAt(); if (shouldSkipRefresh(fetchedAt: fetchedAt, now: DateTime.now(), force: force)) { log('[widget-refresh] snapshot is fresh, skipping refresh'); return; } await _refresh(); } static Future cancelAll() async { await Workmanager().cancelAll(); } } /// Pure debounce decision so it stays unit-testable. A `fetchedAt` in the /// future (clock change, debug time shift) never skips — refreshing is the /// safe direction. bool shouldSkipRefresh({ required DateTime? fetchedAt, required DateTime now, required bool force, }) { if (force || fetchedAt == null) return false; final age = now.difference(fetchedAt); return !age.isNegative && age < WidgetBackgroundTask.refreshDebounce; } @pragma('vm:entry-point') void _callbackDispatcher() { Workmanager().executeTask((task, inputData) async { try { WidgetsFlutterBinding.ensureInitialized(); await WidgetBackgroundTask.runRefreshNow(); return true; } on Exception catch (e, s) { log('[widget-bg] refresh failed: $e', stackTrace: s); // false → Workmanager retries with backoff. Native side keeps the // last good snapshot so the user still sees something. return false; } }); } Future _refresh() async { await WidgetSync.ensureInitialized(); // The background isolate doesn't go through main.dart's BlocBuilder, so we // re-apply the endpoint the foreground last persisted. Without this the // dio singleton would fall back to its hardcoded live default even when // the user picked beta/custom in the in-app settings. final mcBaseUrl = await WidgetSync.getMarianumConnectBaseUrl(); if (mcBaseUrl != null && mcBaseUrl.isNotEmpty) { MarianumConnectEndpoint.update(mcBaseUrl); } final now = WidgetPublisher.widgetNow(); // Fetch window matches the week payload's window so the widget can roll // forward into next week's lessons without fresh data. final weekStart = WidgetDataMapper.startOfCalendarWeek(now); final weekEndExclusive = weekStart.add( const Duration(days: WidgetDataMapper.weekWindowDays), ); // All six requests are independent — run them concurrently so the total // latency is the slowest request, not the sum (matters for the push path's // hard time budget). Reference-data failures fall through to null in the // mapper rather than aborting the whole refresh. final timetableFuture = TimetableGetWeek().run( from: weekStart, until: weekEndExclusive.subtract(const Duration(days: 1)), ); final subjectsFuture = _runOrNull( () => TimetableGetSubjects().run(), ); final roomsFuture = _runOrNull( () => TimetableGetRooms().run(), ); final holidaysFuture = _runOrNull( () => TimetableGetHolidays().run(), ); final timegridFuture = _runOrNull( () => TimetableGetTimegrid().run(), ); final customEventsFuture = _runOrNull( () => GetCustomTimetableEvent( GetCustomTimetableEventParams(AccountData().getUserSecret()), ).run(), ); final timetable = await timetableFuture; final subjects = await subjectsFuture; final rooms = await roomsFuture; final holidays = await holidaysFuture; final timegrid = await timegridFuture; final customEvents = await customEventsFuture; final lessons = timetable.entries; final [connectDouble, isTeacher] = await Future.wait([ WidgetSync.getConnectDoubleLessons(), WidgetSync.getIsTeacher(), ]); final dayData = WidgetDataMapper.buildDayData( now: now, lessons: lessons, subjects: subjects, rooms: rooms, holidays: holidays, timegrid: timegrid, customEvents: customEvents, connectDoubleLessons: connectDouble, showClassInsteadOfTeacher: isTeacher, ); final weekData = WidgetDataMapper.buildWeekData( now: now, lessons: lessons, subjects: subjects, rooms: rooms, holidays: holidays, timegrid: timegrid, customEvents: customEvents, connectDoubleLessons: connectDouble, showClassInsteadOfTeacher: isTeacher, ); await WidgetSync.writeDayData(dayData); await WidgetSync.writeWeekData(weekData); await WidgetSync.setLoggedIn(true); await WidgetSync.triggerUpdate(); log( '[widget-bg] refreshed: day=${dayData.lessons.length} ' 'week=${weekData.lessons.length}', ); } Future _runOrNull(Future Function() task) async { try { return await task(); } on Exception catch (e) { log('[widget-bg] reference fetch failed: $e'); return null; } }