Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
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
13 changes: 13 additions & 0 deletions example/app.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": [
Expand Down
5 changes: 5 additions & 0 deletions example/app/android-widgets/reactive.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import AndroidReactiveWidgetScreen from '~/screens/android/AndroidReactiveWidgetScreen'

export default function AndroidReactiveWidgetIndex() {
return <AndroidReactiveWidgetScreen />
}
112 changes: 112 additions & 0 deletions example/screens/android/AndroidReactiveWidgetScreen.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<ScreenLayout
title="Reactive Widget (Track 4)"
description="Change the AppIntent `city` parameter for the Reactive Weather widget. Hermes resolves the placeholder at render time — no server push, no app update."
>
<View style={styles.section}>
<Text style={styles.label}>City</Text>
<TextInput
style={styles.input}
value={city}
onChangeText={setCity}
placeholder="e.g. Warsaw, Tokyo, Lisbon"
placeholderTextColor="#94A3B8"
autoCorrect={false}
autoCapitalize="words"
returnKeyType="done"
onSubmitEditing={handleSubmit}
editable={!busy}
/>
<Text style={styles.hint}>Add the "Reactive Weather (Track 4)" widget to your home screen first.</Text>
</View>

<View style={styles.section}>
<Button
title={busy ? 'Updating…' : 'Submit'}
variant="primary"
onPress={handleSubmit}
disabled={busy || !city.trim()}
/>
</View>

<View style={styles.footer}>
<Button title="Back" variant="ghost" onPress={() => router.back()} />
</View>
</ScreenLayout>
)
}

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',
},
})
7 changes: 7 additions & 0 deletions example/screens/android/tabs/sections.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[] = [
Expand Down
14 changes: 14 additions & 0 deletions example/server/widget-server.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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 = <AndroidReactiveWeatherWidget />
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`)

Expand Down
25 changes: 25 additions & 0 deletions example/widgets/android/AndroidReactiveWeatherWidget.tsx
Original file line number Diff line number Diff line change
@@ -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 = () => (
<VoltraAndroid.Column style={{ flex: 1, padding: 16, backgroundColor: '#1E293B', cornerRadius: 16 }}>
<VoltraAndroid.Text style={{ fontSize: 22, fontWeight: 'bold', color: '#FFFFFF' }}>
{appIntentParam('city')}
</VoltraAndroid.Text>
<VoltraAndroid.Spacer style={{ height: 6 }} />
<VoltraAndroid.Text style={{ fontSize: 14, color: '#CBD5E1' }}>Reactive Weather</VoltraAndroid.Text>
<VoltraAndroid.Spacer style={{ height: 8 }} />
<VoltraAndroid.Text style={{ fontSize: 11, color: '#94A3B8' }}>
Edit "Reactive widget" screen to set your city
</VoltraAndroid.Text>
</VoltraAndroid.Column>
)
14 changes: 14 additions & 0 deletions example/widgets/android/android-reactive-weather-initial.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { AndroidReactiveWeatherWidget } from './AndroidReactiveWeatherWidget'

const initialState = [
{
size: { width: 200, height: 200 },
content: <AndroidReactiveWeatherWidget />,
},
{
size: { width: 300, height: 200 },
content: <AndroidReactiveWeatherWidget />,
},
]

export default initialState
12 changes: 12 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

30 changes: 30 additions & 0 deletions packages/android-client/android/CMakeLists.txt
Original file line number Diff line number Diff line change
@@ -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
)
27 changes: 27 additions & 0 deletions packages/android-client/android/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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"
Expand Down
Loading
Loading