Skip to content

Fix Android headless background fetch lifecycle - #1153

Open
MangelSpec wants to merge 3 commits into
TomBursch:mainfrom
MangelSpec:main
Open

MangelSpec wants to merge 3 commits into
TomBursch:mainfrom
MangelSpec:main

Conversation

@MangelSpec

@MangelSpec MangelSpec commented Sep 14, 2026 •

Copy link
Copy Markdown
Contributor

What was happening

Android kept starting KitchenOwl's scheduled background job while the app was closed. The captured ADB output contained the same unhandled exception 14 times:

ADB stack trace
E/flutter (19772): [ERROR:flutter/runtime/dart_vm_initializer.cc(40)] Unhandled Exception: Bad state: Cannot emit new states after calling close
E/flutter (19772): #0      BlocBase.emit (package:bloc/src/bloc_base.dart:100)
E/flutter (19772): #1      AuthCubit._caseUnsupported (package:kitchenowl/cubits/auth_cubit.dart:100)
E/flutter (19772): <asynchronous suspension>
E/flutter (19772): #2      AuthCubit.updateState (package:kitchenowl/cubits/auth_cubit.dart:68)
E/flutter (19772): <asynchronous suspension>

The headless callback created an AuthCubit, whose constructor started asynchronous setup. The task could finish and close the cubit before setup completed, so its API listener later tried to emit on the closed cubit.

What changed

  • Run headless synchronization without creating UI state or an AuthCubit.
  • Initialize package and authentication state before syncing.
  • Wait for the work to finish and always notify Android in finally.
  • Add focused tests for completion, failure, and timeout.

Verification

  • flutter test --no-pub test/background_fetch_headless_task_test.dart (3 tests passed)
  • Scoped flutter analyze (no issues)
  • Debug APK built with the repository's Flutter 3.47.5 toolchain

@TomBursch TomBursch added the enhancement New feature or request label Sep 26, 2026
@TomBursch

Copy link
Copy Markdown
Owner

Thanks for the PR! I'll have to split this up a bit and merge some fixes individually.

Comment thread kitchenowl/lib/pages/login_page.dart Outdated
width: null,
),
() {
if (!context.mounted) return;

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I know this was a bit hacky but this is by design. Because the route can be login screen -> loading screen -> login screen when entering a wrong password, where the context would change and no snackbar would be shown if we checked that it is still mounted.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That makes sense, and you’re right that the mounted check alone would drop the snackbar after the login route is recreated. I kinda didn't think of that.

The existing callback is still unsafe, though, because AppLocalizations.of, ScaffoldMessenger.of, and Theme.of perform ancestor lookups using the old login page context after that route has been disposed. That is the exception I reproduced and why I even wanted to fix it.

I think the proper fix is to use a root ScaffoldMessengerKey, which survives the login -> loading -> login transition and can show the snackbar on the current Scaffold without using the deactivated context.

Comment thread kitchenowl/lib/cubits/auth_cubit.dart Outdated
}

@override
Future<void> close() {

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've merged this seperatly, thanks!

Comment thread kitchenowl/lib/app.dart Outdated
localizationsDelegates:
AppLocalizations.localizationsDelegates +
GlobalMaterialLocalizations.delegates +
AppLocalizations.localizationsDelegates +

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hm, this shouldn't be necessary. If I look at the code for the localizations delegate:

static const List<LocalizationsDelegate<dynamic>> localizationsDelegates =
      <LocalizationsDelegate<dynamic>>[
    delegate,
    GlobalMaterialLocalizations.delegate,
    GlobalCupertinoLocalizations.delegate,
    GlobalWidgetsLocalizations.delegate,
  ];

It already contains material and cupertino delegates

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The generated delegates here come from package:flutter_localizations, while AppBar now comes from package:material_ui. After the package split, those MaterialLocalizations types are different.

The material_ui migration docs specifically recommend GlobalMaterialLocalizations.delegates for apps migrating from flutter_localizations. Without it, I reproduced the No MaterialLocalizations red screen on a German device, and the added widget test covers that case:
https://pub.dev/packages/material_ui/versions/1.4.0#migrating-existing-code-to-this-package

@MangelSpec

Copy link
Copy Markdown
Contributor Author

Thanks for the PR! I'll have to split this up a bit and merge some fixes individually.

Thanks for the review. Splitting this up makes sense. Sorry, I was a bit lazy and bundled a few unrelated fixes into one PR ;)
I can update this PR to contain only the headless background-fetch fix and open the localization fix separately.

- Drop the localization and login changes for separate follow-ups.
- Remove the duplicate auth listener cleanup now present upstream.
@MangelSpec MangelSpec changed the title Fix Android background fetch lifecycle and startup exceptions Fix Android headless background fetch lifecycle Sep 28, 2026

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants