From de67959250cabe288281bb16144796a936632cb1 Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Thu, 26 Mar 2026 10:29:32 +0100 Subject: [PATCH 1/6] feat: support Android dynamic widget colors --- packages/android-server/package.json | 1 + packages/android-server/src/index.ts | 66 +++++-- .../android-server/tsconfig.typecheck.json | 1 + packages/android/package.json | 6 + packages/android/src/VoltraModule.ts | 1 + packages/android/src/dynamic-color.ts | 164 ++++++++++++++++++ packages/android/src/index.ts | 6 + packages/android/src/internal.ts | 8 + packages/android/src/server-context.ts | 16 ++ packages/android/src/server.ts | 2 + packages/android/src/widgets/renderer.ts | 30 +++- packages/ios-server/src/index.ts | 8 +- packages/ios/src/VoltraModule.ts | 6 + packages/ios/src/widgets/renderer.ts | 2 +- packages/server/src/index.ts | 3 + .../src/main/java/voltra/VoltraModule.kt | 6 + .../widget/VoltraDynamicColorPalette.kt | 68 ++++++++ .../widget/VoltraRefreshActionCallback.kt | 13 +- .../widget/VoltraWidgetUpdateRequest.kt | 33 ++++ .../voltra/widget/VoltraWidgetUpdateWorker.kt | 13 +- packages/voltra/ios/app/VoltraModule.swift | 4 + packages/voltra/jest.config.js | 2 + packages/voltra/src/VoltraModule.ts | 6 + .../android-dynamic-color.node.test.tsx | 110 ++++++++++++ .../src/__tests__/widget-server.node.test.tsx | 152 +++++++++++++++- .../voltra/src/android/widgets/renderer.ts | 65 +------ packages/voltra/src/widget-server.ts | 32 ++-- packages/voltra/src/widgets/renderer.ts | 2 +- packages/voltra/tsconfig.base.json | 1 + packages/voltra/tsconfig.typecheck.json | 1 + tsconfig.json | 1 + 31 files changed, 711 insertions(+), 118 deletions(-) create mode 100644 packages/android/src/dynamic-color.ts create mode 100644 packages/android/src/internal.ts create mode 100644 packages/android/src/server-context.ts create mode 100644 packages/voltra/android/src/main/java/voltra/widget/VoltraDynamicColorPalette.kt create mode 100644 packages/voltra/android/src/main/java/voltra/widget/VoltraWidgetUpdateRequest.kt create mode 100644 packages/voltra/src/__tests__/android-dynamic-color.node.test.tsx diff --git a/packages/android-server/package.json b/packages/android-server/package.json index 50d559b7..f6864261 100644 --- a/packages/android-server/package.json +++ b/packages/android-server/package.json @@ -25,6 +25,7 @@ "typecheck": "tsc -p tsconfig.typecheck.json --noEmit" }, "dependencies": { + "@use-voltra/android": "1.3.1", "@use-voltra/core": "1.3.1", "@use-voltra/server": "1.3.1" }, diff --git a/packages/android-server/src/index.ts b/packages/android-server/src/index.ts index 3b33675a..fd5fe3d3 100644 --- a/packages/android-server/src/index.ts +++ b/packages/android-server/src/index.ts @@ -1,5 +1,10 @@ /// +import { + AndroidWidgetRenderContextProvider, + createAndroidWidgetRenderContextValue, + type AndroidWidgetRenderContextValue, +} from '@use-voltra/android/internal' import { createVoltraRenderer } from '@use-voltra/core' import type { WidgetRenderRequest, @@ -12,7 +17,7 @@ import { createWidgetUpdateHandler, createWidgetUpdateNodeHandler, } from '@use-voltra/server' -import type { ReactNode } from 'react' +import { createElement, type ReactNode } from 'react' export type { WidgetRenderRequest, @@ -20,6 +25,7 @@ export type { WidgetUpdateHandler, WidgetUpdateNodeHandler, } from '@use-voltra/server' +export type { AndroidDynamicColorPalette, AndroidWidgetRenderContextValue } from '@use-voltra/android' export type { WidgetPlatform, WidgetTheme } from '@use-voltra/server' export type AndroidWidgetSize = { @@ -53,6 +59,10 @@ export type AndroidLiveUpdateVariantsJson = { export type AndroidLiveUpdateJson = AndroidLiveUpdateVariantsJson +type AndroidWidgetRenderOptions = { + renderContext?: AndroidWidgetRenderContextValue +} + const ANDROID_COMPONENT_NAME_TO_ID: Record = { AndroidFilledButton: 0, AndroidImage: 1, @@ -94,7 +104,9 @@ const androidComponentRegistry = { getComponentId: (name: string) => getAndroidComponentId(name), } -export const renderAndroidLiveUpdateToJson = (variants: AndroidLiveUpdateVariants): AndroidLiveUpdateJson => { +export const renderAndroidLiveUpdateToJson = ( + variants: AndroidLiveUpdateVariants +): AndroidLiveUpdateJson => { const renderer = createVoltraRenderer(androidComponentRegistry) if (variants.collapsed) { @@ -118,16 +130,31 @@ export const renderAndroidLiveUpdateToJson = (variants: AndroidLiveUpdateVariant return result } -export const renderAndroidLiveUpdateToString = (variants: AndroidLiveUpdateVariants): string => { +export const renderAndroidLiveUpdateToString = ( + variants: AndroidLiveUpdateVariants +): string => { return JSON.stringify(renderAndroidLiveUpdateToJson(variants)) } -export const renderAndroidWidgetToJson = (variants: AndroidWidgetVariants): Record => { +export const renderAndroidWidgetToJson = ( + variants: AndroidWidgetVariants, + options?: AndroidWidgetRenderOptions +): Record => { const renderer = createVoltraRenderer(androidComponentRegistry) + const renderContext = options?.renderContext ?? { theme: null, dynamicColorPalette: null } for (const { size, content } of variants) { + if (content === null || content === undefined) { + continue + } + const key = `${size.width}x${size.height}` - renderer.addRootNode(key, content) + renderer.addRootNode( + key, + createElement(AndroidWidgetRenderContextProvider, { + value: renderContext, + }, content) + ) } const rendered = renderer.render() @@ -146,8 +173,11 @@ export const renderAndroidWidgetToJson = (variants: AndroidWidgetVariants): Reco return rendered } -export const renderAndroidWidgetToString = (variants: AndroidWidgetVariants): string => { - return JSON.stringify(renderAndroidWidgetToJson(variants)) +export const renderAndroidWidgetToString = ( + variants: AndroidWidgetVariants, + options?: AndroidWidgetRenderOptions +): string => { + return JSON.stringify(renderAndroidWidgetToJson(variants, options)) } export interface AndroidWidgetUpdateHandlerOptions { @@ -155,28 +185,36 @@ export interface AndroidWidgetUpdateHandlerOptions { validateToken?: (token: string) => Promise | boolean } -function toSharedOptions(options: AndroidWidgetUpdateHandlerOptions) { +export const createAndroidWidgetRenderContext = ( + request: Pick +) : AndroidWidgetRenderContextValue => { + return createAndroidWidgetRenderContextValue(request.theme, request.url.searchParams.get('androidPalette')) +} + +const toSharedOptions = (options: AndroidWidgetUpdateHandlerOptions) => { return { validateToken: options.validateToken, renderAndroid: async (request: WidgetRenderRequest) => { const variants = await options.render(request) - return variants ? renderAndroidWidgetToString(variants) : null + return variants ? renderAndroidWidgetToString(variants, { renderContext: createAndroidWidgetRenderContext(request) }) : null }, } } -export function createAndroidWidgetUpdateHandler(options: AndroidWidgetUpdateHandlerOptions): WidgetUpdateHandler { +export const createAndroidWidgetUpdateHandler = ( + options: AndroidWidgetUpdateHandlerOptions +): WidgetUpdateHandler => { return createWidgetUpdateHandler(toSharedOptions(options)) } -export function createAndroidWidgetUpdateNodeHandler( +export const createAndroidWidgetUpdateNodeHandler = ( options: AndroidWidgetUpdateHandlerOptions -): WidgetUpdateNodeHandler { +): WidgetUpdateNodeHandler => { return createWidgetUpdateNodeHandler(toSharedOptions(options)) } -export function createAndroidWidgetUpdateExpressHandler( +export const createAndroidWidgetUpdateExpressHandler = ( options: AndroidWidgetUpdateHandlerOptions -): WidgetUpdateExpressHandler { +): WidgetUpdateExpressHandler => { return createWidgetUpdateExpressHandler(toSharedOptions(options)) } diff --git a/packages/android-server/tsconfig.typecheck.json b/packages/android-server/tsconfig.typecheck.json index f1b7e2fb..d5d9364f 100644 --- a/packages/android-server/tsconfig.typecheck.json +++ b/packages/android-server/tsconfig.typecheck.json @@ -9,6 +9,7 @@ "voltra/*": ["packages/voltra/src/*"], "@use-voltra/android": ["packages/android/src/index.ts"], "@use-voltra/android/client": ["packages/android/src/client.ts"], + "@use-voltra/android/internal": ["packages/android/src/internal.ts"], "@use-voltra/android/server": ["packages/android/src/server.ts"], "@use-voltra/android-server": ["packages/android-server/src/index.ts"], "@use-voltra/core": ["packages/core/src/index.ts"], diff --git a/packages/android/package.json b/packages/android/package.json index 645c5696..3d838fc2 100644 --- a/packages/android/package.json +++ b/packages/android/package.json @@ -24,6 +24,12 @@ "import": "./build/esm/server.js", "default": "./build/esm/server.js" }, + "./internal": { + "types": "./build/types/internal.d.ts", + "require": "./build/cjs/internal.js", + "import": "./build/esm/internal.js", + "default": "./build/esm/internal.js" + }, "./package.json": "./package.json" }, "files": [ diff --git a/packages/android/src/VoltraModule.ts b/packages/android/src/VoltraModule.ts index cf605b8e..da1ddbc9 100644 --- a/packages/android/src/VoltraModule.ts +++ b/packages/android/src/VoltraModule.ts @@ -21,6 +21,7 @@ export interface VoltraAndroidModuleSpec { setWidgetServerCredentials(credentials: WidgetServerCredentials): Promise clearWidgetServerCredentials(): Promise getActiveWidgets(): Promise + getAndroidDynamicColorPalette(): string[] | null addListener(event: string, listener: (event: any) => void): EventSubscription } diff --git a/packages/android/src/dynamic-color.ts b/packages/android/src/dynamic-color.ts new file mode 100644 index 00000000..d5a87f2a --- /dev/null +++ b/packages/android/src/dynamic-color.ts @@ -0,0 +1,164 @@ +import { createContext, useContext } from 'react' + +export type AndroidWidgetRenderTheme = 'light' | 'dark' + +export type AndroidDynamicColorPalette = { + primary: string + onPrimary: string + primaryContainer: string + onPrimaryContainer: string + secondary: string + onSecondary: string + secondaryContainer: string + onSecondaryContainer: string + tertiary: string + onTertiary: string + tertiaryContainer: string + onTertiaryContainer: string + error: string + errorContainer: string + onError: string + onErrorContainer: string + background: string + onBackground: string + surface: string + onSurface: string + surfaceVariant: string + onSurfaceVariant: string + outline: string + inverseOnSurface: string + inverseSurface: string + inversePrimary: string + widgetBackground: string +} + +export type AndroidWidgetRenderContextValue = { + theme: AndroidWidgetRenderTheme | null + dynamicColorPalette: AndroidDynamicColorPalette | null +} + +export const ANDROID_DYNAMIC_COLOR_PALETTE_ORDER = [ + 'primary', + 'onPrimary', + 'primaryContainer', + 'onPrimaryContainer', + 'secondary', + 'onSecondary', + 'secondaryContainer', + 'onSecondaryContainer', + 'tertiary', + 'onTertiary', + 'tertiaryContainer', + 'onTertiaryContainer', + 'error', + 'errorContainer', + 'onError', + 'onErrorContainer', + 'background', + 'onBackground', + 'surface', + 'onSurface', + 'surfaceVariant', + 'onSurfaceVariant', + 'outline', + 'inverseOnSurface', + 'inverseSurface', + 'inversePrimary', + 'widgetBackground', +] as const satisfies readonly (keyof AndroidDynamicColorPalette)[] + +export const AndroidWidgetRenderContext = createContext(null) +export const AndroidWidgetRenderContextProvider = AndroidWidgetRenderContext.Provider +export type GetAndroidDynamicColorPalette = () => string[] | null + +const COLOR_PATTERN = /^#[0-9a-f]{8}$/i + +const normalizePaletteColor = (value: unknown): string | null => { + if (typeof value !== 'string' || !COLOR_PATTERN.test(value)) { + return null + } + + return value.toLowerCase() +} + +export const androidDynamicColorPaletteFromArray = ( + value: unknown +): AndroidDynamicColorPalette | null => { + if (!Array.isArray(value) || value.length !== ANDROID_DYNAMIC_COLOR_PALETTE_ORDER.length) { + return null + } + + const palette = {} as AndroidDynamicColorPalette + + for (const [index, key] of ANDROID_DYNAMIC_COLOR_PALETTE_ORDER.entries()) { + const normalizedValue = normalizePaletteColor(value[index]) + if (!normalizedValue) { + return null + } + + palette[key] = normalizedValue + } + + return palette +} + +export const androidDynamicColorPaletteToArray = ( + palette: AndroidDynamicColorPalette +): string[] | null => { + const colors: string[] = [] + + for (const key of ANDROID_DYNAMIC_COLOR_PALETTE_ORDER) { + const normalizedValue = normalizePaletteColor(palette[key]) + if (!normalizedValue) { + return null + } + + colors.push(normalizedValue) + } + + return colors +} + +export const parseAndroidDynamicColorPalette = ( + serializedPalette: string | null | undefined +): AndroidDynamicColorPalette | null => { + if (!serializedPalette) { + return null + } + + try { + return androidDynamicColorPaletteFromArray(JSON.parse(serializedPalette)) + } catch { + return null + } +} + +export const serializeAndroidDynamicColorPalette = ( + palette: AndroidDynamicColorPalette | null | undefined +): string | null => { + if (!palette) { + return null + } + + const colors = androidDynamicColorPaletteToArray(palette) + return colors ? JSON.stringify(colors) : null +} + +export const createAndroidWidgetRenderContextValue = ( + theme: AndroidWidgetRenderTheme | null, + serializedPalette: string | null | undefined +): AndroidWidgetRenderContextValue => { + return { + theme, + dynamicColorPalette: parseAndroidDynamicColorPalette(serializedPalette), + } +} + +export const useAndroidDynamicColorPalette = (): AndroidDynamicColorPalette | null => { + const renderContext = useContext(AndroidWidgetRenderContext) + if (!renderContext) { + throw new Error('This is an internal problem in Voltra. Please report the issue.') + } + + return renderContext.dynamicColorPalette +} diff --git a/packages/android/src/index.ts b/packages/android/src/index.ts index 0df79118..c36c734a 100644 --- a/packages/android/src/index.ts +++ b/packages/android/src/index.ts @@ -1,5 +1,6 @@ // Android component namespace export * as VoltraAndroid from './jsx/primitives.js' +export { useAndroidDynamicColorPalette } from './dynamic-color.js' // Android types export type { VoltraAndroidBaseProps } from './jsx/baseProps.js' @@ -9,6 +10,11 @@ export type { VoltraAndroidTextStyleProp, VoltraAndroidViewStyle, } from './styles/types.js' +export type { + AndroidDynamicColorPalette, + GetAndroidDynamicColorPalette, + AndroidWidgetRenderContextValue, +} from './dynamic-color.js' // Component prop types export type { BoxProps } from './jsx/Box.js' diff --git a/packages/android/src/internal.ts b/packages/android/src/internal.ts new file mode 100644 index 00000000..d72327c2 --- /dev/null +++ b/packages/android/src/internal.ts @@ -0,0 +1,8 @@ +export { + AndroidWidgetRenderContextProvider, + createAndroidWidgetRenderContextValue, + serializeAndroidDynamicColorPalette, +} from './dynamic-color.js' +export { renderAndroidWidgetToJson, renderAndroidWidgetToString } from './widgets/renderer.js' +export type { AndroidWidgetRenderContextValue, AndroidWidgetRenderTheme } from './dynamic-color.js' +export type { AndroidWidgetRenderOptions } from './widgets/renderer.js' diff --git a/packages/android/src/server-context.ts b/packages/android/src/server-context.ts new file mode 100644 index 00000000..513cc3bb --- /dev/null +++ b/packages/android/src/server-context.ts @@ -0,0 +1,16 @@ +import { + createAndroidWidgetRenderContextValue, + type AndroidWidgetRenderContextValue, + type AndroidWidgetRenderTheme, +} from './dynamic-color.js' + +type AndroidWidgetRenderRequestLike = { + theme: AndroidWidgetRenderTheme + url: URL +} + +export const createAndroidWidgetRenderContext = ( + request: AndroidWidgetRenderRequestLike +) : AndroidWidgetRenderContextValue => { + return createAndroidWidgetRenderContextValue(request.theme, request.url.searchParams.get('androidPalette')) +} diff --git a/packages/android/src/server.ts b/packages/android/src/server.ts index f4d614cd..bbf79124 100644 --- a/packages/android/src/server.ts +++ b/packages/android/src/server.ts @@ -1,2 +1,4 @@ export { renderAndroidLiveUpdateToString } from './live-update/renderer.js' +export { createAndroidWidgetRenderContext } from './server-context.js' export { renderAndroidWidgetToString } from './widgets/renderer.js' +export type { AndroidDynamicColorPalette, AndroidWidgetRenderContextValue } from './dynamic-color.js' diff --git a/packages/android/src/widgets/renderer.ts b/packages/android/src/widgets/renderer.ts index b7f2af2e..9d63a265 100644 --- a/packages/android/src/widgets/renderer.ts +++ b/packages/android/src/widgets/renderer.ts @@ -1,3 +1,9 @@ +import { + AndroidWidgetRenderContextProvider, + type AndroidWidgetRenderContextValue, +} from '../dynamic-color.js' +import { createElement } from 'react' + import { getAndroidComponentId } from '../payload/component-ids.js' import { ComponentRegistry, createVoltraRenderer } from '../renderer/renderer.js' import type { AndroidWidgetVariants } from './types.js' @@ -9,6 +15,10 @@ const androidComponentRegistry: ComponentRegistry = { getComponentId: (name: string) => getAndroidComponentId(name), } +export type AndroidWidgetRenderOptions = { + context?: AndroidWidgetRenderContextValue +} + /** * Renders Android widget variants to JSON with size breakpoints. * @@ -24,13 +34,22 @@ const androidComponentRegistry: ComponentRegistry = { * "e": [...shared elements...] * } */ -export const renderAndroidWidgetToJson = (variants: AndroidWidgetVariants): Record => { +export const renderAndroidWidgetToJson = ( + variants: AndroidWidgetVariants, + options?: AndroidWidgetRenderOptions +): Record => { const renderer = createVoltraRenderer(androidComponentRegistry) + const context = options?.context ?? { theme: null, dynamicColorPalette: null } // Add each size variant with key format "WIDTHxHEIGHT" for (const { size, content } of variants) { const key = `${size.width}x${size.height}` - renderer.addRootNode(key, content) + renderer.addRootNode( + key, + createElement(AndroidWidgetRenderContextProvider, { + value: context, + }, content) + ) } const rendered = renderer.render() @@ -55,6 +74,9 @@ export const renderAndroidWidgetToJson = (variants: AndroidWidgetVariants): Reco /** * Renders Android widget variants to a JSON string. */ -export const renderAndroidWidgetToString = (variants: AndroidWidgetVariants): string => { - return JSON.stringify(renderAndroidWidgetToJson(variants)) +export const renderAndroidWidgetToString = ( + variants: AndroidWidgetVariants, + options?: AndroidWidgetRenderOptions +): string => { + return JSON.stringify(renderAndroidWidgetToJson(variants, options)) } diff --git a/packages/ios-server/src/index.ts b/packages/ios-server/src/index.ts index d3a37cf7..5fd5ad05 100644 --- a/packages/ios-server/src/index.ts +++ b/packages/ios-server/src/index.ts @@ -3,7 +3,11 @@ import { promisify } from 'node:util' import { brotliCompress, constants } from 'node:zlib' -import { type ComponentRegistry, createVoltraRenderer, ensurePayloadWithinBudget } from '@use-voltra/core' +import { + type ComponentRegistry, + createVoltraRenderer, + ensurePayloadWithinBudget, +} from '@use-voltra/core' import type { LiveActivityVariants, WidgetVariants } from '@use-voltra/ios' import type { WidgetRenderRequest, @@ -111,7 +115,7 @@ export const renderWidgetToJson = (variants: WidgetVariants): Record) } } diff --git a/packages/ios/src/VoltraModule.ts b/packages/ios/src/VoltraModule.ts index b8266ef3..d2babf3b 100644 --- a/packages/ios/src/VoltraModule.ts +++ b/packages/ios/src/VoltraModule.ts @@ -153,6 +153,12 @@ export interface VoltraIOSModuleSpec { */ getActiveWidgets(): Promise + /** + * Android dynamic color palette snapshot. + * Returns null on iOS. + */ + getAndroidDynamicColorPalette(): string[] | null + /** * Set server credentials for widget server-driven updates. * Stored securely in Keychain. diff --git a/packages/ios/src/widgets/renderer.ts b/packages/ios/src/widgets/renderer.ts index 32d096d7..7b782c9f 100644 --- a/packages/ios/src/widgets/renderer.ts +++ b/packages/ios/src/widgets/renderer.ts @@ -9,7 +9,7 @@ export const renderWidgetToJson = (variants: WidgetVariants): Record WidgetRenderResul * Contains the widget ID, family, and any auth headers from the request. */ export interface WidgetRenderRequest { + /** Parsed request URL, including all query parameters. */ + url: URL /** The widget ID requesting an update */ widgetId: string /** The platform the request is coming from */ @@ -180,6 +182,7 @@ export function createWidgetUpdateHandler(options: WidgetUpdateHandlerOptions): } const renderRequest: WidgetRenderRequest = { + url, widgetId, platform, theme, diff --git a/packages/voltra/android/src/main/java/voltra/VoltraModule.kt b/packages/voltra/android/src/main/java/voltra/VoltraModule.kt index 13245da4..59a79113 100644 --- a/packages/voltra/android/src/main/java/voltra/VoltraModule.kt +++ b/packages/voltra/android/src/main/java/voltra/VoltraModule.kt @@ -12,6 +12,7 @@ import kotlinx.coroutines.awaitAll import kotlinx.coroutines.runBlocking import voltra.events.VoltraEventBus import voltra.images.VoltraImageManager +import voltra.widget.VoltraDynamicColorPalette import voltra.widget.VoltraGlanceWidget import voltra.widget.VoltraWidgetManager @@ -223,6 +224,11 @@ class VoltraModule : Module() { activeWidgets } + Function("getAndroidDynamicColorPalette") { + val context = appContext.reactContext ?: return@Function null + VoltraDynamicColorPalette.snapshotColorArray(context) + } + AsyncFunction("requestPinGlanceAppWidget") { widgetId: String, options: Map?, diff --git a/packages/voltra/android/src/main/java/voltra/widget/VoltraDynamicColorPalette.kt b/packages/voltra/android/src/main/java/voltra/widget/VoltraDynamicColorPalette.kt new file mode 100644 index 00000000..965e240d --- /dev/null +++ b/packages/voltra/android/src/main/java/voltra/widget/VoltraDynamicColorPalette.kt @@ -0,0 +1,68 @@ +package voltra.widget + +import android.content.Context +import androidx.compose.ui.graphics.toArgb +import androidx.glance.color.ColorProviders +import androidx.glance.color.DynamicThemeColorProviders +import androidx.glance.unit.ColorProvider +import java.util.Locale + +object VoltraDynamicColorPalette { + private fun collectColorProviders(colors: ColorProviders): List = + listOf( + colors.primary, + colors.onPrimary, + colors.primaryContainer, + colors.onPrimaryContainer, + colors.secondary, + colors.onSecondary, + colors.secondaryContainer, + colors.onSecondaryContainer, + colors.tertiary, + colors.onTertiary, + colors.tertiaryContainer, + colors.onTertiaryContainer, + colors.error, + colors.errorContainer, + colors.onError, + colors.onErrorContainer, + colors.background, + colors.onBackground, + colors.surface, + colors.onSurface, + colors.surfaceVariant, + colors.onSurfaceVariant, + colors.outline, + colors.inverseOnSurface, + colors.inverseSurface, + colors.inversePrimary, + colors.widgetBackground, + ) + + private fun colorProviderToHex( + colorProvider: ColorProvider, + context: Context, + ): String { + val argb = colorProvider.getColor(context).toArgb() + val red = (argb shr 16) and 0xFF + val green = (argb shr 8) and 0xFF + val blue = argb and 0xFF + val alpha = (argb ushr 24) and 0xFF + + return String.format(Locale.US, "#%02x%02x%02x%02x", red, green, blue, alpha) + } + + fun snapshotColorArray(context: Context): List { + val colors = DynamicThemeColorProviders + return collectColorProviders(colors).map { provider -> + colorProviderToHex(provider, context) + } + } + + fun toQueryValue(context: Context): String = + snapshotColorArray(context).joinToString( + prefix = "[\"", + separator = "\",\"", + postfix = "\"]", + ) +} diff --git a/packages/voltra/android/src/main/java/voltra/widget/VoltraRefreshActionCallback.kt b/packages/voltra/android/src/main/java/voltra/widget/VoltraRefreshActionCallback.kt index 3b612487..ef4de2a4 100644 --- a/packages/voltra/android/src/main/java/voltra/widget/VoltraRefreshActionCallback.kt +++ b/packages/voltra/android/src/main/java/voltra/widget/VoltraRefreshActionCallback.kt @@ -3,7 +3,6 @@ package voltra.widget import android.appwidget.AppWidgetManager import android.content.ComponentName import android.content.Context -import android.content.res.Configuration import android.util.Log import android.widget.RemoteViews import androidx.glance.GlanceId @@ -56,17 +55,7 @@ class VoltraRefreshActionCallback : ActionCallback { val jsonString = withContext(Dispatchers.IO) { try { - val nightModeFlags = - context.resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK - val theme = if (nightModeFlags == Configuration.UI_MODE_NIGHT_YES) "dark" else "light" - - val urlBuilder = StringBuilder(serverUrl) - urlBuilder.append(if (serverUrl.contains("?")) "&" else "?") - urlBuilder.append("widgetId=").append(widgetId) - urlBuilder.append("&platform=android") - urlBuilder.append("&theme=").append(theme) - - val url = URL(urlBuilder.toString()) + val url = VoltraWidgetUpdateRequest.buildUrl(serverUrl, widgetId, context) val connection = url.openConnection() as HttpURLConnection try { diff --git a/packages/voltra/android/src/main/java/voltra/widget/VoltraWidgetUpdateRequest.kt b/packages/voltra/android/src/main/java/voltra/widget/VoltraWidgetUpdateRequest.kt new file mode 100644 index 00000000..7382a543 --- /dev/null +++ b/packages/voltra/android/src/main/java/voltra/widget/VoltraWidgetUpdateRequest.kt @@ -0,0 +1,33 @@ +package voltra.widget + +import android.content.Context +import android.content.res.Configuration +import android.net.Uri +import java.net.URL + +object VoltraWidgetUpdateRequest { + private const val ANDROID_PALETTE_QUERY_PARAM = "androidPalette" + + fun currentTheme(context: Context): String { + val nightModeFlags = context.resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK + return if (nightModeFlags == Configuration.UI_MODE_NIGHT_YES) "dark" else "light" + } + + fun buildUrl( + serverUrl: String, + widgetId: String, + context: Context, + ): URL { + val uri = + Uri + .parse(serverUrl) + .buildUpon() + .appendQueryParameter("widgetId", widgetId) + .appendQueryParameter("platform", "android") + .appendQueryParameter("theme", currentTheme(context)) + .appendQueryParameter(ANDROID_PALETTE_QUERY_PARAM, VoltraDynamicColorPalette.toQueryValue(context)) + .build() + + return URL(uri.toString()) + } +} diff --git a/packages/voltra/android/src/main/java/voltra/widget/VoltraWidgetUpdateWorker.kt b/packages/voltra/android/src/main/java/voltra/widget/VoltraWidgetUpdateWorker.kt index 2969b1c0..26011388 100644 --- a/packages/voltra/android/src/main/java/voltra/widget/VoltraWidgetUpdateWorker.kt +++ b/packages/voltra/android/src/main/java/voltra/widget/VoltraWidgetUpdateWorker.kt @@ -3,7 +3,6 @@ package voltra.widget import android.appwidget.AppWidgetManager import android.content.ComponentName import android.content.Context -import android.content.res.Configuration import android.util.Log import android.widget.RemoteViews import androidx.work.CoroutineWorker @@ -58,17 +57,7 @@ class VoltraWidgetUpdateWorker( try { // 1. Build URL with query parameters - val nightModeFlags = - applicationContext.resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK - val theme = if (nightModeFlags == Configuration.UI_MODE_NIGHT_YES) "dark" else "light" - - val urlBuilder = StringBuilder(serverUrl) - urlBuilder.append(if (serverUrl.contains("?")) "&" else "?") - urlBuilder.append("widgetId=").append(widgetId) - urlBuilder.append("&platform=android") - urlBuilder.append("&theme=").append(theme) - - val url = URL(urlBuilder.toString()) + val url = VoltraWidgetUpdateRequest.buildUrl(serverUrl, widgetId, applicationContext) val connection = url.openConnection() as HttpURLConnection try { diff --git a/packages/voltra/ios/app/VoltraModule.swift b/packages/voltra/ios/app/VoltraModule.swift index c1ba8967..cf4fe48d 100644 --- a/packages/voltra/ios/app/VoltraModule.swift +++ b/packages/voltra/ios/app/VoltraModule.swift @@ -121,6 +121,10 @@ public class VoltraModule: Module { return try await self.impl.getActiveWidgets() } + Function("getAndroidDynamicColorPalette") { () -> [String]? in + return nil + } + // Widget Server Credentials AsyncFunction("setWidgetServerCredentials") { (credentials: [String: Any]) in guard let token = credentials["token"] as? String else { diff --git a/packages/voltra/jest.config.js b/packages/voltra/jest.config.js index a04ba3d3..108c0d19 100644 --- a/packages/voltra/jest.config.js +++ b/packages/voltra/jest.config.js @@ -10,6 +10,7 @@ module.exports = { moduleNameMapper: { '^@use-voltra/android$': '/../android/src/index.ts', '^@use-voltra/android/client$': '/../android/src/client.ts', + '^@use-voltra/android/internal$': '/../android/src/internal.ts', '^@use-voltra/android/server$': '/../android/src/server.ts', '^@use-voltra/android-server$': '/../android-server/src/index.ts', '^@use-voltra/core$': '/../core/src/index.ts', @@ -33,6 +34,7 @@ module.exports = { moduleNameMapper: { '^@use-voltra/android$': '/../android/src/index.ts', '^@use-voltra/android/client$': '/../android/src/client.ts', + '^@use-voltra/android/internal$': '/../android/src/internal.ts', '^@use-voltra/android/server$': '/../android/src/server.ts', '^@use-voltra/android-server$': '/../android-server/src/index.ts', '^@use-voltra/core$': '/../core/src/index.ts', diff --git a/packages/voltra/src/VoltraModule.ts b/packages/voltra/src/VoltraModule.ts index 3eb2e6da..445f033b 100644 --- a/packages/voltra/src/VoltraModule.ts +++ b/packages/voltra/src/VoltraModule.ts @@ -214,6 +214,12 @@ export interface VoltraModuleSpec { */ getActiveWidgets(): Promise + /** + * Android dynamic color palette snapshot. + * Returns null on iOS. + */ + getAndroidDynamicColorPalette(): string[] | null + /** * Set server credentials for widget server-driven updates. * Stored securely in Keychain (iOS) or encrypted DataStore via Tink (Android). diff --git a/packages/voltra/src/__tests__/android-dynamic-color.node.test.tsx b/packages/voltra/src/__tests__/android-dynamic-color.node.test.tsx new file mode 100644 index 00000000..e046d100 --- /dev/null +++ b/packages/voltra/src/__tests__/android-dynamic-color.node.test.tsx @@ -0,0 +1,110 @@ +import React from 'react' +import { NativeModules } from 'react-native' + +import { AndroidWidgetRenderContextProvider } from '@use-voltra/android/internal' +import { createAndroidWidgetRenderContext } from '@use-voltra/android/server' +import { VoltraAndroid, useAndroidDynamicColorPalette } from '../android/index.js' +import { renderAndroidWidgetToJson } from '../android/widgets/renderer.js' +import { renderAndroidVariantToJson } from '../renderer/renderer.js' + +describe('Android dynamic color hook', () => { + beforeEach(() => { + delete (globalThis as { expo?: unknown }).expo + delete NativeModules.VoltraModule + }) + + it('reads the provider palette during synchronous render', () => { + const Component = () => { + const palette = useAndroidDynamicColorPalette() + return {palette?.primary ?? 'missing'} + } + + const output = renderAndroidVariantToJson( + + + + ) as { c?: string } + + expect(output.c).toBe('#11223344') + }) + + it('reads the automatically injected widget render context', () => { + const Component = () => { + const palette = useAndroidDynamicColorPalette() + return {palette === null ? 'missing' : 'present'} + } + + const output = renderAndroidWidgetToJson([ + { + size: { width: 150, height: 100 }, + content: , + }, + ]) as { variants?: Record } + + expect(output.variants?.['150x100']?.c).toBe('missing') + }) + + it('throws when no provider is present', () => { + const getAndroidDynamicColorPalette = jest.fn(() => + Array.from({ length: 27 }, () => '#aabbccdd') + ) + + NativeModules.VoltraModule = { + getAndroidDynamicColorPalette, + } + + const Component = () => { + useAndroidDynamicColorPalette() + return unreachable + } + + expect(() => renderAndroidVariantToJson()).toThrow( + 'This is an internal problem in Voltra. Please report the issue.' + ) + expect(getAndroidDynamicColorPalette).not.toHaveBeenCalled() + }) + + it('parses server query parameters into render context', () => { + const renderContext = createAndroidWidgetRenderContext({ + theme: 'light', + url: new URL('https://example.com/widgets?androidPalette=%5B%22%2311223344%22%2C%22%2311223344%22%2C%22%2311223344%22%2C%22%2311223344%22%2C%22%2311223344%22%2C%22%2311223344%22%2C%22%2311223344%22%2C%22%2311223344%22%2C%22%2311223344%22%2C%22%2311223344%22%2C%22%2311223344%22%2C%22%2311223344%22%2C%22%2311223344%22%2C%22%2311223344%22%2C%22%2311223344%22%2C%22%2311223344%22%2C%22%2311223344%22%2C%22%2311223344%22%2C%22%2311223344%22%2C%22%2311223344%22%2C%22%2311223344%22%2C%22%2311223344%22%2C%22%2311223344%22%2C%22%2311223344%22%2C%22%2311223344%22%2C%22%2311223344%22%2C%22%2311223344%22%5D'), + }) + + expect(renderContext.theme).toBe('light') + expect(renderContext.dynamicColorPalette?.primary).toBe('#11223344') + expect(renderContext.dynamicColorPalette?.widgetBackground).toBe('#11223344') + }) +}) diff --git a/packages/voltra/src/__tests__/widget-server.node.test.tsx b/packages/voltra/src/__tests__/widget-server.node.test.tsx index ac4f07a8..a63cb3b1 100644 --- a/packages/voltra/src/__tests__/widget-server.node.test.tsx +++ b/packages/voltra/src/__tests__/widget-server.node.test.tsx @@ -1,7 +1,8 @@ import { createAndroidWidgetUpdateHandler, renderAndroidWidgetToString } from '@use-voltra/android-server' +import { serializeAndroidDynamicColorPalette } from '@use-voltra/android/internal' import { createIOSWidgetUpdateHandler, renderWidgetToString, Voltra } from '@use-voltra/ios-server' import { createWidgetUpdateHandler as createSharedWidgetUpdateHandler } from '@use-voltra/server' -import { VoltraAndroid } from '../android/index.js' +import { VoltraAndroid, useAndroidDynamicColorPalette } from '../android/index.js' import { createWidgetUpdateHandler as createCompatibilityWidgetUpdateHandler } from '../widget-server.js' describe('server package split', () => { @@ -93,6 +94,24 @@ describe('server package split', () => { expect(await androidResponse.text()).toBe('{"platform":"android"}') }) + it('includes the parsed URL on the render request', async () => { + let renderUrl: string | undefined + + const handler = createSharedWidgetUpdateHandler({ + renderAndroid: async (request) => { + renderUrl = request.url.toString() + return '{"platform":"android"}' + }, + }) + + const response = await handler( + new Request('https://example.com/widgets?widgetId=clock&platform=android&theme=dark') + ) + + expect(response.status).toBe(200) + expect(renderUrl).toBe('https://example.com/widgets?widgetId=clock&platform=android&theme=dark') + }) + it('serializes iOS widgets through the iOS server package adapter', async () => { const variants = { systemSmall: Hello, @@ -124,6 +143,137 @@ describe('server package split', () => { expect(await response.text()).toBe(renderAndroidWidgetToString(variants)) }) + it('injects Android palette context into Android server handlers', async () => { + const palette = { + primary: '#11223344', + onPrimary: '#111111ff', + primaryContainer: '#111111ff', + onPrimaryContainer: '#111111ff', + secondary: '#111111ff', + onSecondary: '#111111ff', + secondaryContainer: '#111111ff', + onSecondaryContainer: '#111111ff', + tertiary: '#111111ff', + onTertiary: '#111111ff', + tertiaryContainer: '#111111ff', + onTertiaryContainer: '#111111ff', + error: '#111111ff', + errorContainer: '#111111ff', + onError: '#111111ff', + onErrorContainer: '#111111ff', + background: '#111111ff', + onBackground: '#111111ff', + surface: '#111111ff', + onSurface: '#111111ff', + surfaceVariant: '#111111ff', + onSurfaceVariant: '#111111ff', + outline: '#111111ff', + inverseOnSurface: '#111111ff', + inverseSurface: '#111111ff', + inversePrimary: '#111111ff', + widgetBackground: '#111111ff', + } + const serializedPalette = serializeAndroidDynamicColorPalette(palette) + + const PaletteReader = () => { + const dynamicColorPalette = useAndroidDynamicColorPalette() + return {dynamicColorPalette?.primary ?? 'missing'} + } + + const handler = createAndroidWidgetUpdateHandler({ + render: async () => [ + { + size: { width: 150, height: 100 }, + content: , + }, + ], + }) + + const response = await handler( + new Request( + `https://example.com/widgets?widgetId=clock&platform=android&androidPalette=${encodeURIComponent( + serializedPalette! + )}` + ) + ) + + expect(response.status).toBe(200) + expect(await response.text()).toBe( + renderAndroidWidgetToString([ + { + size: { width: 150, height: 100 }, + content: #11223344, + }, + ]) + ) + }) + + it('injects Android palette context into the root compatibility handler', async () => { + const palette = { + primary: '#55667788', + onPrimary: '#111111ff', + primaryContainer: '#111111ff', + onPrimaryContainer: '#111111ff', + secondary: '#111111ff', + onSecondary: '#111111ff', + secondaryContainer: '#111111ff', + onSecondaryContainer: '#111111ff', + tertiary: '#111111ff', + onTertiary: '#111111ff', + tertiaryContainer: '#111111ff', + onTertiaryContainer: '#111111ff', + error: '#111111ff', + errorContainer: '#111111ff', + onError: '#111111ff', + onErrorContainer: '#111111ff', + background: '#111111ff', + onBackground: '#111111ff', + surface: '#111111ff', + onSurface: '#111111ff', + surfaceVariant: '#111111ff', + onSurfaceVariant: '#111111ff', + outline: '#111111ff', + inverseOnSurface: '#111111ff', + inverseSurface: '#111111ff', + inversePrimary: '#111111ff', + widgetBackground: '#111111ff', + } + const serializedPalette = serializeAndroidDynamicColorPalette(palette) + + const PaletteReader = () => { + const dynamicColorPalette = useAndroidDynamicColorPalette() + return {dynamicColorPalette?.primary ?? 'missing'} + } + + const handler = createCompatibilityWidgetUpdateHandler({ + renderIos: async () => null, + renderAndroid: async () => [ + { + size: { width: 150, height: 100 }, + content: , + }, + ], + }) + + const response = await handler( + new Request( + `https://example.com/widgets?widgetId=clock&platform=android&androidPalette=${encodeURIComponent( + serializedPalette! + )}` + ) + ) + + expect(response.status).toBe(200) + expect(await response.text()).toBe( + renderAndroidWidgetToString([ + { + size: { width: 150, height: 100 }, + content: #55667788, + }, + ]) + ) + }) + it('keeps the root compatibility handler API unchanged', async () => { const variants = { systemSmall: Compat, diff --git a/packages/voltra/src/android/widgets/renderer.ts b/packages/voltra/src/android/widgets/renderer.ts index a55dadf8..362d57a1 100644 --- a/packages/voltra/src/android/widgets/renderer.ts +++ b/packages/voltra/src/android/widgets/renderer.ts @@ -1,60 +1,5 @@ -import { ComponentRegistry, createVoltraRenderer } from '../../renderer/renderer.js' -import { getAndroidComponentId } from '../payload/component-ids.js' -import type { AndroidWidgetVariants } from './types.js' - -/** - * Android component registry that uses Android component ID mappings - */ -const androidComponentRegistry: ComponentRegistry = { - getComponentId: (name: string) => getAndroidComponentId(name), -} - -/** - * Renders Android widget variants to JSON with size breakpoints. - * - * Output format: - * { - * "v": 1, - * "variants": { - * "150x100": { ... node tree ... }, - * "150x200": { ... node tree ... }, - * "215x100": { ... node tree ... } - * }, - * "s": [...shared styles...], - * "e": [...shared elements...] - * } - */ -export const renderAndroidWidgetToJson = (variants: AndroidWidgetVariants): Record => { - const renderer = createVoltraRenderer(androidComponentRegistry) - - // Add each size variant with key format "WIDTHxHEIGHT" - for (const { size, content } of variants) { - const key = `${size.width}x${size.height}` - renderer.addRootNode(key, content) - } - - const rendered = renderer.render() - - // Extract variant keys (everything except v, s, e which are metadata) - const variantsMap: Record = {} - const metadataKeys = ['v', 's', 'e'] - - for (const key of Object.keys(rendered)) { - if (!metadataKeys.includes(key)) { - variantsMap[key] = rendered[key] - delete rendered[key] - } - } - - // Add variants as a nested object (expected by Kotlin parser) - rendered.variants = variantsMap - - return rendered -} - -/** - * Renders Android widget variants to a JSON string. - */ -export const renderAndroidWidgetToString = (variants: AndroidWidgetVariants): string => { - return JSON.stringify(renderAndroidWidgetToJson(variants)) -} +export { + renderAndroidWidgetToJson, + renderAndroidWidgetToString, + type AndroidWidgetRenderOptions, +} from '@use-voltra/android/internal' diff --git a/packages/voltra/src/widget-server.ts b/packages/voltra/src/widget-server.ts index c889abaf..deebaf5b 100644 --- a/packages/voltra/src/widget-server.ts +++ b/packages/voltra/src/widget-server.ts @@ -1,6 +1,10 @@ /// +import { createAndroidWidgetRenderContext } from '@use-voltra/android-server' import type { AndroidWidgetVariants } from '@use-voltra/android-server' +import { renderAndroidWidgetToString } from '@use-voltra/android-server' +import { renderWidgetToString } from '@use-voltra/ios-server' +import type { WidgetVariants } from '@use-voltra/ios-server' import { createWidgetUpdateExpressHandler as createSharedWidgetUpdateExpressHandler } from '@use-voltra/server' import { createWidgetUpdateHandler as createSharedWidgetUpdateHandler } from '@use-voltra/server' import { createWidgetUpdateNodeHandler as createSharedWidgetUpdateNodeHandler } from '@use-voltra/server' @@ -10,9 +14,6 @@ import type { WidgetUpdateHandler, WidgetUpdateNodeHandler, } from '@use-voltra/server' -import { renderAndroidWidgetToString } from '@use-voltra/android-server' -import { renderWidgetToString } from '@use-voltra/ios-server' -import type { WidgetVariants } from '@use-voltra/ios-server' export { renderAndroidWidgetToString } from '@use-voltra/android-server' export type { AndroidWidgetVariants } from '@use-voltra/android-server' @@ -24,6 +25,7 @@ export type { WidgetUpdateHandler, WidgetUpdateNodeHandler, } from '@use-voltra/server' +export type { AndroidWidgetRenderContextValue } from '@use-voltra/android-server' export type { WidgetPlatform, WidgetTheme } from '@use-voltra/server' /** @@ -35,30 +37,38 @@ export interface WidgetUpdateHandlerOptions { validateToken?: (token: string) => Promise | boolean } -function toSharedOptions(options: WidgetUpdateHandlerOptions) { +const toSharedOptions = (options: WidgetUpdateHandlerOptions) => { + const renderAndroid = options.renderAndroid + return { validateToken: options.validateToken, renderIos: async (request: WidgetRenderRequest) => { const variants = await options.renderIos(request) return variants ? renderWidgetToString(variants) : null }, - renderAndroid: options.renderAndroid + renderAndroid: renderAndroid ? async (request: WidgetRenderRequest) => { - const variants = await options.renderAndroid?.(request) - return variants ? renderAndroidWidgetToString(variants) : null - } + const variants = await renderAndroid(request) + return variants + ? renderAndroidWidgetToString(variants, { renderContext: createAndroidWidgetRenderContext(request) }) + : null + } : undefined, } } -export function createWidgetUpdateHandler(options: WidgetUpdateHandlerOptions): WidgetUpdateHandler { +export const createWidgetUpdateHandler = (options: WidgetUpdateHandlerOptions): WidgetUpdateHandler => { return createSharedWidgetUpdateHandler(toSharedOptions(options)) } -export function createWidgetUpdateNodeHandler(options: WidgetUpdateHandlerOptions): WidgetUpdateNodeHandler { +export const createWidgetUpdateNodeHandler = ( + options: WidgetUpdateHandlerOptions +): WidgetUpdateNodeHandler => { return createSharedWidgetUpdateNodeHandler(toSharedOptions(options)) } -export function createWidgetUpdateExpressHandler(options: WidgetUpdateHandlerOptions): WidgetUpdateExpressHandler { +export const createWidgetUpdateExpressHandler = ( + options: WidgetUpdateHandlerOptions +): WidgetUpdateExpressHandler => { return createSharedWidgetUpdateExpressHandler(toSharedOptions(options)) } diff --git a/packages/voltra/src/widgets/renderer.ts b/packages/voltra/src/widgets/renderer.ts index 32d096d7..7b782c9f 100644 --- a/packages/voltra/src/widgets/renderer.ts +++ b/packages/voltra/src/widgets/renderer.ts @@ -9,7 +9,7 @@ export const renderWidgetToJson = (variants: WidgetVariants): Record Date: Thu, 26 Mar 2026 10:34:34 +0100 Subject: [PATCH 2/6] fix: types resolution --- packages/android-server/tsconfig.base.json | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/android-server/tsconfig.base.json b/packages/android-server/tsconfig.base.json index aae39635..e8edd987 100644 --- a/packages/android-server/tsconfig.base.json +++ b/packages/android-server/tsconfig.base.json @@ -4,6 +4,9 @@ "lib": ["ES2020", "DOM"], "rootDir": "./src", "moduleResolution": "node", + "paths": { + "@use-voltra/android/internal": ["../android/build/types/internal.d.ts"] + }, "jsx": "react-jsx", "strict": true, "esModuleInterop": true, From fa26c119db609db24d05545740aa252266ee47f8 Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Thu, 26 Mar 2026 10:35:39 +0100 Subject: [PATCH 3/6] chore: reformat --- packages/android-server/src/index.ts | 28 +++++++++---------- packages/android/src/dynamic-color.ts | 8 ++---- packages/android/src/server-context.ts | 2 +- packages/android/src/widgets/renderer.ts | 15 +++++----- packages/ios-server/src/index.ts | 6 +--- .../android-dynamic-color.node.test.tsx | 8 +++--- packages/voltra/src/widget-server.ts | 18 +++++------- 7 files changed, 37 insertions(+), 48 deletions(-) diff --git a/packages/android-server/src/index.ts b/packages/android-server/src/index.ts index fd5fe3d3..549fac39 100644 --- a/packages/android-server/src/index.ts +++ b/packages/android-server/src/index.ts @@ -104,9 +104,7 @@ const androidComponentRegistry = { getComponentId: (name: string) => getAndroidComponentId(name), } -export const renderAndroidLiveUpdateToJson = ( - variants: AndroidLiveUpdateVariants -): AndroidLiveUpdateJson => { +export const renderAndroidLiveUpdateToJson = (variants: AndroidLiveUpdateVariants): AndroidLiveUpdateJson => { const renderer = createVoltraRenderer(androidComponentRegistry) if (variants.collapsed) { @@ -130,9 +128,7 @@ export const renderAndroidLiveUpdateToJson = ( return result } -export const renderAndroidLiveUpdateToString = ( - variants: AndroidLiveUpdateVariants -): string => { +export const renderAndroidLiveUpdateToString = (variants: AndroidLiveUpdateVariants): string => { return JSON.stringify(renderAndroidLiveUpdateToJson(variants)) } @@ -151,9 +147,13 @@ export const renderAndroidWidgetToJson = ( const key = `${size.width}x${size.height}` renderer.addRootNode( key, - createElement(AndroidWidgetRenderContextProvider, { - value: renderContext, - }, content) + createElement( + AndroidWidgetRenderContextProvider, + { + value: renderContext, + }, + content + ) ) } @@ -187,7 +187,7 @@ export interface AndroidWidgetUpdateHandlerOptions { export const createAndroidWidgetRenderContext = ( request: Pick -) : AndroidWidgetRenderContextValue => { +): AndroidWidgetRenderContextValue => { return createAndroidWidgetRenderContextValue(request.theme, request.url.searchParams.get('androidPalette')) } @@ -196,14 +196,14 @@ const toSharedOptions = (options: AndroidWidgetUpdateHandlerOptions) => { validateToken: options.validateToken, renderAndroid: async (request: WidgetRenderRequest) => { const variants = await options.render(request) - return variants ? renderAndroidWidgetToString(variants, { renderContext: createAndroidWidgetRenderContext(request) }) : null + return variants + ? renderAndroidWidgetToString(variants, { renderContext: createAndroidWidgetRenderContext(request) }) + : null }, } } -export const createAndroidWidgetUpdateHandler = ( - options: AndroidWidgetUpdateHandlerOptions -): WidgetUpdateHandler => { +export const createAndroidWidgetUpdateHandler = (options: AndroidWidgetUpdateHandlerOptions): WidgetUpdateHandler => { return createWidgetUpdateHandler(toSharedOptions(options)) } diff --git a/packages/android/src/dynamic-color.ts b/packages/android/src/dynamic-color.ts index d5a87f2a..cc590dcb 100644 --- a/packages/android/src/dynamic-color.ts +++ b/packages/android/src/dynamic-color.ts @@ -81,9 +81,7 @@ const normalizePaletteColor = (value: unknown): string | null => { return value.toLowerCase() } -export const androidDynamicColorPaletteFromArray = ( - value: unknown -): AndroidDynamicColorPalette | null => { +export const androidDynamicColorPaletteFromArray = (value: unknown): AndroidDynamicColorPalette | null => { if (!Array.isArray(value) || value.length !== ANDROID_DYNAMIC_COLOR_PALETTE_ORDER.length) { return null } @@ -102,9 +100,7 @@ export const androidDynamicColorPaletteFromArray = ( return palette } -export const androidDynamicColorPaletteToArray = ( - palette: AndroidDynamicColorPalette -): string[] | null => { +export const androidDynamicColorPaletteToArray = (palette: AndroidDynamicColorPalette): string[] | null => { const colors: string[] = [] for (const key of ANDROID_DYNAMIC_COLOR_PALETTE_ORDER) { diff --git a/packages/android/src/server-context.ts b/packages/android/src/server-context.ts index 513cc3bb..13c8aca8 100644 --- a/packages/android/src/server-context.ts +++ b/packages/android/src/server-context.ts @@ -11,6 +11,6 @@ type AndroidWidgetRenderRequestLike = { export const createAndroidWidgetRenderContext = ( request: AndroidWidgetRenderRequestLike -) : AndroidWidgetRenderContextValue => { +): AndroidWidgetRenderContextValue => { return createAndroidWidgetRenderContextValue(request.theme, request.url.searchParams.get('androidPalette')) } diff --git a/packages/android/src/widgets/renderer.ts b/packages/android/src/widgets/renderer.ts index 9d63a265..4421fee6 100644 --- a/packages/android/src/widgets/renderer.ts +++ b/packages/android/src/widgets/renderer.ts @@ -1,7 +1,4 @@ -import { - AndroidWidgetRenderContextProvider, - type AndroidWidgetRenderContextValue, -} from '../dynamic-color.js' +import { AndroidWidgetRenderContextProvider, type AndroidWidgetRenderContextValue } from '../dynamic-color.js' import { createElement } from 'react' import { getAndroidComponentId } from '../payload/component-ids.js' @@ -46,9 +43,13 @@ export const renderAndroidWidgetToJson = ( const key = `${size.width}x${size.height}` renderer.addRootNode( key, - createElement(AndroidWidgetRenderContextProvider, { - value: context, - }, content) + createElement( + AndroidWidgetRenderContextProvider, + { + value: context, + }, + content + ) ) } diff --git a/packages/ios-server/src/index.ts b/packages/ios-server/src/index.ts index 5fd5ad05..796c1249 100644 --- a/packages/ios-server/src/index.ts +++ b/packages/ios-server/src/index.ts @@ -3,11 +3,7 @@ import { promisify } from 'node:util' import { brotliCompress, constants } from 'node:zlib' -import { - type ComponentRegistry, - createVoltraRenderer, - ensurePayloadWithinBudget, -} from '@use-voltra/core' +import { type ComponentRegistry, createVoltraRenderer, ensurePayloadWithinBudget } from '@use-voltra/core' import type { LiveActivityVariants, WidgetVariants } from '@use-voltra/ios' import type { WidgetRenderRequest, diff --git a/packages/voltra/src/__tests__/android-dynamic-color.node.test.tsx b/packages/voltra/src/__tests__/android-dynamic-color.node.test.tsx index e046d100..e3c8d7d6 100644 --- a/packages/voltra/src/__tests__/android-dynamic-color.node.test.tsx +++ b/packages/voltra/src/__tests__/android-dynamic-color.node.test.tsx @@ -78,9 +78,7 @@ describe('Android dynamic color hook', () => { }) it('throws when no provider is present', () => { - const getAndroidDynamicColorPalette = jest.fn(() => - Array.from({ length: 27 }, () => '#aabbccdd') - ) + const getAndroidDynamicColorPalette = jest.fn(() => Array.from({ length: 27 }, () => '#aabbccdd')) NativeModules.VoltraModule = { getAndroidDynamicColorPalette, @@ -100,7 +98,9 @@ describe('Android dynamic color hook', () => { it('parses server query parameters into render context', () => { const renderContext = createAndroidWidgetRenderContext({ theme: 'light', - url: new URL('https://example.com/widgets?androidPalette=%5B%22%2311223344%22%2C%22%2311223344%22%2C%22%2311223344%22%2C%22%2311223344%22%2C%22%2311223344%22%2C%22%2311223344%22%2C%22%2311223344%22%2C%22%2311223344%22%2C%22%2311223344%22%2C%22%2311223344%22%2C%22%2311223344%22%2C%22%2311223344%22%2C%22%2311223344%22%2C%22%2311223344%22%2C%22%2311223344%22%2C%22%2311223344%22%2C%22%2311223344%22%2C%22%2311223344%22%2C%22%2311223344%22%2C%22%2311223344%22%2C%22%2311223344%22%2C%22%2311223344%22%2C%22%2311223344%22%2C%22%2311223344%22%2C%22%2311223344%22%2C%22%2311223344%22%2C%22%2311223344%22%5D'), + url: new URL( + 'https://example.com/widgets?androidPalette=%5B%22%2311223344%22%2C%22%2311223344%22%2C%22%2311223344%22%2C%22%2311223344%22%2C%22%2311223344%22%2C%22%2311223344%22%2C%22%2311223344%22%2C%22%2311223344%22%2C%22%2311223344%22%2C%22%2311223344%22%2C%22%2311223344%22%2C%22%2311223344%22%2C%22%2311223344%22%2C%22%2311223344%22%2C%22%2311223344%22%2C%22%2311223344%22%2C%22%2311223344%22%2C%22%2311223344%22%2C%22%2311223344%22%2C%22%2311223344%22%2C%22%2311223344%22%2C%22%2311223344%22%2C%22%2311223344%22%2C%22%2311223344%22%2C%22%2311223344%22%2C%22%2311223344%22%2C%22%2311223344%22%5D' + ), }) expect(renderContext.theme).toBe('light') diff --git a/packages/voltra/src/widget-server.ts b/packages/voltra/src/widget-server.ts index deebaf5b..377d6ccd 100644 --- a/packages/voltra/src/widget-server.ts +++ b/packages/voltra/src/widget-server.ts @@ -48,11 +48,11 @@ const toSharedOptions = (options: WidgetUpdateHandlerOptions) => { }, renderAndroid: renderAndroid ? async (request: WidgetRenderRequest) => { - const variants = await renderAndroid(request) - return variants - ? renderAndroidWidgetToString(variants, { renderContext: createAndroidWidgetRenderContext(request) }) - : null - } + const variants = await renderAndroid(request) + return variants + ? renderAndroidWidgetToString(variants, { renderContext: createAndroidWidgetRenderContext(request) }) + : null + } : undefined, } } @@ -61,14 +61,10 @@ export const createWidgetUpdateHandler = (options: WidgetUpdateHandlerOptions): return createSharedWidgetUpdateHandler(toSharedOptions(options)) } -export const createWidgetUpdateNodeHandler = ( - options: WidgetUpdateHandlerOptions -): WidgetUpdateNodeHandler => { +export const createWidgetUpdateNodeHandler = (options: WidgetUpdateHandlerOptions): WidgetUpdateNodeHandler => { return createSharedWidgetUpdateNodeHandler(toSharedOptions(options)) } -export const createWidgetUpdateExpressHandler = ( - options: WidgetUpdateHandlerOptions -): WidgetUpdateExpressHandler => { +export const createWidgetUpdateExpressHandler = (options: WidgetUpdateHandlerOptions): WidgetUpdateExpressHandler => { return createSharedWidgetUpdateExpressHandler(toSharedOptions(options)) } From da04f0ea619ebaa17ba46ef6609fc5e118afcb2e Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Thu, 26 Mar 2026 10:59:38 +0100 Subject: [PATCH 4/6] chore: add example --- example/app.json | 15 + .../app/android-widgets/material-colors.tsx | 5 + .../android/AndroidMaterialColorsScreen.tsx | 331 ++++++++++++++++++ example/screens/android/AndroidScreen.tsx | 7 + example/server/widget-server.tsx | 23 ++ .../android/AndroidMaterialColorsWidget.tsx | 156 +++++++++ .../android-material-colors-initial.tsx | 14 + 7 files changed, 551 insertions(+) create mode 100644 example/app/android-widgets/material-colors.tsx create mode 100644 example/screens/android/AndroidMaterialColorsScreen.tsx create mode 100644 example/widgets/android/AndroidMaterialColorsWidget.tsx create mode 100644 example/widgets/android/android-material-colors-initial.tsx diff --git a/example/app.json b/example/app.json index f0a0c578..9afa7709 100644 --- a/example/app.json +++ b/example/app.json @@ -128,6 +128,21 @@ "intervalMinutes": 15, "refresh": true } + }, + { + "id": "material_colors", + "displayName": "Material Colors Widget", + "description": "Compare client-side and server-side rendering with Android dynamic colors", + "targetCellWidth": 2, + "targetCellHeight": 2, + "resizeMode": "horizontal|vertical", + "widgetCategory": "home_screen", + "initialStatePath": "./widgets/android/android-material-colors-initial.tsx", + "serverUpdate": { + "url": "http://10.0.2.2:3333", + "intervalMinutes": 15, + "refresh": true + } } ] }, diff --git a/example/app/android-widgets/material-colors.tsx b/example/app/android-widgets/material-colors.tsx new file mode 100644 index 00000000..d5f2d285 --- /dev/null +++ b/example/app/android-widgets/material-colors.tsx @@ -0,0 +1,5 @@ +import AndroidMaterialColorsScreen from '~/screens/android/AndroidMaterialColorsScreen' + +export default function AndroidMaterialColorsRoute() { + return +} diff --git a/example/screens/android/AndroidMaterialColorsScreen.tsx b/example/screens/android/AndroidMaterialColorsScreen.tsx new file mode 100644 index 00000000..ebc14865 --- /dev/null +++ b/example/screens/android/AndroidMaterialColorsScreen.tsx @@ -0,0 +1,331 @@ +import { useRouter } from 'expo-router' +import React, { useState } from 'react' +import { Alert, NativeModules, Platform, ScrollView, StyleSheet, Text, View } from 'react-native' +import { + reloadAndroidWidgets, + requestPinAndroidWidget, + setWidgetServerCredentials, + updateAndroidWidget, + VoltraWidgetPreview, +} from 'voltra/android/client' +import type { AndroidDynamicColorPalette } from 'voltra/android' + +import { Button } from '~/components/Button' +import { Card } from '~/components/Card' +import { AndroidMaterialColorsWidget, type AndroidMaterialColorsRenderSource } from '~/widgets/android/AndroidMaterialColorsWidget' + +const WIDGET_ID = 'material_colors' +const DEMO_TOKEN = 'demo-token' + +const PALETTE_KEYS = [ + 'primary', + 'onPrimary', + 'primaryContainer', + 'onPrimaryContainer', + 'secondary', + 'onSecondary', + 'secondaryContainer', + 'onSecondaryContainer', + 'tertiary', + 'onTertiary', + 'tertiaryContainer', + 'onTertiaryContainer', + 'error', + 'errorContainer', + 'onError', + 'onErrorContainer', + 'background', + 'onBackground', + 'surface', + 'onSurface', + 'surfaceVariant', + 'onSurfaceVariant', + 'outline', + 'inverseOnSurface', + 'inverseSurface', + 'inversePrimary', + 'widgetBackground', +] as const satisfies readonly (keyof AndroidDynamicColorPalette)[] + +const getPaletteSnapshot = (): AndroidDynamicColorPalette | null => { + const values = (NativeModules.VoltraModule as { getAndroidDynamicColorPalette?: () => string[] | null } | undefined) + ?.getAndroidDynamicColorPalette?.() + + if (!Array.isArray(values) || values.length !== PALETTE_KEYS.length) { + return null + } + + const palette = {} as AndroidDynamicColorPalette + + for (const [index, key] of PALETTE_KEYS.entries()) { + const value = values[index] + if (typeof value !== 'string') { + return null + } + palette[key] = value + } + + return palette +} + +const formatRenderTime = () => + new Date().toLocaleTimeString([], { + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + }) + +export default function AndroidMaterialColorsScreen() { + const router = useRouter() + const [isPinning, setIsPinning] = useState(false) + const [isRenderingClient, setIsRenderingClient] = useState(false) + const [isRenderingServer, setIsRenderingServer] = useState(false) + const [previewSource, setPreviewSource] = useState('initial') + const [previewTimestamp, setPreviewTimestamp] = useState('waiting for render') + const [previewPalette, setPreviewPalette] = useState(() => getPaletteSnapshot()) + + const handlePinWidget = async () => { + if (Platform.OS !== 'android') { + Alert.alert('Not Available', 'This widget demo is only available on Android devices.') + return + } + + setIsPinning(true) + try { + const success = await requestPinAndroidWidget(WIDGET_ID, { + previewWidth: 220, + previewHeight: 220, + }) + + if (success) { + Alert.alert('Pin requested', 'Add the widget on your home screen, then use the render buttons below.') + } else { + Alert.alert('Not supported', 'Widget pinning is not available on this device.') + } + } catch (error: any) { + const message = error?.message || String(error) + Alert.alert('Error', `Failed to pin widget: ${message}`) + } finally { + setIsPinning(false) + } + } + + const handleRenderOnClient = async () => { + if (Platform.OS !== 'android') { + Alert.alert('Not Available', 'Client-side widget rendering is only available on Android devices.') + return + } + + setIsRenderingClient(true) + try { + const palette = getPaletteSnapshot() + const renderedAt = formatRenderTime() + + await updateAndroidWidget(WIDGET_ID, [ + { + size: { width: 200, height: 200 }, + content: , + }, + { + size: { width: 300, height: 200 }, + content: , + }, + ]) + + setPreviewPalette(palette) + setPreviewSource('client') + setPreviewTimestamp(renderedAt) + Alert.alert('Client render complete', 'The widget JSON was rendered inside the app and pushed straight to Android.') + } catch (error: any) { + const message = error?.message || String(error) + Alert.alert('Error', `Failed to render on client: ${message}`) + } finally { + setIsRenderingClient(false) + } + } + + const handleRenderOnServer = async () => { + if (Platform.OS !== 'android') { + Alert.alert('Not Available', 'Server-side widget rendering is only available on Android devices.') + return + } + + setIsRenderingServer(true) + try { + await setWidgetServerCredentials({ + token: DEMO_TOKEN, + headers: { + 'X-Widget-Source': 'voltra-example', + }, + }) + + await reloadAndroidWidgets([WIDGET_ID]) + + setPreviewPalette(getPaletteSnapshot()) + setPreviewSource('server') + setPreviewTimestamp('server timestamp') + Alert.alert( + 'Server render requested', + 'The widget will fetch fresh JSON from the example server. Make sure `npm run widget:server --workspace voltra-example` is running on your host machine.' + ) + } catch (error: any) { + const message = error?.message || String(error) + Alert.alert('Error', `Failed to render on server: ${message}`) + } finally { + setIsRenderingServer(false) + } + } + + return ( + + + Material Colors Widget + + Test the same Android widget through both render paths. Client render snapshots the device palette inside the + app, while server render sends the Android Material palette to the widget server and lets SSR produce the + widget JSON. + + + + 1. Pin the Widget + Add the widget to your home screen once, then switch between client-side and server-side renders. + +