diff --git a/example/app.json b/example/app.json
index 445654c6..2f3051a1 100644
--- a/example/app.json
+++ b/example/app.json
@@ -103,6 +103,7 @@
"@use-voltra/android-client",
{
"enableNotifications": true,
+ "widgetConfigurationRoute": "voltraui/android-widget-config",
"widgets": [
{
"id": "voltra",
diff --git a/example/app/voltraui/[activityName].tsx b/example/app/voltraui/[activityName].tsx
index 6531ec73..5feb48e5 100644
--- a/example/app/voltraui/[activityName].tsx
+++ b/example/app/voltraui/[activityName].tsx
@@ -1,13 +1,49 @@
import { useLocalSearchParams, useRouter } from 'expo-router'
-import { StyleSheet, Text, View } from 'react-native'
+import { useState } from 'react'
+import { Alert, StyleSheet, Text, TextInput, View } from 'react-native'
+import {
+ cancelWidgetConfiguration,
+ completeWidgetConfiguration,
+ setWidgetInstanceConfiguration,
+} from '@use-voltra/android-client'
import { Button } from '~/components/Button'
export default function DeepLinkIndexScreen() {
- const { activityName } = useLocalSearchParams<{ activityName: string }>()
+ const { activityName, appWidgetId, widgetId } = useLocalSearchParams<{
+ activityName: string
+ appWidgetId?: string
+ widgetId?: string
+ }>()
const router = useRouter()
+ const isWidgetConfig = activityName === 'android-widget-config'
+ const parsedAppWidgetId = typeof appWidgetId === 'string' ? Number(appWidgetId) : Number.NaN
+ const [label, setLabel] = useState('Hello')
+ const [saving, setSaving] = useState(false)
+
+ const saveWidgetConfiguration = async () => {
+ if (!Number.isFinite(parsedAppWidgetId)) {
+ Alert.alert('Missing widget id', 'The configuration activity did not receive a valid appWidgetId.')
+ return
+ }
+
+ setSaving(true)
+ try {
+ await setWidgetInstanceConfiguration(parsedAppWidgetId, 'label', label)
+ await completeWidgetConfiguration(parsedAppWidgetId)
+ } catch (error: any) {
+ Alert.alert('Error', error?.message || String(error))
+ } finally {
+ setSaving(false)
+ }
+ }
const goBack = () => {
+ if (isWidgetConfig) {
+ cancelWidgetConfiguration().catch(() => {})
+ return
+ }
+
if (router.canGoBack()) {
router.back()
} else {
@@ -15,6 +51,34 @@ export default function DeepLinkIndexScreen() {
}
}
+ if (isWidgetConfig) {
+ return (
+
+
+ Configure widget instance
+ Widget type: {widgetId}
+ Instance: #{appWidgetId}
+
+
+ env.configuration.label
+
+
+
+
+
+
+
+
+
+ )
+ }
+
return (
@@ -42,6 +106,31 @@ const styles = StyleSheet.create({
alignItems: 'center',
gap: 16,
},
+ form: {
+ width: '100%',
+ gap: 8,
+ },
+ label: {
+ color: 'white',
+ fontSize: 14,
+ fontWeight: '600',
+ },
+ input: {
+ minWidth: 260,
+ width: '100%',
+ backgroundColor: 'rgba(255, 255, 255, 0.08)',
+ borderColor: 'rgba(255, 255, 255, 0.18)',
+ borderWidth: 1,
+ borderRadius: 12,
+ paddingHorizontal: 14,
+ paddingVertical: 12,
+ color: 'white',
+ },
+ buttonRow: {
+ flexDirection: 'row',
+ gap: 12,
+ marginTop: 8,
+ },
title: {
color: 'white',
fontSize: 24,
diff --git a/example/screens/android/AndroidWidgetPinScreen.tsx b/example/screens/android/AndroidWidgetPinScreen.tsx
index 908d9452..848e1943 100644
--- a/example/screens/android/AndroidWidgetPinScreen.tsx
+++ b/example/screens/android/AndroidWidgetPinScreen.tsx
@@ -1,7 +1,12 @@
import { useRouter } from 'expo-router'
-import React, { useState } from 'react'
+import React, { useCallback, useEffect, useState } from 'react'
import { Alert, Platform, StyleSheet, Text, TextInput, View } from 'react-native'
-import { requestPinAndroidWidget, setWidgetConfiguration } from '@use-voltra/android-client'
+import {
+ getActiveWidgets,
+ requestPinAndroidWidget,
+ setWidgetInstanceConfiguration,
+ type WidgetInfo,
+} from '@use-voltra/android-client'
import { Button } from '~/components/Button'
import { ScreenLayout } from '~/components/ScreenLayout'
@@ -37,6 +42,8 @@ const AVAILABLE_WIDGETS = [
},
]
+const CONFIGURABLE_WIDGET_ID = 'AndroidClientDemoWidget'
+
export default function AndroidWidgetPinScreen() {
const router = useRouter()
const [selectedWidgetId, setSelectedWidgetId] = useState('voltra')
@@ -44,21 +51,76 @@ export default function AndroidWidgetPinScreen() {
const [previewHeight, setPreviewHeight] = useState('150')
const [isPinning, setIsPinning] = useState(false)
const [configLabel, setConfigLabel] = useState('')
+ const [activeWidgets, setActiveWidgets] = useState([])
+ const [selectedWidgetInstanceId, setSelectedWidgetInstanceId] = useState(null)
+
+ const selectedWidget = AVAILABLE_WIDGETS.find((w) => w.id === selectedWidgetId) || AVAILABLE_WIDGETS[0]
+
+ const resetPreviewDimensions = (widget = selectedWidget) => {
+ // Use dimensions that match actual widget content variants for better previews
+ if (widget.id === 'interactive_todos') {
+ setPreviewWidth('250')
+ setPreviewHeight('150')
+ } else {
+ setPreviewWidth(String(widget.defaultPreviewWidth))
+ setPreviewHeight(String(widget.defaultPreviewHeight))
+ }
+ }
+
+ const refreshActiveWidgets = useCallback(async () => {
+ if (Platform.OS !== 'android') {
+ return
+ }
+
+ try {
+ const widgets = await getActiveWidgets()
+ setActiveWidgets(widgets)
+ setSelectedWidgetInstanceId((current) => {
+ if (widgets.some((widget) => widget.widgetId === current && widget.name === CONFIGURABLE_WIDGET_ID)) {
+ return current
+ }
+
+ return widgets.find((widget) => widget.name === CONFIGURABLE_WIDGET_ID)?.widgetId ?? null
+ })
+ } catch (error: any) {
+ Alert.alert('Error', error?.message || String(error))
+ }
+ }, [])
+
+ useEffect(() => {
+ refreshActiveWidgets()
+ }, [refreshActiveWidgets])
const handleSetConfig = async () => {
if (Platform.OS !== 'android') {
return
}
+
+ if (selectedWidgetInstanceId == null) {
+ Alert.alert('No widget selected', 'Pin the configurable demo widget first, then choose that instance.')
+ return
+ }
+
+ const selectedWidget = activeWidgets.find(
+ (widget) => widget.widgetId === selectedWidgetInstanceId && widget.name === CONFIGURABLE_WIDGET_ID
+ )
+ if (!selectedWidget) {
+ Alert.alert('Unsupported widget', 'Only the client-rendered demo widget is configurable in this example.')
+ return
+ }
+
try {
- await setWidgetConfiguration(selectedWidgetId, 'label', configLabel)
- Alert.alert('Saved', `Set config "label" = "${configLabel}" for ${selectedWidgetId}. The widget re-renders.`)
+ await setWidgetInstanceConfiguration(selectedWidgetInstanceId, 'label', configLabel)
+ Alert.alert(
+ 'Saved',
+ `Set config "label" = "${configLabel}" for widget instance #${selectedWidgetInstanceId}. The widget re-renders.`
+ )
+ await refreshActiveWidgets()
} catch (error: any) {
Alert.alert('Error', error?.message || String(error))
}
}
- const selectedWidget = AVAILABLE_WIDGETS.find((w) => w.id === selectedWidgetId) || AVAILABLE_WIDGETS[0]
-
const handlePinWidget = async (usePreview: boolean) => {
if (Platform.OS !== 'android') {
Alert.alert('Not Available', 'Widget pinning is only available on Android devices.')
@@ -81,6 +143,7 @@ export default function AndroidWidgetPinScreen() {
'Success',
`Pin request sent for ${selectedWidget.name}! Check your home screen to complete the pinning.`
)
+ await refreshActiveWidgets()
} else {
Alert.alert(
'Not Supported',
@@ -96,17 +159,6 @@ export default function AndroidWidgetPinScreen() {
}
}
- const resetPreviewDimensions = () => {
- // Use dimensions that match actual widget content variants for better previews
- if (selectedWidget.id === 'interactive_todos') {
- setPreviewWidth('250')
- setPreviewHeight('150')
- } else {
- setPreviewWidth(String(selectedWidget.defaultPreviewWidth))
- setPreviewHeight(String(selectedWidget.defaultPreviewHeight))
- }
- }
-
return (
{
setSelectedWidgetId(widget.id)
- resetPreviewDimensions()
+ resetPreviewDimensions(widget)
}}
style={styles.widgetButton}
/>
@@ -131,7 +183,34 @@ export default function AndroidWidgetPinScreen() {
- Configuration (Dynamic Widget)
+
+ Configuration (Dynamic Widget)
+
+
+
+ Configure one placed client-rendered widget instance at a time. This uses the new instance-level API, so two
+ widgets of the same type can keep different values.
+
+ Active widget instances
+ {activeWidgets.some((widget) => widget.name === CONFIGURABLE_WIDGET_ID) ? (
+ activeWidgets
+ .filter((widget) => widget.name === CONFIGURABLE_WIDGET_ID)
+ .map((widget) => (
+
+
+ ))
+ ) : (
+ Pin the client-rendered demo widget first to configure an instance.
+ )}
env.configuration.label
-
+
@@ -204,12 +283,30 @@ const styles = StyleSheet.create({
section: {
marginBottom: 24,
},
+ sectionHeaderRow: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ justifyContent: 'space-between',
+ gap: 12,
+ },
sectionTitle: {
fontSize: 16,
fontWeight: '600',
color: '#FFFFFF',
marginBottom: 12,
},
+ sectionDescription: {
+ fontSize: 12,
+ lineHeight: 18,
+ color: '#94A3B8',
+ marginBottom: 12,
+ },
+ sectionSubtitle: {
+ fontSize: 13,
+ fontWeight: '600',
+ color: '#CBD5E1',
+ marginBottom: 8,
+ },
widgetOption: {
marginTop: 12,
},
@@ -222,6 +319,18 @@ const styles = StyleSheet.create({
marginTop: 4,
marginLeft: 4,
},
+ widgetInstanceOption: {
+ marginTop: 12,
+ },
+ widgetInstanceButton: {
+ marginBottom: 4,
+ },
+ widgetInstanceDescription: {
+ fontSize: 12,
+ color: '#94A3B8',
+ marginTop: 4,
+ marginLeft: 4,
+ },
previewInputs: {
marginTop: 16,
gap: 12,
@@ -258,8 +367,16 @@ const styles = StyleSheet.create({
loadingText: {
marginTop: 12,
fontSize: 12,
- color: '#8232FF',
- textAlign: 'center',
+ color: '#94A3B8',
+ },
+ smallButton: {
+ paddingHorizontal: 14,
+ paddingVertical: 8,
+ },
+ emptyState: {
+ color: '#94A3B8',
+ fontSize: 12,
+ marginBottom: 12,
},
footer: {
marginTop: 24,
diff --git a/packages/android-client/android/src/main/java/voltra/VoltraModule.kt b/packages/android-client/android/src/main/java/voltra/VoltraModule.kt
index 3565d52b..ac2d2c42 100644
--- a/packages/android-client/android/src/main/java/voltra/VoltraModule.kt
+++ b/packages/android-client/android/src/main/java/voltra/VoltraModule.kt
@@ -1,7 +1,9 @@
package voltra
+import android.app.Activity
import android.appwidget.AppWidgetManager
import android.content.ComponentCallbacks
+import android.content.Intent
import android.content.res.Configuration
import android.util.Log
import androidx.compose.ui.unit.DpSize
@@ -222,6 +224,79 @@ class VoltraModule(
}
}
+ override fun setWidgetInstanceConfiguration(
+ appWidgetId: Double,
+ key: String,
+ value: String,
+ promise: Promise,
+ ) {
+ Log.d(TAG, "setWidgetInstanceConfiguration called with appWidgetId=$appWidgetId")
+
+ runBlocking {
+ try {
+ val widgetInstanceId = appWidgetId.toInt()
+ val widgetInfo =
+ AppWidgetManager.getInstance(reactApplicationContext).getAppWidgetInfo(widgetInstanceId)
+ ?: throw IllegalArgumentException("Unknown widget instance: $widgetInstanceId")
+ val widgetId = extractWidgetId(widgetInfo.provider.shortClassName)
+ val configurationStore = VoltraConfigurationStore(reactApplicationContext)
+ configurationStore.setInstance(widgetInstanceId, key, value)
+
+ val glanceId = GlanceAppWidgetManager(reactApplicationContext).getGlanceIdBy(widgetInstanceId)
+ VoltraWidgetReceiver.triggerGlanceUpdate(reactApplicationContext, widgetId, glanceId)
+ promise.resolve(null)
+ } catch (e: Exception) {
+ Log.e(TAG, "setWidgetInstanceConfiguration failed", e)
+ promise.reject("VOLTRA_WIDGET_CONFIG_ERROR", e.message, e)
+ }
+ }
+ }
+
+ override fun completeWidgetConfiguration(
+ appWidgetId: Double,
+ promise: Promise,
+ ) {
+ val activity =
+ currentActivity
+ ?: run {
+ promise.reject("VOLTRA_WIDGET_CONFIG_ERROR", "No active widget configuration activity")
+ return
+ }
+
+ val resultIntent =
+ Intent().apply {
+ putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, appWidgetId.toInt())
+ }
+
+ activity.setResult(Activity.RESULT_OK, resultIntent)
+ activity.finish()
+ promise.resolve(null)
+ }
+
+ override fun cancelWidgetConfiguration(promise: Promise) {
+ val activity =
+ currentActivity
+ ?: run {
+ promise.reject("VOLTRA_WIDGET_CONFIG_ERROR", "No active widget configuration activity")
+ return
+ }
+
+ val appWidgetId =
+ activity.intent?.getIntExtra(
+ AppWidgetManager.EXTRA_APPWIDGET_ID,
+ AppWidgetManager.INVALID_APPWIDGET_ID,
+ ) ?: AppWidgetManager.INVALID_APPWIDGET_ID
+
+ val resultIntent =
+ Intent().apply {
+ putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, appWidgetId)
+ }
+
+ activity.setResult(Activity.RESULT_CANCELED, resultIntent)
+ activity.finish()
+ promise.resolve(null)
+ }
+
override fun clearAndroidWidget(
widgetId: String,
promise: Promise,
@@ -458,4 +533,14 @@ class VoltraModule(
}
promise.resolve(activeWidgets)
}
+
+ private fun extractWidgetId(shortClassName: String): String {
+ val prefix = ".widget.VoltraWidget_"
+ val suffix = "Receiver"
+ return if (shortClassName.startsWith(prefix) && shortClassName.endsWith(suffix)) {
+ shortClassName.substring(prefix.length, shortClassName.length - suffix.length)
+ } else {
+ shortClassName
+ }
+ }
}
diff --git a/packages/android-client/android/src/main/java/voltra/runtime/VoltraConfigurationStore.kt b/packages/android-client/android/src/main/java/voltra/runtime/VoltraConfigurationStore.kt
index 19a5cb0e..8b20a582 100644
--- a/packages/android-client/android/src/main/java/voltra/runtime/VoltraConfigurationStore.kt
+++ b/packages/android-client/android/src/main/java/voltra/runtime/VoltraConfigurationStore.kt
@@ -13,22 +13,34 @@ import java.io.IOException
* DataStore-backed per-widget configuration for Dynamic Widgets โ the values surfaced as
* `env.configuration` in the widget's `(props, env) => JSX` render.
*
- * Two layers, merged at read time:
+ * Three layers, merged at read time:
* - **Defaults** declared in code (`app.json` widget `appIntent.parameters[].default`), emitted by
* the config plugin to `assets/voltra/widget_config_defaults.json`. These are the values the
* widget shows before the user configures anything โ the Android equivalent of iOS's
* `@Parameter(default:)`.
- * - **Stored** values written at runtime (see setWidgetConfiguration), which override the defaults.
+ * - **Widget-type stored** values written at runtime (see setWidgetConfiguration), which override
+ * the defaults for every instance of the same widget type.
+ * - **Instance stored** values written at runtime (see setWidgetInstanceConfiguration), which
+ * override both defaults and widget-type values for one specific `appWidgetId`.
*
* Stands in for a real Glance configuration activity: Android has no system-managed widget
* configuration equivalent of iOS's WidgetConfigurationIntent, so runtime values are written by an
- * in-app screen and read here at render time. Keys are namespaced `voltra.config..`.
+ * in-app screen and read here at render time. Keys are namespaced
+ * `voltra.config.widget..` for widget-type values and
+ * `voltra.config.instance..` for per-instance values.
*/
internal class VoltraConfigurationStore(
private val context: Context,
) {
- /** Code-declared defaults merged with stored runtime values (stored wins). */
- suspend fun get(widgetId: String): Map = loadDefaults(widgetId) + getStored(widgetId)
+ /** Code-declared defaults merged with widget-type and per-instance runtime values (stored wins). */
+ suspend fun get(
+ widgetId: String,
+ appWidgetId: Int? = null,
+ ): Map {
+ val typeValues = getStored(widgetId)
+ val instanceValues = if (appWidgetId != null) getStoredInstance(appWidgetId) else emptyMap()
+ return loadDefaults(widgetId) + typeValues + instanceValues
+ }
suspend fun set(
widgetId: String,
@@ -39,8 +51,43 @@ internal class VoltraConfigurationStore(
context.voltraConfigurationDataStore.edit { it[prefKey] = value }
}
+ suspend fun setInstance(
+ appWidgetId: Int,
+ key: String,
+ value: String,
+ ) {
+ val prefKey = stringPreferencesKey(keyPrefix(appWidgetId) + key)
+ context.voltraConfigurationDataStore.edit { it[prefKey] = value }
+ }
+
+ suspend fun clearInstance(appWidgetId: Int) {
+ val prefix = keyPrefix(appWidgetId)
+ context.voltraConfigurationDataStore.edit { preferences ->
+ val keysToRemove = preferences.asMap().keys.filter { it.name.startsWith(prefix) }
+ keysToRemove.forEach { preferences.remove(it) }
+ }
+ }
+
private suspend fun getStored(widgetId: String): Map {
val prefix = keyPrefix(widgetId)
+ val legacyPrefix = legacyKeyPrefix(widgetId)
+ val snapshot = context.voltraConfigurationDataStore.data.first()
+ val out = mutableMapOf()
+ snapshot.asMap().forEach { (key, value) ->
+ if (key.name.startsWith(legacyPrefix) && value is String) {
+ out[key.name.substring(legacyPrefix.length)] = value
+ }
+ }
+ snapshot.asMap().forEach { (key, value) ->
+ if (key.name.startsWith(prefix) && value is String) {
+ out[key.name.substring(prefix.length)] = value
+ }
+ }
+ return out
+ }
+
+ private suspend fun getStoredInstance(appWidgetId: Int): Map {
+ val prefix = keyPrefix(appWidgetId)
val snapshot = context.voltraConfigurationDataStore.data.first()
val out = mutableMapOf()
snapshot.asMap().forEach { (key, value) ->
@@ -80,7 +127,11 @@ internal class VoltraConfigurationStore(
return all[widgetId] ?: emptyMap()
}
- private fun keyPrefix(widgetId: String): String = "voltra.config.$widgetId."
+ private fun keyPrefix(widgetId: String): String = "voltra.config.widget.$widgetId."
+
+ private fun legacyKeyPrefix(widgetId: String): String = "voltra.config.$widgetId."
+
+ private fun keyPrefix(appWidgetId: Int): String = "voltra.config.instance.$appWidgetId."
companion object {
private const val TAG = "VoltraConfigurationStore"
diff --git a/packages/android-client/android/src/main/java/voltra/widget/VoltraClientGlanceWidget.kt b/packages/android-client/android/src/main/java/voltra/widget/VoltraClientGlanceWidget.kt
index 9bdd4d20..a13a7f18 100644
--- a/packages/android-client/android/src/main/java/voltra/widget/VoltraClientGlanceWidget.kt
+++ b/packages/android-client/android/src/main/java/voltra/widget/VoltraClientGlanceWidget.kt
@@ -13,6 +13,7 @@ import androidx.glance.GlanceModifier
import androidx.glance.LocalContext
import androidx.glance.LocalSize
import androidx.glance.appwidget.GlanceAppWidget
+import androidx.glance.appwidget.GlanceAppWidgetManager
import androidx.glance.appwidget.SizeMode
import androidx.glance.appwidget.provideContent
import androidx.glance.layout.Alignment
@@ -190,7 +191,8 @@ class VoltraClientGlanceWidget(
// Read user-configured params (DataStore) off the composition so env.configuration is
// available synchronously during render.
- val configuration = VoltraConfigurationStore(context).get(widgetId)
+ val appWidgetId = runCatching { GlanceAppWidgetManager(context).getAppWidgetId(id) }.getOrNull()
+ val configuration = VoltraConfigurationStore(context).get(widgetId, appWidgetId)
provideContent {
Content(bundleReady, configuration)
diff --git a/packages/android-client/android/src/main/java/voltra/widget/VoltraWidgetReceiver.kt b/packages/android-client/android/src/main/java/voltra/widget/VoltraWidgetReceiver.kt
index 4b2de596..921172b7 100644
--- a/packages/android-client/android/src/main/java/voltra/widget/VoltraWidgetReceiver.kt
+++ b/packages/android-client/android/src/main/java/voltra/widget/VoltraWidgetReceiver.kt
@@ -11,6 +11,7 @@ import androidx.glance.appwidget.GlanceAppWidgetReceiver
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
+import voltra.runtime.VoltraConfigurationStore
/**
* Base widget receiver for Voltra home screen widgets.
@@ -135,6 +136,20 @@ abstract class VoltraWidgetReceiver : GlanceAppWidgetReceiver() {
onWidgetResized(context)
}
+ override fun onDeleted(
+ context: Context,
+ appWidgetIds: IntArray,
+ ) {
+ super.onDeleted(context, appWidgetIds)
+
+ CoroutineScope(Dispatchers.IO).launch {
+ val store = VoltraConfigurationStore(context.applicationContext)
+ for (appWidgetId in appWidgetIds) {
+ store.clearInstance(appWidgetId)
+ }
+ }
+ }
+
/**
* Re-render after a resize. Server-rendered widgets re-render from cached data โ the payload
* carries all size variants, so RemoteViews(sizeMapping) picks the closest match; no network
diff --git a/packages/android-client/expo-plugin/src/android/files/clientRenderedPrerender.node.test.ts b/packages/android-client/expo-plugin/src/android/files/clientRenderedPrerender.node.test.ts
index 5da00c3d..3d6c310a 100644
--- a/packages/android-client/expo-plugin/src/android/files/clientRenderedPrerender.node.test.ts
+++ b/packages/android-client/expo-plugin/src/android/files/clientRenderedPrerender.node.test.ts
@@ -65,4 +65,33 @@ describe('prerenderClientRenderedAndroidWidgets', () => {
cleanup()
}
})
+
+ it('resolves project-relative client source paths before evaluating placeholders', async () => {
+ const { projectRoot, cleanup } = makeTempProject({
+ 'widgets/android/Dynamic.tsx': `
+ export default function DynamicWidget() {
+ return { source: 'relative-entry' }
+ }
+ `,
+ })
+
+ try {
+ const widget: DetectedAndroidWidget = {
+ id: 'dynamic_widget',
+ entry: './widgets/android/Dynamic.tsx',
+ displayName: 'Dynamic Widget',
+ description: 'Uses a project-relative entry',
+ targetCellWidth: 2,
+ targetCellHeight: 2,
+ clientRendered: true,
+ clientSourcePath: './widgets/android/Dynamic.tsx',
+ }
+
+ const result = await prerenderClientRenderedAndroidWidgets([widget], projectRoot)
+
+ expect(result.get('dynamic_widget')?.get('__default')).toContain('"source":"relative-entry"')
+ } finally {
+ cleanup()
+ }
+ })
})
diff --git a/packages/android-client/expo-plugin/src/android/files/clientRenderedPrerender.ts b/packages/android-client/expo-plugin/src/android/files/clientRenderedPrerender.ts
index 58965685..f382014e 100644
--- a/packages/android-client/expo-plugin/src/android/files/clientRenderedPrerender.ts
+++ b/packages/android-client/expo-plugin/src/android/files/clientRenderedPrerender.ts
@@ -1,3 +1,5 @@
+import path from 'path'
+
import { evaluateWidgetModuleExports, logger, type PrerenderedWidgetStates } from '@use-voltra/expo-plugin'
import type { DetectedAndroidWidget } from '../clientRendered'
@@ -56,12 +58,11 @@ export async function prerenderClientRenderedAndroidWidgets(
for (const widget of clientWidgets) {
try {
- const widgetModule = evaluateWidgetModuleExports(projectRoot, widget.clientSourcePath)
+ const clientSourcePath = path.resolve(projectRoot, widget.clientSourcePath)
+ const widgetModule = evaluateWidgetModuleExports(projectRoot, clientSourcePath)
const widgetFn = widgetModule?.default ?? widgetModule
if (typeof widgetFn !== 'function') {
- throw new Error(
- `Expected the entry module at ${widget.clientSourcePath} to default-export a function or component.`
- )
+ throw new Error(`Expected the entry module at ${clientSourcePath} to default-export a function or component.`)
}
const element = widgetFn({}, placeholderEnv)
diff --git a/packages/android-client/expo-plugin/src/android/files/index.ts b/packages/android-client/expo-plugin/src/android/files/index.ts
index 36cc0b27..1c859390 100644
--- a/packages/android-client/expo-plugin/src/android/files/index.ts
+++ b/packages/android-client/expo-plugin/src/android/files/index.ts
@@ -2,17 +2,21 @@ import { ConfigPlugin, withDangerousMod } from '@expo/config-plugins'
import type { AndroidWidgetConfig } from '../../types'
import { detectClientRenderedWidgets } from '../clientRendered'
+import type { DetectedAndroidWidget } from '../clientRendered'
import { generateAndroidAssets } from './assets'
import { generateAndroidConfigDefaults } from './configDefaults'
import { copyAndroidFonts } from './fonts'
import { generateAndroidInitialStates } from './initialStates'
-import { generateWidgetReceivers } from './kotlin'
+import { generateWidgetConfigurationActivities, generateWidgetReceivers } from './kotlin'
import { generateWidgetInfoFiles, generateWidgetPlaceholderLayouts, generateWidgetPreviewLayouts } from './xml'
export interface GenerateAndroidWidgetFilesProps {
widgets: AndroidWidgetConfig[]
+ detectedWidgets?: DetectedAndroidWidget[]
userImagesPath?: string
fonts?: string[]
+ scheme?: string
+ widgetConfigurationRoute?: string
}
/**
@@ -28,7 +32,14 @@ export interface GenerateAndroidWidgetFilesProps {
* This should run before configureAndroidManifest so the files exist when the manifest is configured.
*/
export const generateAndroidWidgetFiles: ConfigPlugin = (config, props) => {
- const { widgets, userImagesPath, fonts } = props
+ const {
+ widgets,
+ detectedWidgets: providedDetectedWidgets,
+ userImagesPath,
+ fonts,
+ scheme,
+ widgetConfigurationRoute,
+ } = props
return withDangerousMod(config, [
'android',
@@ -48,7 +59,7 @@ export const generateAndroidWidgetFiles: ConfigPlugin void } {
+ const platformProjectRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'voltra-android-kotlin-'))
+
+ return {
+ platformProjectRoot,
+ cleanup: () => fs.rmSync(platformProjectRoot, { recursive: true, force: true }),
+ }
+}
+
+describe('generateWidgetConfigurationActivities', () => {
+ it('writes a deep-link trampoline activity for configurable widgets', async () => {
+ const { platformProjectRoot, cleanup } = makeTempProject()
+
+ try {
+ await generateWidgetConfigurationActivities({
+ platformProjectRoot,
+ packageName: 'com.example.voltra',
+ scheme: 'exampleapp',
+ widgetConfigurationRoute: 'widget-config',
+ widgets: [
+ {
+ id: 'demo',
+ displayName: 'Demo',
+ description: 'Demo widget',
+ targetCellWidth: 2,
+ targetCellHeight: 2,
+ clientRendered: true,
+ appIntent: {
+ parameters: [{ name: 'label', title: 'Label', default: 'Hello' }],
+ },
+ },
+ {
+ id: 'server_only',
+ displayName: 'Server',
+ description: 'Server widget',
+ targetCellWidth: 2,
+ targetCellHeight: 2,
+ clientRendered: false,
+ appIntent: {
+ parameters: [{ name: 'label', title: 'Label', default: 'Hello' }],
+ },
+ },
+ ],
+ })
+
+ const filePath = path.join(
+ platformProjectRoot,
+ 'app',
+ 'src',
+ 'main',
+ 'java',
+ 'com',
+ 'example',
+ 'voltra',
+ 'widget',
+ 'VoltraWidget_demoConfigurationActivity.kt'
+ )
+
+ const content = fs.readFileSync(filePath, 'utf8')
+ expect(content).toContain('class VoltraWidget_demoConfigurationActivity : ReactActivity()')
+ expect(content).toContain('exampleapp://widget-config?widgetId=demo&appWidgetId=$appWidgetId')
+ expect(content).toContain('ReactActivityDelegateWrapper')
+ expect(content).toContain('BuildConfig.IS_NEW_ARCHITECTURE_ENABLED')
+ expect(
+ fs.existsSync(
+ path.join(
+ platformProjectRoot,
+ 'app',
+ 'src',
+ 'main',
+ 'java',
+ 'com',
+ 'example',
+ 'voltra',
+ 'widget',
+ 'VoltraWidget_server_onlyConfigurationActivity.kt'
+ )
+ )
+ ).toBe(false)
+ } finally {
+ cleanup()
+ }
+ })
+
+ it('uses the default route when widgetConfigurationRoute is omitted', async () => {
+ const { platformProjectRoot, cleanup } = makeTempProject()
+
+ try {
+ await generateWidgetConfigurationActivities({
+ platformProjectRoot,
+ packageName: 'com.example.voltra',
+ scheme: 'exampleapp',
+ widgets: [
+ {
+ id: 'demo',
+ displayName: 'Demo',
+ description: 'Demo widget',
+ targetCellWidth: 2,
+ targetCellHeight: 2,
+ clientRendered: true,
+ appIntent: {
+ parameters: [{ name: 'label', title: 'Label', default: 'Hello' }],
+ },
+ },
+ ],
+ })
+
+ const filePath = path.join(
+ platformProjectRoot,
+ 'app',
+ 'src',
+ 'main',
+ 'java',
+ 'com',
+ 'example',
+ 'voltra',
+ 'widget',
+ 'VoltraWidget_demoConfigurationActivity.kt'
+ )
+
+ const content = fs.readFileSync(filePath, 'utf8')
+ expect(content).toContain('exampleapp://voltraui/android-widget-config?widgetId=demo&appWidgetId=$appWidgetId')
+ } finally {
+ cleanup()
+ }
+ })
+})
diff --git a/packages/android-client/expo-plugin/src/android/files/kotlin.ts b/packages/android-client/expo-plugin/src/android/files/kotlin.ts
index 235ffe0f..3949f550 100644
--- a/packages/android-client/expo-plugin/src/android/files/kotlin.ts
+++ b/packages/android-client/expo-plugin/src/android/files/kotlin.ts
@@ -10,6 +10,8 @@ export interface GenerateKotlinFilesProps {
platformProjectRoot: string
packageName: string
widgets: DetectedAndroidWidget[]
+ scheme?: string
+ widgetConfigurationRoute?: string
}
// ============================================================================
@@ -41,6 +43,39 @@ export async function generateWidgetReceivers(props: GenerateKotlinFilesProps):
}
}
+/**
+ * Generates ReactActivity bridge classes for widgets that expose appIntent parameters.
+ *
+ * The generated activity is a thin trampoline that deep-links into the host app's JS route,
+ * where the example app can render a widget-configuration form and finish the activity once the
+ * instance settings are saved.
+ */
+export async function generateWidgetConfigurationActivities(props: GenerateKotlinFilesProps): Promise {
+ const { platformProjectRoot, packageName, widgets, scheme, widgetConfigurationRoute } = props
+ const configurableWidgets = widgets.filter(
+ (widget) => widget.clientRendered && (widget.appIntent?.parameters?.length ?? 0) > 0
+ )
+
+ if (configurableWidgets.length === 0) {
+ return
+ }
+
+ const packagePath = packageName.replace(/\./g, '/')
+ const widgetDir = path.join(platformProjectRoot, 'app', 'src', 'main', 'java', packagePath, 'widget')
+
+ if (!fs.existsSync(widgetDir)) {
+ fs.mkdirSync(widgetDir, { recursive: true })
+ }
+
+ for (const widget of configurableWidgets) {
+ const className = `VoltraWidget_${widget.id}ConfigurationActivity`
+ const filePath = path.join(widgetDir, `${className}.kt`)
+ const content = generateWidgetConfigurationActivityClass(widget, packageName, scheme, widgetConfigurationRoute)
+
+ fs.writeFileSync(filePath, content, 'utf8')
+ }
+}
+
// ============================================================================
// Widget Receiver
// ============================================================================
@@ -139,3 +174,112 @@ function generateWidgetReceiverClass(widget: DetectedAndroidWidget, packageName:
}
`
}
+
+function generateWidgetConfigurationActivityClass(
+ widget: DetectedAndroidWidget,
+ packageName: string,
+ scheme?: string,
+ widgetConfigurationRoute?: string
+): string {
+ const className = `VoltraWidget_${widget.id}ConfigurationActivity`
+ const labelForComment = widgetLabelEnglish(widget.displayName)
+ const widgetConfigScheme = escapeKotlinString(normalizeWidgetConfigurationScheme(scheme ?? packageName))
+ const widgetConfigRoute = escapeKotlinString(normalizeWidgetConfigurationRoute(widgetConfigurationRoute))
+ const widgetId = escapeKotlinString(widget.id)
+
+ return dedent`
+ package ${packageName}.widget
+
+ import android.app.Activity
+ import android.appwidget.AppWidgetManager
+ import android.content.Intent
+ import android.net.Uri
+ import android.os.Bundle
+ import com.facebook.react.ReactActivity
+ import com.facebook.react.ReactActivityDelegate
+ import com.facebook.react.defaults.DefaultNewArchitectureEntryPoint.fabricEnabled
+ import com.facebook.react.defaults.DefaultReactActivityDelegate
+ import expo.modules.ReactActivityDelegateWrapper
+ import ${packageName}.BuildConfig
+
+ /**
+ * Auto-generated widget configuration trampoline for ${labelForComment}
+ * Widget ID: ${widget.id}
+ */
+ class ${className} : ReactActivity() {
+ override fun getMainComponentName(): String = "main"
+
+ override fun createReactActivityDelegate(): ReactActivityDelegate {
+ return ReactActivityDelegateWrapper(
+ this,
+ BuildConfig.IS_NEW_ARCHITECTURE_ENABLED,
+ object : DefaultReactActivityDelegate(
+ this,
+ mainComponentName,
+ fabricEnabled,
+ ) {},
+ )
+ }
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ val appWidgetId =
+ intent?.getIntExtra(
+ AppWidgetManager.EXTRA_APPWIDGET_ID,
+ AppWidgetManager.INVALID_APPWIDGET_ID,
+ ) ?: AppWidgetManager.INVALID_APPWIDGET_ID
+
+ val canceledResult =
+ Intent().apply {
+ putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, appWidgetId)
+ }
+
+ if (appWidgetId == AppWidgetManager.INVALID_APPWIDGET_ID) {
+ setResult(Activity.RESULT_CANCELED, canceledResult)
+ finish()
+ return
+ }
+
+ val configRoute =
+ Uri.parse(
+ "${widgetConfigScheme}://${widgetConfigRoute}?widgetId=${widgetId}&appWidgetId=$appWidgetId",
+ )
+
+ setResult(Activity.RESULT_CANCELED, canceledResult)
+ setIntent(Intent(intent).apply {
+ data = configRoute
+ })
+
+ super.onCreate(null)
+ }
+ }
+ `
+}
+
+function normalizeWidgetConfigurationRoute(route?: string): string {
+ const trimmed = route?.trim() || 'voltraui/android-widget-config'
+
+ const normalized = trimmed.replace(/^\/+/, '').replace(/\/+$/, '')
+ if (!normalized) {
+ throw new Error('widgetConfigurationRoute must not be empty')
+ }
+
+ if (!/^[A-Za-z0-9._/-]+$/.test(normalized)) {
+ throw new Error(
+ `widgetConfigurationRoute must be a route path made of letters, numbers, periods, underscores, slashes, or hyphens. Got: ${route}`
+ )
+ }
+
+ return normalized
+}
+
+function normalizeWidgetConfigurationScheme(scheme: string): string {
+ if (!/^[A-Za-z][A-Za-z0-9+.-]*$/.test(scheme)) {
+ throw new Error(`Invalid widget configuration scheme: ${scheme}`)
+ }
+
+ return scheme
+}
+
+function escapeKotlinString(value: string): string {
+ return value.replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\$/g, '\\$')
+}
diff --git a/packages/android-client/expo-plugin/src/android/files/xml.node.test.ts b/packages/android-client/expo-plugin/src/android/files/xml.node.test.ts
index 78ef6765..552ef5a9 100644
--- a/packages/android-client/expo-plugin/src/android/files/xml.node.test.ts
+++ b/packages/android-client/expo-plugin/src/android/files/xml.node.test.ts
@@ -1,4 +1,17 @@
-import { __test__ } from './xml'
+import * as fs from 'fs'
+import * as os from 'os'
+import * as path from 'path'
+
+import { generateWidgetPreviewLayouts, __test__ } from './xml'
+
+function makeTempProject(): { platformProjectRoot: string; cleanup: () => void } {
+ const platformProjectRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'voltra-android-xml-'))
+
+ return {
+ platformProjectRoot,
+ cleanup: () => fs.rmSync(platformProjectRoot, { recursive: true, force: true }),
+ }
+}
describe('localeKeyToAndroidValuesQualifier', () => {
it('maps plain language tags to classic Android qualifiers', () => {
@@ -16,3 +29,61 @@ describe('localeKeyToAndroidValuesQualifier', () => {
expect(__test__.localeKeyToAndroidValuesQualifier('sr-Latn-RS')).toBe('b+sr+Latn+RS')
})
})
+
+describe('generateWidgetPreviewLayouts', () => {
+ it('adds a configure activity only for client-rendered widgets with appIntent parameters', async () => {
+ const { platformProjectRoot, cleanup } = makeTempProject()
+
+ try {
+ fs.mkdirSync(path.join(platformProjectRoot, 'app', 'src', 'main', 'res', 'xml'), { recursive: true })
+
+ await generateWidgetPreviewLayouts({
+ platformProjectRoot,
+ projectRoot: platformProjectRoot,
+ packageName: 'com.example.voltra',
+ widgets: [
+ {
+ id: 'demo',
+ displayName: 'Demo',
+ description: 'Demo widget',
+ targetCellWidth: 2,
+ targetCellHeight: 2,
+ clientRendered: true,
+ appIntent: {
+ parameters: [{ name: 'label', title: 'Label', default: 'Hello' }],
+ },
+ },
+ {
+ id: 'server_only',
+ displayName: 'Server',
+ description: 'Server widget',
+ targetCellWidth: 2,
+ targetCellHeight: 2,
+ clientRendered: false,
+ appIntent: {
+ parameters: [{ name: 'label', title: 'Label', default: 'Hello' }],
+ },
+ },
+ ],
+ previewImageMap: new Map(),
+ })
+
+ const xmlPath = path.join(platformProjectRoot, 'app', 'src', 'main', 'res', 'xml', 'voltra_widget_demo_info.xml')
+ expect(fs.readFileSync(xmlPath, 'utf8')).toContain(
+ 'android:configure="com.example.voltra.widget.VoltraWidget_demoConfigurationActivity"'
+ )
+ const serverXmlPath = path.join(
+ platformProjectRoot,
+ 'app',
+ 'src',
+ 'main',
+ 'res',
+ 'xml',
+ 'voltra_widget_server_only_info.xml'
+ )
+ expect(fs.readFileSync(serverXmlPath, 'utf8')).not.toContain('android:configure=')
+ } finally {
+ cleanup()
+ }
+ })
+})
diff --git a/packages/android-client/expo-plugin/src/android/files/xml.ts b/packages/android-client/expo-plugin/src/android/files/xml.ts
index bd67a558..809efc9c 100644
--- a/packages/android-client/expo-plugin/src/android/files/xml.ts
+++ b/packages/android-client/expo-plugin/src/android/files/xml.ts
@@ -5,12 +5,14 @@ import * as path from 'path'
import { isWidgetLocalizedMap, logger, widgetLabelEnglish } from '@use-voltra/expo-plugin'
import type { AndroidWidgetConfig } from '../../types'
+import type { DetectedAndroidWidget } from '../clientRendered'
import { androidWidgetResourceId } from '../resourceName'
export interface GenerateXmlFilesProps {
platformProjectRoot: string
projectRoot: string
- widgets: AndroidWidgetConfig[]
+ packageName: string
+ widgets: DetectedAndroidWidget[]
previewImageMap: Map
}
@@ -83,7 +85,7 @@ export async function generateWidgetPlaceholderLayouts(props: { platformProjectR
* Generates preview layouts for widgets
*/
export async function generateWidgetPreviewLayouts(props: GenerateXmlFilesProps): Promise {
- const { platformProjectRoot, projectRoot, widgets, previewImageMap } = props
+ const { platformProjectRoot, projectRoot, packageName, widgets, previewImageMap } = props
const layoutPath = path.join(platformProjectRoot, 'app', 'src', 'main', 'res', 'layout')
const xmlPath = path.join(platformProjectRoot, 'app', 'src', 'main', 'res', 'xml')
@@ -99,7 +101,15 @@ export async function generateWidgetPreviewLayouts(props: GenerateXmlFilesProps)
const widgetInfoPath = path.join(xmlPath, `voltra_widget_${androidWidgetResourceId(widget.id)}_info.xml`)
const previewImageResourceName = previewImageMap.get(widget.id)
const previewLayoutResourceName = previewLayoutMap.get(widget.id)
- const widgetInfoContent = generateWidgetInfoXml(widget, previewImageResourceName, previewLayoutResourceName)
+ const configActivityClassName = hasWidgetConfiguration(widget)
+ ? `${packageName}.widget.VoltraWidget_${widget.id}ConfigurationActivity`
+ : undefined
+ const widgetInfoContent = generateWidgetInfoXml(
+ widget,
+ previewImageResourceName,
+ previewLayoutResourceName,
+ configActivityClassName
+ )
fs.writeFileSync(widgetInfoPath, widgetInfoContent, 'utf8')
}
}
@@ -114,7 +124,8 @@ export async function generateWidgetPreviewLayouts(props: GenerateXmlFilesProps)
function generateWidgetInfoXml(
widget: AndroidWidgetConfig,
previewImageResourceName?: string,
- previewLayoutResourceName?: string
+ previewLayoutResourceName?: string,
+ configureActivityName?: string
): string {
const { targetCellWidth, targetCellHeight } = widget
const resizeMode = widget.resizeMode || 'horizontal|vertical'
@@ -138,6 +149,7 @@ function generateWidgetInfoXml(
const previewLayoutAttr = previewLayoutResourceName
? `\n android:previewLayout="@layout/${previewLayoutResourceName}"`
: ''
+ const configureAttr = configureActivityName ? `\n android:configure="${configureActivityName}"` : ''
return dedent`
@@ -150,7 +162,7 @@ function generateWidgetInfoXml(
android:widgetCategory="${widgetCategory}"
android:description="@string/voltra_widget_${androidWidgetResourceId(
widget.id
- )}_description"${previewImageAttr}${previewLayoutAttr}>
+ )}_description"${previewImageAttr}${previewLayoutAttr}${configureAttr}>
`
}
@@ -247,6 +259,10 @@ function collectAndroidLocaleKeysFromWidgets(widgets: AndroidWidgetConfig[]): Se
return locales
}
+function hasWidgetConfiguration(widget: DetectedAndroidWidget): boolean {
+ return widget.clientRendered && (widget.appIntent?.parameters?.length ?? 0) > 0
+}
+
function resolveAndroidWidgetLabel(
widget: AndroidWidgetConfig,
field: 'displayName' | 'description',
diff --git a/packages/android-client/expo-plugin/src/android/index.ts b/packages/android-client/expo-plugin/src/android/index.ts
index 6be478f6..c05c358b 100644
--- a/packages/android-client/expo-plugin/src/android/index.ts
+++ b/packages/android-client/expo-plugin/src/android/index.ts
@@ -1,5 +1,7 @@
import { ConfigPlugin, withPlugins } from '@expo/config-plugins'
+import { detectClientRenderedWidgets } from './clientRendered'
+import type { DetectedAndroidWidget } from './clientRendered'
import type { AndroidPluginProps } from '../types'
import { validateAndroidWidgetConfig } from '../validation'
import { generateAndroidWidgetFiles } from './files'
@@ -10,7 +12,7 @@ import { configureAndroidManifest } from './manifest'
* Orchestrates Android widget file generation and AndroidManifest configuration.
*/
export const withAndroid: ConfigPlugin = (config, props) => {
- const { enableNotifications, widgets, userImagesPath, fonts } = props
+ const { enableNotifications, widgets, userImagesPath, fonts, scheme, widgetConfigurationRoute } = props
if (!config.android?.package) {
throw new Error(
@@ -19,12 +21,20 @@ export const withAndroid: ConfigPlugin = (config, props) =>
}
const projectRoot = (config as { modRequest?: { projectRoot?: string } }).modRequest?.projectRoot
+ const detectedWidgets = projectRoot ? detectClientRenderedWidgets(widgets, projectRoot) : undefined
+ const manifestWidgets: DetectedAndroidWidget[] =
+ detectedWidgets ??
+ widgets.map((widget) =>
+ widget.entry === undefined
+ ? { ...widget, clientRendered: false as const }
+ : { ...widget, clientRendered: true as const, clientSourcePath: widget.entry }
+ )
widgets.forEach((widget) => validateAndroidWidgetConfig(widget, projectRoot))
return withPlugins(config, [
- [generateAndroidWidgetFiles, { widgets, userImagesPath, fonts }],
- [configureAndroidManifest, { enableNotifications, widgets }],
+ [generateAndroidWidgetFiles, { widgets, detectedWidgets, userImagesPath, fonts, scheme, widgetConfigurationRoute }],
+ [configureAndroidManifest, { enableNotifications, widgets: manifestWidgets }],
[withWidgetBundleGradle, { widgets }],
])
}
diff --git a/packages/android-client/expo-plugin/src/android/manifest.node.test.ts b/packages/android-client/expo-plugin/src/android/manifest.node.test.ts
new file mode 100644
index 00000000..c116f610
--- /dev/null
+++ b/packages/android-client/expo-plugin/src/android/manifest.node.test.ts
@@ -0,0 +1,58 @@
+import { upsertAndroidWidgetManifestEntries } from './manifest'
+
+describe('upsertAndroidWidgetManifestEntries', () => {
+ it('adds a configuration activity only for client-rendered widgets', () => {
+ const application = { $: {}, activity: [], receiver: [] } as any
+ upsertAndroidWidgetManifestEntries(application, 'com.example.voltra', [
+ {
+ id: 'demo',
+ displayName: 'Demo',
+ description: 'Demo widget',
+ targetCellWidth: 2,
+ targetCellHeight: 2,
+ clientRendered: true,
+ appIntent: {
+ parameters: [{ name: 'label', title: 'Label', default: 'Hello' }],
+ },
+ },
+ {
+ id: 'server_only',
+ displayName: 'Server',
+ description: 'Server widget',
+ targetCellWidth: 2,
+ targetCellHeight: 2,
+ clientRendered: false,
+ appIntent: {
+ parameters: [{ name: 'label', title: 'Label', default: 'Hello' }],
+ },
+ },
+ ])
+
+ const activities = application.activity ?? []
+
+ expect(activities).toEqual(
+ expect.arrayContaining([
+ expect.objectContaining({
+ $: expect.objectContaining({
+ 'android:name': 'com.example.voltra.widget.VoltraWidget_demoConfigurationActivity',
+ 'android:exported': 'true',
+ 'android:theme': '@android:style/Theme.Translucent.NoTitleBar',
+ 'android:noHistory': 'true',
+ }),
+ 'intent-filter': expect.arrayContaining([
+ expect.objectContaining({
+ action: expect.arrayContaining([
+ expect.objectContaining({
+ $: expect.objectContaining({
+ 'android:name': 'android.appwidget.action.APPWIDGET_CONFIGURE',
+ }),
+ }),
+ ]),
+ }),
+ ]),
+ }),
+ ])
+ )
+ expect(activities).toHaveLength(1)
+ })
+})
diff --git a/packages/android-client/expo-plugin/src/android/manifest.ts b/packages/android-client/expo-plugin/src/android/manifest.ts
index 4f45b15c..ef7ad9fd 100644
--- a/packages/android-client/expo-plugin/src/android/manifest.ts
+++ b/packages/android-client/expo-plugin/src/android/manifest.ts
@@ -1,12 +1,101 @@
import { ConfigPlugin, withAndroidManifest } from '@expo/config-plugins'
import { AndroidConfig } from 'expo/config-plugins'
-import type { AndroidWidgetConfig } from '../types'
+import type { DetectedAndroidWidget } from './clientRendered'
import { androidWidgetResourceId } from './resourceName'
export interface ConfigureAndroidManifestProps {
enableNotifications?: boolean
- widgets: AndroidWidgetConfig[]
+ widgets: DetectedAndroidWidget[]
+}
+
+export function upsertAndroidWidgetManifestEntries(
+ mainApplication: any,
+ packageName: string,
+ widgets: DetectedAndroidWidget[]
+): void {
+ const existingReceivers = (mainApplication.receiver || []) as any[]
+ const existingActivities = (mainApplication.activity || []) as any[]
+
+ // Add a receiver for each widget
+ for (const widget of widgets) {
+ const receiverClassName = `.widget.VoltraWidget_${widget.id}Receiver`
+ const resId = androidWidgetResourceId(widget.id)
+ const hasConfiguration = widget.clientRendered && (widget.appIntent?.parameters?.length ?? 0) > 0
+
+ // Check if receiver already exists
+ const alreadyExists = existingReceivers.some((receiver: any) => receiver.$?.['android:name'] === receiverClassName)
+
+ if (!alreadyExists) {
+ // Create the receiver entry
+ const receiver = {
+ $: {
+ 'android:name': receiverClassName,
+ 'android:exported': 'true' as const,
+ 'android:label': `@string/voltra_widget_${resId}_label`,
+ },
+ 'intent-filter': [
+ {
+ action: [
+ {
+ $: {
+ 'android:name': 'android.appwidget.action.APPWIDGET_UPDATE',
+ },
+ },
+ ],
+ },
+ ],
+ 'meta-data': [
+ {
+ $: {
+ 'android:name': 'android.appwidget.provider',
+ 'android:resource': `@xml/voltra_widget_${resId}_info`,
+ },
+ },
+ ],
+ }
+
+ // Add the receiver to the application
+ if (!mainApplication.receiver) {
+ mainApplication.receiver = []
+ }
+ mainApplication.receiver.push(receiver)
+ }
+
+ if (hasConfiguration) {
+ const activityClassName = `${packageName}.widget.VoltraWidget_${widget.id}ConfigurationActivity`
+ const legacyActivityClassName = `.widget.VoltraWidget_${widget.id}ConfigurationActivity`
+ const activityAlreadyExists = existingActivities.some(
+ (activity: any) =>
+ activity.$?.['android:name'] === activityClassName || activity.$?.['android:name'] === legacyActivityClassName
+ )
+
+ if (!activityAlreadyExists) {
+ if (!mainApplication.activity) {
+ mainApplication.activity = []
+ }
+ mainApplication.activity.push({
+ $: {
+ 'android:name': activityClassName,
+ 'android:exported': 'true',
+ 'android:theme': '@android:style/Theme.Translucent.NoTitleBar',
+ 'android:noHistory': 'true',
+ },
+ 'intent-filter': [
+ {
+ action: [
+ {
+ $: {
+ 'android:name': 'android.appwidget.action.APPWIDGET_CONFIGURE',
+ },
+ },
+ ],
+ },
+ ],
+ })
+ }
+ }
+ }
}
/**
@@ -22,8 +111,14 @@ export const configureAndroidManifest: ConfigPlugin {
const mainApplication = AndroidConfig.Manifest.getMainApplicationOrThrow(config.modResults) as any
+ const packageName = config.android?.package
+
+ if (!packageName) {
+ throw new Error('Voltra Android manifest generation requires expo.android.package to be set.')
+ }
const usesPermissions = (config.modResults.manifest['uses-permission'] || []) as any[]
+ const existingReceivers = (mainApplication.receiver || []) as any[]
const ensurePermission = (permissionName: string) => {
const exists = usesPermissions.some((permission) => permission.$?.['android:name'] === permissionName)
if (!exists) {
@@ -42,7 +137,6 @@ export const configureAndroidManifest: ConfigPlugin receiver.$?.['android:name'] === receiverClassName
- )
-
- if (!alreadyExists) {
- // Create the receiver entry
- const receiver = {
- $: {
- 'android:name': receiverClassName,
- 'android:exported': 'true' as const,
- 'android:label': `@string/voltra_widget_${resId}_label`,
- },
- 'intent-filter': [
- {
- action: [
- {
- $: {
- 'android:name': 'android.appwidget.action.APPWIDGET_UPDATE',
- },
- },
- ],
- },
- ],
- 'meta-data': [
- {
- $: {
- 'android:name': 'android.appwidget.provider',
- 'android:resource': `@xml/voltra_widget_${resId}_info`,
- },
- },
- ],
- }
-
- // Add the receiver to the application
- if (!mainApplication.receiver) {
- mainApplication.receiver = []
- }
- mainApplication.receiver.push(receiver)
- }
- }
+ upsertAndroidWidgetManifestEntries(mainApplication, packageName, widgets)
return config
})
diff --git a/packages/android-client/expo-plugin/src/index.ts b/packages/android-client/expo-plugin/src/index.ts
index 860dfa0a..467eae57 100644
--- a/packages/android-client/expo-plugin/src/index.ts
+++ b/packages/android-client/expo-plugin/src/index.ts
@@ -3,6 +3,28 @@ import { generateAndroidDynamicWidgetsManifest } from './android/files/manifest'
import type { VoltraAndroidConfigPlugin } from './types'
import { validateAndroidConfigPluginProps } from './validation'
+function resolveScheme(config: unknown): string | undefined {
+ const expoConfig = config as { scheme?: unknown; android?: { package?: unknown } }
+ const rawScheme = expoConfig.scheme
+
+ if (Array.isArray(rawScheme)) {
+ const scheme = rawScheme.find((value) => typeof value === 'string' && value.trim())
+ if (typeof scheme === 'string') {
+ return scheme.trim()
+ }
+ }
+
+ if (typeof rawScheme === 'string' && rawScheme.trim()) {
+ return rawScheme.trim()
+ }
+
+ if (typeof expoConfig.android?.package === 'string' && expoConfig.android.package.trim()) {
+ return expoConfig.android.package.trim()
+ }
+
+ return undefined
+}
+
/**
* Voltra Android Expo config plugin.
*
@@ -13,6 +35,12 @@ const withVoltraAndroid: VoltraAndroidConfigPlugin = (config, props = {}) => {
validateAndroidConfigPluginProps(props, projectRoot)
const widgets = props.widgets ?? []
+ const scheme = resolveScheme(config)
+
+ if (scheme && !(config as { scheme?: unknown }).scheme) {
+ ;(config as { scheme?: string }).scheme = scheme
+ }
+
config = generateAndroidDynamicWidgetsManifest(config, { widgets })
if (!config.android?.package) {
@@ -27,6 +55,8 @@ const withVoltraAndroid: VoltraAndroidConfigPlugin = (config, props = {}) => {
enableNotifications: props.enableNotifications,
widgets,
...(props.fonts ? { fonts: props.fonts } : {}),
+ ...(scheme ? { scheme } : {}),
+ ...(props.widgetConfigurationRoute ? { widgetConfigurationRoute: props.widgetConfigurationRoute } : {}),
})
}
diff --git a/packages/android-client/expo-plugin/src/types.ts b/packages/android-client/expo-plugin/src/types.ts
index 31e483c4..7d0199cb 100644
--- a/packages/android-client/expo-plugin/src/types.ts
+++ b/packages/android-client/expo-plugin/src/types.ts
@@ -18,6 +18,10 @@ export interface AndroidWidgetAppIntentConfig {
parameters: AppIntentParameter[]
}
+export interface AndroidWidgetConfigurationConfig {
+ deepLink: string
+}
+
/**
* Configuration for a single Android home screen widget.
*
@@ -46,6 +50,10 @@ export interface AndroidWidgetConfig extends DynamicWidgetEntryConfig {
* it before any runtime configuration; runtime values (set via `setWidgetConfiguration`) override.
*/
appIntent?: AndroidWidgetAppIntentConfig
+ /**
+ * Android-only widget configuration entrypoint. Supported only for Dynamic Widgets (`entry`).
+ */
+ configuration?: AndroidWidgetConfigurationConfig
}
/**
@@ -66,6 +74,11 @@ export interface AndroidConfigPluginProps {
enableNotifications?: boolean
widgets?: AndroidWidgetConfig[]
fonts?: string[]
+ /**
+ * Route path used by generated widget configuration trampolines.
+ * Defaults to `voltraui/android-widget-config` to match the example app.
+ */
+ widgetConfigurationRoute?: string
}
export type VoltraAndroidConfigPlugin = ConfigPlugin
@@ -75,4 +88,10 @@ export interface AndroidPluginProps {
widgets: AndroidWidgetConfig[]
userImagesPath?: string
fonts?: string[]
+ /**
+ * Deep-link scheme used by generated widget configuration trampolines.
+ * When omitted, the plugin derives one from the Expo app config.
+ */
+ scheme?: string
+ widgetConfigurationRoute?: string
}
diff --git a/packages/android-client/expo-plugin/src/validation.node.test.ts b/packages/android-client/expo-plugin/src/validation.node.test.ts
index 4dfdfa4e..51266ee9 100644
--- a/packages/android-client/expo-plugin/src/validation.node.test.ts
+++ b/packages/android-client/expo-plugin/src/validation.node.test.ts
@@ -2,6 +2,8 @@ import * as fs from 'fs'
import * as os from 'os'
import * as path from 'path'
+import { logger } from '@use-voltra/expo-plugin'
+
import { validateAndroidConfigPluginProps } from './validation'
function makeTempProject(files: Record): { projectRoot: string; cleanup: () => void } {
@@ -78,6 +80,116 @@ describe('validateAndroidConfigPluginProps', () => {
).not.toThrow()
})
+ it('accepts Dynamic Widget configuration with deepLink', () => {
+ expect(() =>
+ validateAndroidConfigPluginProps({
+ widgets: [
+ {
+ id: 'demo',
+ entry: './widgets/demo.tsx',
+ displayName: 'Demo',
+ description: 'Demo widget',
+ targetCellWidth: 2,
+ targetCellHeight: 2,
+ appIntent: {
+ parameters: [{ name: 'city' }],
+ },
+ configuration: {
+ deepLink: 'voltra://widget-config',
+ },
+ },
+ ],
+ })
+ ).not.toThrow()
+ })
+
+ it('rejects legacy widget configuration without an entry', () => {
+ expect(() =>
+ validateAndroidConfigPluginProps({
+ widgets: [
+ {
+ id: 'demo',
+ displayName: 'Demo',
+ description: 'Demo widget',
+ targetCellWidth: 2,
+ targetCellHeight: 2,
+ configuration: {
+ deepLink: 'voltra://widget-config',
+ },
+ },
+ ],
+ })
+ ).toThrow(/configuration is supported only for Dynamic Widgets with entry/)
+ })
+
+ it.each([[null], [[]], [123]])('rejects malformed configuration value: %p', (configuration) => {
+ expect(() =>
+ validateAndroidConfigPluginProps({
+ widgets: [
+ {
+ id: 'demo',
+ entry: './widgets/demo.tsx',
+ displayName: 'Demo',
+ description: 'Demo widget',
+ targetCellWidth: 2,
+ targetCellHeight: 2,
+ configuration: configuration as never,
+ },
+ ],
+ })
+ ).toThrow(/configuration must be an object with deepLink/)
+ })
+
+ it.each([[''], [' '], [123 as unknown as string]])('rejects invalid configuration.deepLink: %p', (deepLink) => {
+ expect(() =>
+ validateAndroidConfigPluginProps({
+ widgets: [
+ {
+ id: 'demo',
+ entry: './widgets/demo.tsx',
+ displayName: 'Demo',
+ description: 'Demo widget',
+ targetCellWidth: 2,
+ targetCellHeight: 2,
+ configuration: {
+ deepLink,
+ },
+ },
+ ],
+ })
+ ).toThrow(/configuration\.deepLink must be a non-empty string/)
+ })
+
+ it('warns when configuration.deepLink is set without appIntent parameters', () => {
+ const warnSpy = jest.spyOn(logger, 'warn').mockImplementation(() => undefined)
+
+ try {
+ expect(() =>
+ validateAndroidConfigPluginProps({
+ widgets: [
+ {
+ id: 'demo',
+ entry: './widgets/demo.tsx',
+ displayName: 'Demo',
+ description: 'Demo widget',
+ targetCellWidth: 2,
+ targetCellHeight: 2,
+ configuration: {
+ deepLink: 'voltra://widget-config',
+ },
+ },
+ ],
+ })
+ ).not.toThrow()
+ expect(warnSpy).toHaveBeenCalledTimes(1)
+ expect(warnSpy).toHaveBeenCalledWith(
+ expect.stringContaining('configuration.deepLink is set but appIntent.parameters is missing or empty')
+ )
+ } finally {
+ warnSpy.mockRestore()
+ }
+ })
+
it('requires a valid entry when present and normalizes it against the project root', () => {
const { projectRoot, cleanup } = makeTempProject({
'widgets/demo.tsx': 'export default () => null\n',
diff --git a/packages/android-client/expo-plugin/src/validation.ts b/packages/android-client/expo-plugin/src/validation.ts
index 9140ad50..dc2ca722 100644
--- a/packages/android-client/expo-plugin/src/validation.ts
+++ b/packages/android-client/expo-plugin/src/validation.ts
@@ -2,6 +2,7 @@ import * as fs from 'fs'
import * as path from 'path'
import {
+ logger,
validateHomeScreenWidgetId,
validateInitialStatePath,
validateWidgetEntry,
@@ -20,6 +21,8 @@ export function validateAndroidWidgetConfig(widget: AndroidWidgetConfig, project
validateWidgetEntry(widget.entry, widget.id, projectRoot)
}
+ validateAndroidWidgetConfiguration(widget)
+
if (typeof widget.targetCellWidth !== 'number') {
throw new Error(`Widget '${widget.id}': targetCellWidth is required and must be a number`)
}
@@ -88,6 +91,31 @@ export function validateAndroidWidgetConfig(widget: AndroidWidgetConfig, project
}
}
+function validateAndroidWidgetConfiguration(widget: AndroidWidgetConfig): void {
+ const { configuration } = widget
+ if (configuration === undefined) {
+ return
+ }
+
+ if (configuration === null || typeof configuration !== 'object' || Array.isArray(configuration)) {
+ throw new Error(`Widget '${widget.id}': configuration must be an object with deepLink`)
+ }
+
+ if (widget.entry === undefined) {
+ throw new Error(`Widget '${widget.id}': configuration is supported only for Dynamic Widgets with entry`)
+ }
+
+ if (typeof configuration.deepLink !== 'string' || !configuration.deepLink.trim()) {
+ throw new Error(`Widget '${widget.id}': configuration.deepLink must be a non-empty string`)
+ }
+
+ if ((widget.appIntent?.parameters?.length ?? 0) === 0) {
+ logger.warn(
+ `Widget '${widget.id}': configuration.deepLink is set but appIntent.parameters is missing or empty; widget configuration UI will have no declared parameters.`
+ )
+ }
+}
+
export function validateAndroidConfigPluginProps(props: AndroidConfigPluginProps, projectRoot?: string): void {
if (props.enableNotifications !== undefined && typeof props.enableNotifications !== 'boolean') {
throw new Error('enableNotifications must be a boolean')
diff --git a/packages/android-client/src/index.ts b/packages/android-client/src/index.ts
index 9f9ef0e0..cbd64af3 100644
--- a/packages/android-client/src/index.ts
+++ b/packages/android-client/src/index.ts
@@ -50,7 +50,10 @@ export {
getActiveWidgets,
reloadAndroidWidgets,
requestPinAndroidWidget,
+ cancelWidgetConfiguration,
+ completeWidgetConfiguration,
setWidgetConfiguration,
+ setWidgetInstanceConfiguration,
updateAndroidWidget,
} from './widgets/api.js'
export type {
diff --git a/packages/android-client/src/native/NativeVoltraAndroid.ts b/packages/android-client/src/native/NativeVoltraAndroid.ts
index 118a3d6d..c5045362 100644
--- a/packages/android-client/src/native/NativeVoltraAndroid.ts
+++ b/packages/android-client/src/native/NativeVoltraAndroid.ts
@@ -94,6 +94,9 @@ export interface Spec extends TurboModule {
updateAndroidWidget(widgetId: string, jsonString: string, options?: Readonly<{ deepLinkUrl?: string }>): Promise
reloadAndroidWidgets(widgetIds?: string[] | null): Promise
setWidgetConfiguration(widgetId: string, key: string, value: string): Promise
+ setWidgetInstanceConfiguration(appWidgetId: number, key: string, value: string): Promise
+ completeWidgetConfiguration(appWidgetId: number): Promise
+ cancelWidgetConfiguration(): Promise
clearAndroidWidget(widgetId: string): Promise
clearAllAndroidWidgets(): Promise
requestPinGlanceAppWidget(widgetId: string, options?: RequestPinGlanceAppWidgetOptionsSpec): Promise
diff --git a/packages/android-client/src/widgets/api.ts b/packages/android-client/src/widgets/api.ts
index 256ff5a2..9ccebb86 100644
--- a/packages/android-client/src/widgets/api.ts
+++ b/packages/android-client/src/widgets/api.ts
@@ -51,10 +51,38 @@ export const getActiveWidgets = async (): Promise => {
}
/**
- * Set a configuration value for a Dynamic Widget and re-render it. The value is
+ * Set a widget-type configuration value for a Dynamic Widget and re-render it. The value is
* surfaced as `env.configuration[key]` in the widget's `(props, env) => JSX` render. Stand-in
* for a Glance configuration activity.
*/
export const setWidgetConfiguration = async (widgetId: string, key: string, value: string): Promise => {
return getNativeVoltraAndroid().setWidgetConfiguration(widgetId, key, value)
}
+
+/**
+ * Set a configuration value for one placed Android widget instance.
+ *
+ * The instance is identified by its `appWidgetId`, which lets multiple widgets of the same type
+ * keep different configuration values.
+ */
+export const setWidgetInstanceConfiguration = async (
+ appWidgetId: number,
+ key: string,
+ value: string
+): Promise => {
+ return getNativeVoltraAndroid().setWidgetInstanceConfiguration(appWidgetId, key, value)
+}
+
+/**
+ * Finish a widget configuration activity after the app has saved the instance settings.
+ */
+export const completeWidgetConfiguration = async (appWidgetId: number): Promise => {
+ return getNativeVoltraAndroid().completeWidgetConfiguration(appWidgetId)
+}
+
+/**
+ * Cancel a widget configuration activity without saving changes.
+ */
+export const cancelWidgetConfiguration = async (): Promise => {
+ return getNativeVoltraAndroid().cancelWidgetConfiguration()
+}
diff --git a/packages/cli/src/config/normalize.ts b/packages/cli/src/config/normalize.ts
index 0a4a021b..bdfee29c 100644
--- a/packages/cli/src/config/normalize.ts
+++ b/packages/cli/src/config/normalize.ts
@@ -1,6 +1,7 @@
import path from 'node:path'
import { resolveFromRoot } from '../fs/path'
+import { formatWarning } from '../reporting/summary'
import { CLI_DEFAULTS } from './defaults'
import type {
@@ -329,6 +330,41 @@ function normalizeServerUpdate(
}
}
+function normalizeAndroidWidgetConfiguration(
+ widget: Pick,
+ appIntent: AndroidWidgetAppIntentConfig | undefined,
+ context: string
+): AndroidWidgetConfig['configuration'] {
+ const { configuration } = widget
+ if (configuration === undefined) {
+ return undefined
+ }
+
+ if (configuration === null || typeof configuration !== 'object' || Array.isArray(configuration)) {
+ throw new VoltraConfigNormalizationError(`${context}.configuration must be an object with deepLink`)
+ }
+
+ if (widget.entry === undefined) {
+ throw new VoltraConfigNormalizationError(
+ `${context}.configuration is supported only for Dynamic Widgets with entry`
+ )
+ }
+
+ assertNonEmptyString(configuration.deepLink, `${context}.configuration.deepLink`)
+
+ if ((appIntent?.parameters?.length ?? 0) === 0) {
+ console.warn(
+ formatWarning(
+ `android widget '${widget.id}' has configuration.deepLink set but appIntent.parameters is missing or empty; widget configuration UI will have no declared parameters.`
+ )
+ )
+ }
+
+ return {
+ deepLink: configuration.deepLink,
+ }
+}
+
function normalizeAndroidWidget(projectRoot: string, widget: AndroidWidgetConfig): NormalizedAndroidWidgetConfig {
assertObject(widget, 'android.widgets[]')
assertNonEmptyString(widget.id, 'android.widgets[].id')
@@ -338,11 +374,14 @@ function normalizeAndroidWidget(projectRoot: string, widget: AndroidWidgetConfig
assertOptionalPositiveInteger(widget.minCellWidth, `android.widgets[${widget.id}].minCellWidth`)
assertOptionalPositiveInteger(widget.minCellHeight, `android.widgets[${widget.id}].minCellHeight`)
+ const entry = normalizeOptionalWidgetEntry(widget.entry, `android.widgets[${widget.id}].entry`)
+ const appIntent = normalizeAndroidAppIntent(widget.appIntent, `android.widgets[${widget.id}].appIntent`)
+
return {
...widget,
displayName: normalizeLabel(widget.displayName, `android.widgets[${widget.id}].displayName`),
description: normalizeLabel(widget.description, `android.widgets[${widget.id}].description`),
- entry: normalizeOptionalWidgetEntry(widget.entry, `android.widgets[${widget.id}].entry`),
+ entry,
initialStatePath: normalizeInitialStatePath(
projectRoot,
widget.initialStatePath,
@@ -350,7 +389,12 @@ function normalizeAndroidWidget(projectRoot: string, widget: AndroidWidgetConfig
),
previewImage: resolveOptionalPathFromProjectRoot(projectRoot, widget.previewImage),
previewLayout: resolveOptionalPathFromProjectRoot(projectRoot, widget.previewLayout),
- appIntent: normalizeAndroidAppIntent(widget.appIntent, `android.widgets[${widget.id}].appIntent`),
+ appIntent,
+ configuration: normalizeAndroidWidgetConfiguration(
+ { ...widget, entry },
+ appIntent,
+ `android.widgets[${widget.id}]`
+ ),
serverUpdate: widget.serverUpdate
? normalizeServerUpdate(
widget.serverUpdate,
diff --git a/packages/cli/src/config/types.ts b/packages/cli/src/config/types.ts
index 1a5eae36..4d951749 100644
--- a/packages/cli/src/config/types.ts
+++ b/packages/cli/src/config/types.ts
@@ -37,6 +37,11 @@ export interface AndroidWidgetAppIntentConfig {
parameters: AndroidWidgetAppIntentParameter[]
}
+export interface AndroidWidgetConfigurationConfig {
+ /** Deep link opened to configure this Dynamic Widget instance. */
+ deepLink: string
+}
+
export interface AndroidWidgetConfig {
/** Stable widget identifier used in generated files and registrations. */
id: string
@@ -70,6 +75,8 @@ export interface AndroidWidgetConfig {
previewImage?: string
/** Path to a preview layout XML file shown in widget pickers. */
previewLayout?: string
+ /** Android-only Dynamic Widget configuration entrypoint. */
+ configuration?: AndroidWidgetConfigurationConfig
/** Dynamic Widget configuration parameters surfaced to env.configuration. */
appIntent?: AndroidWidgetAppIntentConfig
}
diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts
index 6ba5c350..24950f2a 100644
--- a/packages/cli/src/index.ts
+++ b/packages/cli/src/index.ts
@@ -169,6 +169,7 @@ export type {
AndroidWidgetAppIntentConfig,
AndroidWidgetAppIntentParameter,
AndroidWidgetConfig,
+ AndroidWidgetConfigurationConfig,
AndroidWidgetServerUpdateConfig,
CliDefaults,
IOSProjectOverrides,
diff --git a/packages/cli/test/cli.test.js b/packages/cli/test/cli.test.js
index f3cc95b5..b84d34db 100644
--- a/packages/cli/test/cli.test.js
+++ b/packages/cli/test/cli.test.js
@@ -430,6 +430,193 @@ test('config normalization keeps Dynamic Widget entry project-relative', () => {
})
})
+test('android config normalization accepts Dynamic Widget configuration deepLink', () => {
+ const { normalizeVoltraConfig } = loadCliModule()
+ const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'voltra-cli-test-'))
+
+ const normalized = normalizeVoltraConfig({
+ configDir: tempDir,
+ configPath: path.join(tempDir, 'voltra.config.json'),
+ config: {
+ android: {
+ widgets: [
+ {
+ id: 'portfolio',
+ entry: './widgets/portfolio.tsx',
+ displayName: 'Portfolio',
+ description: 'Track holdings',
+ targetCellWidth: 2,
+ targetCellHeight: 2,
+ appIntent: {
+ parameters: [{ name: 'accountId', title: 'Account', default: 'main' }],
+ },
+ configuration: {
+ deepLink: 'voltra://widget-config',
+ },
+ },
+ ],
+ },
+ },
+ })
+
+ assert.equal(normalized.android.widgets[0].entry, 'widgets/portfolio.tsx')
+ assert.equal(normalized.android.widgets[0].configuration.deepLink, 'voltra://widget-config')
+ assert.deepEqual(normalized.android.widgets[0].appIntent.parameters, [
+ { name: 'accountId', title: 'Account', default: 'main' },
+ ])
+})
+
+test('android config normalization rejects legacy widget configuration without entry', () => {
+ const { VoltraConfigNormalizationError, normalizeVoltraConfig } = loadCliModule()
+ const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'voltra-cli-test-'))
+
+ assert.throws(
+ () => {
+ normalizeVoltraConfig({
+ configDir: tempDir,
+ configPath: path.join(tempDir, 'voltra.config.json'),
+ config: {
+ android: {
+ widgets: [
+ {
+ id: 'portfolio',
+ displayName: 'Portfolio',
+ description: 'Track holdings',
+ targetCellWidth: 2,
+ targetCellHeight: 2,
+ configuration: {
+ deepLink: 'voltra://widget-config',
+ },
+ },
+ ],
+ },
+ },
+ })
+ },
+ (error) => {
+ assert.ok(error instanceof VoltraConfigNormalizationError)
+ assert.match(error.message, /configuration is supported only for Dynamic Widgets with entry/)
+ return true
+ }
+ )
+})
+
+test('android config normalization rejects malformed widget configuration', () => {
+ const { VoltraConfigNormalizationError, normalizeVoltraConfig } = loadCliModule()
+ const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'voltra-cli-test-'))
+
+ for (const configuration of [null, [], 123]) {
+ assert.throws(
+ () => {
+ normalizeVoltraConfig({
+ configDir: tempDir,
+ configPath: path.join(tempDir, 'voltra.config.json'),
+ config: {
+ android: {
+ widgets: [
+ {
+ id: 'portfolio',
+ entry: './widgets/portfolio.tsx',
+ displayName: 'Portfolio',
+ description: 'Track holdings',
+ targetCellWidth: 2,
+ targetCellHeight: 2,
+ configuration,
+ },
+ ],
+ },
+ },
+ })
+ },
+ (error) => {
+ assert.ok(error instanceof VoltraConfigNormalizationError)
+ assert.match(error.message, /configuration must be an object with deepLink/)
+ return true
+ }
+ )
+ }
+})
+
+test('android config normalization rejects invalid configuration.deepLink', () => {
+ const { VoltraConfigNormalizationError, normalizeVoltraConfig } = loadCliModule()
+ const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'voltra-cli-test-'))
+
+ for (const deepLink of ['', ' ', 123]) {
+ assert.throws(
+ () => {
+ normalizeVoltraConfig({
+ configDir: tempDir,
+ configPath: path.join(tempDir, 'voltra.config.json'),
+ config: {
+ android: {
+ widgets: [
+ {
+ id: 'portfolio',
+ entry: './widgets/portfolio.tsx',
+ displayName: 'Portfolio',
+ description: 'Track holdings',
+ targetCellWidth: 2,
+ targetCellHeight: 2,
+ configuration: {
+ deepLink,
+ },
+ },
+ ],
+ },
+ },
+ })
+ },
+ (error) => {
+ assert.ok(error instanceof VoltraConfigNormalizationError)
+ assert.match(error.message, /configuration\.deepLink must be a non-empty string/)
+ return true
+ }
+ )
+ }
+})
+
+test('android config normalization warns when configuration deepLink has no appIntent parameters', () => {
+ const { normalizeVoltraConfig } = loadCliModule()
+ const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'voltra-cli-test-'))
+ const warnings = []
+ const originalWarn = console.warn
+
+ console.warn = (message) => {
+ warnings.push(message)
+ }
+
+ try {
+ const normalized = normalizeVoltraConfig({
+ configDir: tempDir,
+ configPath: path.join(tempDir, 'voltra.config.json'),
+ config: {
+ android: {
+ widgets: [
+ {
+ id: 'portfolio',
+ entry: './widgets/portfolio.tsx',
+ displayName: 'Portfolio',
+ description: 'Track holdings',
+ targetCellWidth: 2,
+ targetCellHeight: 2,
+ configuration: {
+ deepLink: 'voltra://widget-config',
+ },
+ },
+ ],
+ },
+ },
+ })
+
+ assert.equal(normalized.android.widgets[0].configuration.deepLink, 'voltra://widget-config')
+ assert.equal(warnings.length, 1)
+ assert.match(warnings[0], /\[voltra\] Warning:/)
+ assert.match(warnings[0], /configuration\.deepLink set but appIntent\.parameters is missing or empty/)
+ } finally {
+ console.warn = originalWarn
+ }
+})
+
test('generateIOSFiles writes Dynamic Widget manifest and AppIntent Swift scaffolding', async () => {
const { generateIOSFiles } = loadCliModule()
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'voltra-cli-test-'))