Files
Client/lib/background/widget_background_task.dart
T
2026-08-06 20:22:32 +02:00

238 lines
9.4 KiB
Dart

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<void> 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<void> 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<void> 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<void> 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<void> _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<TimetableGetSubjectsResponse>(
() => TimetableGetSubjects().run(),
);
final roomsFuture = _runOrNull<TimetableGetRoomsResponse>(
() => TimetableGetRooms().run(),
);
final holidaysFuture = _runOrNull<TimetableGetHolidaysResponse>(
() => TimetableGetHolidays().run(),
);
final timegridFuture = _runOrNull<TimetableGetTimegridResponse>(
() => TimetableGetTimegrid().run(),
);
final customEventsFuture = _runOrNull<GetCustomTimetableEventResponse>(
() => 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 = await WidgetSync.getConnectDoubleLessons();
final dayData = WidgetDataMapper.buildDayData(
now: now,
lessons: lessons,
subjects: subjects,
rooms: rooms,
holidays: holidays,
timegrid: timegrid,
customEvents: customEvents,
connectDoubleLessons: connectDouble,
);
final weekData = WidgetDataMapper.buildWeekData(
now: now,
lessons: lessons,
subjects: subjects,
rooms: rooms,
holidays: holidays,
timegrid: timegrid,
customEvents: customEvents,
connectDoubleLessons: connectDouble,
);
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<T?> _runOrNull<T>(Future<T> Function() task) async {
try {
return await task();
} on Exception catch (e) {
log('[widget-bg] reference fetch failed: $e');
return null;
}
}