diff --git a/example/app.json b/example/app.json
index 752d545d..885cfa4f 100644
--- a/example/app.json
+++ b/example/app.json
@@ -204,6 +204,19 @@
"intervalMinutes": 15,
"refresh": true
}
+ },
+ {
+ "id": "android_reactive_weather",
+ "displayName": "Reactive Weather (Track 4)",
+ "description": "Hermes-resolved AppIntent parameter — change city from the in-app Reactive Widget screen",
+ "targetCellWidth": 2,
+ "targetCellHeight": 2,
+ "resizeMode": "horizontal|vertical",
+ "widgetCategory": "home_screen",
+ "initialStatePath": "./widgets/android/android-reactive-weather-initial.tsx",
+ "appIntent": {
+ "parameters": [{ "name": "city", "title": "City", "default": "New York" }]
+ }
}
],
"fonts": [
diff --git a/example/app/android-widgets/reactive.tsx b/example/app/android-widgets/reactive.tsx
new file mode 100644
index 00000000..94941924
--- /dev/null
+++ b/example/app/android-widgets/reactive.tsx
@@ -0,0 +1,5 @@
+import AndroidReactiveWidgetScreen from '~/screens/android/AndroidReactiveWidgetScreen'
+
+export default function AndroidReactiveWidgetIndex() {
+ return
+}
diff --git a/example/screens/android/AndroidReactiveWidgetScreen.tsx b/example/screens/android/AndroidReactiveWidgetScreen.tsx
new file mode 100644
index 00000000..26aa3381
--- /dev/null
+++ b/example/screens/android/AndroidReactiveWidgetScreen.tsx
@@ -0,0 +1,112 @@
+import { useRouter } from 'expo-router'
+import React, { useState } from 'react'
+import { Alert, Platform, StyleSheet, Text, TextInput, View } from 'react-native'
+import { setAppIntentParam } from '@use-voltra/android-client'
+
+import { Button } from '~/components/Button'
+import { ScreenLayout } from '~/components/ScreenLayout'
+
+/**
+ * Track 4 PoC — in-app stand-in for a future Glance configuration activity.
+ * Writes the `city` AppIntent parameter into Voltra's DataStore and triggers a
+ * Glance update; the Hermes resolver substitutes the placeholder at render
+ * time and the widget re-renders with the new value.
+ */
+export default function AndroidReactiveWidgetScreen() {
+ const router = useRouter()
+ const [city, setCity] = useState('')
+ const [busy, setBusy] = useState(false)
+
+ const handleSubmit = async () => {
+ if (Platform.OS !== 'android') {
+ Alert.alert('Not available', 'This screen demonstrates the Android-only Track 4 PoC.')
+ return
+ }
+ const value = city.trim()
+ if (!value) {
+ Alert.alert('Empty input', 'Type a city name before submitting.')
+ return
+ }
+ setBusy(true)
+ try {
+ await setAppIntentParam('android_reactive_weather', 'city', value)
+ Alert.alert(
+ 'Param updated',
+ `Wrote city="${value}" to DataStore and triggered the Glance update. The Reactive Weather widget should now show "${value}".`
+ )
+ } catch (e: unknown) {
+ const message = e instanceof Error ? e.message : String(e)
+ Alert.alert('Error', `Failed to update param: ${message}`)
+ } finally {
+ setBusy(false)
+ }
+ }
+
+ return (
+
+
+ City
+
+ Add the "Reactive Weather (Track 4)" widget to your home screen first.
+
+
+
+
+
+
+
+
+
+ )
+}
+
+const styles = StyleSheet.create({
+ section: {
+ marginBottom: 24,
+ },
+ label: {
+ fontSize: 14,
+ fontWeight: '600',
+ color: '#E2E8F0',
+ marginBottom: 8,
+ },
+ input: {
+ backgroundColor: 'rgba(130, 50, 255, 0.1)',
+ borderWidth: 1,
+ borderColor: 'rgba(130, 50, 255, 0.4)',
+ borderRadius: 8,
+ paddingHorizontal: 12,
+ paddingVertical: 10,
+ fontSize: 14,
+ color: '#FFFFFF',
+ },
+ hint: {
+ fontSize: 12,
+ color: '#94A3B8',
+ marginTop: 8,
+ },
+ footer: {
+ marginTop: 24,
+ alignItems: 'center',
+ },
+})
diff --git a/example/screens/android/tabs/sections.ts b/example/screens/android/tabs/sections.ts
index 958276ac..b2ebbf64 100644
--- a/example/screens/android/tabs/sections.ts
+++ b/example/screens/android/tabs/sections.ts
@@ -28,6 +28,13 @@ export const ANDROID_WIDGET_SECTIONS: ExampleSection[] = [
'Serve dynamic widget content from a remote server using Voltra SSR. This example includes a sample widget server implementation.',
route: '/android-widgets/server-driven',
},
+ {
+ id: 'reactive-widget',
+ title: 'Reactive Widget (Track 4)',
+ description:
+ 'Change an AppIntent parameter (city) and watch the Reactive Weather widget re-render. Hermes resolves the placeholder on-device, no server push.',
+ route: '/android-widgets/reactive',
+ },
]
export const ANDROID_OTHER_SECTIONS: ExampleSection[] = [
diff --git a/example/server/widget-server.tsx b/example/server/widget-server.tsx
index 043b58c8..dbf703ea 100644
--- a/example/server/widget-server.tsx
+++ b/example/server/widget-server.tsx
@@ -15,6 +15,7 @@ import React from 'react'
import { IosPortfolioWidget } from '../widgets/ios/IosPortfolioWidget'
import { AndroidMaterialColorsServerWidget } from '../widgets/android/AndroidMaterialColorsWidget'
import { AndroidPortfolioWidget } from '../widgets/android/AndroidPortfolioWidget'
+import { AndroidReactiveWeatherWidget } from '../widgets/android/AndroidReactiveWeatherWidget'
const PORTFOLIO_TIMES = [
'09:00',
@@ -74,6 +75,19 @@ const handler = createWidgetUpdateNodeHandler({
renderAndroid: async (req: any) => {
const now = new Date().toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit', hour12: false })
+ if (req.widgetId === 'android_reactive_weather') {
+ // Track 4 PoC: server emits the widget with appIntentParam('city') →
+ // "{{ appIntent.city }}" preserved in the payload. The Glance widget
+ // process resolves it via Hermes at render time against DataStore params.
+ console.log(`[${now}] [Android] Rendering reactive weather widget`)
+ const content =
+ const variants = [
+ { size: { width: 200, height: 200 }, content },
+ { size: { width: 300, height: 200 }, content },
+ ]
+ return renderAndroidWidgetToString(variants)
+ }
+
if (req.widgetId === 'material_colors') {
console.log(`[${now}] [Android] Rendering material colors widget`)
diff --git a/example/widgets/android/AndroidReactiveWeatherWidget.tsx b/example/widgets/android/AndroidReactiveWeatherWidget.tsx
new file mode 100644
index 00000000..e0b62399
--- /dev/null
+++ b/example/widgets/android/AndroidReactiveWeatherWidget.tsx
@@ -0,0 +1,25 @@
+import React from 'react'
+import { VoltraAndroid, appIntentParam } from '@use-voltra/android'
+
+/**
+ * Track 4 PoC — mirror of iOS Track 2's `IosReactiveWeatherWidget`.
+ *
+ * The server renders this JSX to a compact JSON payload that includes the
+ * `appIntentParam('city')` template expression verbatim. At render time inside
+ * the Glance widget process, `VoltraJSRenderer` (Hermes) resolves the
+ * placeholder against the current AppIntent parameter value — no server push,
+ * no app update required for the value change to take effect.
+ */
+export const AndroidReactiveWeatherWidget = () => (
+
+
+ {appIntentParam('city')}
+
+
+ Reactive Weather
+
+
+ Edit "Reactive widget" screen to set your city
+
+
+)
diff --git a/example/widgets/android/android-reactive-weather-initial.tsx b/example/widgets/android/android-reactive-weather-initial.tsx
new file mode 100644
index 00000000..0481ad2d
--- /dev/null
+++ b/example/widgets/android/android-reactive-weather-initial.tsx
@@ -0,0 +1,14 @@
+import { AndroidReactiveWeatherWidget } from './AndroidReactiveWeatherWidget'
+
+const initialState = [
+ {
+ size: { width: 200, height: 200 },
+ content: ,
+ },
+ {
+ size: { width: 300, height: 200 },
+ content: ,
+ },
+]
+
+export default initialState
diff --git a/package-lock.json b/package-lock.json
index 46eea9dd..241165de 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -8235,6 +8235,10 @@
"resolved": "packages/android-client",
"link": true
},
+ "node_modules/@use-voltra/android-renderer": {
+ "resolved": "packages/android-renderer",
+ "link": true
+ },
"node_modules/@use-voltra/android-server": {
"resolved": "packages/android-server",
"link": true
@@ -21891,6 +21895,14 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
+ "packages/android-renderer": {
+ "name": "@use-voltra/android-renderer",
+ "version": "1.4.1",
+ "license": "MIT",
+ "devDependencies": {
+ "esbuild": "*"
+ }
+ },
"packages/android-server": {
"name": "@use-voltra/android-server",
"version": "1.4.1",
diff --git a/packages/android-client/android/CMakeLists.txt b/packages/android-client/android/CMakeLists.txt
new file mode 100644
index 00000000..ad7c447e
--- /dev/null
+++ b/packages/android-client/android/CMakeLists.txt
@@ -0,0 +1,30 @@
+cmake_minimum_required(VERSION 3.13)
+project(voltra)
+
+set(CMAKE_CXX_STANDARD 17)
+set(CMAKE_CXX_STANDARD_REQUIRED ON)
+
+# React Native publishes JSI + Hermes via Prefab modules in their AARs.
+# We consume them through find_package below.
+find_package(ReactAndroid REQUIRED CONFIG)
+find_package(hermes-engine REQUIRED CONFIG)
+find_package(fbjni REQUIRED CONFIG)
+
+# ---------------------------------------------------------------------------
+# Voltra JS Renderer (Track 4 — Hermes-on-Android)
+# ---------------------------------------------------------------------------
+# Standalone Hermes runtime owned by Voltra (independent of the React Native
+# bridge). Evaluates the @use-voltra/android-renderer bundle once per process,
+# then resolves `{{ appIntent.X }}` placeholders in widget payloads at render
+# time. JNI surface defined in voltra_js_renderer.cpp.
+add_library(voltra_js_renderer SHARED
+ src/main/cpp/voltra_js_renderer.cpp
+)
+
+target_link_libraries(voltra_js_renderer
+ android
+ log
+ ReactAndroid::jsi
+ hermes-engine::hermesvm
+ fbjni::fbjni
+)
\ No newline at end of file
diff --git a/packages/android-client/android/build.gradle b/packages/android-client/android/build.gradle
index b020d862..6b5a43cd 100644
--- a/packages/android-client/android/build.gradle
+++ b/packages/android-client/android/build.gradle
@@ -39,11 +39,34 @@ android {
versionCode 1
versionName "0.1.0"
buildConfigField "String", "VOLTRA_VERSION", "\"${voltraVersion}\""
+
+ externalNativeBuild {
+ cmake {
+ cppFlags "-std=c++17", "-fexceptions", "-frtti"
+ arguments "-DANDROID_STL=c++_shared"
+ }
+ }
+ }
+
+ externalNativeBuild {
+ cmake {
+ path "CMakeLists.txt"
+ }
}
buildFeatures {
buildConfig true
compose true
+ prefab true
+ }
+
+ packagingOptions {
+ excludes += [
+ "**/libreact_nativemodule_core.so",
+ "**/libfbjni.so",
+ "**/libjsi.so",
+ "**/libhermes.so",
+ ]
}
lintOptions {
@@ -68,6 +91,10 @@ dependencies {
// React Native
implementation "com.facebook.react:react-android"
+ // Hermes engine (provides libhermes.so + headers via prefab)
+ // Used by the Voltra widget JS resolver for AppIntent reactivity (Track 4 PoC)
+ implementation "com.facebook.react:hermes-android"
+
// Jetpack Glance
api "androidx.glance:glance:1.2.0-rc01"
api "androidx.glance:glance-appwidget:1.2.0-rc01"
diff --git a/packages/android-client/android/src/main/cpp/voltra_js_renderer.cpp b/packages/android-client/android/src/main/cpp/voltra_js_renderer.cpp
new file mode 100644
index 00000000..d6972df7
--- /dev/null
+++ b/packages/android-client/android/src/main/cpp/voltra_js_renderer.cpp
@@ -0,0 +1,130 @@
+// Voltra JS Renderer — Hermes-on-Android JNI wrapper (Track 4).
+//
+// Owns a single standalone Hermes runtime per process. The runtime evaluates
+// the @use-voltra/android-renderer bundle once on first init, after which
+// `nativeResolve` invokes `globalThis.VoltraRenderer.resolve(payload, params)`
+// to substitute `{{ appIntent.X }}` placeholders in a widget payload.
+//
+// Architectural mirror of iOS Track 2's VoltraJSRenderer.swift, one layer
+// lower at the JNI boundary because Android has no Kotlin/Java Hermes API.
+
+#include
+#include
+
+#include
+#include
+
+#include
+#include
+#include
+
+#define LOG_TAG "VoltraJSRenderer"
+#define LOGI(...) __android_log_print(ANDROID_LOG_INFO, LOG_TAG, __VA_ARGS__)
+#define LOGE(...) __android_log_print(ANDROID_LOG_ERROR, LOG_TAG, __VA_ARGS__)
+
+namespace jsi = facebook::jsi;
+
+namespace {
+
+struct State {
+ std::unique_ptr runtime;
+};
+
+std::mutex g_mutex;
+std::unique_ptr g_state;
+
+std::string jstringToStd(JNIEnv *env, jstring jstr) {
+ if (jstr == nullptr) {
+ return {};
+ }
+ const char *chars = env->GetStringUTFChars(jstr, nullptr);
+ std::string out(chars);
+ env->ReleaseStringUTFChars(jstr, chars);
+ return out;
+}
+
+} // namespace
+
+extern "C" JNIEXPORT jboolean JNICALL
+Java_voltra_runtime_VoltraJSRenderer_nativeInit(
+ JNIEnv *env, jobject /* this */, jstring jBundleSource) {
+ std::lock_guard lock(g_mutex);
+ try {
+ const std::string bundleSource = jstringToStd(env, jBundleSource);
+
+ auto runtime = facebook::hermes::makeHermesRuntime();
+ runtime->evaluateJavaScript(
+ std::make_unique(bundleSource),
+ "@use-voltra/android-renderer");
+
+ // Sanity-check that the bundle exposed VoltraRenderer.resolve.
+ auto renderer =
+ runtime->global().getProperty(*runtime, "VoltraRenderer");
+ if (!renderer.isObject()) {
+ LOGE("init: bundle did not define globalThis.VoltraRenderer");
+ return JNI_FALSE;
+ }
+ auto resolve = renderer.asObject(*runtime).getProperty(*runtime, "resolve");
+ if (!resolve.isObject() ||
+ !resolve.asObject(*runtime).isFunction(*runtime)) {
+ LOGE("init: VoltraRenderer.resolve is not a function");
+ return JNI_FALSE;
+ }
+
+ g_state = std::make_unique();
+ g_state->runtime = std::move(runtime);
+ LOGI("Hermes runtime initialized");
+ return JNI_TRUE;
+ } catch (const std::exception &e) {
+ LOGE("init failed: %s", e.what());
+ return JNI_FALSE;
+ } catch (...) {
+ LOGE("init failed: unknown");
+ return JNI_FALSE;
+ }
+}
+
+extern "C" JNIEXPORT jstring JNICALL
+Java_voltra_runtime_VoltraJSRenderer_nativeResolve(
+ JNIEnv *env, jobject /* this */, jstring jPayloadJSON,
+ jstring jParamsJSON) {
+ std::lock_guard lock(g_mutex);
+ if (!g_state || !g_state->runtime) {
+ LOGE("resolve called before init");
+ return nullptr;
+ }
+ try {
+ auto &rt = *g_state->runtime;
+ const std::string payloadJSON = jstringToStd(env, jPayloadJSON);
+ const std::string paramsJSON = jstringToStd(env, jParamsJSON);
+
+ auto json = rt.global().getPropertyAsObject(rt, "JSON");
+ auto parse = json.getPropertyAsFunction(rt, "parse");
+ auto stringify = json.getPropertyAsFunction(rt, "stringify");
+
+ jsi::Value payloadVal = parse.call(
+ rt, jsi::String::createFromUtf8(rt, payloadJSON));
+ jsi::Value paramsVal = parse.call(
+ rt, jsi::String::createFromUtf8(rt, paramsJSON));
+
+ auto renderer = rt.global().getPropertyAsObject(rt, "VoltraRenderer");
+ auto resolve = renderer.getPropertyAsFunction(rt, "resolve");
+
+ jsi::Value result =
+ resolve.call(rt, std::move(payloadVal), std::move(paramsVal));
+
+ jsi::Value stringified = stringify.call(rt, std::move(result));
+ if (!stringified.isString()) {
+ LOGE("resolve: stringify did not return a string");
+ return nullptr;
+ }
+ std::string resultStr = stringified.asString(rt).utf8(rt);
+ return env->NewStringUTF(resultStr.c_str());
+ } catch (const std::exception &e) {
+ LOGE("resolve threw: %s", e.what());
+ return nullptr;
+ } catch (...) {
+ LOGE("resolve threw: unknown");
+ return nullptr;
+ }
+}
\ No newline at end of file
diff --git a/packages/android-client/android/src/main/java/voltra/VoltraModule.kt b/packages/android-client/android/src/main/java/voltra/VoltraModule.kt
index 825fd1d9..cd5c0f84 100644
--- a/packages/android-client/android/src/main/java/voltra/VoltraModule.kt
+++ b/packages/android-client/android/src/main/java/voltra/VoltraModule.kt
@@ -15,6 +15,7 @@ import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.runBlocking
import voltra.images.VoltraImageManager
+import voltra.runtime.AppIntentParamsStore
import voltra.widget.VoltraGlanceWidget
import voltra.widget.VoltraWidgetManager
@@ -389,4 +390,27 @@ class VoltraModule(
}
promise.resolve(activeWidgets)
}
+
+ /**
+ * Track 4 PoC: store an AppIntent parameter value for a widget, then trigger
+ * a Glance update so the resolver picks it up on the next render. Stand-in
+ * for a future Glance configuration activity.
+ */
+ override fun setAppIntentParam(
+ widgetId: String,
+ name: String,
+ value: String,
+ promise: Promise,
+ ) {
+ runBlocking {
+ try {
+ AppIntentParamsStore(reactApplicationContext).setParam(widgetId, name, value)
+ VoltraGlanceWidget.triggerUpdate(reactApplicationContext, widgetId)
+ promise.resolve(null)
+ } catch (e: Exception) {
+ Log.e(TAG, "setAppIntentParam failed", e)
+ promise.reject("VOLTRA_APPINTENT_PARAM_ERROR", e.message, e)
+ }
+ }
+ }
}
diff --git a/packages/android-client/android/src/main/java/voltra/runtime/AppIntentParamsStore.kt b/packages/android-client/android/src/main/java/voltra/runtime/AppIntentParamsStore.kt
new file mode 100644
index 00000000..9432ab1a
--- /dev/null
+++ b/packages/android-client/android/src/main/java/voltra/runtime/AppIntentParamsStore.kt
@@ -0,0 +1,99 @@
+package voltra.runtime
+
+import android.content.Context
+import android.util.Log
+import androidx.datastore.preferences.core.edit
+import androidx.datastore.preferences.core.stringPreferencesKey
+import androidx.datastore.preferences.preferencesDataStore
+import kotlinx.coroutines.flow.first
+import org.json.JSONObject
+import java.io.IOException
+
+/**
+ * DataStore-backed storage for per-widget AppIntent parameter values.
+ *
+ * Mirrors iOS Track 2's AppIntent-parameter source: on iOS those values live in
+ * the `WidgetConfigurationIntent` struct populated by WidgetKit when the user
+ * edits the widget. Android has no system-managed equivalent today — for the
+ * Track 4 PoC the values are written by an in-app screen (Phase 4) and consumed
+ * by `VoltraGlanceWidget.provideGlance()` to feed the Hermes resolver.
+ *
+ * Keys are namespaced as `voltra.appintent..` so multiple
+ * widget ids and multiple parameters per widget coexist cleanly.
+ *
+ * Defaults declared in `app.json` are written by the config plugin to
+ * `assets/voltra/appintent_defaults.json`. [getParamsWithDefaults] merges them
+ * under any user-set values so a freshly-installed widget renders meaningfully
+ * before the user has interacted with the parameter source.
+ */
+internal class AppIntentParamsStore(
+ private val context: Context,
+) {
+ suspend fun getParams(widgetId: String): Map {
+ val prefix = paramPrefix(widgetId)
+ val snapshot = context.appIntentParamsDataStore.data.first()
+ val out = mutableMapOf()
+ snapshot.asMap().forEach { (key, value) ->
+ val name = key.name
+ if (name.startsWith(prefix) && value is String) {
+ out[name.substring(prefix.length)] = value
+ }
+ }
+ return out
+ }
+
+ suspend fun getParamsWithDefaults(widgetId: String): Map {
+ val defaults = loadDefaults(widgetId)
+ val stored = getParams(widgetId)
+ return defaults + stored
+ }
+
+ suspend fun setParam(
+ widgetId: String,
+ name: String,
+ value: String,
+ ) {
+ val key = stringPreferencesKey(paramPrefix(widgetId) + name)
+ context.appIntentParamsDataStore.edit { it[key] = value }
+ }
+
+ private fun loadDefaults(widgetId: String): Map {
+ val cached = defaultsCache
+ if (cached != null) return cached[widgetId] ?: emptyMap()
+
+ val parsed =
+ try {
+ context.assets
+ .open(DEFAULTS_ASSET_PATH)
+ .bufferedReader()
+ .use { it.readText() }
+ } catch (e: IOException) {
+ Log.d(TAG, "No appintent_defaults.json — defaulting to empty params")
+ defaultsCache = emptyMap()
+ return emptyMap()
+ }
+
+ val root = JSONObject(parsed)
+ val all = mutableMapOf>()
+ root.keys().forEach { id ->
+ val obj = root.getJSONObject(id)
+ val widgetMap = mutableMapOf()
+ obj.keys().forEach { paramName -> widgetMap[paramName] = obj.getString(paramName) }
+ all[id] = widgetMap
+ }
+ defaultsCache = all
+ return all[widgetId] ?: emptyMap()
+ }
+
+ private fun paramPrefix(widgetId: String): String = "voltra.appintent.$widgetId."
+
+ companion object {
+ private const val TAG = "AppIntentParamsStore"
+ private const val DEFAULTS_ASSET_PATH = "voltra/appintent_defaults.json"
+
+ @Volatile
+ private var defaultsCache: Map>? = null
+ }
+}
+
+private val Context.appIntentParamsDataStore by preferencesDataStore(name = "voltra_appintent_params")
diff --git a/packages/android-client/android/src/main/java/voltra/runtime/VoltraJSRenderer.kt b/packages/android-client/android/src/main/java/voltra/runtime/VoltraJSRenderer.kt
new file mode 100644
index 00000000..01a7679b
--- /dev/null
+++ b/packages/android-client/android/src/main/java/voltra/runtime/VoltraJSRenderer.kt
@@ -0,0 +1,115 @@
+package voltra.runtime
+
+import android.content.Context
+import android.util.Log
+import org.json.JSONObject
+import java.io.IOException
+
+/**
+ * Standalone Hermes runtime owned by Voltra (independent of React Native's
+ * bridge). Evaluates the `@use-voltra/android-renderer` bundle once per
+ * process and exposes a single `resolve()` entry point that substitutes
+ * `{{ appIntent.X }}` placeholders in a widget payload at render time.
+ *
+ * Architectural mirror of iOS Track 2's `VoltraJSRenderer` (Swift enum).
+ * Lifecycle: lazy singleton — first [ensureInitialized] call evaluates the
+ * bundle; subsequent calls reuse the cached runtime for the process lifetime.
+ * Thread safety: a single mutex guards both init and resolve; matches Track 2's
+ * NSLock pattern.
+ */
+object VoltraJSRenderer {
+ private const val TAG = "VoltraJSRenderer"
+ private const val BUNDLE_ASSET_PATH = "voltra/android-renderer.js"
+
+ init {
+ System.loadLibrary("voltra_js_renderer")
+ }
+
+ @Volatile
+ private var initialized = false
+ private val lock = Any()
+
+ private external fun nativeInit(bundleSource: String): Boolean
+
+ private external fun nativeResolve(
+ payloadJSON: String,
+ paramsJSON: String,
+ ): String?
+
+ /**
+ * Initialize the resolver with the JS bundle source. Idempotent — once
+ * init has succeeded subsequent calls are no-ops and return true.
+ *
+ * Returns false if the Hermes runtime fails to load the bundle.
+ */
+ fun ensureInitialized(bundleSource: String): Boolean {
+ if (initialized) return true
+ synchronized(lock) {
+ if (initialized) return true
+ val t0 = System.nanoTime()
+ initialized = nativeInit(bundleSource)
+ val ms = (System.nanoTime() - t0) / 1_000_000.0
+ if (!initialized) {
+ Log.e(TAG, "Hermes init failed after ${"%.2f".format(ms)} ms")
+ } else {
+ Log.i(TAG, "Hermes cold-start: ${"%.2f".format(ms)} ms (bundle eval, ${bundleSource.length} chars)")
+ }
+ return initialized
+ }
+ }
+
+ /**
+ * Initialize the resolver by loading the JS bundle from the Voltra assets
+ * directory (`assets/voltra/android-renderer.js`). The config plugin copies
+ * the bundle there on prebuild when any widget declares `appIntent`.
+ *
+ * Returns false if the asset is missing (in which case reactive resolution
+ * is skipped and the original payload renders unchanged).
+ */
+ fun ensureInitializedFromAssets(context: Context): Boolean {
+ if (initialized) return true
+ val t0 = System.nanoTime()
+ val source =
+ try {
+ context.assets
+ .open(BUNDLE_ASSET_PATH)
+ .bufferedReader()
+ .use { it.readText() }
+ } catch (e: IOException) {
+ Log.w(TAG, "Bundle not found at assets/$BUNDLE_ASSET_PATH — reactive resolution disabled")
+ return false
+ }
+ val readMs = (System.nanoTime() - t0) / 1_000_000.0
+ Log.d(TAG, "Asset read: ${"%.2f".format(readMs)} ms (${source.length} chars)")
+ return ensureInitialized(source)
+ }
+
+ /**
+ * Resolve `{{ appIntent.X }}` placeholders in [payloadJSON] against
+ * [appIntentParams]. Returns the resolved JSON string, or null if the
+ * resolver is not initialized or the engine errors.
+ */
+ fun resolve(
+ payloadJSON: String,
+ appIntentParams: Map,
+ ): String? {
+ if (!initialized) {
+ Log.e(TAG, "resolve called before ensureInitialized")
+ return null
+ }
+ val paramsJson = JSONObject()
+ appIntentParams.forEach { (k, v) -> paramsJson.put(k, v) }
+ val paramsJSON = paramsJson.toString()
+ val t0 = System.nanoTime()
+ val result =
+ synchronized(lock) {
+ nativeResolve(payloadJSON, paramsJSON)
+ }
+ val ms = (System.nanoTime() - t0) / 1_000_000.0
+ Log.i(
+ TAG,
+ "Hermes resolve: ${"%.2f".format(ms)} ms (payload=${payloadJSON.length}c, params=${appIntentParams.size})",
+ )
+ return result
+ }
+}
diff --git a/packages/android-client/android/src/main/java/voltra/widget/VoltraGlanceWidget.kt b/packages/android-client/android/src/main/java/voltra/widget/VoltraGlanceWidget.kt
index ac771933..7c2ca6b7 100644
--- a/packages/android-client/android/src/main/java/voltra/widget/VoltraGlanceWidget.kt
+++ b/packages/android-client/android/src/main/java/voltra/widget/VoltraGlanceWidget.kt
@@ -31,6 +31,8 @@ import androidx.glance.text.TextStyle
import voltra.glance.GlanceFactory
import voltra.models.VoltraPayload
import voltra.parsing.VoltraPayloadParser
+import voltra.runtime.AppIntentParamsStore
+import voltra.runtime.VoltraJSRenderer
class VoltraGlanceWidget(
private val widgetId: String = "default",
@@ -70,7 +72,12 @@ class VoltraGlanceWidget(
) {
// Parse data outside of composition to avoid try/catch in composable
val widgetManager = VoltraWidgetManager(context)
- val jsonString = widgetManager.readWidgetJson(widgetId)
+ val rawJsonString = widgetManager.readWidgetJson(widgetId)
+
+ // Resolve `{{ appIntent.X }}` placeholders via Hermes if present (Track 4).
+ // Skip the engine entirely when the payload has no template — keeps existing
+ // (non-reactive) widgets on the original code path with zero overhead.
+ val jsonString = rawJsonString?.let { resolveAppIntentTemplates(context, it) }
val payload: VoltraPayload? =
if (jsonString != null) {
@@ -190,6 +197,33 @@ class VoltraGlanceWidget(
}
}
+ /**
+ * If [json] contains `{{ appIntent.X }}` placeholders, run it through the
+ * Hermes-backed resolver against the params stored for this widget.
+ *
+ * Returns the resolved JSON, the original on resolver failure, or the
+ * original when no template tokens are present (cheap fast-path).
+ */
+ private suspend fun resolveAppIntentTemplates(
+ context: Context,
+ json: String,
+ ): String {
+ if (!json.contains("{{ appIntent.")) return json
+
+ if (!VoltraJSRenderer.ensureInitializedFromAssets(context)) {
+ return json
+ }
+
+ val params = AppIntentParamsStore(context).getParamsWithDefaults(widgetId)
+ val resolved = VoltraJSRenderer.resolve(json, params)
+ if (resolved == null) {
+ Log.w(TAG, "Hermes resolve returned null for widgetId=$widgetId; falling back to raw payload")
+ return json
+ }
+ Log.i(TAG, "Resolved AppIntent placeholders for widgetId=$widgetId (params=$params)")
+ return resolved
+ }
+
/**
* Select the best variant key based on current widget size.
* Matches against size keys in format "WIDTHxHEIGHT" (e.g., "150x100").
diff --git a/packages/android-client/expo-plugin/src/android/files/bundle.ts b/packages/android-client/expo-plugin/src/android/files/bundle.ts
new file mode 100644
index 00000000..907b0f4d
--- /dev/null
+++ b/packages/android-client/expo-plugin/src/android/files/bundle.ts
@@ -0,0 +1,68 @@
+import { logger } from '@use-voltra/expo-plugin'
+import * as fs from 'fs'
+import * as path from 'path'
+
+import type { AndroidWidgetConfig } from '../../types'
+
+const VOLTRA_ASSETS_DIR = ['app', 'src', 'main', 'assets', 'voltra']
+const BUNDLE_FILE_NAME = 'android-renderer.js'
+const DEFAULTS_FILE_NAME = 'appintent_defaults.json'
+
+interface CopyRendererBundleProps {
+ platformProjectRoot: string
+ projectRoot: string
+ widgets: AndroidWidgetConfig[]
+}
+
+/**
+ * Copy `@use-voltra/android-renderer`'s bundled JS into the Android assets
+ * directory and emit a per-widget AppIntent defaults JSON, but only when any
+ * widget declares `appIntent` (Track 4 PoC).
+ *
+ * Opt-in by design: widgets without `appIntent` don't ship either file, so the
+ * resolver and its defaults aren't paid by apps that don't use reactive widgets.
+ */
+export async function copyAndroidRendererBundle({
+ platformProjectRoot,
+ projectRoot,
+ widgets,
+}: CopyRendererBundleProps): Promise {
+ const reactiveWidgets = widgets.filter((w) => w.appIntent)
+ if (reactiveWidgets.length === 0) {
+ return
+ }
+
+ const assetsDir = path.join(platformProjectRoot, ...VOLTRA_ASSETS_DIR)
+ fs.mkdirSync(assetsDir, { recursive: true })
+
+ // Copy bundled resolver JS
+ const candidates = [
+ path.join(projectRoot, 'node_modules', '@use-voltra', 'android-renderer', 'bundle', BUNDLE_FILE_NAME),
+ path.join(projectRoot, '..', 'node_modules', '@use-voltra', 'android-renderer', 'bundle', BUNDLE_FILE_NAME),
+ path.join(projectRoot, '..', 'packages', 'android-renderer', 'bundle', BUNDLE_FILE_NAME),
+ ]
+
+ const source = candidates.find((p) => fs.existsSync(p))
+ if (!source) {
+ logger.warn(
+ 'android-renderer.js not found — run `npm run build:bundle -w @use-voltra/android-renderer` then re-run prebuild'
+ )
+ } else {
+ fs.copyFileSync(source, path.join(assetsDir, BUNDLE_FILE_NAME))
+ logger.info(`Copied ${BUNDLE_FILE_NAME} to ${[...VOLTRA_ASSETS_DIR, BUNDLE_FILE_NAME].join('/')}`)
+ }
+
+ // Emit defaults JSON: { widgetId: { paramName: defaultValue } }
+ const defaults: Record> = {}
+ for (const widget of reactiveWidgets) {
+ const widgetDefaults: Record = {}
+ for (const param of widget.appIntent?.parameters ?? []) {
+ if (typeof param.default === 'string') {
+ widgetDefaults[param.name] = param.default
+ }
+ }
+ defaults[widget.id] = widgetDefaults
+ }
+ fs.writeFileSync(path.join(assetsDir, DEFAULTS_FILE_NAME), `${JSON.stringify(defaults, null, 2)}\n`)
+ logger.info(`Wrote ${DEFAULTS_FILE_NAME} to ${[...VOLTRA_ASSETS_DIR, DEFAULTS_FILE_NAME].join('/')}`)
+}
diff --git a/packages/android-client/expo-plugin/src/android/files/index.ts b/packages/android-client/expo-plugin/src/android/files/index.ts
index b0212c9b..c2ca2778 100644
--- a/packages/android-client/expo-plugin/src/android/files/index.ts
+++ b/packages/android-client/expo-plugin/src/android/files/index.ts
@@ -2,6 +2,7 @@ import { ConfigPlugin, withDangerousMod } from '@expo/config-plugins'
import type { AndroidWidgetConfig } from '../../types'
import { generateAndroidAssets } from './assets'
+import { copyAndroidRendererBundle } from './bundle'
import { copyAndroidFonts } from './fonts'
import { generateAndroidInitialStates } from './initialStates'
import { generateWidgetReceivers } from './kotlin'
@@ -92,6 +93,14 @@ export const generateAndroidWidgetFiles: ConfigPlugin
clearWidgetServerCredentials(): Promise
getActiveWidgets(): Promise>
+ /**
+ * Store an AppIntent parameter value for a widget and trigger a Glance update
+ * so the new value is picked up at the next render (Track 4 PoC).
+ *
+ * Stand-in for a future Glance configuration activity — for now the example
+ * app's "Reactive widget" screen calls this directly.
+ */
+ setAppIntentParam(widgetId: string, name: string, value: string): Promise
}
export function getNativeVoltraAndroid(): Spec {
diff --git a/packages/android-client/src/widgets/api.ts b/packages/android-client/src/widgets/api.ts
index 8ce67d34..3df79b28 100644
--- a/packages/android-client/src/widgets/api.ts
+++ b/packages/android-client/src/widgets/api.ts
@@ -49,3 +49,14 @@ export const requestPinAndroidWidget = async (
export const getActiveWidgets = async (): Promise => {
return getNativeVoltraAndroid().getActiveWidgets() as Promise
}
+
+/**
+ * Track 4 PoC: write an AppIntent parameter for a widget and trigger an
+ * immediate Glance update so the new value gets picked up at the next render.
+ *
+ * Stand-in for a future Glance configuration activity — the example app's
+ * Reactive Widget screen calls this directly.
+ */
+export const setAppIntentParam = async (widgetId: string, name: string, value: string): Promise => {
+ return getNativeVoltraAndroid().setAppIntentParam(widgetId, name, value)
+}
diff --git a/packages/android-renderer/.gitignore b/packages/android-renderer/.gitignore
new file mode 100644
index 00000000..5a9d84b8
--- /dev/null
+++ b/packages/android-renderer/.gitignore
@@ -0,0 +1,2 @@
+/bundle
+/build
\ No newline at end of file
diff --git a/packages/android-renderer/README.md b/packages/android-renderer/README.md
new file mode 100644
index 00000000..a67f8dd3
--- /dev/null
+++ b/packages/android-renderer/README.md
@@ -0,0 +1,7 @@
+# @use-voltra/android-renderer
+
+Voltra widget payload resolver bundled for execution inside the standalone Hermes runtime on Android. Substitutes `{{ appIntent.X }}` placeholders in a widget payload at render time, mirroring the iOS counterpart (`@use-voltra/ios-renderer`) one-to-one.
+
+This package ships as part of **Track 4 — Hermes-in-Process** (PoC). Track 4 is a proof-of-concept that the JS-resolver pattern from iOS Track 2 generalises to Android via Hermes — same JS bundle, two platforms.
+
+Build the bundle once with `npm run build:bundle`; the output (`bundle/android-renderer.js`) is what the config plugin copies into `android/app/src/main/assets/voltra/` during prebuild.
diff --git a/packages/android-renderer/package.json b/packages/android-renderer/package.json
new file mode 100644
index 00000000..f3fd3d96
--- /dev/null
+++ b/packages/android-renderer/package.json
@@ -0,0 +1,47 @@
+{
+ "name": "@use-voltra/android-renderer",
+ "version": "1.4.1",
+ "description": "Voltra widget payload resolver — runs inside the standalone Hermes runtime on Android (Track 4 PoC)",
+ "main": "build/cjs/index.js",
+ "module": "build/esm/index.js",
+ "types": "build/types/index.d.ts",
+ "exports": {
+ ".": {
+ "types": "./build/types/index.d.ts",
+ "require": "./build/cjs/index.js",
+ "import": "./build/esm/index.js",
+ "default": "./build/esm/index.js"
+ },
+ "./bundle": "./bundle/android-renderer.js",
+ "./package.json": "./package.json"
+ },
+ "files": [
+ "build",
+ "bundle",
+ "README.md"
+ ],
+ "scripts": {
+ "build": "node ../../scripts/build-package.mjs packages/android-renderer && npm run build:bundle",
+ "build:bundle": "node scripts/build-bundle.mjs",
+ "clean": "rm -rf build bundle",
+ "lint": "oxlint src",
+ "typecheck": "tsc -p tsconfig.typecheck.json --noEmit",
+ "test": "node --test"
+ },
+ "devDependencies": {
+ "esbuild": "*"
+ },
+ "keywords": [
+ "voltra",
+ "widget",
+ "hermes",
+ "android"
+ ],
+ "license": "MIT",
+ "homepage": "https://use-voltra.dev",
+ "repository": {
+ "type": "git",
+ "url": "git+https://github.com/callstackincubator/voltra.git",
+ "directory": "packages/android-renderer"
+ }
+}
diff --git a/packages/android-renderer/scripts/build-bundle.mjs b/packages/android-renderer/scripts/build-bundle.mjs
new file mode 100644
index 00000000..43813a8a
--- /dev/null
+++ b/packages/android-renderer/scripts/build-bundle.mjs
@@ -0,0 +1,19 @@
+import { build } from 'esbuild'
+import path from 'node:path'
+import { fileURLToPath } from 'node:url'
+
+const packageDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
+
+await build({
+ entryPoints: [path.join(packageDir, 'src/bundle-entry.ts')],
+ outfile: path.join(packageDir, 'bundle/android-renderer.js'),
+ bundle: true,
+ format: 'iife',
+ platform: 'neutral',
+ target: 'es2017',
+ minify: false,
+ sourcemap: false,
+ legalComments: 'none',
+})
+
+console.log('android-renderer bundle written to bundle/android-renderer.js')
diff --git a/packages/android-renderer/src/bundle-entry.ts b/packages/android-renderer/src/bundle-entry.ts
new file mode 100644
index 00000000..4e92f29d
--- /dev/null
+++ b/packages/android-renderer/src/bundle-entry.ts
@@ -0,0 +1,5 @@
+import { resolve } from './index'
+
+// Expose for Hermes evaluation in the Android widget extension.
+// Kotlin (via JNI) calls: globalThis.VoltraRenderer.resolve(payload, appIntentParams)
+;(globalThis as unknown as Record)['VoltraRenderer'] = { resolve }
diff --git a/packages/android-renderer/src/index.ts b/packages/android-renderer/src/index.ts
new file mode 100644
index 00000000..23318a2d
--- /dev/null
+++ b/packages/android-renderer/src/index.ts
@@ -0,0 +1,57 @@
+// Voltra widget payload resolver — Android (Track 4 PoC).
+//
+// Pure-JS template substitution for `{{ appIntent.X }}` placeholders. Runs inside
+// the standalone Hermes runtime owned by VoltraJSRenderer.kt (see
+// packages/android-client/android/src/main/cpp/voltra_js_renderer.cpp).
+//
+// Mirror of iOS Track 2's @use-voltra/ios-renderer — same logic, different
+// packaging target. If both PoCs eventually merge, the packages collapse into a
+// single platform-neutral `@use-voltra/widget-renderer`.
+
+export type AppIntentParams = Record
+
+// Keys whose values are never traversed for resolution.
+// 'v' (version) and 'e' (shared elements / $r refs) are structural — leave untouched.
+// 's' (shared stylesheet) is NOT a passthrough: AppIntent template expressions may
+// appear in style values.
+const PASSTHROUGH_KEYS = new Set(['v', 'e'])
+
+/**
+ * Substitutes {{ appIntent.paramName }} template expressions.
+ * Unknown parameters are replaced with an empty string.
+ */
+function resolveTemplate(value: string, appIntentParams: AppIntentParams): string {
+ return value.replace(/\{\{\s*appIntent\.(\w+)\s*\}\}/g, (_, key: string) => appIntentParams[key] ?? '')
+}
+
+function resolveValue(value: unknown, appIntentParams: AppIntentParams): unknown {
+ if (typeof value === 'string') {
+ return resolveTemplate(value, appIntentParams)
+ }
+ if (Array.isArray(value)) {
+ return value.map((item) => resolveValue(item, appIntentParams))
+ }
+ if (value !== null && typeof value === 'object') {
+ const obj = value as Record
+ // Element refs ($r) are resolved by the Swift/Kotlin layer after resolution — pass through unchanged
+ if ('$r' in obj) return obj
+ return Object.fromEntries(Object.entries(obj).map(([k, v]) => [k, resolveValue(v, appIntentParams)]))
+ }
+ return value
+}
+
+/**
+ * Resolves AppIntent template expressions in a Voltra payload, returning a payload
+ * ready for the Glance render path.
+ *
+ * The version (v) and shared elements (e) keys are passed through unchanged so the
+ * Kotlin interpreter can still resolve element refs.
+ */
+export function resolve(payload: Record, appIntentParams: AppIntentParams): Record {
+ return Object.fromEntries(
+ Object.entries(payload).map(([key, value]) => [
+ key,
+ PASSTHROUGH_KEYS.has(key) ? value : resolveValue(value, appIntentParams),
+ ])
+ )
+}
diff --git a/packages/android-renderer/test/resolve.test.js b/packages/android-renderer/test/resolve.test.js
new file mode 100644
index 00000000..07eac60a
--- /dev/null
+++ b/packages/android-renderer/test/resolve.test.js
@@ -0,0 +1,78 @@
+const test = require('node:test')
+const assert = require('node:assert/strict')
+
+const { resolve } = require('../build/cjs/index.js')
+
+// Simulates the compact JSON payload the server renderer produces from:
+// {appIntentParam('city')}
+//
+// In practice the payload is rendered by packages/android/src/widgets/renderer.ts,
+// but since {{ appIntent.X }} expressions pass through the server unchanged,
+// we test resolve() directly against a representative payload shape.
+const makePayload = () => ({
+ v: 1,
+ systemSmall: {
+ t: 11, // VStack
+ c: [
+ {
+ t: 0, // Text
+ c: '{{ appIntent.city }}',
+ p: { fs: 22, fw: '700' },
+ },
+ {
+ t: 0,
+ c: 'Reactive Weather',
+ p: { fs: 14, mt: 6 },
+ },
+ ],
+ p: { pad: 16, al: 'leading', fl: 1 },
+ },
+ systemMedium: {
+ t: 11,
+ c: [
+ {
+ t: 0,
+ c: '{{ appIntent.city }}',
+ p: { fs: 22, fw: '700' },
+ },
+ ],
+ p: { pad: 16, al: 'leading', fl: 1 },
+ },
+})
+
+test('resolve — replaces {{ appIntent.city }} with the configured city', () => {
+ const result = resolve(makePayload(), { city: 'Warsaw' })
+ assert.equal(result.systemSmall.c[0].c, 'Warsaw')
+})
+
+test('resolve — replaces template in all families', () => {
+ const result = resolve(makePayload(), { city: 'Warsaw' })
+ assert.equal(result.systemMedium.c[0].c, 'Warsaw')
+})
+
+test('resolve — replaces unknown param with empty string', () => {
+ const result = resolve(makePayload(), {})
+ assert.equal(result.systemSmall.c[0].c, '')
+})
+
+test('resolve — passthrough v (version) is not traversed', () => {
+ const result = resolve(makePayload(), { city: 'Warsaw' })
+ assert.equal(result.v, 1)
+})
+
+test('resolve — non-string numeric props are preserved', () => {
+ const result = resolve(makePayload(), { city: 'Warsaw' })
+ assert.equal(result.systemSmall.c[0].p.fs, 22)
+})
+
+test('resolve — resolved payload can be serialised to JSON and back', () => {
+ const result = resolve(makePayload(), { city: 'Warsaw' })
+ const parsed = JSON.parse(JSON.stringify(result))
+ assert.equal(parsed.systemSmall.c[0].c, 'Warsaw')
+})
+
+test('resolve — same payload, different params, produce different outputs', () => {
+ const warsaw = resolve(makePayload(), { city: 'Warsaw' })
+ const tokyo = resolve(makePayload(), { city: 'Tokyo' })
+ assert.notEqual(warsaw.systemSmall.c[0].c, tokyo.systemSmall.c[0].c)
+})
diff --git a/packages/android-renderer/tsconfig.base.json b/packages/android-renderer/tsconfig.base.json
new file mode 100644
index 00000000..a8c5ace5
--- /dev/null
+++ b/packages/android-renderer/tsconfig.base.json
@@ -0,0 +1,15 @@
+{
+ "compilerOptions": {
+ "target": "ES2020",
+ "lib": ["ES2020"],
+ "rootDir": "./src",
+ "moduleResolution": "node",
+ "strict": true,
+ "esModuleInterop": true,
+ "allowSyntheticDefaultImports": true,
+ "skipLibCheck": true,
+ "isolatedModules": true
+ },
+ "include": ["./src"],
+ "exclude": ["**/__tests__/*"]
+}
diff --git a/packages/android-renderer/tsconfig.cjs.json b/packages/android-renderer/tsconfig.cjs.json
new file mode 100644
index 00000000..a6b3ca9c
--- /dev/null
+++ b/packages/android-renderer/tsconfig.cjs.json
@@ -0,0 +1,9 @@
+{
+ "extends": "./tsconfig.base.json",
+ "compilerOptions": {
+ "module": "CommonJS",
+ "outDir": "./build/cjs",
+ "declaration": false,
+ "sourceMap": true
+ }
+}
diff --git a/packages/android-renderer/tsconfig.esm.json b/packages/android-renderer/tsconfig.esm.json
new file mode 100644
index 00000000..2bb18d33
--- /dev/null
+++ b/packages/android-renderer/tsconfig.esm.json
@@ -0,0 +1,9 @@
+{
+ "extends": "./tsconfig.base.json",
+ "compilerOptions": {
+ "module": "ES2020",
+ "outDir": "./build/esm",
+ "declaration": false,
+ "sourceMap": true
+ }
+}
diff --git a/packages/android-renderer/tsconfig.typecheck.json b/packages/android-renderer/tsconfig.typecheck.json
new file mode 100644
index 00000000..572e7da6
--- /dev/null
+++ b/packages/android-renderer/tsconfig.typecheck.json
@@ -0,0 +1,7 @@
+{
+ "extends": "./tsconfig.base.json",
+ "compilerOptions": {
+ "noEmit": true
+ },
+ "include": ["./src", "./scripts"]
+}
diff --git a/packages/android-renderer/tsconfig.types.json b/packages/android-renderer/tsconfig.types.json
new file mode 100644
index 00000000..8ec821ee
--- /dev/null
+++ b/packages/android-renderer/tsconfig.types.json
@@ -0,0 +1,10 @@
+{
+ "extends": "./tsconfig.base.json",
+ "compilerOptions": {
+ "module": "ES2020",
+ "outDir": "./build/types",
+ "declaration": true,
+ "emitDeclarationOnly": true,
+ "declarationMap": true
+ }
+}
diff --git a/packages/android/src/app-intent.ts b/packages/android/src/app-intent.ts
new file mode 100644
index 00000000..9be5077e
--- /dev/null
+++ b/packages/android/src/app-intent.ts
@@ -0,0 +1,16 @@
+/**
+ * Returns a template expression that the server renderer emits verbatim and the
+ * widget extension resolves at render time against the current AppIntent
+ * parameter value (Track 4 PoC).
+ *
+ * Mirrors `appIntentParam` from `@use-voltra/ios` so the developer experience
+ * is identical across platforms.
+ *
+ * @example
+ * {appIntentParam('city')}
+ * // Server renders as: "{{ appIntent.city }}"
+ * // Hermes resolves to the configured value at render time.
+ */
+export function appIntentParam(name: string): string {
+ return `{{ appIntent.${name} }}`
+}
diff --git a/packages/android/src/index.ts b/packages/android/src/index.ts
index 4c7b2ffc..c9f68926 100644
--- a/packages/android/src/index.ts
+++ b/packages/android/src/index.ts
@@ -1,5 +1,6 @@
// Android component namespace
export * as VoltraAndroid from './jsx/primitives.js'
+export { appIntentParam } from './app-intent.js'
export { AndroidDynamicColors } from './dynamic-colors.js'
export {
getAndroidComponentId,