Skip to content

PoC: Hermes-in-process AppIntent reactivity (Track 4 — Android)#170

Draft
burczu wants to merge 7 commits into
callstackincubator:mainfrom
burczu:poc/widget-reactivity-track-4
Draft

PoC: Hermes-in-process AppIntent reactivity (Track 4 — Android)#170
burczu wants to merge 7 commits into
callstackincubator:mainfrom
burczu:poc/widget-reactivity-track-4

Conversation

@burczu
Copy link
Copy Markdown
Contributor

@burczu burczu commented Jun 2, 2026

Closes #165

Summary

Proof-of-concept for the Android equivalent of Track 2 — making Voltra widgets reactive to
user-configured parameters without a server push, by running a thin JS resolver inside the main
app process using Hermes.

Unlike iOS, Android widget updates already run inside the main app process via WorkManager — there
is no sandboxed extension to slot JS into. So the "JS-in-extension" framing doesn't translate
directly. Instead, Voltra instantiates a standalone Hermes runtime (via a custom JNI/NDK wrapper)
inside VoltraGlanceWidget.provideGlance(), evaluates the same @use-voltra/*-renderer JS bundle
that ships on iOS, and resolves {{ appIntent.X }} placeholders against parameters stored in
DataStore.

The architectural claim: one JS resolver bundle, two platforms. The PoC validates that Hermes
can be embedded standalone (independent of RN's bridge) and invoked from a Glance render path with
acceptable cold-start latency and memory footprint.

WorkManager / Glance render
    ↓
VoltraGlanceWidget.provideGlance() reads payload + reads DataStore params
    ↓
VoltraJSRenderer (Kotlin singleton) → JNI → standalone Hermes runtime
    ↓ (evaluates ~1.1 KB JS bundle, calls resolve(payload, params))
resolved VoltraPayload
    ↓
existing GlanceFactory render path (unchanged)

Developer experience

Same surface as iOS Track 2 — a developer writes no Kotlin, no C++, no JNI. The workflow:

  1. Write a JSX component using appIntentParam():

    import { VoltraAndroid, appIntentParam } from '@use-voltra/android'
    
    export const AndroidReactiveWeatherWidget = () => (
      <VoltraAndroid.Column style={{ flex: 1, padding: 16, backgroundColor: '#1E293B' }}>
        <VoltraAndroid.Text style={{ fontSize: 22, color: '#FFFFFF' }}>
          {appIntentParam('city')}
        </VoltraAndroid.Text>
      </VoltraAndroid.Column>
    )
  2. Add an appIntent block to app.json under the Android widget config.

  3. Run expo prebuild — the config plugin copies the resolver bundle + a per-widget defaults
    JSON into the Android assets directory and registers the widget.

appIntentParam('city') returns {{ appIntent.city }}, which passes through the server renderer
unchanged and is resolved locally by Hermes at render time.

For the PoC, the parameter source is a DataStore-backed value populated by an in-app
TextInput + Submit button (the stand-in for a future Glance configuration activity).

What was built

Artifact Location Purpose
@use-voltra/android-renderer packages/android-renderer/ ~1.1 KB IIFE JS bundle — same source as iOS Track 2's ios-renderer, mirrored for Android consumption
Custom JNI wrapper packages/android-client/android/src/main/cpp/voltra_js_renderer.cpp C++/JNI shim over hermes::makeHermesRuntime() + Runtime::evaluateJavaScript()
Native build config packages/android-client/android/CMakeLists.txt, build.gradle NDK linkage to Hermes via hermes-engine::hermesvm prefab module
VoltraJSRenderer packages/android-client/android/src/main/java/voltra/runtime/VoltraJSRenderer.kt Kotlin singleton, lazy-init Hermes runtime, thread-safe resolve()
VoltraGlanceWidget hook packages/android-client/android/src/main/java/voltra/widget/VoltraGlanceWidget.kt Calls resolver before VoltraPayloadParser.parse(...) (fast-path skip for non-reactive payloads)
AppIntentParamsStore packages/android-client/android/src/main/java/voltra/runtime/AppIntentParamsStore.kt DataStore-backed param storage + asset-loaded defaults
setAppIntentParam(widgetId, name, value) packages/android-client/src/widgets/api.ts + TurboModule JS → Kotlin bridge: write param + trigger Glance update
Config plugin step packages/android-client/expo-plugin/src/android/files/bundle.ts Copies bundle + emits defaults JSON to assets/voltra/ (opt-in when widgets have appIntent)
Example widget example/widgets/android/AndroidReactiveWeatherWidget.tsx End-to-end example mirroring iOS Track 2's Reactive Weather
Example UI example/screens/android/AndroidReactiveWidgetScreen.tsx TextInput + Submit button for live param updates

Exit criterion — proved on Android emulator (2026-06-02)

  • Hermes can be instantiated standalone via custom JNI wrapper ✅
  • @use-voltra/android-renderer bundle evaluates successfully ✅
  • {{ appIntent.X }} placeholders resolve at render time ✅
  • Widget re-renders on demand (TextInput submit → DataStore → Glance trigger → resolved render) ✅
  • Cold-start latency of Hermes init ✅ (emulator x86_64, see below) / pending real device
  • Memory footprint of the Hermes runtime ✅ (lost in GC noise on emulator) / pending real device

Measurements (Android emulator, x86_64, fresh process)

Captured via VoltraJSRenderer instrumentation (adb logcat -s VoltraJSRenderer:V) on a
cold-started app process:

Stage Time Detail
Asset read 0.53 ms assets/voltra/android-renderer.js — 1,148 chars from APK
Hermes runtime + bundle eval 3.70 ms hermes::makeHermesRuntime() + evaluateJavaScript()
First resolve() call 0.29 ms JNI round-trip incl. JSON.parse × 2 + JS call + JSON.stringify
Total first reactive render overhead ~4.5 ms

Memory: before/after dumpsys meminfo deltas stayed within ~1–2 MB GC noise on a baseline
~298 MB total PSS (typical RN app). Since RN already links libhermes.so for its bridge, the
shared library cost is zero; the standalone runtime adds only a VM instance + bundle bytecode.

Resolver fast-path: widgets without any {{ appIntent. token never invoke Hermes — the
fast-path check happens in pure Kotlin, so non-reactive widgets pay zero overhead.

Real-device arm64 numbers should be re-captured before any merge decision; emulator figures are a
useful lower bound but not a substitute for production hardware.

Not yet proved (explicit deferrals)

  • Glance configuration activity integration (Android equivalent of #147)
  • Performance comparison vs PR #130's opcode-tuple resolver
  • Rename to @use-voltra/widget-renderer (cleanup at merge time if both Track 2 and Track 4 land)
  • Build-time codegen equivalent on Android (Track 3 analog — separate PoC if pursued)
  • Alternative JS engines on Android (QuickJS, V8) — separate PoC parallel to expo-widgets exploration on iOS

Critical risk (resolved)

The JNI path was the single biggest unknown going in. Hermes's hermes-engine::hermesvm prefab
module (RN 0.83) exposes hermes::makeHermesRuntime() directly to a Voltra-owned native target —
no ABI surprises, no reflection into RN internals. The Phase 0 smoke test cleared this gate at
the start of implementation.

Test plan

  • Smoke test: standalone Hermes runtime created, 1 + 1 evaluates to 2
  • expo prebuild in example/ — bundle + defaults JSON copied to android/app/src/main/assets/voltra/
  • Build via npm run androidBUILD SUCCESSFUL
  • Add android_reactive_weather widget on home screen → renders "New York" from app.json default
  • Open example app → Android tab → Reactive Widget → enter "Warsaw" → tap Submit → widget re-renders
  • Real device (arm64): re-capture Hermes init cold-start time
  • Real device (arm64): re-capture runtime memory footprint

burczu added 6 commits June 1, 2026 14:31
Validates that:
  - Voltra's android-client AAR can carry native code
  - Hermes headers + libhermes are linkable from a Voltra-owned target
    via the `hermes-engine::hermesvm` prefab (RN 0.83)
  - `hermes::makeHermesRuntime()` works outside the React Native bridge

Verified in logcat on Android emulator:
  VoltraSmokeTest: Hermes OK: 1 + 1 = 2

Throwaway code — Phase 1 promotes this into permanent JNI infra.
…e 1)

Promotes the Phase 0 smoke test into permanent infrastructure:

- voltra_js_renderer.cpp — JNI surface with nativeInit/nativeResolve over a
  process-singleton Hermes runtime; mirrors iOS Track 2's VoltraJSRenderer.swift
  one layer lower (no Kotlin/Java Hermes API exists on Android)
- VoltraJSRenderer.kt — Kotlin singleton, lazy init, thread-safe via
  synchronized lock; public API: ensureInitialized(source) + resolve(payload, params)
- CMakeLists target renamed voltra_smoke → voltra_js_renderer; smoke sources
  deleted

Phase 1 self-test (temporary, removed in Phase 3): VoltraModule.init loads a
minimal in-Kotlin test bundle and resolves a sample payload to verify the full
Kotlin → JNI → Hermes → JS → back round-trip works.

Verified on emulator:
  VoltraJSRenderer: Hermes runtime initialized
  VoltraModule: [Phase 1 self-test] resolved =
    {"v":1,"systemSmall":{"t":0,"c":"Warsaw weather","p":{"fs":22}}}
…ase 2)

JS resolver package consumed by VoltraJSRenderer (the Hermes runtime owned by
@use-voltra/android-client). Exposes `globalThis.VoltraRenderer.resolve` which
substitutes `{{ appIntent.X }}` placeholders in a widget payload — same shape
the iOS Track 2 PoC's @use-voltra/ios-renderer ships, but packaged separately
for the Android-only branch.

Bundle build via esbuild → bundle/android-renderer.js (~1.1 KB IIFE).
7 node:test cases against build/cjs/ verify substitution, passthrough keys,
unknown-param fallback, and JSON round-trip.

If both Track 2 and Track 4 eventually land, both packages collapse into a
single platform-neutral @use-voltra/widget-renderer.
…th (Phase 3)

- AppIntentParamsStore: DataStore-backed per-widget parameter storage. Keyed as
  `voltra.appintent.<widgetId>.<paramName>`. Future in-app screen (Phase 4)
  writes here; provideGlance reads.
- VoltraJSRenderer: new ensureInitializedFromAssets(context) — lazy-loads the
  android-renderer.js bundle from `assets/voltra/`. Silent no-op when missing
  (existing widgets unaffected; reactive ones fall back to unresolved payload).
- VoltraGlanceWidget.provideGlance: invokes resolver before parsing, but only
  when the payload contains `{{ appIntent.` — non-reactive widgets pay zero
  Hermes overhead.
- VoltraModule: Phase 1 self-test removed; the real Glance render path now
  exercises the same code.

Verified on emulator: existing weather/portfolio widgets render unchanged,
no Hermes init triggered (fast-path skip), no errors.
End-to-end demo: change a city parameter in-app → Glance widget re-renders with
the new value via Hermes, no server push.

Plumbing
- expo-plugin: AppIntentParameter + AndroidWidgetAppIntentConfig types,
  bundle.ts step that copies @use-voltra/android-renderer + emits
  appintent_defaults.json under `assets/voltra/` when any widget has appIntent
- TurboModule: setAppIntentParam(widgetId, name, value) — DataStore write +
  Glance update trigger
- @use-voltra/android: appIntentParam('name') JSX helper mirroring iOS Track 2
- AppIntentParamsStore.getParamsWithDefaults: merges DataStore values over
  defaults loaded from the asset emitted by the plugin

Example app
- AndroidReactiveWeatherWidget — mirrors iOS Track 2's IosReactiveWeatherWidget,
  styled with a dark slate background + rounded corners
- AndroidReactiveWidgetScreen — TextInput + Submit (stand-in for Glance config)
- New widget config in app.json (`android_reactive_weather`, default city "New York")
- Server-side renderAndroid case for the widget id

Verified on emulator: widget shows "New York" on first install, re-renders
with submitted values, ~zero overhead for non-reactive widgets (fast-path
skips Hermes entirely when payload has no `{{ appIntent.` token).
…imings (Phase 5)

Adds nanosecond timing around bundle asset read, native Hermes init, and each
JSI call. Logged at INFO so the PoC's claim is verifiable from logcat.

Emulator (x86_64, fresh process):
  Asset read         : 0.53 ms  (1148 chars)
  Hermes cold-start  : 3.70 ms  (makeHermesRuntime + evaluate bundle)
  Hermes resolve     : 0.29 ms  (JSON.parse × 2 + JS call + JSON.stringify)
  Total first render : ~4.5 ms

Memory delta is lost in GC noise (~1–2 MB) on a baseline ~298 MB RN app PSS.
The Hermes runtime instance amortises over the process lifetime; subsequent
resolve calls hit the cached runtime and stay sub-millisecond.

Numbers should be re-measured on a real arm64 device before any merge decision.
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.

Widget reactivity: support on-device state changes without a server push

1 participant