Skip to content
Open
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
7 changes: 7 additions & 0 deletions .changeset/android-dynamic-widget-theme.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'@use-voltra/android': minor
'@use-voltra/android-client': minor
'@use-voltra/metro': minor
---

An Android Dynamic Widget can now export a `theme` next to its render function, giving `day` and `night` colors for any `AndroidDynamicColors` role. On Android 11 and older, the widget's role tokens then resolve to the theme instead of the Material baseline colors. With `dynamicColors: false`, the theme also wins over the system palette on Android 12 and later. The new `AndroidWidgetTheme` and `AndroidThemeColorRole` types describe it.
17 changes: 16 additions & 1 deletion example/widgets/android/AndroidClientDemoWidget.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,21 @@
import { Platform, StyleSheet } from 'react-native'

import { AndroidDynamicColors, VoltraAndroid, type WidgetEnvironment } from '@use-voltra/android'
import {
AndroidDynamicColors,
VoltraAndroid,
type AndroidWidgetTheme,
type WidgetEnvironment,
} from '@use-voltra/android'

// The colors the role tokens below resolve to. Roles left out keep the default palette.
// `dynamicColors: false` lets the theme win over the system's dynamic palette on Android 12+ too,
// so the theme is visible on the API 35 emulator; leave it out to keep Material You there and use
// the theme only below Android 12.
export const theme: AndroidWidgetTheme = {
day: { primary: '#E65100', surface: '#FFF3E0' },
night: { primary: '#FFB74D', surface: '#3E2723' },
dynamicColors: false,
}

// Static styles live outside the element tree; the Material You colors below are resolved
// per render and merged in with StyleSheet.flatten.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,8 @@
// captures the bundle's `render(props, env)` export under
// `globalThis.__voltraWidgets[<widgetId>]` (the capture bootstrap is appended on
// the Kotlin side, mirroring iOS `VoltraJSRenderer.swift`). `nativeRender` then
// invokes that function on every Glance render.
// invokes that function on every Glance render, and `nativeThemeJSON` reads the
// widget's optional `getThemeJSON()` export after each evaluation.
//
// Architectural mirror of iOS's VoltraJSRenderer.swift (JSC), one layer lower at
// the JNI boundary because Android has no Kotlin/Java Hermes API.
Expand Down Expand Up @@ -184,3 +185,55 @@ Java_voltra_dynamicwidget_VoltraJSRenderer_nativeRender(
return nullptr;
}
}

// Invoke the previously-captured `getThemeJSON()` for a widget. The Android
// widget entry generated by Metro exports it next to `render`; it returns the
// module's `theme` export serialised to JSON, or null when there is none.
// Returns null when the function is absent, returns null, or throws; Kotlin
// treats a string result as opaque.
extern "C" JNIEXPORT jstring JNICALL
Java_voltra_dynamicwidget_VoltraJSRenderer_nativeThemeJSON(
JNIEnv *env, jobject /* this */, jstring jWidgetId) {
std::lock_guard<std::mutex> lock(g_mutex);
if (!g_runtime) {
LOGE("themeJSON called before evaluateBundle");
return nullptr;
}
try {
auto &rt = *g_runtime;
const std::string widgetId = jstringToStd(env, jWidgetId);

auto registry = rt.global().getProperty(rt, "__voltraWidgets");
if (!registry.isObject()) {
LOGE("themeJSON: __voltraWidgets missing (bundle not evaluated?)");
return nullptr;
}
auto widget = registry.asObject(rt).getProperty(rt, widgetId.c_str());
if (!widget.isObject()) {
LOGE("themeJSON: no captured widget for widgetId=%s", widgetId.c_str());
return nullptr;
}
auto themeProp = widget.asObject(rt).getProperty(rt, "getThemeJSON");
if (!themeProp.isObject() ||
!themeProp.asObject(rt).isFunction(rt)) {
return nullptr;
}
auto themeFn = themeProp.asObject(rt).asFunction(rt);

jsi::Value result = themeFn.call(rt);
if (!result.isString()) {
return nullptr;
}
std::string resultStr = result.asString(rt).utf8(rt);
return env->NewStringUTF(resultStr.c_str());
} catch (const jsi::JSError &e) {
LOGE("themeJSON JSError: %s", e.getMessage().c_str());
return nullptr;
} catch (const std::exception &e) {
LOGE("themeJSON threw: %s", e.what());
return nullptr;
} catch (...) {
LOGE("themeJSON threw: unknown");
return nullptr;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
package voltra.dynamicwidget

import android.util.Log
import androidx.compose.ui.graphics.Color
import androidx.glance.color.ColorProvider
import androidx.glance.color.ColorProviders
import androidx.glance.color.colorProviders
import org.json.JSONException
import org.json.JSONObject
import org.json.JSONTokener
import voltra.styling.JSColorParser
import voltra.styling.VoltraColorValue
import voltra.styling.VoltraThemeColorRole
import voltra.styling.toColorProvider

/**
* A Dynamic Widget's `theme` export: a day and a night color for any of the
* [VoltraThemeColorRole]s, named as in `AndroidDynamicColors` (`primary`, `onPrimary`, …).
*
* Only a role with a color on both sides is themed; every other role keeps the palette the
* widget would have used without a theme.
*/
internal class DynamicWidgetTheme(
private val pairs: Map<VoltraThemeColorRole, Pair<Color, Color>>,
/** Whether the system's dynamic palette (API 31+) wins over this theme. Defaults to true. */
val dynamicColors: Boolean,
) {
/** All 27 roles: this theme's day/night pair where it has one, [fallback]'s provider otherwise. */
fun toColorProviders(fallback: ColorProviders): ColorProviders {
fun role(role: VoltraThemeColorRole) =
pairs[role]?.let { (day, night) -> ColorProvider(day = day, night = night) }
?: role.toColorProvider(fallback)

return colorProviders(
primary = role(VoltraThemeColorRole.PRIMARY),
onPrimary = role(VoltraThemeColorRole.ON_PRIMARY),
primaryContainer = role(VoltraThemeColorRole.PRIMARY_CONTAINER),
onPrimaryContainer = role(VoltraThemeColorRole.ON_PRIMARY_CONTAINER),
secondary = role(VoltraThemeColorRole.SECONDARY),
onSecondary = role(VoltraThemeColorRole.ON_SECONDARY),
secondaryContainer = role(VoltraThemeColorRole.SECONDARY_CONTAINER),
onSecondaryContainer = role(VoltraThemeColorRole.ON_SECONDARY_CONTAINER),
tertiary = role(VoltraThemeColorRole.TERTIARY),
onTertiary = role(VoltraThemeColorRole.ON_TERTIARY),
tertiaryContainer = role(VoltraThemeColorRole.TERTIARY_CONTAINER),
onTertiaryContainer = role(VoltraThemeColorRole.ON_TERTIARY_CONTAINER),
error = role(VoltraThemeColorRole.ERROR),
errorContainer = role(VoltraThemeColorRole.ERROR_CONTAINER),
onError = role(VoltraThemeColorRole.ON_ERROR),
onErrorContainer = role(VoltraThemeColorRole.ON_ERROR_CONTAINER),
background = role(VoltraThemeColorRole.BACKGROUND),
onBackground = role(VoltraThemeColorRole.ON_BACKGROUND),
surface = role(VoltraThemeColorRole.SURFACE),
onSurface = role(VoltraThemeColorRole.ON_SURFACE),
surfaceVariant = role(VoltraThemeColorRole.SURFACE_VARIANT),
onSurfaceVariant = role(VoltraThemeColorRole.ON_SURFACE_VARIANT),
outline = role(VoltraThemeColorRole.OUTLINE),
inverseOnSurface = role(VoltraThemeColorRole.INVERSE_ON_SURFACE),
inverseSurface = role(VoltraThemeColorRole.INVERSE_SURFACE),
inversePrimary = role(VoltraThemeColorRole.INVERSE_PRIMARY),
widgetBackground = role(VoltraThemeColorRole.WIDGET_BACKGROUND),
)
}

companion object {
private const val TAG = "DynamicWidgetTheme"

/**
* Parses the JSON [VoltraJSRenderer.themeJSON] returns; null JSON means no theme.
*
* A theme bug never blanks the widget, and each problem costs exactly one warning:
* - a top level that isn't an object (which includes the strings Metro sends for a function,
* Symbol or unserialisable theme), or a `day`/`night` that isn't an object → no theme;
* - a role defined on one side only, or whose value on a side is not a literal color (role
* tokens and `{ day, night }` pairs included) → that role is left out.
*/
fun parse(json: String?): DynamicWidgetTheme? {
if (json == null) return null
val root =
try {
JSONTokener(json).nextValue()
} catch (e: JSONException) {
null
}
if (root !is JSONObject) {
Log.w(TAG, "Ignoring the widget theme: expected an object with day and night, got $json")
return null
}
val day = root.opt("day")
val night = root.opt("night")
if (day !is JSONObject || night !is JSONObject) {
Log.w(TAG, "Ignoring the widget theme: day and night must both be objects, got $json")
return null
}
val pairs =
VoltraThemeColorRole.entries
.mapNotNull { role ->
val key = role.themeKey
rolePair(key, day.opt(key), night.opt(key))?.let { role to it }
}.toMap()
return DynamicWidgetTheme(pairs, dynamicColors = root.optBoolean("dynamicColors", true))
}

/** The role's day and night colors, or null (with one warning when it was half-written). */
private fun rolePair(
key: String,
dayValue: Any?,
nightValue: Any?,
): Pair<Color, Color>? {
if (dayValue == null && nightValue == null) return null
val problem =
when {
dayValue == null -> {
"it is only defined for night"
}

nightValue == null -> {
"it is only defined for day"
}

else -> {
// The night side is parsed only once the day side is valid, so a role broken
// on both sides costs no more warnings than one broken on a single side.
val dayColor = literalColor(dayValue)
val nightColor = dayColor?.let { literalColor(nightValue) }
if (dayColor != null && nightColor != null) return dayColor to nightColor
val side = if (dayColor == null) "day" else "night"
"its $side value ${if (dayColor == null) dayValue else nightValue} is not a literal color"
}
}
Log.w(TAG, "Ignoring theme role $key: $problem")
return null
}

/** A literal color string; role tokens, pairs and other values are not. */
private fun literalColor(value: Any): Color? =
(value as? String)?.let { (JSColorParser.parse(it) as? VoltraColorValue.Static)?.color }

/** The role's key in `AndroidDynamicColors`: `ON_PRIMARY_CONTAINER` → `onPrimaryContainer`. */
private val VoltraThemeColorRole.themeKey: String
get() =
name
.lowercase()
.split('_')
.mapIndexed { index, word -> if (index == 0) word else word.replaceFirstChar { it.uppercase() } }
.joinToString("")
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
package voltra.dynamicwidget

import android.os.Build
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.glance.GlanceTheme
import voltra.glance.GlanceFactory
import voltra.models.VoltraNode

/**
* Renders a Dynamic Widget's [node] inside the theme its bundle exports ([themeJSON], as read by
* [VoltraJSRenderer.themeJSON]), so role tokens such as `~p` resolve to the theme's colors.
*
* Without a theme, or from Android 12 when the theme leaves `dynamicColors` on, the node renders
* exactly as it would without this wrapper. Roles the theme does not define keep the palette in
* scope here. [GlanceFactory] itself stays theme-free, so payload-driven widgets never see this.
*/
@Composable
internal fun DynamicWidgetThemedContent(
themeJSON: String?,
node: VoltraNode,
factory: GlanceFactory,
) {
val theme = remember(themeJSON) { DynamicWidgetTheme.parse(themeJSON) }
val systemPaletteWins = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && theme?.dynamicColors == true
if (theme == null || systemPaletteWins) {
factory.Render(node)
return
}

GlanceTheme(colors = theme.toColorProviders(fallback = GlanceTheme.colors)) {
factory.Render(node)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,8 @@ import java.net.URL
* via [VoltraJSRenderer.evaluateBundle].
* 2. Content (composable): read LocalSize/locale/scheme, build the WidgetEnvironment JSON, call
* [VoltraJSRenderer.render], decompress the resulting node, and hand it to [GlanceFactory] —
* the same renderer server-rendered widgets use, so the two look identical.
* the same renderer server-rendered widgets use, so the two look identical — inside the
* bundle's exported theme, if any ([DynamicWidgetThemedContent]).
*
* On fetch/eval/render failure the widget shows a minimal fallback; the config plugin supplies
* a prerendered placeholder for the first paint and the offline case.
Expand Down Expand Up @@ -322,16 +323,22 @@ class VoltraClientGlanceWidget(

// Live render when the bundle is ready; otherwise fall back to the plugin-prerendered
// placeholder node (first paint / offline / Metro down).
val node =
(
if (bundleReady) {
renderNode(context, size, configuration, dynamicWidgetRenderInput)
} else {
null
}
) ?: placeholderNode(context)
val liveNode =
if (bundleReady) {
renderNode(context, size, configuration, dynamicWidgetRenderInput)
} else {
null
}
val node = liveNode ?: placeholderNode(context)
if (node != null) {
GlanceFactory(widgetId, null, null, size).Render(node)
// The bundle's theme, read on every composition rather than handed down from
// provideGlance: a live session outlives a re-evaluation, and the renderer swaps the
// theme in step with the bundle. The placeholder never sees it.
DynamicWidgetThemedContent(
themeJSON = if (liveNode != null) VoltraJSRenderer.themeJSON(widgetId) else null,
node = node,
factory = GlanceFactory(widgetId, null, null, size),
)
} else {
Fallback()
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,10 @@ import android.util.Log
* dev-mode hot reload (always-refetch).
*
* Thread safety: a single lock guards both evaluate and render, matching iOS's NSLock.
*
* Theme: right after each successful evaluation, under the same lock, the widget's optional
* `theme` export is read once as an opaque JSON string and kept until the next evaluation, so
* [themeJSON] always describes the bundle [render] runs.
*/
object VoltraJSRenderer {
private const val TAG = "VoltraJSRenderer"
Expand All @@ -24,6 +28,7 @@ object VoltraJSRenderer {
}

private val lock = Any()
private val themes = HashMap<String, String>()

private external fun nativeEvaluateBundle(
wrappedSource: String,
Expand All @@ -37,6 +42,8 @@ object VoltraJSRenderer {
envJSON: String,
): String?

private external fun nativeThemeJSON(widgetId: String): String?

/**
* Minimal global shims. The widget Metro bundle runs in a *bare* Hermes runtime (no RN
* preamble — the widget Metro config strips polyfills), but React's dev build and a few
Expand Down Expand Up @@ -65,8 +72,8 @@ object VoltraJSRenderer {
""".trimIndent()

/**
* Evaluate a widget bundle (raw Metro `/voltra/widgets/<id>.bundle` output) and capture
* its `render(props, env)` export. Idempotent: re-evaluating overwrites the capture.
* Evaluate a widget bundle (raw Metro `/voltra/widgets/<id>.bundle` output), capture its
* `render(props, env)` export and read its theme. Idempotent: re-evaluating overwrites both.
*
* The raw bundle ends with Metro's entry invocation `__r(<entryId>);`. We re-invoke that
* entry and stash its exports in `globalThis.__voltraWidgets[<id>]` (Metro's `__r` caches
Expand Down Expand Up @@ -100,6 +107,7 @@ object VoltraJSRenderer {
Log.e(TAG, "nativeEvaluateBundle threw for widgetId=$widgetId", t)
false
}
if (ok) readTheme(widgetId)
ok
}

Expand All @@ -122,6 +130,25 @@ object VoltraJSRenderer {
}
}

/**
* The widget's `theme` export as JSON, as read by its last successful evaluation, or null when
* it has none. A map lookup: it never calls into JavaScript and never inspects the JSON.
*/
fun themeJSON(widgetId: String): String? = synchronized(lock) { themes[widgetId] }

/** Replaces the widget's theme with the just-evaluated bundle's. Never throws. */
private fun readTheme(widgetId: String) {
themes.remove(widgetId)
val theme =
try {
nativeThemeJSON(widgetId)
} catch (t: Throwable) {
Log.e(TAG, "nativeThemeJSON threw for widgetId=$widgetId", t)
null
}
if (theme != null) themes[widgetId] = theme
}

/**
* Find the entry module id Metro emitted in the bundle's trailing `__r(<id>);`. Every
* `__d(...)` module body also contains internal `__r(...)` calls, so we take the LAST
Expand Down
Loading