From c5adb0581ad959b22d07e8b68da18fb52b1b5ec7 Mon Sep 17 00:00:00 2001 From: MangelSpec <74370284+MangelSpec@users.noreply.github.com> Date: Mon, 14 Sep 2026 14:29:33 +0200 Subject: [PATCH 1/2] fix: stabilize Android background sync and startup --- kitchenowl/lib/app.dart | 3 +- kitchenowl/lib/cubits/auth_cubit.dart | 6 ++ kitchenowl/lib/main.dart | 43 +++++++++----- kitchenowl/lib/pages/login_page.dart | 33 +++++++---- kitchenowl/lib/services/background_task.dart | 52 +++++++++++++++++ .../background_fetch_headless_task_test.dart | 56 +++++++++++++++++++ .../test/material_localizations_test.dart | 19 +++++++ 7 files changed, 184 insertions(+), 28 deletions(-) create mode 100644 kitchenowl/test/background_fetch_headless_task_test.dart create mode 100644 kitchenowl/test/material_localizations_test.dart diff --git a/kitchenowl/lib/app.dart b/kitchenowl/lib/app.dart index cb1a23d86..df49707cb 100644 --- a/kitchenowl/lib/app.dart +++ b/kitchenowl/lib/app.dart @@ -220,7 +220,8 @@ class _AppState extends State with WidgetsBindingObserver { onGenerateTitle: (BuildContext context) => AppLocalizations.of(context)!.appTitle, localizationsDelegates: - AppLocalizations.localizationsDelegates + + GlobalMaterialLocalizations.delegates + + AppLocalizations.localizationsDelegates + [ LocaleNamesLocalizationsDelegate(), ], diff --git a/kitchenowl/lib/cubits/auth_cubit.dart b/kitchenowl/lib/cubits/auth_cubit.dart index e4e3c28ef..f8c2fc11c 100644 --- a/kitchenowl/lib/cubits/auth_cubit.dart +++ b/kitchenowl/lib/cubits/auth_cubit.dart @@ -29,6 +29,12 @@ class AuthCubit extends Cubit { setup(); } + @override + Future close() { + ApiService.getInstance().removeListener(updateState); + return super.close(); + } + void setup() async { String? url; url = kIsWeb 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/pages/login_page.dart b/kitchenowl/lib/pages/login_page.dart index 6c8851a6d..8fccdb4ac 100644 --- a/kitchenowl/lib/pages/login_page.dart +++ b/kitchenowl/lib/pages/login_page.dart @@ -142,12 +142,17 @@ class _LoginPageState extends State { BlocProvider.of(context).login( usernameController.text, passwordController.text, - () => showSnackbar( - context: context, - content: Text(AppLocalizations.of(context)! - .wrongUsernameOrPassword), - width: null, - ), + () { + if (!context.mounted) return; + showSnackbar( + context: context, + content: Text( + AppLocalizations.of(context)! + .wrongUsernameOrPassword, + ), + width: null, + ); + }, ), decoration: InputDecoration( labelText: @@ -162,13 +167,17 @@ class _LoginPageState extends State { BlocProvider.of(context).login( usernameController.text, passwordController.text, - () => showSnackbar( - context: context, - content: Text( + () { + if (!context.mounted) return; + showSnackbar( + context: context, + content: Text( AppLocalizations.of(context)! - .wrongUsernameOrPassword), - width: null, - ), + .wrongUsernameOrPassword, + ), + width: null, + ); + }, ), child: Text(AppLocalizations.of(context)!.login), 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']); + }); +} diff --git a/kitchenowl/test/material_localizations_test.dart b/kitchenowl/test/material_localizations_test.dart new file mode 100644 index 000000000..44d97576e --- /dev/null +++ b/kitchenowl/test/material_localizations_test.dart @@ -0,0 +1,19 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:kitchenowl/kitchenowl.dart'; +import 'package:material_ui/material_ui.dart'; + +void main() { + testWidgets('provides Material localizations for German', (tester) async { + await tester.pumpWidget( + MaterialApp( + locale: const Locale('de'), + localizationsDelegates: GlobalMaterialLocalizations.delegates + + AppLocalizations.localizationsDelegates, + supportedLocales: const [Locale('de')], + home: Scaffold(appBar: AppBar(title: const Text('KitchenOwl'))), + ), + ); + + expect(tester.takeException(), isNull); + }); +} From 4e84d4c4b75a170b8da2e9df2352b27046798fb5 Mon Sep 17 00:00:00 2001 From: MangelSpec <74370284+MangelSpec@users.noreply.github.com> Date: Mon, 28 Sep 2026 01:58:38 +0200 Subject: [PATCH 2/2] chore: narrow PR to background fetch fix - Drop the localization and login changes for separate follow-ups. - Remove the duplicate auth listener cleanup now present upstream. --- kitchenowl/lib/app.dart | 3 +- kitchenowl/lib/cubits/auth_cubit.dart | 6 ---- kitchenowl/lib/pages/login_page.dart | 33 +++++++------------ .../test/material_localizations_test.dart | 19 ----------- 4 files changed, 13 insertions(+), 48 deletions(-) delete mode 100644 kitchenowl/test/material_localizations_test.dart diff --git a/kitchenowl/lib/app.dart b/kitchenowl/lib/app.dart index df49707cb..cb1a23d86 100644 --- a/kitchenowl/lib/app.dart +++ b/kitchenowl/lib/app.dart @@ -220,8 +220,7 @@ class _AppState extends State with WidgetsBindingObserver { onGenerateTitle: (BuildContext context) => AppLocalizations.of(context)!.appTitle, localizationsDelegates: - GlobalMaterialLocalizations.delegates + - AppLocalizations.localizationsDelegates + + AppLocalizations.localizationsDelegates + [ LocaleNamesLocalizationsDelegate(), ], diff --git a/kitchenowl/lib/cubits/auth_cubit.dart b/kitchenowl/lib/cubits/auth_cubit.dart index dd7d126d7..2280c8ee4 100644 --- a/kitchenowl/lib/cubits/auth_cubit.dart +++ b/kitchenowl/lib/cubits/auth_cubit.dart @@ -29,12 +29,6 @@ class AuthCubit extends Cubit { setup(); } - @override - Future close() { - ApiService.getInstance().removeListener(updateState); - return super.close(); - } - void setup() async { String? url; url = kIsWeb diff --git a/kitchenowl/lib/pages/login_page.dart b/kitchenowl/lib/pages/login_page.dart index 8fccdb4ac..6c8851a6d 100644 --- a/kitchenowl/lib/pages/login_page.dart +++ b/kitchenowl/lib/pages/login_page.dart @@ -142,17 +142,12 @@ class _LoginPageState extends State { BlocProvider.of(context).login( usernameController.text, passwordController.text, - () { - if (!context.mounted) return; - showSnackbar( - context: context, - content: Text( - AppLocalizations.of(context)! - .wrongUsernameOrPassword, - ), - width: null, - ); - }, + () => showSnackbar( + context: context, + content: Text(AppLocalizations.of(context)! + .wrongUsernameOrPassword), + width: null, + ), ), decoration: InputDecoration( labelText: @@ -167,17 +162,13 @@ class _LoginPageState extends State { BlocProvider.of(context).login( usernameController.text, passwordController.text, - () { - if (!context.mounted) return; - showSnackbar( - context: context, - content: Text( + () => showSnackbar( + context: context, + content: Text( AppLocalizations.of(context)! - .wrongUsernameOrPassword, - ), - width: null, - ); - }, + .wrongUsernameOrPassword), + width: null, + ), ), child: Text(AppLocalizations.of(context)!.login), diff --git a/kitchenowl/test/material_localizations_test.dart b/kitchenowl/test/material_localizations_test.dart deleted file mode 100644 index 44d97576e..000000000 --- a/kitchenowl/test/material_localizations_test.dart +++ /dev/null @@ -1,19 +0,0 @@ -import 'package:flutter_test/flutter_test.dart'; -import 'package:kitchenowl/kitchenowl.dart'; -import 'package:material_ui/material_ui.dart'; - -void main() { - testWidgets('provides Material localizations for German', (tester) async { - await tester.pumpWidget( - MaterialApp( - locale: const Locale('de'), - localizationsDelegates: GlobalMaterialLocalizations.delegates + - AppLocalizations.localizationsDelegates, - supportedLocales: const [Locale('de')], - home: Scaffold(appBar: AppBar(title: const Text('KitchenOwl'))), - ), - ); - - expect(tester.takeException(), isNull); - }); -}