Skip to content

feat: Add direct background execution mode bypassing WorkManager#406

Open
armandsLa wants to merge 2 commits into
ABausG:mainfrom
armandsLa:features/direct
Open

feat: Add direct background execution mode bypassing WorkManager#406
armandsLa wants to merge 2 commits into
ABausG:mainfrom
armandsLa:features/direct

Conversation

@armandsLa
Copy link
Copy Markdown
Contributor

Description

Adds a new direct execution mode for Android background callbacks that runs Dart code directly in the BroadcastReceiver using goAsync(), bypassing WorkManager entirely.

On some devices, WorkManager tasks are delayed between being queued and executed, and on others, they don’t run at all. I’m not sure whether this is an existing issue or something that started happening more recently. #361

HomeWidgetBackgroundIntent.getBroadcast(context, uri, direct = true)

Checklist

  • I have updated/added tests for ALL new/updated/fixed functionality.
  • I have updated/added relevant documentation and added code (documentation) comments where necessary.
  • I have updated/added relevant examples in example or documentation.

Breaking Change?

  • Yes, this PR is a breaking change.
  • No, this PR is not a breaking change.

@docs-page
Copy link
Copy Markdown

docs-page Bot commented Mar 19, 2026

To view this pull requests documentation preview, visit the following URL:

docs.page/abausg/home_widget~406

Documentation is deployed and generated using docs.page.

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Mar 19, 2026

Walkthrough

Broadcast receiver now conditionally routes background intents using an intent extra EXTRA_DIRECT: when true it calls HomeWidgetBackgroundRunner.execute(...); otherwise it enqueues work to the existing worker. A new HomeWidgetBackgroundRunner singleton was added and getBroadcast gains a direct parameter to set the flag.

Changes

Cohort / File(s) Summary
Receiver & Runner
packages/home_widget/android/src/main/kotlin/es/antonborri/home_widget/HomeWidgetBackgroundReceiver.kt, packages/home_widget/android/src/main/kotlin/es/antonborri/home_widget/HomeWidgetBackgroundRunner.kt
Receiver now checks EXTRA_DIRECT and either calls HomeWidgetBackgroundRunner.execute(context, intent, goAsync()) or falls back to HomeWidgetBackgroundWorker.enqueueWork(...). Added HomeWidgetBackgroundRunner singleton: maintains lazy FlutterEngine & MethodChannel (home_widget/background), queues work until background initialization, dispatches work to main thread, and implements MethodChannel.MethodCallHandler.
Intent API
packages/home_widget/android/src/main/kotlin/es/antonborri/home_widget/HomeWidgetIntent.kt
getBroadcast(...) signature updated to accept direct: Boolean = false and the value is written to the intent extra HomeWidgetBackgroundReceiver.EXTRA_DIRECT.

Sequence Diagram(s)

sequenceDiagram
    participant Broadcast as BroadcastReceiver
    participant Runner as HomeWidgetBackgroundRunner
    participant Engine as FlutterEngine
    participant Channel as MethodChannel
    participant Main as MainThread

    Broadcast->>Runner: execute(context, intent, pendingResult)
    activate Runner
    Runner->>Runner: launch coroutine (background)
    alt Engine not initialized
        Runner->>Engine: initialize FlutterEngine using dispatcher handle
        Engine->>Channel: create channel "home_widget/background" and set handler
    end
    Runner->>Runner: prepare args [handle, data] and enqueue or dispatch
    Runner->>Main: post invocation to main thread
    Main->>Channel: invokeMethod("", args, callback)
    Channel->>Runner: invokeResult -> pendingResult.finish()
    deactivate Runner
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

🚥 Pre-merge checks | ✅ 1 | ❌ 2

❌ Failed checks (1 warning, 1 inconclusive)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
Description check ❓ Inconclusive The description addresses the main change and motivation, but critical checklist items (tests, documentation, examples) remain unchecked and no code comments are mentioned as added. Confirm whether tests, documentation, code comments, and examples have been added; if not, either add them or explicitly document their absence and justification.
✅ Passed checks (1 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately summarizes the main change: adding a direct background execution mode that bypasses WorkManager for Android.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
📝 Coding Plan
  • Generate coding plan for human review comments

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Tip

Migrating from UI to YAML configuration.

Use the @coderabbitai configuration command in a PR comment to get a dump of all your UI settings in YAML format. You can then edit this YAML file and upload it to the root of your repository to configure CodeRabbit programmatically.

@codecov
Copy link
Copy Markdown

codecov Bot commented Mar 19, 2026

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 100.00%. Comparing base (fa7453a) to head (8fda7f5).

Additional details and impacted files
@@            Coverage Diff            @@
##              main      #406   +/-   ##
=========================================
  Coverage   100.00%   100.00%           
=========================================
  Files            3         3           
  Lines          115       115           
=========================================
  Hits           115       115           

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 2

🧹 Nitpick comments (2)
packages/home_widget/android/src/main/kotlin/es/antonborri/home_widget/HomeWidgetBackgroundRunner.kt (2)

48-48: Empty method name in invokeMethod("") appears intentional but should be documented.

Using an empty string as the method name is unconventional. If this is the expected protocol for the Dart side's "home_widget/background" channel, please add a brief comment explaining this design choice for maintainability.

📝 Suggested documentation
+        // The Dart background channel expects an empty method name with [callbackHandle, data] as arguments
         mainHandler.post { channel?.invokeMethod("", args) }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@packages/home_widget/android/src/main/kotlin/es/antonborri/home_widget/HomeWidgetBackgroundRunner.kt`
at line 48, The call mainHandler.post { channel?.invokeMethod("", args) } in
HomeWidgetBackgroundRunner.kt uses an empty string for the method name which is
unconventional; add a brief inline comment immediately above the
mainHandler.post (or next to channel?.invokeMethod) explaining that an empty
string is the expected protocol for the Dart-side "home_widget/background"
channel and why (e.g., used as a signal/message-only invocation), so future
maintainers understand the intentional design choice; keep the comment short and
reference the channel name "home_widget/background" and the invokeMethod call.

78-100: Engine initialization exceptions will crash the coroutine but pendingResult.finish() is still called - verify exception propagation.

The initializeFlutterEngine method throws IllegalStateException if the callback handle is missing or lookup fails. These exceptions will propagate up and be caught by the finally block, ensuring pendingResult.finish() is called. This is correct behavior.

However, if FlutterEngine(context) or executeDartCallback throws on the main thread (line 91, 98), the exception will propagate through withContext(Dispatchers.Main) and terminate the coroutine. Consider whether you want to log these errors or handle them more gracefully.

📝 Suggested error handling
   private suspend fun initializeFlutterEngine(context: Context) {
     val callbackHandle = HomeWidgetPlugin.getDispatcherHandle(context)
     if (callbackHandle == 0L) {
-      throw IllegalStateException(
-          "No callbackHandle saved. Did you call HomeWidget.registerBackgroundCallback?"
-      )
+      android.util.Log.e("HomeWidgetBackgroundRunner", 
+          "No callbackHandle saved. Did you call HomeWidget.registerBackgroundCallback?")
+      return
     }
 
     val callbackInfo =
         FlutterCallbackInformation.lookupCallbackInformation(callbackHandle)
-            ?: throw IllegalStateException("Failed to lookup callback information")
+    if (callbackInfo == null) {
+      android.util.Log.e("HomeWidgetBackgroundRunner", "Failed to lookup callback information")
+      return
+    }
 
     withContext(Dispatchers.Main) {
+      try {
         engine = FlutterEngine(context)
         // ...
+      } catch (e: Exception) {
+        android.util.Log.e("HomeWidgetBackgroundRunner", "Failed to initialize FlutterEngine", e)
+      }
     }
   }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@packages/home_widget/android/src/main/kotlin/es/antonborri/home_widget/HomeWidgetBackgroundRunner.kt`
around lines 78 - 100, The initializeFlutterEngine function can throw on the
main thread when constructing FlutterEngine or executing the Dart callback; wrap
the critical part inside withContext(Dispatchers.Main) in a try/catch that
catches Throwable, log the error (including the exception and context such as
the callbackHandle and callbackInfo) and handle it gracefully (e.g., leave
engine null, avoid rethrowing if you want pendingResult.finish() to run
normally, or rethrow after logging if preferred). Specifically, surround the
FlutterEngine(context) creation and
engine?.dartExecutor?.executeDartCallback(callback) calls with a catch block,
use your module logger or Android Log.e to record the exception and relevant
identifiers (callbackHandle, callbackInfo), and ensure the rest of the
coroutine/ finally block (pendingResult.finish()) behaves according to your
chosen recovery strategy.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In
`@packages/home_widget/android/src/main/kotlin/es/antonborri/home_widget/HomeWidgetBackgroundRunner.kt`:
- Around line 51-54: The current finally block calls pendingResult.finish()
before the Dart callback completes; modify HomeWidgetBackgroundRunner.kt so
pendingResult is not finished in the finally block but passed through to the
posted Runnable and finished from the MethodChannel.Result callback of
channel?.invokeMethod (or an equivalent callback) after
success/error/notImplemented, e.g., post the invokeMethod on mainHandler and
call pendingResult.finish() inside each Result override; remove the premature
finish in finally and ensure pendingResult is accessible to the invoked method
call so Dart completion confirms before finishing.
- Around line 33-40: The code has a race where concurrent execute() calls can
both see engine == null and call initializeFlutterEngine(), and channel is
written on Dispatchers.Default but read on main thread causing visibility
issues; fix by adding a dedicated init lock (e.g., private val initLock = Any())
and an ensureEngineInitialized(context) helper (called from execute()) that does
a double-checked pattern: if (engine != null) return; synchronized(initLock) {
if (engine != null) return; initializeFlutterEngine(context) } and mark shared
vars like engine and channel as `@Volatile` to ensure visibility across threads
(references: HomeWidgetBackgroundRunner, execute, initializeFlutterEngine,
engine, channel, initLock, ensureEngineInitialized).

---

Nitpick comments:
In
`@packages/home_widget/android/src/main/kotlin/es/antonborri/home_widget/HomeWidgetBackgroundRunner.kt`:
- Line 48: The call mainHandler.post { channel?.invokeMethod("", args) } in
HomeWidgetBackgroundRunner.kt uses an empty string for the method name which is
unconventional; add a brief inline comment immediately above the
mainHandler.post (or next to channel?.invokeMethod) explaining that an empty
string is the expected protocol for the Dart-side "home_widget/background"
channel and why (e.g., used as a signal/message-only invocation), so future
maintainers understand the intentional design choice; keep the comment short and
reference the channel name "home_widget/background" and the invokeMethod call.
- Around line 78-100: The initializeFlutterEngine function can throw on the main
thread when constructing FlutterEngine or executing the Dart callback; wrap the
critical part inside withContext(Dispatchers.Main) in a try/catch that catches
Throwable, log the error (including the exception and context such as the
callbackHandle and callbackInfo) and handle it gracefully (e.g., leave engine
null, avoid rethrowing if you want pendingResult.finish() to run normally, or
rethrow after logging if preferred). Specifically, surround the
FlutterEngine(context) creation and
engine?.dartExecutor?.executeDartCallback(callback) calls with a catch block,
use your module logger or Android Log.e to record the exception and relevant
identifiers (callbackHandle, callbackInfo), and ensure the rest of the
coroutine/ finally block (pendingResult.finish()) behaves according to your
chosen recovery strategy.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: f2cb7aea-a98d-4f85-bbe8-d0fec9b8c712

📥 Commits

Reviewing files that changed from the base of the PR and between fa7453a and e8910d0.

📒 Files selected for processing (3)
  • packages/home_widget/android/src/main/kotlin/es/antonborri/home_widget/HomeWidgetBackgroundReceiver.kt
  • packages/home_widget/android/src/main/kotlin/es/antonborri/home_widget/HomeWidgetBackgroundRunner.kt
  • packages/home_widget/android/src/main/kotlin/es/antonborri/home_widget/HomeWidgetIntent.kt

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🧹 Nitpick comments (1)
packages/home_widget/android/src/main/kotlin/es/antonborri/home_widget/HomeWidgetBackgroundRunner.kt (1)

111-148: Engine lifecycle consideration.

The FlutterEngine is created once and never destroyed. While this provides faster response for subsequent broadcasts, it may consume memory if no further widget interactions occur. Consider whether adding a cleanup mechanism (e.g., destroy engine after a timeout of inactivity) would be beneficial for resource-constrained devices.

This is not a blocking concern for the current implementation—keeping the engine warm is a reasonable tradeoff for widget responsiveness.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@packages/home_widget/android/src/main/kotlin/es/antonborri/home_widget/HomeWidgetBackgroundRunner.kt`
around lines 111 - 148, initializeFlutterEngine currently creates a
FlutterEngine and never releases it (engine and channel stay alive), which can
leak memory on idle devices; add a cleanup mechanism by implementing a
destroy/cleanup method (e.g., destroyEngine or shutdownEngine) that calls
engine.destroy() and clears channel and engine references, and wire it to a
timeout/inactivity scheduler (use a CoroutineScope with a delayed job or
Handler) started after engine initialization in initializeFlutterEngine and
cancelled when the engine is reused; ensure the timeout duration is configurable
and that destroyEngine is also called from any explicit lifecycle teardown
points to avoid leaks.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In
`@packages/home_widget/android/src/main/kotlin/es/antonborri/home_widget/HomeWidgetBackgroundRunner.kt`:
- Around line 45-46: The code uses HomeWidgetPlugin.getHandle(context) without
validating its return value; update HomeWidgetBackgroundRunner (before building
args/listOf(...)) to check the handle returned by
HomeWidgetPlugin.getHandle(context) and if it equals 0L, log or abort the
background invocation and return early instead of constructing args and invoking
Dart; ensure you reference the handle variable (e.g., val handle =
HomeWidgetPlugin.getHandle(context)) and only proceed to create args =
listOf(handle, data) and call the dispatcher when handle != 0L.

---

Nitpick comments:
In
`@packages/home_widget/android/src/main/kotlin/es/antonborri/home_widget/HomeWidgetBackgroundRunner.kt`:
- Around line 111-148: initializeFlutterEngine currently creates a FlutterEngine
and never releases it (engine and channel stay alive), which can leak memory on
idle devices; add a cleanup mechanism by implementing a destroy/cleanup method
(e.g., destroyEngine or shutdownEngine) that calls engine.destroy() and clears
channel and engine references, and wire it to a timeout/inactivity scheduler
(use a CoroutineScope with a delayed job or Handler) started after engine
initialization in initializeFlutterEngine and cancelled when the engine is
reused; ensure the timeout duration is configurable and that destroyEngine is
also called from any explicit lifecycle teardown points to avoid leaks.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: 6bd0a9f9-b91b-4b81-9c96-e5427226cb43

📥 Commits

Reviewing files that changed from the base of the PR and between e8910d0 and 8fda7f5.

📒 Files selected for processing (1)
  • packages/home_widget/android/src/main/kotlin/es/antonborri/home_widget/HomeWidgetBackgroundRunner.kt

Comment on lines +45 to +46
val data = intent.data?.toString() ?: ""
val args = listOf(HomeWidgetPlugin.getHandle(context), data)
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Missing validation of getHandle() return value.

Per the relevant code snippet, HomeWidgetPlugin.getHandle(context) returns 0L if the callback was never registered or after app data is cleared. While getDispatcherHandle() is validated in initializeFlutterEngine(), getHandle() is not. Passing 0L to the Dart side will cause the callback lookup to fail silently.

Consider adding validation:

🛡️ Proposed fix
       val data = intent.data?.toString() ?: ""
+      val handle = HomeWidgetPlugin.getHandle(context)
+      if (handle == 0L) {
+        Log.w(TAG, "No callback handle registered. Ignoring background event.")
+        pendingResult.finish()
+        return@launch
+      }
-      val args = listOf(HomeWidgetPlugin.getHandle(context), data)
+      val args = listOf(handle, data)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@packages/home_widget/android/src/main/kotlin/es/antonborri/home_widget/HomeWidgetBackgroundRunner.kt`
around lines 45 - 46, The code uses HomeWidgetPlugin.getHandle(context) without
validating its return value; update HomeWidgetBackgroundRunner (before building
args/listOf(...)) to check the handle returned by
HomeWidgetPlugin.getHandle(context) and if it equals 0L, log or abort the
background invocation and return early instead of constructing args and invoking
Dart; ensure you reference the handle variable (e.g., val handle =
HomeWidgetPlugin.getHandle(context)) and only proceed to create args =
listOf(handle, data) and call the dispatcher when handle != 0L.

@ABausG
Copy link
Copy Markdown
Owner

ABausG commented Mar 25, 2026

Can you say a bit more on what the functionality of the change is?

Also did you check that this mode works in all scenarios of the app? E.g App is running, App is fully closed, App is in background mode.

Also could this potentially be combined with the existing work manager and then skipping the queue there and do the work directly?

Wondering if this could be combined for two reasons:

  1. Reduce Code Duplication (e.g seeing quite a bit of duplication between this and the Background Worker)
  2. Is there some conditions where we could generally say that this direct approach is better in certain scenarios and only if not met fall back to work manager. Once again for this it would be great to hear your input on what use case you have for this change.

Thanks for your continued contributions!

@armandsLa
Copy link
Copy Markdown
Contributor Author

Recently, some of my users reported slow or unresponsive widget actions.

After investigating, I found the following:

  • On certain devices, WorkManager tasks may be postponed, which prevents immediate widget execution.
  • Even when executed, WorkManager consistently introduces additional latency

In my testing, when the app is already “warm” (not the first action after a longer idle period):

  • Direct execution completes in ~300 ms
  • WorkManager execution takes ~1200 ms

I have ~50k active users in a project that relies heavily on widgets. I recently released an update using direct execution, and so far the results have been very positive. Users are reporting significantly faster and more responsive widget interactions.

Regarding your questions:

  1. Yes, I tested this across all app states: foreground, background, and inactive
  2. I agree there is some code duplication. I initially considered merging the WorkManager and direct execution paths, but that increases the risk of regressions. Keeping them separate made the changes easier to review and safer to release. This can be refactored if needed.

To be honest, I struggle to see the benefit of using WorkManager at all. Widget actions are explicitly user-initiated and should execute immediately. Relying on a system like WorkManager, which is subject to Doze mode, OEM restrictions, and scheduling delays, feels counterintuitive for this use case. Am I missing something?

@ABausG
Copy link
Copy Markdown
Owner

ABausG commented Apr 9, 2026

Thanks for the reasoning and the work on this!
All makes a lot of sense and I like that this also allows a potential quite smooth transition to shift to this.

Agree with also potentially always using this going forward but with this approach I think it leaves the option open as well!

Copy link
Copy Markdown
Owner

@ABausG ABausG left a comment

Choose a reason for hiding this comment

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

Overall looks good.

Apart from the formatting failure can you please also add a brief paragraph to the docs to indicate about this option 🙏

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants