Skip to content

feat(android-client): surface Dynamic Widget lifecycle events to the app - #361

Open
konstmar wants to merge 3 commits into
callstackincubator:mainfrom
konstmar:feat/android-widget-lifecycle-events
Open

konstmar wants to merge 3 commits into
callstackincubator:mainfrom
konstmar:feat/android-widget-lifecycle-events

Conversation

@konstmar

Copy link
Copy Markdown

Closes #315. Supersedes the transport proposed in #314.

What is this?

An Android app can now find out when a user places, resizes, removes or restores one of its Dynamic Widgets, and when the first placement of a widget type appears or the last one goes away. It subscribes with the existing addVoltraListener to six events: widgetAdded, widgetResized, widgetRemoved, widgetRestored, widgetEnabled and widgetDisabled. Events that happen while the app is closed are delivered the next time it subscribes.

Until now addVoltraListener was a no-op on Android, and the only way to notice a placement was to poll getActiveWidgets() and diff snapshots. That can't tell "removed" from "never placed" and can't run while the app is dead. getActiveWidgets() also gains sizes, so a placement looks the same whether it is read or received. The design decisions, including where this departs from #314 and #315, are recorded in ADR 0014.

addVoltraListener('widgetAdded', ({ widgetType, appWidgetId, width, height, sizes }) => {
  seedConfiguration(widgetType, appWidgetId, sizes)
})

How does it work?

When the launcher calls one of the Dynamic Widget receiver's callbacks, the receiver first runs its existing native cleanup, then updates a placement registry, then records the event in a native queue persisted in SharedPreferences. This happens whether or not the app is running. If JavaScript is running, native signals that events are available. JavaScript then removes the events for the types it listens to and receives them in one synchronous call, oldest first. If the app was dead, the same pull happens as soon as a listener subscribes or the app returns to the foreground.

This is the pull design ADR 0011 chose for iOS interactions. Native never tracks whether JavaScript is listening, so #314's listener-active flag and hot delivery are not used.

  • At most once. popEvents removes what it returns, so a JavaScript reload never redelivers. Events whose listeners all unsubscribe mid-dispatch go back to the queue.
  • Bounded. Lifecycle events are kept 7 days and any other type 15 minutes, with at most 200 in total. Repeated widgetResized events for one placement collapse to the latest.
  • Placement registry. It records each placement's last size, so widgetAdded and widgetEnabled fire only for real changes. The system's re-announcements at boot and after a package update add or enable nothing, though a launcher that reports a new size at boot produces a widgetResized. On first use the registry is seeded silently from the existing placements.
  • Payload widgets are unchanged.

Why is this useful?

Apps can seed a new placement's configuration or initial props when it appears, stop their own sockets, subscriptions or analytics when it goes away, and send props suited to a new size. They can also count placements without diffing snapshots across launches. The channel is generic, so the widget interactions in #314 and the notification actions in #316 can add producers later without a new transport.

Konstantin Marushchak added 3 commits October 26, 2026 16:08
Adds a persisted native event queue with retention, a size cap and coalescing, exposed through popEvents/restoreEvents/onEventsAvailable, and a JS dispatcher behind addVoltraListener for the six widget lifecycle event types.
The Dynamic Widget receiver now records widgetAdded, widgetResized, widgetRemoved, widgetRestored, widgetEnabled and widgetDisabled through a placement registry, and getActiveWidgets() reports each placement's launcher sizes.
Adds ADR 0014, a website page for the lifecycle events and the sizes field, and the version plan.
@konstmar

konstmar commented Oct 26, 2026 •

Copy link
Copy Markdown
Author

Emulator evidence

Test layout: the Widgets tab of the example app has an Android placements card. It lists getActiveWidgets() with each placement's sizes, and below that keeps a running log of every lifecycle event it receives (local time, type, widget type, appWidgetId, and sizes when present). The Dynamic Widget is AndroidClientDemoWidget. The payload-driven widget is voltra.

Build: a debug build of the example app from this branch @ f1c91fc, before the history was squashed, with Metro serving the bundle. The later commits fold the test hooks back into the receiver, rename the size checks, guard one size read and change the off-Android warning. None of that changes what these clips show.

Android 15 emulator, API 35 (AVD Pixel_9)

1. Enable, add and resize with the app open

clip1-enable-add-resize.mp4

No AndroidClientDemoWidget was placed. Pinned one from the example's pin screen, then dragged its resize handle from 2 to 4 columns. The log shows widgetEnabled, widgetAdded #9 with its sizes, and two widgetResized events, because the drag stopped at 3 columns on the way to 4. The card shows the new placement's sizes.

This clip was recorded right after clip 3, whose last placement removal ended in widgetDisabled, so it also shows widgetEnabled firing again after a disable.

2. Payload widget

clip2-payload-widget-negative.mp4

Added and resized the payload-driven voltra widget (#6). Nothing is logged.

3. App dead

clip3a-dead-actions.mp4
clip3b-relaunch.mp4

Backgrounded the app, killed its process with kill -9 (not force-stop), resized #8 three times, then removed it by hand. On relaunch the log shows one widgetResized with the latest size, then widgetRemoved #8, then widgetDisabled, each once, and the native queue is empty afterwards.

A process check between the steps confirmed no Activity existed until the relaunch, so JavaScript never ran. Each broadcast briefly started a receiver-only process, and that process recorded the events.

4. JavaScript reload

clip4-js-reload.mp4

Reloaded JavaScript. The log stays empty, so events that were already delivered don't arrive again.

5. Reboot with placements present

clip5-boot.mp4

With AndroidClientDemoWidget #5 and voltra #6 placed, rebooted and opened the app. No widgetAdded or widgetEnabled is logged.

One widgetResized for #5 arrives. About a minute after boot, before the app opened, the launcher re-sent #5's options with a different landscape width (630.86 instead of 685.71 dp), so this is a real size change. The docs page says the launcher can report a new size after a reboot.

6. Cleared app data

clip6-cleared-data.mp4

With #5 placed, ran pm clear and relaunched. Nothing is logged. The registry is seeded again with #5, and the event queue is empty.

Steps:

  1. Start Metro from example/ and install a debug build of the example app.
  2. Open the Widgets tab and pin widgets from the example's pin screen.
  3. Resize with adb shell input swipe on a placement's resize handle. Pixel Launcher rejects a scripted drag-to-remove, so placements were removed by hand.
  4. Kill the backgrounded app with adb shell run-as <package> kill -9 <pid>, then check pidof and the activity stack.
  5. Reboot with adb reboot, and clear data with adb shell pm clear <package>.
  6. Read the native queue and registry from the app's shared preferences: voltra_event_queue_v1 and voltra_dynamic_widget_placements_v1.

Unit tests: pnpm test:kotlin (664 tests), pnpm test, pnpm typecheck, pnpm format:js:check, pnpm format:kotlin:check and the website build pass on this branch, and so do the pre-push test-js/test-kotlin hooks.

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

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Surface Android Dynamic Widget lifecycle events (added, removed, resized, restored, enabled, disabled) to the app

1 participant