PoC: Hermes-in-process AppIntent reactivity (Track 4 — Android)#170
Draft
burczu wants to merge 7 commits into
Draft
PoC: Hermes-in-process AppIntent reactivity (Track 4 — Android)#170burczu wants to merge 7 commits into
burczu wants to merge 7 commits into
Conversation
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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/*-rendererJS bundlethat ships on iOS, and resolves
{{ appIntent.X }}placeholders against parameters stored inDataStore.
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.
Developer experience
Same surface as iOS Track 2 — a developer writes no Kotlin, no C++, no JNI. The workflow:
Write a JSX component using
appIntentParam():Add an
appIntentblock toapp.jsonunder the Android widget config.Run
expo prebuild— the config plugin copies the resolver bundle + a per-widget defaultsJSON into the Android assets directory and registers the widget.
appIntentParam('city')returns{{ appIntent.city }}, which passes through the server rendererunchanged 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
@use-voltra/android-rendererpackages/android-renderer/ios-renderer, mirrored for Android consumptionpackages/android-client/android/src/main/cpp/voltra_js_renderer.cpphermes::makeHermesRuntime()+Runtime::evaluateJavaScript()packages/android-client/android/CMakeLists.txt,build.gradlehermes-engine::hermesvmprefab moduleVoltraJSRendererpackages/android-client/android/src/main/java/voltra/runtime/VoltraJSRenderer.ktresolve()VoltraGlanceWidgethookpackages/android-client/android/src/main/java/voltra/widget/VoltraGlanceWidget.ktVoltraPayloadParser.parse(...)(fast-path skip for non-reactive payloads)AppIntentParamsStorepackages/android-client/android/src/main/java/voltra/runtime/AppIntentParamsStore.ktsetAppIntentParam(widgetId, name, value)packages/android-client/src/widgets/api.ts+ TurboModulepackages/android-client/expo-plugin/src/android/files/bundle.tsassets/voltra/(opt-in when widgets haveappIntent)example/widgets/android/AndroidReactiveWeatherWidget.tsxexample/screens/android/AndroidReactiveWidgetScreen.tsxExit criterion — proved on Android emulator (2026-06-02)
@use-voltra/android-rendererbundle evaluates successfully ✅{{ appIntent.X }}placeholders resolve at render time ✅Measurements (Android emulator, x86_64, fresh process)
Captured via
VoltraJSRendererinstrumentation (adb logcat -s VoltraJSRenderer:V) on acold-started app process:
assets/voltra/android-renderer.js— 1,148 chars from APKhermes::makeHermesRuntime()+evaluateJavaScript()resolve()callJSON.parse × 2+ JS call +JSON.stringifyMemory: before/after
dumpsys meminfodeltas stayed within ~1–2 MB GC noise on a baseline~298 MB total PSS (typical RN app). Since RN already links
libhermes.sofor its bridge, theshared 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 — thefast-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)
@use-voltra/widget-renderer(cleanup at merge time if both Track 2 and Track 4 land)expo-widgetsexploration on iOSCritical risk (resolved)
The JNI path was the single biggest unknown going in. Hermes's
hermes-engine::hermesvmprefabmodule (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
1 + 1evaluates to2expo prebuildinexample/— bundle + defaults JSON copied toandroid/app/src/main/assets/voltra/npm run android—BUILD SUCCESSFULandroid_reactive_weatherwidget on home screen → renders "New York" fromapp.jsondefault