From 0586d2712241a18986b5fd8b5ce8cb781ca612a4 Mon Sep 17 00:00:00 2001 From: Konstantin Maruschak Date: Tue, 27 Oct 2026 13:35:44 +0100 Subject: [PATCH 1/2] feat(android): let a Dynamic Widget export a theme for its color tokens A Dynamic Widget can export a theme with day and night colors for any AndroidDynamicColors role, and its role tokens then resolve to those colors. dynamicColors: false lets the theme win over Material You on Android 12+. --- .changeset/android-dynamic-widget-theme.md | 7 + .../android/AndroidClientDemoWidget.tsx | 17 +- .../src/main/cpp/voltra_js_renderer.cpp | 55 ++++- .../dynamicwidget/DynamicWidgetTheme.kt | 148 ++++++++++++ .../DynamicWidgetThemedContent.kt | 34 +++ .../dynamicwidget/VoltraClientGlanceWidget.kt | 27 ++- .../voltra/dynamicwidget/VoltraJSRenderer.kt | 31 ++- .../dynamicwidget/DynamicWidgetThemeTest.kt | 213 ++++++++++++++++++ .../VoltraJSRendererJniNamesTest.kt | 6 + packages/android/src/index.ts | 1 + packages/android/src/widget-theme.ts | 21 ++ .../metro/src/widgetRegistry.node.test.ts | 126 +++++++++-- packages/metro/src/widgetRegistry.ts | 31 ++- 13 files changed, 677 insertions(+), 40 deletions(-) create mode 100644 .changeset/android-dynamic-widget-theme.md create mode 100644 packages/android-client/android/src/main/java/voltra/dynamicwidget/DynamicWidgetTheme.kt create mode 100644 packages/android-client/android/src/main/java/voltra/dynamicwidget/DynamicWidgetThemedContent.kt create mode 100644 packages/android-client/android/src/test/java/voltra/dynamicwidget/DynamicWidgetThemeTest.kt create mode 100644 packages/android/src/widget-theme.ts diff --git a/.changeset/android-dynamic-widget-theme.md b/.changeset/android-dynamic-widget-theme.md new file mode 100644 index 000000000..9d7cf96b5 --- /dev/null +++ b/.changeset/android-dynamic-widget-theme.md @@ -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. diff --git a/example/widgets/android/AndroidClientDemoWidget.tsx b/example/widgets/android/AndroidClientDemoWidget.tsx index 810985b32..d26e3a877 100644 --- a/example/widgets/android/AndroidClientDemoWidget.tsx +++ b/example/widgets/android/AndroidClientDemoWidget.tsx @@ -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. 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 index 645610a26..83f63d442 100644 --- a/packages/android-client/android/src/main/cpp/voltra_js_renderer.cpp +++ b/packages/android-client/android/src/main/cpp/voltra_js_renderer.cpp @@ -5,7 +5,8 @@ // captures the bundle's `render(props, env)` export under // `globalThis.__voltraWidgets[]` (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. @@ -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 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; + } +} diff --git a/packages/android-client/android/src/main/java/voltra/dynamicwidget/DynamicWidgetTheme.kt b/packages/android-client/android/src/main/java/voltra/dynamicwidget/DynamicWidgetTheme.kt new file mode 100644 index 000000000..c148e7a3f --- /dev/null +++ b/packages/android-client/android/src/main/java/voltra/dynamicwidget/DynamicWidgetTheme.kt @@ -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>, + /** 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? { + 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("") + } +} diff --git a/packages/android-client/android/src/main/java/voltra/dynamicwidget/DynamicWidgetThemedContent.kt b/packages/android-client/android/src/main/java/voltra/dynamicwidget/DynamicWidgetThemedContent.kt new file mode 100644 index 000000000..3f7f50146 --- /dev/null +++ b/packages/android-client/android/src/main/java/voltra/dynamicwidget/DynamicWidgetThemedContent.kt @@ -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) + } +} diff --git a/packages/android-client/android/src/main/java/voltra/dynamicwidget/VoltraClientGlanceWidget.kt b/packages/android-client/android/src/main/java/voltra/dynamicwidget/VoltraClientGlanceWidget.kt index b9b92b194..747da5137 100644 --- a/packages/android-client/android/src/main/java/voltra/dynamicwidget/VoltraClientGlanceWidget.kt +++ b/packages/android-client/android/src/main/java/voltra/dynamicwidget/VoltraClientGlanceWidget.kt @@ -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. @@ -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() } diff --git a/packages/android-client/android/src/main/java/voltra/dynamicwidget/VoltraJSRenderer.kt b/packages/android-client/android/src/main/java/voltra/dynamicwidget/VoltraJSRenderer.kt index 34bf6ecee..77819b74a 100644 --- a/packages/android-client/android/src/main/java/voltra/dynamicwidget/VoltraJSRenderer.kt +++ b/packages/android-client/android/src/main/java/voltra/dynamicwidget/VoltraJSRenderer.kt @@ -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" @@ -24,6 +28,7 @@ object VoltraJSRenderer { } private val lock = Any() + private val themes = HashMap() private external fun nativeEvaluateBundle( wrappedSource: String, @@ -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 @@ -65,8 +72,8 @@ object VoltraJSRenderer { """.trimIndent() /** - * Evaluate a widget bundle (raw Metro `/voltra/widgets/.bundle` output) and capture - * its `render(props, env)` export. Idempotent: re-evaluating overwrites the capture. + * Evaluate a widget bundle (raw Metro `/voltra/widgets/.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();`. We re-invoke that * entry and stash its exports in `globalThis.__voltraWidgets[]` (Metro's `__r` caches @@ -100,6 +107,7 @@ object VoltraJSRenderer { Log.e(TAG, "nativeEvaluateBundle threw for widgetId=$widgetId", t) false } + if (ok) readTheme(widgetId) ok } @@ -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();`. Every * `__d(...)` module body also contains internal `__r(...)` calls, so we take the LAST diff --git a/packages/android-client/android/src/test/java/voltra/dynamicwidget/DynamicWidgetThemeTest.kt b/packages/android-client/android/src/test/java/voltra/dynamicwidget/DynamicWidgetThemeTest.kt new file mode 100644 index 000000000..e76ca9e52 --- /dev/null +++ b/packages/android-client/android/src/test/java/voltra/dynamicwidget/DynamicWidgetThemeTest.kt @@ -0,0 +1,213 @@ +package voltra.dynamicwidget + +import android.content.res.Configuration +import android.content.res.Configuration.UI_MODE_NIGHT_NO +import android.content.res.Configuration.UI_MODE_NIGHT_YES +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.toArgb +import androidx.glance.color.ColorProviders +import androidx.glance.color.colorProviders +import androidx.glance.unit.ColorProvider +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertSame +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.shadows.ShadowLog + +@RunWith(RobolectricTestRunner::class) +class DynamicWidgetThemeTest { + @Before + fun clearLogs() { + ShadowLog.clear() + } + + @Test + fun noThemeJsonIsNoThemeAndLogsNothing() { + assertNull(DynamicWidgetTheme.parse(null)) + assertTrue(ShadowLog.getLogs().none { it.tag == "DynamicWidgetTheme" }) + } + + @Test + fun aValidThemeGivesEachDefinedRoleItsDayAndNightColor() { + val theme = + DynamicWidgetTheme.parse( + """ + { + "day": { "primary": "#6750A4", "surface": "rgb(255, 251, 254)" }, + "night": { "primary": "#D0BCFF", "surface": "black" }, + "dynamicColors": false + } + """.trimIndent(), + )!! + + val colors = theme.toColorProviders(fallback) + + assertEquals(0xFF6750A4.toInt(), colors.primary.under(UI_MODE_NIGHT_NO)) + assertEquals(0xFFD0BCFF.toInt(), colors.primary.under(UI_MODE_NIGHT_YES)) + assertEquals(0xFFFFFBFE.toInt(), colors.surface.under(UI_MODE_NIGHT_NO)) + assertEquals(0xFF000000.toInt(), colors.surface.under(UI_MODE_NIGHT_YES)) + assertFalse(theme.dynamicColors) + } + + @Test + fun rolesAPartialThemeLeavesOutKeepTheFallbackPalette() { + val theme = + DynamicWidgetTheme.parse("""{"day":{"tertiary":"#00FF00"},"night":{"tertiary":"#008800"}}""")!! + + val colors = theme.toColorProviders(fallback) + + assertEquals(0xFF00FF00.toInt(), colors.tertiary.under(UI_MODE_NIGHT_NO)) + assertSame(fallback.primary, colors.primary) + assertSame(fallback.widgetBackground, colors.widgetBackground) + assertSame(fallback.onErrorContainer, colors.onErrorContainer) + } + + @Test + fun dynamicColorsDefaultsToTrue() { + val theme = DynamicWidgetTheme.parse("""{"day":{},"night":{}}""")!! + + assertTrue(theme.dynamicColors) + } + + @Test + fun aThemeThatIsNotAnObjectIsIgnoredWithOneWarning() { + // The first three are the strings Metro sends for a function, a Symbol and a cyclic theme. + val cases = + listOf( + "\"function\"", + "\"symbol\"", + "\"Converting circular structure to JSON\"", + "[]", + "42", + "null", + "not json", + ) + + for (json in cases) { + ShadowLog.clear() + + assertNull(json, DynamicWidgetTheme.parse(json)) + assertEquals(json, 1, warnings().size) + } + } + + @Test + fun aThemeWhoseDayOrNightIsNotAnObjectIsIgnoredWithOneWarning() { + val cases = + listOf( + """{"day":"#ffffff","night":{}}""", + """{"day":{},"night":["#000000"]}""", + """{"day":{}}""", + """{"night":{}}""", + ) + + for (json in cases) { + ShadowLog.clear() + + assertNull(json, DynamicWidgetTheme.parse(json)) + assertEquals(json, 1, warnings().size) + } + } + + @Test + fun aRoleThatCannotBeUsedOnBothSidesIsDroppedWithOneWarning() { + val cases = + mapOf( + "one-sided" to """{"day":{"primary":"#00FF00"},"night":{}}""", + "night only" to """{"day":{},"night":{"primary":"#00FF00"}}""", + "unparsable side" to """{"day":{"primary":"not-a-color"},"night":{"primary":"#00FF00"}}""", + "unparsable on both sides" to """{"day":{"primary":"nope"},"night":{"primary":"nope"}}""", + "role token" to """{"day":{"primary":"~s"},"night":{"primary":"#00FF00"}}""", + "pair" to """{"day":{"primary":{"day":"#000000","night":"#FFFFFF"}},"night":{"primary":"#00FF00"}}""", + "number" to """{"day":{"primary":255},"night":{"primary":"#00FF00"}}""", + ) + + for ((name, json) in cases) { + ShadowLog.clear() + + val theme = DynamicWidgetTheme.parse(json) + + assertSame(name, fallback.primary, theme!!.toColorProviders(fallback).primary) + assertEquals(name, 1, warnings().size) + assertTrue(name, warnings().single().contains("primary")) + } + } + + @Test + fun aRoleMalformedOnBothSidesWarnsNoMoreThanOneMalformedOnOneSide() { + // The color parser adds its own warning for a malformed hex until it has a quiet path, so + // compare the two cases rather than asserting a total. + fun warningsFor(json: String): List { + ShadowLog.clear() + DynamicWidgetTheme.parse(json) + return warnings() + } + + val oneSide = warningsFor("""{"day":{"primary":"#zzzzzz"},"night":{"primary":"#00FF00"}}""") + val bothSides = warningsFor("""{"day":{"primary":"#zzzzzz"},"night":{"primary":"#yyyyyy"}}""") + + assertEquals(oneSide.size, bothSides.size) + assertEquals(1, bothSides.count { it.startsWith("Ignoring theme role primary") }) + } + + @Test + fun aDroppedRoleLeavesTheOtherRolesThemed() { + val theme = + DynamicWidgetTheme.parse( + """{"day":{"primary":"#00FF00","surface":"#FFFFFF"},"night":{"surface":"#000000"}}""", + )!! + + assertEquals(0xFF000000.toInt(), theme.toColorProviders(fallback).surface.under(UI_MODE_NIGHT_YES)) + assertEquals(1, warnings().size) + } + + /** Every warning (or worse) logged by anyone: a malformed theme must cost exactly one. */ + private fun warnings(): List = + ShadowLog.getLogs().filter { it.type >= android.util.Log.WARN }.map { it.msg } + + private fun ColorProvider.under(nightMode: Int): Int { + val app = RuntimeEnvironment.getApplication() + val configuration = + Configuration(app.resources.configuration).apply { + uiMode = (uiMode and Configuration.UI_MODE_NIGHT_MASK.inv()) or nightMode + } + return getColor(app.createConfigurationContext(configuration)).toArgb() + } + + private val fallback: ColorProviders = + colorProviders( + primary = ColorProvider(Color(0xFF000001)), + onPrimary = ColorProvider(Color(0xFF000002)), + primaryContainer = ColorProvider(Color(0xFF000003)), + onPrimaryContainer = ColorProvider(Color(0xFF000004)), + secondary = ColorProvider(Color(0xFF000005)), + onSecondary = ColorProvider(Color(0xFF000006)), + secondaryContainer = ColorProvider(Color(0xFF000007)), + onSecondaryContainer = ColorProvider(Color(0xFF000008)), + tertiary = ColorProvider(Color(0xFF000009)), + onTertiary = ColorProvider(Color(0xFF00000A)), + tertiaryContainer = ColorProvider(Color(0xFF00000B)), + onTertiaryContainer = ColorProvider(Color(0xFF00000C)), + error = ColorProvider(Color(0xFF00000D)), + errorContainer = ColorProvider(Color(0xFF00000E)), + onError = ColorProvider(Color(0xFF00000F)), + onErrorContainer = ColorProvider(Color(0xFF000010)), + background = ColorProvider(Color(0xFF000011)), + onBackground = ColorProvider(Color(0xFF000012)), + surface = ColorProvider(Color(0xFF000013)), + onSurface = ColorProvider(Color(0xFF000014)), + surfaceVariant = ColorProvider(Color(0xFF000015)), + onSurfaceVariant = ColorProvider(Color(0xFF000016)), + outline = ColorProvider(Color(0xFF000017)), + inverseOnSurface = ColorProvider(Color(0xFF000018)), + inverseSurface = ColorProvider(Color(0xFF000019)), + inversePrimary = ColorProvider(Color(0xFF00001A)), + widgetBackground = ColorProvider(Color(0xFF00001B)), + ) +} diff --git a/packages/android-client/android/src/test/java/voltra/dynamicwidget/VoltraJSRendererJniNamesTest.kt b/packages/android-client/android/src/test/java/voltra/dynamicwidget/VoltraJSRendererJniNamesTest.kt index a86b0d8cb..386872ee2 100644 --- a/packages/android-client/android/src/test/java/voltra/dynamicwidget/VoltraJSRendererJniNamesTest.kt +++ b/packages/android-client/android/src/test/java/voltra/dynamicwidget/VoltraJSRendererJniNamesTest.kt @@ -32,6 +32,12 @@ class VoltraJSRendererJniNamesTest { "updating the JNI symbol names?", cppSource.contains("${expectedPrefix}nativeRender"), ) + assertTrue( + "Expected to find JNI export '${expectedPrefix}nativeThemeJSON' in " + + "voltra_js_renderer.cpp — did VoltraJSRenderer move or get renamed without " + + "updating the JNI symbol names?", + cppSource.contains("${expectedPrefix}nativeThemeJSON"), + ) } /** diff --git a/packages/android/src/index.ts b/packages/android/src/index.ts index c6758c60c..6e9ef10b0 100644 --- a/packages/android/src/index.ts +++ b/packages/android/src/index.ts @@ -27,6 +27,7 @@ export type { VoltraAndroidViewStyle, } from './styles/types.js' export type { AndroidColorValue, AndroidDynamicColorRole, AndroidDynamicColorToken } from './dynamic-colors.js' +export type { AndroidThemeColorRole, AndroidWidgetTheme } from './widget-theme.js' export type { AndroidLiveUpdateJson, AndroidLiveUpdateVariants, diff --git a/packages/android/src/widget-theme.ts b/packages/android/src/widget-theme.ts new file mode 100644 index 000000000..16c9bc31c --- /dev/null +++ b/packages/android/src/widget-theme.ts @@ -0,0 +1,21 @@ +import type { AndroidDynamicColors } from './dynamic-colors.js' + +/** A color role a Dynamic Widget theme can set: any key of {@link AndroidDynamicColors}. */ +export type AndroidThemeColorRole = keyof typeof AndroidDynamicColors + +/** + * The `theme` a Dynamic Widget's entry module may export next to its default render function. + * + * Each side maps roles to literal colors (hex, `rgb()`, `hsl()` or a named color). A role set on + * both sides replaces that role's color wherever the widget uses its token (`~p` for `primary`, + * for example); every other role keeps the default palette. + */ +export type AndroidWidgetTheme = { + day: Partial> + night: Partial> + /** + * Whether the system's dynamic palette wins over this theme from Android 12. Defaults to `true`; + * below Android 12 there is no dynamic palette and the theme always applies. + */ + dynamicColors?: boolean +} diff --git a/packages/metro/src/widgetRegistry.node.test.ts b/packages/metro/src/widgetRegistry.node.test.ts index 8998a8cc0..e8ac41d7f 100644 --- a/packages/metro/src/widgetRegistry.node.test.ts +++ b/packages/metro/src/widgetRegistry.node.test.ts @@ -46,6 +46,48 @@ function toPosix(value: string): string { return value.split(path.sep).join('/') } +type GeneratedAdapterExports = { + render: (propsJSON: string, envJSON: string) => string + getThemeJSON?: () => string | null +} + +/** + * Runs a generated widget entry without Metro: `react` and the render shim are passed in, and the + * user's widget module is supplied as `widgetModule` whether the entry imports its default export or + * the whole namespace. + */ +function loadGeneratedAdapter( + generatedEntryPath: string, + { + createElement, + renderVariantToJson, + widgetModule, + }: { + createElement: unknown + renderVariantToJson: unknown + widgetModule: Record + } +): GeneratedAdapterExports { + const body = fs + .readFileSync(generatedEntryPath, 'utf8') + .split('\n') + .map((line) => { + if (/^import Widget from /.test(line)) return 'const Widget = WidgetModule.default' + if (line.startsWith('import ')) return '' + if (line === 'export default render') return '' + return line.replace(/^export function /, 'function ') + }) + .join('\n') + const exportedNames = ['render', 'getThemeJSON'].filter((name) => body.includes(`function ${name}(`)) + + return new Function( + 'createElement', + 'renderVariantToJson', + 'WidgetModule', + `${body}\nreturn { ${exportedNames.join(', ')} }` + )(createElement, renderVariantToJson, widgetModule) as GeneratedAdapterExports +} + describe('@use-voltra/metro manifest registry', () => { const cleanups: Array<() => void> = [] @@ -148,35 +190,18 @@ describe('@use-voltra/metro manifest registry', () => { const dynamicWidget = registry.getWidget(platform, 'inbox') assert.ok(dynamicWidget) - const generatedAdapter = fs.readFileSync(dynamicWidget.generatedEntryPath, 'utf8') - const executableAdapter = generatedAdapter - .split('\n') - .filter((line) => !line.startsWith('import ')) - .join('\n') - .replace('export function render(propsJSON, envJSON)', 'function render(propsJSON, envJSON)') - .replace('export default render', 'return render') - - type DynamicWidgetRender = (propsJSON: string, envJSON: string) => string type DynamicWidgetElement = { type: (props: Record) => unknown props: Record } - type CreateDynamicWidgetElement = ( - type: DynamicWidgetElement['type'], - props: Record - ) => DynamicWidgetElement - - const createAdapter = new Function('createElement', 'renderVariantToJson', 'Widget', executableAdapter) as ( - createElement: CreateDynamicWidgetElement, - renderVariantToJson: (element: DynamicWidgetElement) => unknown, - Widget: (props: Record, env: Record) => unknown - ) => DynamicWidgetRender - - const render = createAdapter( - createElement as unknown as CreateDynamicWidgetElement, - (element) => element.type(element.props), - (props, env) => ({ props, env }) - ) + + const { render } = loadGeneratedAdapter(dynamicWidget.generatedEntryPath, { + createElement, + renderVariantToJson: (element: DynamicWidgetElement) => element.type(element.props), + widgetModule: { + default: (props: Record, env: Record) => ({ props, env }), + }, + }) const dynamicWidgetProps = { key: 'primary-inbox', unreadCount: 7, @@ -198,6 +223,57 @@ describe('@use-voltra/metro manifest registry', () => { }) } + test('exposes the Android widget theme through getThemeJSON and leaves iOS entries without it', () => { + const { projectRoot, cleanup } = makeTempProject({ + '.voltra/manifest.ios.json': JSON.stringify({ + version: 1, + platform: 'ios', + widgets: [{ id: 'home', entry: 'widgets/home.js' }], + }), + '.voltra/manifest.android.json': JSON.stringify({ + version: 1, + platform: 'android', + widgets: [{ id: 'home', entry: 'widgets/home.js' }], + }), + 'widgets/home.js': 'export default function HomeWidget() { return null }\n', + }) + cleanups.push(cleanup) + + const registry = createWidgetRegistry({ projectRoot }) + + try { + const iosEntry = registry.getWidget('ios', 'home')!.generatedEntryPath + const androidEntry = registry.getWidget('android', 'home')!.generatedEntryPath + assert.ok(!fs.readFileSync(iosEntry, 'utf8').includes('getThemeJSON')) + + const load = (widgetModule: Record) => + loadGeneratedAdapter(androidEntry, { + createElement, + renderVariantToJson: () => null, + widgetModule: { default: () => null, ...widgetModule }, + }) + + assert.equal(load({}).getThemeJSON?.(), null) + + const theme = { day: { primary: '#6750A4' }, night: { primary: '#D0BCFF' }, dynamicColors: false } + assert.deepEqual(JSON.parse(load({ theme }).getThemeJSON?.() ?? 'null'), theme) + + // A theme JSON cannot carry becomes a JSON string, which the native validator rejects with a warning. + const themeJSONOf = (value: unknown) => JSON.parse(load({ theme: value }).getThemeJSON?.() ?? 'null') + assert.equal( + themeJSONOf(() => ({})), + 'function' + ) + assert.equal(themeJSONOf(Symbol('theme')), 'symbol') + const cyclic: Record = { day: {} } + cyclic.night = cyclic + assert.match(themeJSONOf(cyclic), /circular/i) + assert.match(themeJSONOf({ day: { primary: 1n }, night: {} }), /BigInt/i) + } finally { + registry.close() + } + }) + test('surfaces a missing platform manifest when a widget is requested', () => { const { projectRoot, cleanup } = makeTempProject({ '.voltra/manifest.ios.json': JSON.stringify( diff --git a/packages/metro/src/widgetRegistry.ts b/packages/metro/src/widgetRegistry.ts index 9d1598cf5..a50fedf92 100644 --- a/packages/metro/src/widgetRegistry.ts +++ b/packages/metro/src/widgetRegistry.ts @@ -350,10 +350,38 @@ function createGeneratedEntry( path.relative(generatedEntryRoot, path.join(generatedRoot, RENDER_SHIM_BASENAME)) ) + // Android also reads the module's optional `theme` export, so it imports the namespace: a missing + // named export is then `undefined` rather than a link error under real ESM. + const isAndroid = widget.platform === 'android' + const widgetImport = isAndroid + ? [`import * as WidgetModule from ${JSON.stringify(entryImportPath)}`, '', 'const Widget = WidgetModule.default'] + : [`import Widget from ${JSON.stringify(entryImportPath)}`] + // A theme JSON cannot carry is sent as a JSON string instead (its `typeof`, or the serialisation + // error), which the native side rejects with a warning: `console` is a no-op in the widget runtime. + const themeExport = isAndroid + ? [ + '', + 'export function getThemeJSON() {', + ' const theme = WidgetModule.theme', + " if (typeof theme === 'undefined') {", + ' return null', + ' }', + " if (typeof theme === 'function' || typeof theme === 'symbol') {", + ' return JSON.stringify(typeof theme)', + ' }', + ' try {', + ' return JSON.stringify(theme)', + ' } catch (error) {', + ' return JSON.stringify(error instanceof Error ? error.message : String(error))', + ' }', + '}', + ] + : [] + const content = [ "import { createElement } from 'react'", `import { renderVariantToJson } from ${JSON.stringify(renderShimImportPath)}`, - `import Widget from ${JSON.stringify(entryImportPath)}`, + ...widgetImport, '', `if (typeof Widget === 'undefined') {`, ` throw new Error(${JSON.stringify( @@ -382,6 +410,7 @@ function createGeneratedEntry( ' const resolved = renderVariantToJson(createElement(WidgetWithEnv, { __voltraProps: props }))', ' return JSON.stringify(resolved)', '}', + ...themeExport, '', 'export default render', '', From a8c97e93b3e6fcf783a286d79f725ba046da0bd3 Mon Sep 17 00:00:00 2001 From: Konstantin Maruschak Date: Tue, 27 Oct 2026 13:35:44 +0100 Subject: [PATCH 2/2] docs(android): document the Dynamic Widget theme export Covers the theme export, its precedence over dynamic colors, and how malformed themes are handled. --- .../android/development/dynamic-colors.md | 57 ++++++++++++++++++- .../android/development/dynamic-widgets.md | 15 +++-- 2 files changed, 67 insertions(+), 5 deletions(-) diff --git a/website/src/content/docs/android/development/dynamic-colors.md b/website/src/content/docs/android/development/dynamic-colors.md index b3c216d5c..0891d6683 100644 --- a/website/src/content/docs/android/development/dynamic-colors.md +++ b/website/src/content/docs/android/development/dynamic-colors.md @@ -5,7 +5,7 @@ description: "Use AndroidDynamicColors semantic tokens so widgets follow the Mat Voltra supports Android dynamic colors through semantic tokens exposed from `@use-voltra/android`. -These colors follow the current Android Material palette, so widgets can pick up wallpaper and theme changes without waiting for JavaScript to run again. +Each token names a color role, such as `primary` or `surface`, and resolves through the widget's theme. By default that is the Material You palette on Android 12 and later, so widgets pick up wallpaper and light or dark changes without waiting for JavaScript to run again. A Dynamic Widget can also bring [its own theme](#theming-a-dynamic-widget). ## Importing dynamic colors @@ -93,6 +93,61 @@ You can use `AndroidDynamicColors.*` anywhere Android accepts a color value, inc - progress indicator colors - chart mark colors +## Theming a Dynamic Widget + +Android 11 and older have no system dynamic palette, so tokens there resolve to Glance's fixed Material baseline colors. A [Dynamic Widget](/android/development/dynamic-widgets) can choose the colors its tokens resolve to by exporting a named `theme` next to its default export: + +```tsx +import { AndroidDynamicColors, VoltraAndroid, type AndroidWidgetTheme } from '@use-voltra/android' + +export const theme: AndroidWidgetTheme = { + day: { primary: '#E65100', surface: '#FFF3E0', onSurface: '#3E2723' }, + night: { primary: '#FFB74D', surface: '#3E2723', onSurface: '#FFF3E0' }, +} + +export default function OrderWidget() { + return ( + + Order #1042 + Out for delivery + + ) +} +``` + +The widget keeps using the same `AndroidDynamicColors.*` tokens; the theme only changes what they resolve to. + +- The keys of `day` and `night` are the role names [listed above](#importing-dynamic-colors). +- Values are literal colors: hex, `rgb()`/`rgba()`, `hsl()`/`hsla()` or color names. Tokens and `{ day, night }` pairs are not allowed inside a theme. +- A role is themed only when it has a color on both sides. Roles you leave out keep the colors they would have without a theme. +- Android picks the `day` or `night` side to match the system's light or dark setting, as it does for a [`{ day, night }` pair](/android/development/styling#day-and-night-colors). +- An invalid theme or role is skipped with a warning in Logcat under the `DynamicWidgetTheme` tag, so the widget still renders. + +### Theme or system palette + +On Android 12 and later the system palette wins by default, and the theme only fills in on older versions. Set `dynamicColors: false` to use the theme on every version: + +```tsx +export const theme: AndroidWidgetTheme = { + day: { primary: '#E65100', surface: '#FFF3E0' }, + night: { primary: '#FFB74D', surface: '#3E2723' }, + dynamicColors: false, +} +``` + +| Situation | Android 12+ | Android 11 and older | +| --- | --- | --- | +| No `theme` export | System palette | Material baseline colors | +| `theme`, `dynamicColors` omitted or `true` | System palette | Theme, then baseline colors | +| `theme`, `dynamicColors: false` | Theme, then system palette | Theme, then baseline colors | + +Literal colors and `{ day, night }` pairs on styles and props are always used as written. Only tokens go through the theme. + +### Limitations + +- The pre-rendered `initialStatePath` view ignores the theme. Use `{ day, night }` pairs there if its colors matter. +- Only Dynamic Widgets read `theme`. Payload-driven widgets, including server-rendered ones, keep using the default palette. + ## Server-driven widgets Dynamic color tokens work in server-rendered Android widgets too. diff --git a/website/src/content/docs/android/development/dynamic-widgets.md b/website/src/content/docs/android/development/dynamic-widgets.md index d9141bf84..fe1a27112 100644 --- a/website/src/content/docs/android/development/dynamic-widgets.md +++ b/website/src/content/docs/android/development/dynamic-widgets.md @@ -11,7 +11,7 @@ Your Dynamic Widget can react to: - `env.colorScheme` - `env.locale`, `env.preferredLanguages`, `env.hourCycle`, `env.timeZone` and the other [locale fields](/android/development/localization) - `env.configuration` -- `AndroidDynamicColors` tokens, which resolve to the current Material You palette natively +- `AndroidDynamicColors` tokens, which resolve natively through the widget's theme: the Material You palette by default, or [a `theme` the module exports](/android/development/dynamic-colors#theming-a-dynamic-widget) When you change `app.json`, run Expo Prebuild or Voltra Apply so the updated Dynamic Widget configuration is available on device. If you change only the Dynamic Widget JS, reopen the app in development and the Dynamic Widget updates automatically. @@ -38,9 +38,10 @@ module.exports = withVoltra(config) 1. Add an Android Dynamic Widget declaration to `app.json` with an `id`, an `entry`, and any Dynamic Widget metadata you need. 2. Default-export the Dynamic Widget function or component from the module named by `entry`. -3. Use `initialStatePath` if you want a pre-rendered first view. -4. Re-run Expo Prebuild or Voltra Apply after updating `app.json`. -5. Keep Android and iOS Dynamic Widget declarations separate; the same `id` can exist on both platforms because each platform is configured separately. +3. Optionally export a named `theme` from the same module to choose the colors `AndroidDynamicColors` tokens resolve to. See [Theming a Dynamic Widget](/android/development/dynamic-colors#theming-a-dynamic-widget). +4. Use `initialStatePath` if you want a pre-rendered first view. +5. Re-run Expo Prebuild or Voltra Apply after updating `app.json`. +6. Keep Android and iOS Dynamic Widget declarations separate; the same `id` can exist on both platforms because each platform is configured separately. ```tsx // widgets/android/inbox-widget.tsx @@ -233,6 +234,12 @@ The Home Screen's own edit gesture does not open a Voltra configuration screen y Dynamic Widget props are app-owned state passed as the entry component's first argument. Configuration values are declared through `appIntent.parameters`, updated in-app with `setWidgetConfiguration` for every placement or `setWidgetInstanceConfiguration` for one, and read from `env.configuration`. Updating one does not replace the other. +## Colors in light and dark + +Give colors as `AndroidDynamicColors` tokens or [`{ day, night }` pairs](/android/development/styling#day-and-night-colors) rather than branching on `env.colorScheme`. They follow the system's light or dark setting without running your JavaScript again, and on Android 12 and later the launcher switches them even while your app is not running. + +Use `env.colorScheme` only when the layout itself changes with the scheme, such as showing a different image. It is only as current as the widget's last render. + ## Localization Pass `env.locale` and `env.hourCycle` to every `Intl` and `toLocale*` call, and pick a translation with `resolveLocale(env, messages)`. `Intl.PluralRules`, `Intl.RelativeTimeFormat`, `Intl.ListFormat` and `Intl.DisplayNames` are not available in Android widgets unless you add the polyfills. See [Localization](/android/development/localization) for the polyfills, every locale field, and how to let the app choose the widget language.