From b6b13ad5b7814db2652586da756cc97c27d810a4 Mon Sep 17 00:00:00 2001 From: Bartek Dybowski Date: Mon, 1 Jun 2026 14:31:21 +0200 Subject: [PATCH 1/7] wip(track-4): hermes-on-android smoke test (Phase 0) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../android-client/android/CMakeLists.txt | 32 ++++++++++++ packages/android-client/android/build.gradle | 27 ++++++++++ .../android/src/main/cpp/voltra_smoke.cpp | 49 +++++++++++++++++++ .../src/main/java/voltra/VoltraModule.kt | 7 +++ .../java/voltra/runtime/VoltraSmokeTest.kt | 34 +++++++++++++ 5 files changed, 149 insertions(+) create mode 100644 packages/android-client/android/CMakeLists.txt create mode 100644 packages/android-client/android/src/main/cpp/voltra_smoke.cpp create mode 100644 packages/android-client/android/src/main/java/voltra/runtime/VoltraSmokeTest.kt diff --git a/packages/android-client/android/CMakeLists.txt b/packages/android-client/android/CMakeLists.txt new file mode 100644 index 00000000..22416b98 --- /dev/null +++ b/packages/android-client/android/CMakeLists.txt @@ -0,0 +1,32 @@ +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) + +# --------------------------------------------------------------------------- +# Phase 0 — smoke test +# --------------------------------------------------------------------------- +# Throwaway target that creates a standalone Hermes runtime, evaluates `1+1`, +# and returns the result to Kotlin via JNI. Validates that: +# 1. Voltra's android-client AAR can carry native code +# 2. Hermes headers + libhermes.so are linkable from a Voltra-owned target +# 3. `hermes::makeHermesRuntime()` works outside the React Native bridge +# Delete this target and its sources before merging if the broader PoC fails. +add_library(voltra_smoke SHARED + src/main/cpp/voltra_smoke.cpp +) + +target_link_libraries(voltra_smoke + 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_smoke.cpp b/packages/android-client/android/src/main/cpp/voltra_smoke.cpp new file mode 100644 index 00000000..ad8f94d6 --- /dev/null +++ b/packages/android-client/android/src/main/cpp/voltra_smoke.cpp @@ -0,0 +1,49 @@ +// Phase 0 — Hermes-on-Android smoke test (Track 4) +// +// Validates that: +// 1. Voltra's android-client AAR can carry native code +// 2. Hermes headers + libhermes.so are linkable from a Voltra-owned target +// 3. `hermes::makeHermesRuntime()` works outside the React Native bridge +// +// Throwaway file: delete before merging if the broader PoC fails. + +#include +#include + +#include +#include + +#include +#include + +#define LOG_TAG "VoltraSmokeTest" +#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; + +extern "C" JNIEXPORT jstring JNICALL +Java_voltra_runtime_VoltraSmokeTest_runSmokeTestNative(JNIEnv *env, jobject /* this */) { + try { + LOGI("creating standalone Hermes runtime..."); + auto runtime = facebook::hermes::makeHermesRuntime(); + + LOGI("evaluating `1 + 1`..."); + jsi::Value result = runtime->evaluateJavaScript( + std::make_unique("1 + 1"), + "voltra-smoke.js"); + + const double n = result.asNumber(); + LOGI("result: %f", n); + + const std::string out = "Hermes OK: 1 + 1 = " + std::to_string(static_cast(n)); + return env->NewStringUTF(out.c_str()); + } catch (const std::exception &e) { + LOGE("smoke test threw: %s", e.what()); + const std::string out = std::string("Hermes FAIL: ") + e.what(); + return env->NewStringUTF(out.c_str()); + } catch (...) { + LOGE("smoke test threw unknown"); + return env->NewStringUTF("Hermes FAIL: unknown exception"); + } +} \ 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..0b612dd7 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.VoltraSmokeTest import voltra.widget.VoltraGlanceWidget import voltra.widget.VoltraWidgetManager @@ -25,6 +26,12 @@ class VoltraModule( private const val TAG = "VoltraModule" } + init { + // Phase 0 — Hermes-on-Android smoke test (Track 4 PoC). + // Logs to logcat under tag "VoltraSmokeTest". Throwaway — remove when Phase 1 lands. + VoltraSmokeTest.run() + } + private val notificationManager by lazy { VoltraNotificationManager(reactApplicationContext) } diff --git a/packages/android-client/android/src/main/java/voltra/runtime/VoltraSmokeTest.kt b/packages/android-client/android/src/main/java/voltra/runtime/VoltraSmokeTest.kt new file mode 100644 index 00000000..a9587185 --- /dev/null +++ b/packages/android-client/android/src/main/java/voltra/runtime/VoltraSmokeTest.kt @@ -0,0 +1,34 @@ +package voltra.runtime + +import android.util.Log + +/** + * Phase 0 — Hermes-on-Android smoke test (Track 4). + * + * Validates that a standalone Hermes runtime can be instantiated from Kotlin via JNI. + * Calls into `libvoltra_smoke.so` which creates a runtime, evaluates `1 + 1`, and + * returns the result as a string. + * + * If [run] logs "Hermes OK: 1 + 1 = 2", the PoC gate has passed and we proceed to Phase 1. + * If it logs "Hermes FAIL: ...", investigate the linker/ABI error or fall back to QuickJS. + * + * Throwaway file — delete before merging if the broader PoC fails. + */ +object VoltraSmokeTest { + private const val TAG = "VoltraSmokeTest" + + init { + System.loadLibrary("voltra_smoke") + } + + private external fun runSmokeTestNative(): String + + fun run() { + try { + val result = runSmokeTestNative() + Log.i(TAG, result) + } catch (e: Throwable) { + Log.e(TAG, "smoke test threw on the JNI boundary", e) + } + } +} From 2869bd14369194a9e88e8689186c5d8efcc20911 Mon Sep 17 00:00:00 2001 From: Bartek Dybowski Date: Mon, 1 Jun 2026 14:47:39 +0200 Subject: [PATCH 2/7] =?UTF-8?q?feat(track-4):=20VoltraJSRenderer=20?= =?UTF-8?q?=E2=80=94=20standalone=20Hermes=20JNI=20wrapper=20(Phase=201)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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}}} --- .../android-client/android/CMakeLists.txt | 18 ++- .../src/main/cpp/voltra_js_renderer.cpp | 130 ++++++++++++++++++ .../android/src/main/cpp/voltra_smoke.cpp | 49 ------- .../src/main/java/voltra/VoltraModule.kt | 59 +++++++- .../java/voltra/runtime/VoltraJSRenderer.kt | 74 ++++++++++ .../java/voltra/runtime/VoltraSmokeTest.kt | 34 ----- 6 files changed, 267 insertions(+), 97 deletions(-) create mode 100644 packages/android-client/android/src/main/cpp/voltra_js_renderer.cpp delete mode 100644 packages/android-client/android/src/main/cpp/voltra_smoke.cpp create mode 100644 packages/android-client/android/src/main/java/voltra/runtime/VoltraJSRenderer.kt delete mode 100644 packages/android-client/android/src/main/java/voltra/runtime/VoltraSmokeTest.kt diff --git a/packages/android-client/android/CMakeLists.txt b/packages/android-client/android/CMakeLists.txt index 22416b98..ad7c447e 100644 --- a/packages/android-client/android/CMakeLists.txt +++ b/packages/android-client/android/CMakeLists.txt @@ -11,19 +11,17 @@ find_package(hermes-engine REQUIRED CONFIG) find_package(fbjni REQUIRED CONFIG) # --------------------------------------------------------------------------- -# Phase 0 — smoke test +# Voltra JS Renderer (Track 4 — Hermes-on-Android) # --------------------------------------------------------------------------- -# Throwaway target that creates a standalone Hermes runtime, evaluates `1+1`, -# and returns the result to Kotlin via JNI. Validates that: -# 1. Voltra's android-client AAR can carry native code -# 2. Hermes headers + libhermes.so are linkable from a Voltra-owned target -# 3. `hermes::makeHermesRuntime()` works outside the React Native bridge -# Delete this target and its sources before merging if the broader PoC fails. -add_library(voltra_smoke SHARED - src/main/cpp/voltra_smoke.cpp +# 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_smoke +target_link_libraries(voltra_js_renderer android log ReactAndroid::jsi 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/cpp/voltra_smoke.cpp b/packages/android-client/android/src/main/cpp/voltra_smoke.cpp deleted file mode 100644 index ad8f94d6..00000000 --- a/packages/android-client/android/src/main/cpp/voltra_smoke.cpp +++ /dev/null @@ -1,49 +0,0 @@ -// Phase 0 — Hermes-on-Android smoke test (Track 4) -// -// Validates that: -// 1. Voltra's android-client AAR can carry native code -// 2. Hermes headers + libhermes.so are linkable from a Voltra-owned target -// 3. `hermes::makeHermesRuntime()` works outside the React Native bridge -// -// Throwaway file: delete before merging if the broader PoC fails. - -#include -#include - -#include -#include - -#include -#include - -#define LOG_TAG "VoltraSmokeTest" -#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; - -extern "C" JNIEXPORT jstring JNICALL -Java_voltra_runtime_VoltraSmokeTest_runSmokeTestNative(JNIEnv *env, jobject /* this */) { - try { - LOGI("creating standalone Hermes runtime..."); - auto runtime = facebook::hermes::makeHermesRuntime(); - - LOGI("evaluating `1 + 1`..."); - jsi::Value result = runtime->evaluateJavaScript( - std::make_unique("1 + 1"), - "voltra-smoke.js"); - - const double n = result.asNumber(); - LOGI("result: %f", n); - - const std::string out = "Hermes OK: 1 + 1 = " + std::to_string(static_cast(n)); - return env->NewStringUTF(out.c_str()); - } catch (const std::exception &e) { - LOGE("smoke test threw: %s", e.what()); - const std::string out = std::string("Hermes FAIL: ") + e.what(); - return env->NewStringUTF(out.c_str()); - } catch (...) { - LOGE("smoke test threw unknown"); - return env->NewStringUTF("Hermes FAIL: unknown exception"); - } -} \ 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 0b612dd7..f6fd5b03 100644 --- a/packages/android-client/android/src/main/java/voltra/VoltraModule.kt +++ b/packages/android-client/android/src/main/java/voltra/VoltraModule.kt @@ -15,7 +15,7 @@ import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll import kotlinx.coroutines.runBlocking import voltra.images.VoltraImageManager -import voltra.runtime.VoltraSmokeTest +import voltra.runtime.VoltraJSRenderer import voltra.widget.VoltraGlanceWidget import voltra.widget.VoltraWidgetManager @@ -24,12 +24,63 @@ class VoltraModule( ) : NativeVoltraAndroidSpec(reactContext) { companion object { private const val TAG = "VoltraModule" + + // Phase 1 — temporary self-test bundle. Defines a minimal VoltraRenderer + // matching the iOS Track 2 resolver's `{{ appIntent.X }}` substitution. + // Replaced in Phase 2 by the real @use-voltra/android-renderer bundle + // loaded from assets. Remove this self-test entirely in Phase 3. + private const val PHASE_1_TEST_BUNDLE = """ + (function (g) { + var TPL = /\{\{\s*appIntent\.(\w+)\s*\}\}/g; + function resolveString(s, params) { + return s.replace(TPL, function (_, k) { + return params[k] !== undefined ? params[k] : ''; + }); + } + function resolveValue(v, params) { + if (typeof v === 'string') return resolveString(v, params); + if (Array.isArray(v)) return v.map(function (x) { return resolveValue(x, params); }); + if (v !== null && typeof v === 'object') { + var out = {}; + for (var k in v) { + if (Object.prototype.hasOwnProperty.call(v, k)) { + out[k] = resolveValue(v[k], params); + } + } + return out; + } + return v; + } + g.VoltraRenderer = { + resolve: function (payload, params) { + var out = {}; + for (var k in payload) { + if (k === 'v' || k === 'e') out[k] = payload[k]; + else out[k] = resolveValue(payload[k], params); + } + return out; + } + }; + })(globalThis); + """ } init { - // Phase 0 — Hermes-on-Android smoke test (Track 4 PoC). - // Logs to logcat under tag "VoltraSmokeTest". Throwaway — remove when Phase 1 lands. - VoltraSmokeTest.run() + // Phase 1 self-test — verifies Kotlin → JNI → Hermes → JS → back round-trip. + // Removed in Phase 3 when the real Glance render path wires the resolver in. + runPhase1SelfTest() + } + + private fun runPhase1SelfTest() { + val ok = VoltraJSRenderer.ensureInitialized(PHASE_1_TEST_BUNDLE) + if (!ok) { + Log.e(TAG, "[Phase 1 self-test] ensureInitialized failed") + return + } + val payload = + """{"v":1,"systemSmall":{"t":0,"c":"{{ appIntent.city }} weather","p":{"fs":22}}}""" + val resolved = VoltraJSRenderer.resolve(payload, mapOf("city" to "Warsaw")) + Log.i(TAG, "[Phase 1 self-test] resolved = $resolved") } private val notificationManager by lazy { 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..1c694ae4 --- /dev/null +++ b/packages/android-client/android/src/main/java/voltra/runtime/VoltraJSRenderer.kt @@ -0,0 +1,74 @@ +package voltra.runtime + +import android.util.Log +import org.json.JSONObject + +/** + * 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" + + 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 + initialized = nativeInit(bundleSource) + if (!initialized) { + Log.e(TAG, "Hermes init failed") + } + return initialized + } + } + + /** + * 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() + return synchronized(lock) { + nativeResolve(payloadJSON, paramsJSON) + } + } +} diff --git a/packages/android-client/android/src/main/java/voltra/runtime/VoltraSmokeTest.kt b/packages/android-client/android/src/main/java/voltra/runtime/VoltraSmokeTest.kt deleted file mode 100644 index a9587185..00000000 --- a/packages/android-client/android/src/main/java/voltra/runtime/VoltraSmokeTest.kt +++ /dev/null @@ -1,34 +0,0 @@ -package voltra.runtime - -import android.util.Log - -/** - * Phase 0 — Hermes-on-Android smoke test (Track 4). - * - * Validates that a standalone Hermes runtime can be instantiated from Kotlin via JNI. - * Calls into `libvoltra_smoke.so` which creates a runtime, evaluates `1 + 1`, and - * returns the result as a string. - * - * If [run] logs "Hermes OK: 1 + 1 = 2", the PoC gate has passed and we proceed to Phase 1. - * If it logs "Hermes FAIL: ...", investigate the linker/ABI error or fall back to QuickJS. - * - * Throwaway file — delete before merging if the broader PoC fails. - */ -object VoltraSmokeTest { - private const val TAG = "VoltraSmokeTest" - - init { - System.loadLibrary("voltra_smoke") - } - - private external fun runSmokeTestNative(): String - - fun run() { - try { - val result = runSmokeTestNative() - Log.i(TAG, result) - } catch (e: Throwable) { - Log.e(TAG, "smoke test threw on the JNI boundary", e) - } - } -} From f52f63e5bed77070f52d7b143593642a11c28e88 Mon Sep 17 00:00:00 2001 From: Bartek Dybowski Date: Mon, 1 Jun 2026 15:01:11 +0200 Subject: [PATCH 3/7] feat(track-4): add @use-voltra/android-renderer JS bundle package (Phase 2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- package-lock.json | 12 +++ packages/android-renderer/.gitignore | 2 + packages/android-renderer/README.md | 7 ++ packages/android-renderer/package.json | 47 +++++++++++ .../android-renderer/scripts/build-bundle.mjs | 19 +++++ packages/android-renderer/src/bundle-entry.ts | 5 ++ packages/android-renderer/src/index.ts | 57 ++++++++++++++ .../android-renderer/test/resolve.test.js | 78 +++++++++++++++++++ packages/android-renderer/tsconfig.base.json | 15 ++++ packages/android-renderer/tsconfig.cjs.json | 9 +++ packages/android-renderer/tsconfig.esm.json | 9 +++ .../android-renderer/tsconfig.typecheck.json | 7 ++ packages/android-renderer/tsconfig.types.json | 10 +++ 13 files changed, 277 insertions(+) create mode 100644 packages/android-renderer/.gitignore create mode 100644 packages/android-renderer/README.md create mode 100644 packages/android-renderer/package.json create mode 100644 packages/android-renderer/scripts/build-bundle.mjs create mode 100644 packages/android-renderer/src/bundle-entry.ts create mode 100644 packages/android-renderer/src/index.ts create mode 100644 packages/android-renderer/test/resolve.test.js create mode 100644 packages/android-renderer/tsconfig.base.json create mode 100644 packages/android-renderer/tsconfig.cjs.json create mode 100644 packages/android-renderer/tsconfig.esm.json create mode 100644 packages/android-renderer/tsconfig.typecheck.json create mode 100644 packages/android-renderer/tsconfig.types.json 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-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..7779f7c5 --- /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') \ No newline at end of file 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 + } +} From bbb94739f98e18bde88abecab8f952cbf40ba901 Mon Sep 17 00:00:00 2001 From: Bartek Dybowski Date: Mon, 1 Jun 2026 15:52:36 +0200 Subject: [PATCH 4/7] feat(track-4): wire Hermes resolver into VoltraGlanceWidget render path (Phase 3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - AppIntentParamsStore: DataStore-backed per-widget parameter storage. Keyed as `voltra.appintent..`. 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. --- .../src/main/java/voltra/VoltraModule.kt | 58 ------------------- .../voltra/runtime/AppIntentParamsStore.kt | 49 ++++++++++++++++ .../java/voltra/runtime/VoltraJSRenderer.kt | 26 +++++++++ .../java/voltra/widget/VoltraGlanceWidget.kt | 36 +++++++++++- 4 files changed, 110 insertions(+), 59 deletions(-) create mode 100644 packages/android-client/android/src/main/java/voltra/runtime/AppIntentParamsStore.kt 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 f6fd5b03..825fd1d9 100644 --- a/packages/android-client/android/src/main/java/voltra/VoltraModule.kt +++ b/packages/android-client/android/src/main/java/voltra/VoltraModule.kt @@ -15,7 +15,6 @@ import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll import kotlinx.coroutines.runBlocking import voltra.images.VoltraImageManager -import voltra.runtime.VoltraJSRenderer import voltra.widget.VoltraGlanceWidget import voltra.widget.VoltraWidgetManager @@ -24,63 +23,6 @@ class VoltraModule( ) : NativeVoltraAndroidSpec(reactContext) { companion object { private const val TAG = "VoltraModule" - - // Phase 1 — temporary self-test bundle. Defines a minimal VoltraRenderer - // matching the iOS Track 2 resolver's `{{ appIntent.X }}` substitution. - // Replaced in Phase 2 by the real @use-voltra/android-renderer bundle - // loaded from assets. Remove this self-test entirely in Phase 3. - private const val PHASE_1_TEST_BUNDLE = """ - (function (g) { - var TPL = /\{\{\s*appIntent\.(\w+)\s*\}\}/g; - function resolveString(s, params) { - return s.replace(TPL, function (_, k) { - return params[k] !== undefined ? params[k] : ''; - }); - } - function resolveValue(v, params) { - if (typeof v === 'string') return resolveString(v, params); - if (Array.isArray(v)) return v.map(function (x) { return resolveValue(x, params); }); - if (v !== null && typeof v === 'object') { - var out = {}; - for (var k in v) { - if (Object.prototype.hasOwnProperty.call(v, k)) { - out[k] = resolveValue(v[k], params); - } - } - return out; - } - return v; - } - g.VoltraRenderer = { - resolve: function (payload, params) { - var out = {}; - for (var k in payload) { - if (k === 'v' || k === 'e') out[k] = payload[k]; - else out[k] = resolveValue(payload[k], params); - } - return out; - } - }; - })(globalThis); - """ - } - - init { - // Phase 1 self-test — verifies Kotlin → JNI → Hermes → JS → back round-trip. - // Removed in Phase 3 when the real Glance render path wires the resolver in. - runPhase1SelfTest() - } - - private fun runPhase1SelfTest() { - val ok = VoltraJSRenderer.ensureInitialized(PHASE_1_TEST_BUNDLE) - if (!ok) { - Log.e(TAG, "[Phase 1 self-test] ensureInitialized failed") - return - } - val payload = - """{"v":1,"systemSmall":{"t":0,"c":"{{ appIntent.city }} weather","p":{"fs":22}}}""" - val resolved = VoltraJSRenderer.resolve(payload, mapOf("city" to "Warsaw")) - Log.i(TAG, "[Phase 1 self-test] resolved = $resolved") } private val notificationManager by lazy { 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..228138a2 --- /dev/null +++ b/packages/android-client/android/src/main/java/voltra/runtime/AppIntentParamsStore.kt @@ -0,0 +1,49 @@ +package voltra.runtime + +import android.content.Context +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.stringPreferencesKey +import androidx.datastore.preferences.preferencesDataStore +import kotlinx.coroutines.flow.first + +/** + * 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. + */ +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 setParam( + widgetId: String, + name: String, + value: String, + ) { + val key = stringPreferencesKey(paramPrefix(widgetId) + name) + context.appIntentParamsDataStore.edit { it[key] = value } + } + + private fun paramPrefix(widgetId: String): String = "voltra.appintent.$widgetId." +} + +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 index 1c694ae4..889cfa31 100644 --- a/packages/android-client/android/src/main/java/voltra/runtime/VoltraJSRenderer.kt +++ b/packages/android-client/android/src/main/java/voltra/runtime/VoltraJSRenderer.kt @@ -1,7 +1,9 @@ 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 @@ -17,6 +19,7 @@ import org.json.JSONObject */ object VoltraJSRenderer { private const val TAG = "VoltraJSRenderer" + private const val BUNDLE_ASSET_PATH = "voltra/android-renderer.js" init { System.loadLibrary("voltra_js_renderer") @@ -51,6 +54,29 @@ object VoltraJSRenderer { } } + /** + * 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 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 + } + return ensureInitialized(source) + } + /** * Resolve `{{ appIntent.X }}` placeholders in [payloadJSON] against * [appIntentParams]. Returns the resolved JSON string, or null if the 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..5eeeeab3 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).getParams(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"). From 5b43118863227f35f0952df51d6f2e6330b3ac62 Mon Sep 17 00:00:00 2001 From: Bartek Dybowski Date: Tue, 2 Jun 2026 10:09:44 +0200 Subject: [PATCH 5/7] feat(track-4): reactive Android widget + plugin glue (Phase 4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- example/app.json | 13 ++ example/app/android-widgets/reactive.tsx | 5 + .../android/AndroidReactiveWidgetScreen.tsx | 112 ++++++++++++++++++ example/screens/android/tabs/sections.ts | 7 ++ example/server/widget-server.tsx | 14 +++ .../android/AndroidReactiveWeatherWidget.tsx | 25 ++++ .../android-reactive-weather-initial.tsx | 14 +++ .../src/main/java/voltra/VoltraModule.kt | 24 ++++ .../voltra/runtime/AppIntentParamsStore.kt | 50 ++++++++ .../java/voltra/widget/VoltraGlanceWidget.kt | 2 +- .../expo-plugin/src/android/files/bundle.ts | 68 +++++++++++ .../expo-plugin/src/android/files/index.ts | 9 ++ .../android-client/expo-plugin/src/types.ts | 29 +++++ packages/android-client/src/index.ts | 1 + .../src/native/NativeVoltraAndroid.ts | 8 ++ packages/android-client/src/widgets/api.ts | 11 ++ packages/android/src/app-intent.ts | 16 +++ packages/android/src/index.ts | 1 + 18 files changed, 408 insertions(+), 1 deletion(-) create mode 100644 example/app/android-widgets/reactive.tsx create mode 100644 example/screens/android/AndroidReactiveWidgetScreen.tsx create mode 100644 example/widgets/android/AndroidReactiveWeatherWidget.tsx create mode 100644 example/widgets/android/android-reactive-weather-initial.tsx create mode 100644 packages/android-client/expo-plugin/src/android/files/bundle.ts create mode 100644 packages/android/src/app-intent.ts 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. + + + +