Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
67 commits
Select commit Hold shift + click to select a range
8262fbf
feat: add Voltra widget Metro PoC
V3RON Jun 2, 2026
89f9130
feat: render generated Voltra widget entries
V3RON Jun 2, 2026
40883d0
Merge remote-tracking branch 'upstream/codex/metro-widget-poc' into p…
burczu Jun 2, 2026
4908358
feat(track-5): add WidgetEnvironment type for client-rendered widgets…
burczu Jun 3, 2026
b2b4f99
feat(track-5): thread env into the generated render entry (Phase 2)
burczu Jun 3, 2026
b01a9fc
feat(track-5): runtime smoke test for client-rendered widgets (Phase 3a)
burczu Jun 3, 2026
a0597bf
feat(track-5): env capture + dev hot-reload helper (Phase 3b-ii)
burczu Jun 3, 2026
7b64c8f
feat(track-5): client-rendered widget detection helper (Phase 3b-iii …
burczu Jun 3, 2026
1279bfa
feat(track-5): client-rendered widget runtime helpers (Phase 3b-iii s…
burczu Jun 3, 2026
571d027
feat(track-5): per-widget Swift dispatch on rendering mode (Phase 3b-…
burczu Jun 3, 2026
0e0f0d6
feat(track-5): client-rendered widget prerender + initial state (Phas…
burczu Jun 3, 2026
4719a85
feat(track-5): clientWidgetHotReload opt-in flag, drop polling (Phase…
burczu Jun 3, 2026
d5bad38
chore: gitignore SPM build caches under any iOS-bearing package
burczu Jun 3, 2026
4221ef2
feat(track-5): enableClientWidgetHotReload helper (Phase 3b-iii step 5b)
burczu Jun 3, 2026
78678b7
feat(track-5): wire IosWeatherWidget as plugin-generated widget (Phas…
burczu Jun 3, 2026
c91bfc8
chore(track-5): Phase 3b-iii hot-reload exploration — rip out failed …
burczu Jun 5, 2026
c7edd32
feat(track-5): silent-push handler for client widget hot reload (Phas…
burczu Jun 5, 2026
1752db4
feat(track-5): main app remote-notification background mode (Phase 3b…
burczu Jun 5, 2026
10820aa
feat(track-5): Metro-driven silent push for client widget hot reload …
burczu Jun 8, 2026
4173db8
fix(track-5): register silent-push handler at framework load time (Ph…
burczu Jun 8, 2026
20f17ad
chore(track-5): collapse demo widgets to Track5DemoWidget only
burczu Jun 8, 2026
d0a33b0
Merge remote-tracking branch 'upstream/main' into poc/widget-reactivi…
burczu Jun 8, 2026
5f006f9
chore: strip codename/phase prefixes from client-rendered widget comm…
burczu Jun 8, 2026
7762ca1
refactor: rename Track5DemoWidget to ClientRenderedDemoWidget
burczu Jun 8, 2026
0c3f6e5
refactor: remove silent-push hot-reload wiring for client-rendered wi…
burczu Jun 9, 2026
0a63eea
feat(ios-client): hook Metro __accept to refresh widgets on Fast Refresh
burczu Jun 9, 2026
b12582f
Merge remote-tracking branch 'upstream/main' into poc/widget-reactivi…
burczu Jun 9, 2026
bebc75b
refactor: drop client-rendered widget smoke test surface
burczu Jun 9, 2026
e8afa50
fix: pnpm-strict-layout resolution for client-rendered widgets
burczu Jun 9, 2026
4a3f48c
feat(track-5-android): Phase 0 — standalone Hermes full-bundle smoke …
burczu Jun 11, 2026
c7fe36b
refactor(android-renderer): finalize standalone Hermes engine, drop v…
burczu Jun 11, 2026
4cec9da
feat(metro): resolve client-widget renderer per platform via render shim
burczu Jun 11, 2026
6356d03
feat(android-client): client-rendered Glance widget + on-device env c…
burczu Jun 11, 2026
6a394f0
feat(android-client): config-plugin support for client-rendered widgets
burczu Jun 11, 2026
d80d1c3
feat(android-client): env.configuration via DataStore stand-in + reac…
burczu Jun 11, 2026
b16a26d
feat(android-client): env.materialColors from Material You dynamic co…
burczu Jun 11, 2026
292daae
feat(android-client): dev hot reload for client-rendered widgets
burczu Jun 11, 2026
eba9262
chore(android-client): debug-gated perf instrumentation for client wi…
burczu Jun 11, 2026
9e4c6b7
fix(ios-client): resolve Metro dev-server URL via RCTBundleURLProvide…
burczu Jun 11, 2026
9b0fbbe
refactor(metro): discover client widgets by filesystem scan + watcher
burczu Jun 11, 2026
921e6cf
feat(ios-client): configure client-rendered widgets via AppIntent
burczu Jun 11, 2026
e9e5ee1
fix(metro): restore client-rendered widget hot reload via generated d…
burczu Jun 11, 2026
34a424a
feat(example/metro): one-shot production bundler for client-rendered …
burczu Jun 12, 2026
7473cfb
feat(ios-client): bake client-rendered widget bundles in release builds
burczu Jun 12, 2026
fc40617
fix(ios-client): harden client-widget release render + bundling
burczu Jun 12, 2026
a4d94d3
docs(ios-client): mark client-rendered widgets experimental
burczu Jun 12, 2026
9836b82
test(ios-client): cover AppIntent codegen, experimental warning, rele…
burczu Jun 12, 2026
27ccf52
Merge branch 'poc/widget-reactivity-track-5' into poc/widget-reactivi…
burczu Jun 12, 2026
7f5daed
feat(android-client): code-declared defaults for client widget config…
burczu Jun 12, 2026
5ad3a3b
feat(android-client): bake client-rendered widget bundles in release …
burczu Jun 12, 2026
a18dd8b
feat(android-client): experimental marker + tests for client-rendered…
burczu Jun 12, 2026
8e7f419
feat: add Voltra compiler package
V3RON Jun 12, 2026
42bcef4
chore: merge origin/main into widget reactivity track
V3RON Jun 12, 2026
fd980cb
fix(android-client): depend on hermes-android so release native build…
burczu Jun 12, 2026
7c2c5ba
fix: rename generated voltra metro paths
V3RON Jun 12, 2026
d0f5284
Merge remote-tracking branch 'origin/poc/widget-reactivity-track-5' i…
burczu Jun 15, 2026
5bd330c
feat(metro): per-platform render shim in generated widget entries
burczu Jun 15, 2026
4ed1236
fix(android-client): resolve release widget bundler from @use-voltra/…
burczu Jun 15, 2026
e0375ad
refactor(android-client): detect client widgets via @use-voltra/compiler
burczu Jun 15, 2026
bc22ea8
docs(changeset): note Android release render verified on emulator
burczu Jun 15, 2026
aed6f88
fix(android-client): map @use-voltra/compiler to source in plugin typ…
burczu Jun 15, 2026
3ca712b
fix: syntax error in buildPhases
V3RON Jun 15, 2026
b50d9b4
Merge iOS track-5 (metro path rename + buildPhases fix) into android
burczu Jun 15, 2026
04c5f2f
refactor(client-widgets): Material You via AndroidDynamicColors, drop…
burczu Jun 16, 2026
bee6134
feat(client-widgets): re-render on resize via SizeMode.Exact
burczu Jun 16, 2026
2793587
feat(client-widgets): re-render on light/dark color-scheme change
burczu Jun 16, 2026
daeeb8c
Merge upstream/feat/dynamic-widgets (iOS PR #190) into android track-5
burczu Jun 18, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions .changeset/client-rendered-widgets-android-experimental.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
---
'@use-voltra/android-client': minor
---

**Experimental: client-rendered widgets (Android).** A widget component marked with the
`'use voltra'` directive now renders on-device in a standalone Hermes runtime, called as
`(props, env) => JSX` on every render, so it reacts to live environment values (widget size, color
scheme, locale, and configuration). Material You dynamic colors are consumed via
`AndroidDynamicColors` tokens that the native renderer resolves to the system color scheme (and that
follow light/dark automatically). In development the bundle is served by Metro and editing the JSX
hot-reloads the pinned widget; in release builds the bundle is baked into the app's assets at build
time.

Configuration parameters declared in `app.json` (`appIntent.parameters`, with code-defined
defaults) surface as `env.configuration`; runtime values set via `setWidgetConfiguration` override
the defaults (Android has no system widget-configuration UI, so this is an in-app stand-in).

This feature is **experimental** — usable in production at your own risk; the API and generated
build output may change. Release rendering has been verified on the Android emulator (the baked
bundle renders on-device with Metro stopped); confirming on a physical device is still recommended.
25 changes: 25 additions & 0 deletions example/app.json
Original file line number Diff line number Diff line change
Expand Up @@ -226,6 +226,31 @@
"intervalMinutes": 15,
"refresh": true
}
},
{
"id": "AndroidClientDemoWidget",
"displayName": {
"en": "Client Demo"
},
"description": {
"en": "Client-rendered Voltra widget (on-device Hermes)"
},
"minCellWidth": 2,
"minCellHeight": 2,
"targetCellWidth": 2,
"targetCellHeight": 2,
"resizeMode": "horizontal|vertical",
"widgetCategory": "home_screen",
"initialStatePath": "./widgets/android/AndroidClientDemoWidget.tsx",
"appIntent": {
"parameters": [
{
"name": "label",
"title": "Label",
"default": "Hello"
}
]
}
}
],
"fonts": [
Expand Down
10 changes: 8 additions & 2 deletions example/app/_layout.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,19 @@
import { Stack } from 'expo-router'
import { Platform } from 'react-native'
import { SafeAreaProvider } from 'react-native-safe-area-context'
import { enableWidgetHotReload } from '@use-voltra/ios-client'
import { enableWidgetHotReload as enableIosWidgetHotReload } from '@use-voltra/ios-client'
import { enableWidgetHotReload as enableAndroidWidgetHotReload } from '@use-voltra/android-client'
import '@use-voltra/widget-hot-reload'

import { useVoltraEvents } from '~/hooks/useVoltraEvents'
import { useServerDrivenWidgetToken } from '~/hooks/useServerDrivenWidgetToken'
import { updateAndroidVoltraWidget } from '~/widgets/android/updateAndroidVoltraWidget'

enableWidgetHotReload()
if (Platform.OS === 'android') {
enableAndroidWidgetHotReload()
} else {
enableIosWidgetHotReload()
}
updateAndroidVoltraWidget({ width: 300, height: 200 })

const STACK_SCREEN_OPTIONS = {
Expand Down
38 changes: 37 additions & 1 deletion example/screens/android/AndroidWidgetPinScreen.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { useRouter } from 'expo-router'
import React, { useState } from 'react'
import { Alert, Platform, StyleSheet, Text, TextInput, View } from 'react-native'
import { requestPinAndroidWidget } from '@use-voltra/android-client'
import { requestPinAndroidWidget, setWidgetConfiguration } from '@use-voltra/android-client'

import { Button } from '~/components/Button'
import { ScreenLayout } from '~/components/ScreenLayout'
Expand All @@ -28,6 +28,13 @@ const AVAILABLE_WIDGETS = [
defaultPreviewWidth: 250,
defaultPreviewHeight: 150,
},
{
id: 'AndroidClientDemoWidget',
name: 'Client-Rendered Demo',
description: 'On-device JSX render (Hermes) with live env',
defaultPreviewWidth: 250,
defaultPreviewHeight: 150,
},
]

export default function AndroidWidgetPinScreen() {
Expand All @@ -36,6 +43,19 @@ export default function AndroidWidgetPinScreen() {
const [previewWidth, setPreviewWidth] = useState<string>('250')
const [previewHeight, setPreviewHeight] = useState<string>('150')
const [isPinning, setIsPinning] = useState(false)
const [configLabel, setConfigLabel] = useState<string>('')

const handleSetConfig = async () => {
if (Platform.OS !== 'android') {
return
}
try {
await setWidgetConfiguration(selectedWidgetId, 'label', configLabel)
Alert.alert('Saved', `Set config "label" = "${configLabel}" for ${selectedWidgetId}. The widget re-renders.`)
} catch (error: any) {
Alert.alert('Error', error?.message || String(error))
}
}

const selectedWidget = AVAILABLE_WIDGETS.find((w) => w.id === selectedWidgetId) || AVAILABLE_WIDGETS[0]

Expand Down Expand Up @@ -110,6 +130,22 @@ export default function AndroidWidgetPinScreen() {
))}
</View>

<View style={styles.section}>
<Text style={styles.sectionTitle}>Configuration (client-rendered)</Text>
<View style={styles.inputGroup}>
<Text style={styles.inputLabel}>env.configuration.label</Text>
<TextInput
style={styles.input}
value={configLabel}
onChangeText={setConfigLabel}
placeholder="label value"
placeholderTextColor="#64748B"
autoCapitalize="none"
/>
</View>
<Button title="Set configuration" onPress={handleSetConfig} style={styles.resetButton} />
</View>

<View style={styles.section}>
<Text style={styles.sectionTitle}>Preview Dimensions (optional)</Text>
<View style={styles.previewInputs}>
Expand Down
71 changes: 71 additions & 0 deletions example/widgets/android/AndroidClientDemoWidget.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import { AndroidDynamicColors, VoltraAndroid, type WidgetEnvironment } from '@use-voltra/android'

// Client-rendered Android widget: its JSX runs on-device in Hermes on every render, receiving
// the live `env`. Edit the marker literal below and save — the home-screen widget updates via
// Fast Refresh (dev). Mirrors the iOS ClientRenderedDemoWidget. Themes itself from Material You
// via AndroidDynamicColors tokens, which the native renderer resolves to the system dynamic
// color scheme (and which follow light/dark automatically).

export const AndroidClientDemoWidget = (_props: object, env: WidgetEnvironment = {} as WidgetEnvironment) => {
'use voltra'

// ▼ EDIT THIS LITERAL TO TEST HOT RELOAD ▼
const hotReloadMarker = 'edit me'

const date = env.date ? new Date(env.date) : new Date()
const renderedAt = date.toLocaleTimeString('en-US', {
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hour12: false,
})

const config = env.configuration as Record<string, unknown> | undefined
const configLabel = typeof config?.label === 'string' ? config.label : '(unset)'

// Material You tokens — resolved natively from the system dynamic color scheme.
const bg = AndroidDynamicColors.surface
const fg = AndroidDynamicColors.onSurface
const muted = AndroidDynamicColors.onSurfaceVariant
const accent = AndroidDynamicColors.primary

const label = { fontSize: 10, color: muted } as const
const value = { fontSize: 10, color: fg } as const

const row = (k: string, v: string) => (
<VoltraAndroid.Row>
<VoltraAndroid.Text style={label}>{k} </VoltraAndroid.Text>
<VoltraAndroid.Text style={value}>{v}</VoltraAndroid.Text>
</VoltraAndroid.Row>
)

const swatch = (color: string) => (
<VoltraAndroid.Box style={{ width: 16, height: 16, backgroundColor: color, cornerRadius: 4 }} />
)

return (
<VoltraAndroid.Column
style={{ backgroundColor: bg, width: '100%', height: '100%', padding: 12 }}
verticalAlignment="center-vertically"
>
<VoltraAndroid.Text style={{ fontSize: 12, color: fg }}>Client-rendered demo</VoltraAndroid.Text>
<VoltraAndroid.Text style={{ fontSize: 14, color: accent }}>{hotReloadMarker}</VoltraAndroid.Text>
<VoltraAndroid.Spacer style={{ height: 6 }} />
{row('size:', env.widgetFamily ?? '?')}
{row('scheme:', env.colorScheme ?? '?')}
{row('locale:', env.locale ?? '?')}
{row('config:', configLabel)}
{row('time:', renderedAt)}
<VoltraAndroid.Spacer style={{ height: 8 }} />
<VoltraAndroid.Row>
{swatch(AndroidDynamicColors.primary)}
<VoltraAndroid.Spacer style={{ width: 6 }} />
{swatch(AndroidDynamicColors.secondary)}
<VoltraAndroid.Spacer style={{ width: 6 }} />
{swatch(AndroidDynamicColors.tertiary)}
<VoltraAndroid.Spacer style={{ width: 6 }} />
{swatch(AndroidDynamicColors.error)}
</VoltraAndroid.Row>
</VoltraAndroid.Column>
)
}
24 changes: 24 additions & 0 deletions packages/android-client/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@

- **Home Screen widgets**: Update, reload, pin, and query widgets with `updateAndroidWidget`, `reloadAndroidWidgets`, `getActiveWidgets`, and more.

- **Client-rendered widgets** _(experimental)_: Write a widget as a `'use voltra'` JSX component and have it render on-device (standalone Hermes) from its own JS bundle, with live env (size, color scheme, Material You colors, locale, configuration). See the note below.

- **Ongoing notifications**: Start and update promoted ongoing notifications with `useAndroidOngoingNotification` and related APIs.

- **Fast Refresh**: Previews integrate with your React Native dev workflow via `VoltraWidgetPreview` and `VoltraView`.
Expand All @@ -20,6 +22,28 @@

- **Expo config plugin**: Add `"@use-voltra/android-client"` to `app.json` to register widgets, optional notifications, and build-time initial states.

## Client-rendered widgets (experimental)

> [!WARNING]
> Client-rendered widgets are **experimental** — usable in production at your own risk. The API
> and generated build output may change between releases.
A widget whose component carries the `'use voltra'` directive is rendered **on-device**: its JS
bundle is evaluated in a standalone Hermes runtime on each render and called as `(props, env) => JSX`,
so the widget reacts to live environment values (size, color scheme, Material You `materialColors`,
locale, and `configuration`). In development the bundle is served by Metro (editing the JSX
hot-reloads the pinned widget); in release builds it is baked into the app's assets at build time.

Configuration parameters declared in `app.json` (`appIntent.parameters`, with code-defined
defaults) surface as `env.configuration`. Android has no system-managed widget configuration UI
(unlike iOS's Edit Widget), so runtime values are set in-app via `setWidgetConfiguration` and
override the declared defaults.

Notes:

- The dev loop and release baking rely on Metro scaffolding in your project (see `example/metro`).
- Verify release rendering on a **real device** — emulators are unreliable for widget rendering.

## Documentation

The documentation is available at [use-voltra.dev](https://use-voltra.dev). Relevant topics for this package:
Expand Down
33 changes: 33 additions & 0 deletions packages/android-client/android/CMakeLists.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
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.
#
# NOTE: find_package does NOT error on a wrong target *name* — the failure only
# surfaces at target_link_libraries. The correct Hermes prefab target on Android
# (RN 0.83, com.facebook.react:hermes-android) is `hermes-engine::hermesvm`.
find_package(ReactAndroid REQUIRED CONFIG)
find_package(hermes-engine REQUIRED CONFIG)
find_package(fbjni REQUIRED CONFIG)

# ---------------------------------------------------------------------------
# Voltra JS Renderer (client-rendered widgets on Android)
# ---------------------------------------------------------------------------
# Standalone Hermes runtime owned by Voltra, independent of the React Native
# bridge. Evaluates a per-widget Metro bundle that exposes `render(props, env)`
# and invokes it on every Glance render. JNI surface 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
)
37 changes: 37 additions & 0 deletions packages/android-client/android/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -39,11 +39,39 @@ android {
versionCode 1
versionName "0.1.0"
buildConfigField "String", "VOLTRA_VERSION", "\"${voltraVersion}\""

// Standalone Hermes runtime for client-rendered widgets, via custom JNI.
// See CMakeLists.txt / src/main/cpp/voltra_js_renderer.cpp.
externalNativeBuild {
cmake {
cppFlags "-std=c++17", "-fexceptions", "-frtti"
arguments "-DANDROID_STL=c++_shared"
}
}
}

externalNativeBuild {
cmake {
path "CMakeLists.txt"
}
}

buildFeatures {
buildConfig true
compose true
// Required to consume React Native's published prefab modules (jsi, hermesvm, fbjni).
prefab true
}

packagingOptions {
// These .so files are already provided by the host app's React Native runtime;
// bundling Voltra's copies would cause duplicate-library conflicts at install time.
excludes += [
"**/libreact_nativemodule_core.so",
"**/libfbjni.so",
"**/libjsi.so",
"**/libhermes.so",
]
}

lintOptions {
Expand All @@ -67,6 +95,11 @@ android {
dependencies {
// React Native
implementation "com.facebook.react:react-android"
// Provides the `hermes-engine` prefab (libhermesvm) that CMakeLists.txt's
// find_package(hermes-engine) consumes for the standalone Hermes JS runtime. Without it the
// native build fails to configure in release variants (react-android alone does not publish the
// Hermes prefab). Version is substituted by the React Native Gradle Plugin (applied above).
implementation "com.facebook.react:hermes-android"

// Jetpack Glance
api "androidx.glance:glance:1.2.0-rc01"
Expand All @@ -75,6 +108,10 @@ dependencies {
// Compose runtime (required for Glance)
api "androidx.compose.runtime:runtime:1.6.8"

// Material3 — used to read Material You dynamic color tokens for client widgets'
// env.materialColors (dynamicLightColorScheme / dynamicDarkColorScheme).
implementation "androidx.compose.material3:material3:1.3.2"

// WorkManager (for periodic server-driven widget updates)
api "androidx.work:work-runtime-ktx:2.9.1"

Expand Down
Loading