widget refresh enhancements
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
import 'dart:async';
|
||||
import 'dart:developer';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:workmanager/workmanager.dart';
|
||||
@@ -32,12 +33,20 @@ class WidgetBackgroundTask {
|
||||
|
||||
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,
|
||||
@@ -45,7 +54,21 @@ class WidgetBackgroundTask {
|
||||
);
|
||||
}
|
||||
|
||||
static Future<void> requestImmediateRefresh() async {
|
||||
/// 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,
|
||||
@@ -54,24 +77,61 @@ class WidgetBackgroundTask {
|
||||
);
|
||||
}
|
||||
|
||||
/// 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 AccountData().waitForPopulation();
|
||||
if (!AccountData().isPopulated()) {
|
||||
log('[widget-bg] not logged in, skipping refresh');
|
||||
await WidgetSync.setLoggedIn(false);
|
||||
await WidgetSync.triggerUpdate();
|
||||
return true;
|
||||
}
|
||||
await _refresh();
|
||||
await WidgetBackgroundTask.runRefreshNow();
|
||||
return true;
|
||||
} on Exception catch (e, s) {
|
||||
log('[widget-bg] refresh failed: $e', stackTrace: s);
|
||||
@@ -94,35 +154,44 @@ Future<void> _refresh() async {
|
||||
}
|
||||
|
||||
final now = WidgetPublisher.widgetNow();
|
||||
// 14-day window so the week-widget rolls forward into next Monday's
|
||||
// lessons on Friday evening.
|
||||
final weekStart = _startOfWeek(now);
|
||||
final weekEndExclusive = weekStart.add(const Duration(days: 14));
|
||||
// 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),
|
||||
);
|
||||
|
||||
final timetable = await TimetableGetWeek().run(
|
||||
// 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)),
|
||||
);
|
||||
|
||||
// Reference data — failures fall through to null in the mapper rather
|
||||
// than aborting the whole refresh.
|
||||
final subjects = await _runOrNull<TimetableGetSubjectsResponse>(
|
||||
final subjectsFuture = _runOrNull<TimetableGetSubjectsResponse>(
|
||||
() => TimetableGetSubjects().run(),
|
||||
);
|
||||
final rooms = await _runOrNull<TimetableGetRoomsResponse>(
|
||||
final roomsFuture = _runOrNull<TimetableGetRoomsResponse>(
|
||||
() => TimetableGetRooms().run(),
|
||||
);
|
||||
final holidays = await _runOrNull<TimetableGetHolidaysResponse>(
|
||||
final holidaysFuture = _runOrNull<TimetableGetHolidaysResponse>(
|
||||
() => TimetableGetHolidays().run(),
|
||||
);
|
||||
final timegrid = await _runOrNull<TimetableGetTimegridResponse>(
|
||||
final timegridFuture = _runOrNull<TimetableGetTimegridResponse>(
|
||||
() => TimetableGetTimegrid().run(),
|
||||
);
|
||||
final customEvents = await _runOrNull<GetCustomTimetableEventResponse>(
|
||||
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;
|
||||
|
||||
@@ -158,11 +227,6 @@ Future<void> _refresh() async {
|
||||
);
|
||||
}
|
||||
|
||||
DateTime _startOfWeek(DateTime reference) {
|
||||
final monday = reference.subtract(Duration(days: reference.weekday - 1));
|
||||
return DateTime(monday.year, monday.month, monday.day);
|
||||
}
|
||||
|
||||
Future<T?> _runOrNull<T>(Future<T> Function() task) async {
|
||||
try {
|
||||
return await task();
|
||||
|
||||
Reference in New Issue
Block a user