Skip to content

feat(android-client): add a native-to-JavaScript event channel - #362

Open
xdimota7x wants to merge 1 commit into
callstackincubator:mainfrom
xdimota7x:feat/android-event-channel
Open

xdimota7x wants to merge 1 commit into
callstackincubator:mainfrom
xdimota7x:feat/android-event-channel

Conversation

@xdimota7x

Copy link
Copy Markdown

What is this?

Android has no way to tell an app's JavaScript that something happened while the app was not watching. addVoltraListener warns "only supported on iOS" for every event and returns a no-op. #354 removed the Kotlin event bus because it could never hold an event, and the one native-to-JavaScript signal that exists, onOngoingNotificationEnded (ADR 0012), is internal, not queued, and only reaches a running app.

This adds the event channel that #314 (Dynamic Widget taps) and #316 (ongoing notification actions, replies, taps and dismissals) depend on. On Android, addVoltraListener now returns a working subscription for interaction, notificationDismissed and notificationTapped. A new registerInteractionHandler registers an interaction handler at module scope. Nothing produces events yet: the notification receivers come in the follow-up PR for #316. The decision is recorded in ADR 0014.

How does it work?

  • Persist first. Native code sends every event to a queue in SharedPreferences, written with commit() before send returns, so an event survives the process being reclaimed right after a broadcast receiver finishes. Events are ordered by a persisted seq and carry a random eventId. The queue follows Android Dynamic Widgets: deliver interaction events to the React Native app (app-level half of #276) #314's rules: at most 200 events (oldest dropped with a warning), entries older than 7 days pruned, and unreadable entries skipped without losing the rest. All event types share one queue.
  • Deliver to an active listener, per type. JavaScript turns a type on when its first listener registers (setEventListenerActive(type, true)) and off when its last one is removed. Turning a type on drains that type's queued events, oldest first, through a codegen onVoltraEvent emitter, marked delivery: 'replay'. An event sent while a listener is active is emitted at once as 'live'. Events of types nobody listens to keep their place, so a notificationDismissed listener cannot consume interactions meant for a listener that registers a moment later. Sending and draining share one lock, so an event sent during a replay is emitted after the replayed ones. Each event is removed from the queue just before it is emitted: delivery is at most once.
  • Wake JavaScript only for interactions, and only when nothing is running. The delivery policy, the seam Android Dynamic Widgets: deliver interaction events to the React Native app (app-level half of #276) #314 describes, starts a HeadlessJsTaskService when an interaction finds no React instance in the process and the app is not in the foreground. The receiver takes the wake lock and calls startForegroundService before it returns. The service runs as a shortService foreground service with a silent, low-importance notification. Dismissals and taps never wake JavaScript; they wait for the next listener. The headless task, registered by @use-voltra/android-client on import, does no work itself: loading the bundle runs the app's module-scope listeners, which drain the queue, and the task keeps the process alive until their promises settle.
  • Plugin. With enableNotifications, the Expo plugin declares the service and adds FOREGROUND_SERVICE and WAKE_LOCK.
  • VoltraView on Android stops subscribing to interaction. Android views never emitted interaction events, and a view's listener would now drain notification actions meant for the app's own listener. Its onInteraction prop still warns, and its callback is now typed with VoltraViewInteractionEvent, which has the same fields as before.

ADR 0014 explains why the channel differs from #314 in places: one queue instead of one per event kind, and headless delivery instead of only a seam for it.

Why is this useful?

Native code on Android can now report events that JavaScript receives reliably: in order, once, and even when they happen while the app is not running. #316 builds on this so that a press on "Pause" or "Add tip" in an ongoing notification runs app code without opening the app. #314 can add widget taps by producing new VoltraEvent subclasses, without changing the queue, the policy or the JavaScript API.

Android gains a persisted event queue that delivers native events to
`addVoltraListener` (ADR 0014): `interaction`, `notificationDismissed`
and `notificationTapped`, replayed in order to the first listener for
their type, at most once. An `interaction` that finds no running app
starts the app's JavaScript through a headless task.

This is the channel callstackincubator#314 and callstackincubator#316 depend on; the receivers that produce
notification events follow separately.

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.

1 participant