Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 28 additions & 15 deletions kitchenowl/lib/main.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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<void> handleBackgroundFetchHeadlessTask(
String taskId, {
required bool isTimeout,
Future<void> 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);
}
}
52 changes: 52 additions & 0 deletions kitchenowl/lib/services/background_task.dart
Original file line number Diff line number Diff line change
@@ -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<void> run(AuthCubit authCubit) async {
Expand All @@ -19,4 +23,52 @@ class BackgroundTask {
]);
}
}

static Future<void> 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<void> _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);
}
}
}
56 changes: 56 additions & 0 deletions kitchenowl/test/background_fetch_headless_task_test.dart
Original file line number Diff line number Diff line change
@@ -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 = <String>[];

await handleBackgroundFetchHeadlessTask(
'test-task',
isTimeout: false,
runTask: () async {
events.add('started');
await Future<void>.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 = <String>[];

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 = <String>[];

await handleBackgroundFetchHeadlessTask(
'test-task',
isTimeout: true,
runTask: () async => events.add('started'),
dispose: () => events.add('disposed'),
finish: (_) => events.add('finished'),
);

expect(events, ['finished']);
});
}
Loading