Skip to content

feat(android): send ongoing notification actions, replies, taps and dismissals to JavaScript - #365

Open
xdimota7x wants to merge 4 commits into
callstackincubator:mainfrom
xdimota7x:feat/android-ongoing-notification-actions-on-361
Open

xdimota7x wants to merge 4 commits into
callstackincubator:mainfrom
xdimota7x:feat/android-ongoing-notification-actions-on-361

Conversation

@xdimota7x

Copy link
Copy Markdown

What is this?

Android ongoing notification actions can now run app code instead of only opening a deep link. An action can set an actionId, with an optional string payload and an inline reply field (remoteInput, with optional choices). Pressing it sends an interaction event to JavaScript, even when the app is in the background or its process has been killed. Apps also receive notificationTapped when the user taps the notification body, and notificationDismissed when the user swipes it away. Actions can also set authenticationRequired and semanticAction.

Closes #316. This PR builds on the Android event channel from #361 and should be reviewed and merged after it.

How does it work?

AndroidOngoingNotification.Action takes either deepLinkUrl (unchanged) or actionId, never both. The renderer rejects invalid combinations, and existing deep-link payloads serialise exactly as before. On Android 12+ a notification receiver may not start an Activity, so one press can either open the app or send an event, not both.

An event action uses a broadcast PendingIntent that targets VoltraOngoingNotificationActionReceiver. The receiver:

The channel never starts JavaScript itself, so this PR adds that step. When the app is not in the foreground, the receiver starts VoltraHeadlessEventService, a short foreground service that runs the VoltraEvents headless task. The task pulls the queued events and waits for the promises the listeners return, so the app can update the notification before the process can be frozen. When the app is in the foreground, nothing is started; the app pulls the event as usual.

Taps and dismissals are recorded in the same channel and never start JavaScript. A tap is read from the launch intent on a cold start and from onNewIntent on a warm start, and is reported once.

Apps listen with addVoltraListener('interaction' | 'notificationTapped' | 'notificationDismissed', …) at module scope. On iOS these three event types log a warning and do nothing. Events wait up to 15 minutes for a listener, the channel's default.

Each record now keeps its last payload and a revision. The revision increases on every update, so an app can ignore a press on buttons from an older version of the notification. stopOngoingNotification and endAllOngoingNotifications cancel the action intents, so buttons left in the shade after a stop do nothing. The Expo plugin registers the receivers and the service, and adds the FOREGROUND_SERVICE and WAKE_LOCK permissions under enableNotifications.

ADR 0015 records the design, including where it departs from #316:

  • Taps are queued like other events, since the channel has no separate path for live-only events.
  • Events are built as plain channel events, so toMap/fromMap are not needed.
  • End-to-end tests kill the process instead of force-stopping the app.

Why is this useful?

Apps can handle actions like "Pause", "Add tip" or "Stop navigation" from the notification shade without bringing the app to the foreground, even after Android has killed the process. Before this, every action had to open the app through a deep link, and the app never learned that its notification was tapped or dismissed. This brings Android ongoing notifications closer to the interaction events iOS Live Activities already have.

Konstantin Marushchak and others added 4 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.
…ismissals to JavaScript

An `AndroidOngoingNotification.Action` can now take an `actionId`
instead of a `deepLinkUrl`. A press records an `interaction` event in
the Android event channel (ADR 0014) without opening the app; when the
app is not in the foreground, a short headless service runs its
JavaScript until the listeners finish. Event actions can carry a
`payload` and a `remoteInput` for direct reply; both action forms accept
`authenticationRequired` and `semanticAction`. Dismissals and content
taps are reported as `notificationDismissed` and `notificationTapped`.

Implements callstackincubator#316 on top of the channel from callstackincubator#361; decisions recorded in
ADR 0015.

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.

Android ongoing-notification actions beyond deep links: JS action events, direct reply, authentication-required actions, tap and dismiss events

1 participant