diff --git a/kitchenowl/lib/main.dart b/kitchenowl/lib/main.dart index a49735e13..e3e04e2be 100644 --- a/kitchenowl/lib/main.dart +++ b/kitchenowl/lib/main.dart @@ -6,7 +6,6 @@ import 'package:material_ui/material_ui.dart'; import 'package:intl/intl_standalone.dart'; // ignore: depend_on_referenced_packages import 'package:flutter_web_plugins/url_strategy.dart'; -import 'package:kitchenowl/cubits/auth_cubit.dart'; import 'package:kitchenowl/services/api/api_service.dart'; import 'package:kitchenowl/services/background_task.dart'; import 'app.dart'; @@ -25,26 +24,40 @@ Future main() async { // [Android-only] This "Headless Task" is run when the Android app is terminated with `enableHeadless: true` @pragma('vm:entry-point') void backgroundFetchHeadlessTask(HeadlessTask task) async { - String taskId = task.taskId; - bool isTimeout = task.timeout; + await handleBackgroundFetchHeadlessTask( + task.taskId, + isTimeout: task.timeout, + ); +} + +@visibleForTesting +Future handleBackgroundFetchHeadlessTask( + String taskId, { + required bool isTimeout, + Future Function()? runTask, + void Function()? dispose, + void Function(String)? finish, +}) async { + final execute = runTask ?? BackgroundTask.runHeadless; + final cleanUp = dispose ?? () => ApiService.getInstance().dispose(); + final complete = finish ?? + (String completedTaskId) { + BackgroundFetch.finish(completedTaskId); + }; + if (isTimeout) { // This task has exceeded its allowed running-time. // You must stop what you're doing and immediately .finish(taskId) debugPrint("[BackgroundFetch] Headless task timed-out: $taskId"); - BackgroundFetch.finish(taskId); + complete(taskId); return; } debugPrint('[BackgroundFetch] Headless event received.'); - // setup - final AuthCubit authCubit = AuthCubit(reloadTokenBeforeRequest: true); - - // fetch - await BackgroundTask.run(authCubit); - - // teardown - ApiService.getInstance().dispose(); - await authCubit.close(); - - BackgroundFetch.finish(taskId); + try { + await execute(); + } finally { + cleanUp(); + complete(taskId); + } } diff --git a/kitchenowl/lib/services/background_task.dart b/kitchenowl/lib/services/background_task.dart index 6671e14ff..6aebef6fe 100644 --- a/kitchenowl/lib/services/background_task.dart +++ b/kitchenowl/lib/services/background_task.dart @@ -1,8 +1,12 @@ +import 'package:kitchenowl/config.dart'; import 'package:kitchenowl/cubits/auth_cubit.dart'; import 'package:kitchenowl/models/household.dart'; +import 'package:kitchenowl/services/api/api_service.dart'; +import 'package:kitchenowl/services/storage/mem_storage.dart'; import 'package:kitchenowl/services/storage/storage.dart'; import 'package:kitchenowl/services/transaction_handler.dart'; import 'package:kitchenowl/services/transactions/shoppinglist.dart'; +import 'package:package_info_plus/package_info_plus.dart'; class BackgroundTask { static Future run(AuthCubit authCubit) async { @@ -19,4 +23,52 @@ class BackgroundTask { ]); } } + + static Future runHeadless() async { + final preferenceStorage = PreferenceStorage.getInstance(); + final forcedOfflineMode = + await preferenceStorage.readBool(key: 'forcedOfflineMode') ?? false; + if (forcedOfflineMode) return; + + Config.packageInfo = PackageInfo.fromPlatform(); + final url = + await preferenceStorage.read(key: 'URL') ?? Config.defaultServer; + final secureStorage = SecureStorage.getInstance(); + final token = await secureStorage.read(key: 'TOKEN'); + + ApiService.setTokenRotationHandler( + (token) => secureStorage.write(key: 'TOKEN', value: token), + ); + ApiService.setTokenBeforeReauthHandler((token) { + if (token == null) return Future.value(token); + return secureStorage.read(key: 'TOKEN').then((value) => value ?? token); + }); + + await ApiService.connectTo(url, refreshToken: token); + if (!ApiService.getInstance().isAuthenticated()) return; + + await Future.wait([ + TransactionHandler.getInstance().runOpenTransactions(), + _refreshShoppingLists(preferenceStorage), + ]); + } + + static Future _refreshShoppingLists( + PreferenceStorage preferenceStorage, + ) async { + final householdId = await preferenceStorage.readInt(key: 'lastHouseholdId'); + if (householdId == null) return; + + final recentItemsCount = + await preferenceStorage.readInt(key: 'recentItemsCount') ?? 9; + final household = Household(id: householdId); + final shoppingLists = await ApiService.getInstance().getShoppingLists( + household, + recentItemlimit: recentItemsCount + 3, + ); + if (shoppingLists != null) { + await MemStorage.getInstance() + .writeShoppingLists(household, shoppingLists); + } + } } diff --git a/kitchenowl/test/background_fetch_headless_task_test.dart b/kitchenowl/test/background_fetch_headless_task_test.dart new file mode 100644 index 000000000..23ad0f36b --- /dev/null +++ b/kitchenowl/test/background_fetch_headless_task_test.dart @@ -0,0 +1,56 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:kitchenowl/main.dart'; + +void main() { + test('headless work completes before resources are released', () async { + final events = []; + + await handleBackgroundFetchHeadlessTask( + 'test-task', + isTimeout: false, + runTask: () async { + events.add('started'); + await Future.delayed(Duration.zero); + events.add('completed'); + }, + dispose: () => events.add('disposed'), + finish: (_) => events.add('finished'), + ); + + expect(events, ['started', 'completed', 'disposed', 'finished']); + }); + + test('headless task is finished when background work fails', () async { + final events = []; + + await expectLater( + handleBackgroundFetchHeadlessTask( + 'test-task', + isTimeout: false, + runTask: () async { + events.add('started'); + throw StateError('sync failed'); + }, + dispose: () => events.add('disposed'), + finish: (_) => events.add('finished'), + ), + throwsStateError, + ); + + expect(events, ['started', 'disposed', 'finished']); + }); + + test('timed-out headless task finishes without starting new work', () async { + final events = []; + + await handleBackgroundFetchHeadlessTask( + 'test-task', + isTimeout: true, + runTask: () async => events.add('started'), + dispose: () => events.add('disposed'), + finish: (_) => events.add('finished'), + ); + + expect(events, ['finished']); + }); +}