From cd57ea487d35314adc1649d15ff9fcf45ae21557 Mon Sep 17 00:00:00 2001 From: MazurDorian Date: Mon, 23 Feb 2026 17:14:34 +0100 Subject: [PATCH 01/18] feat: server driven widgets --- android/build.gradle | 6 + android/src/main/java/voltra/VoltraModule.kt | 33 ++ .../widget/VoltraWidgetCredentialStore.kt | 168 ++++++ .../widget/VoltraWidgetUpdateScheduler.kt | 90 +++ .../voltra/widget/VoltraWidgetUpdateWorker.kt | 156 ++++++ .../ios/widget-snapshots.harness.tsx | 2 +- example/app.json | 32 +- example/app/_layout.tsx | 2 +- .../testing-grounds/server-driven-widgets.tsx | 5 + example/package-lock.json | 528 ++++++++++++++++++ example/package.json | 3 + .../android/AndroidImageFallbackScreen.tsx | 2 +- .../ServerDrivenWidgetsScreen.tsx | 339 +++++++++++ .../testing-grounds/TestingGroundsScreen.tsx | 7 + .../weather/WeatherTestingScreen.tsx | 2 +- example/server/tsconfig.json | 13 + example/server/widget-server.tsx | 132 +++++ .../android/AndroidDynamicWeatherWidget.tsx | 45 ++ .../AndroidImageFallbackWidget.tsx | 0 .../{ => android}/AndroidVoltraWidget.tsx | 0 .../android-dynamic-weather-initial.tsx | 18 + .../android-image-fallback-initial.tsx | 0 .../android-voltra-widget-initial.tsx | 0 .../updateAndroidVoltraWidget.tsx | 0 .../widgets/ios/IosWeatherDynamicWidget.tsx | 48 ++ .../IosWeatherWidget.tsx} | 4 +- .../ios/ios-weather-dynamic-initial.tsx | 11 + example/widgets/ios/ios-weather-initial.tsx | 11 + example/widgets/weather-initial.tsx | 11 - ios/app/VoltraModule.swift | 15 + ios/app/VoltraModuleImpl.swift | 18 + ios/shared/VoltraConfig.swift | 6 + ios/shared/VoltraKeychainHelper.swift | 177 ++++++ ios/shared/VoltraWidgetServerFetcher.swift | 135 +++++ ios/target/VoltraHomeWidget.swift | 63 ++- plugin/src/android/files/kotlin.ts | 55 +- plugin/src/index.ts | 5 + plugin/src/ios-widget/files/entitlements.ts | 15 +- plugin/src/ios-widget/files/index.ts | 6 +- plugin/src/ios-widget/files/infoPlist.ts | 8 +- plugin/src/ios-widget/index.ts | 17 +- plugin/src/ios-widget/widgetPlist.ts | 30 +- plugin/src/ios/entitlements.ts | 40 +- plugin/src/ios/index.ts | 6 + plugin/src/ios/infoPlist.ts | 30 + plugin/src/types.ts | 57 ++ src/VoltraModule.ts | 19 +- src/android/client.ts | 7 + src/android/widgets/server-credentials.ts | 27 + src/client.ts | 7 + src/server.ts | 8 + src/types.ts | 17 + src/widget-server.ts | 217 +++++++ src/widgets/server-credentials.ts | 58 ++ 54 files changed, 2668 insertions(+), 43 deletions(-) create mode 100644 android/src/main/java/voltra/widget/VoltraWidgetCredentialStore.kt create mode 100644 android/src/main/java/voltra/widget/VoltraWidgetUpdateScheduler.kt create mode 100644 android/src/main/java/voltra/widget/VoltraWidgetUpdateWorker.kt create mode 100644 example/app/testing-grounds/server-driven-widgets.tsx create mode 100644 example/screens/testing-grounds/ServerDrivenWidgetsScreen.tsx create mode 100644 example/server/tsconfig.json create mode 100644 example/server/widget-server.tsx create mode 100644 example/widgets/android/AndroidDynamicWeatherWidget.tsx rename example/widgets/{ => android}/AndroidImageFallbackWidget.tsx (100%) rename example/widgets/{ => android}/AndroidVoltraWidget.tsx (100%) create mode 100644 example/widgets/android/android-dynamic-weather-initial.tsx rename example/widgets/{ => android}/android-image-fallback-initial.tsx (100%) rename example/widgets/{ => android}/android-voltra-widget-initial.tsx (100%) rename example/widgets/{ => android}/updateAndroidVoltraWidget.tsx (100%) create mode 100644 example/widgets/ios/IosWeatherDynamicWidget.tsx rename example/widgets/{WeatherWidget.tsx => ios/IosWeatherWidget.tsx} (97%) create mode 100644 example/widgets/ios/ios-weather-dynamic-initial.tsx create mode 100644 example/widgets/ios/ios-weather-initial.tsx delete mode 100644 example/widgets/weather-initial.tsx create mode 100644 ios/shared/VoltraKeychainHelper.swift create mode 100644 ios/shared/VoltraWidgetServerFetcher.swift create mode 100644 src/android/widgets/server-credentials.ts create mode 100644 src/widget-server.ts create mode 100644 src/widgets/server-credentials.ts diff --git a/android/build.gradle b/android/build.gradle index 87f1ee74..89f08773 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -73,6 +73,12 @@ dependencies { // Compose runtime (required for Glance) api "androidx.compose.runtime:runtime:1.6.8" + // WorkManager (for periodic server-driven widget updates) + api "androidx.work:work-runtime-ktx:2.9.1" + + // DataStore (secure credential storage for widget server updates) + api "androidx.datastore:datastore-preferences:1.1.4" + // JSON parsing implementation "com.google.code.gson:gson:2.10.1" diff --git a/android/src/main/java/voltra/VoltraModule.kt b/android/src/main/java/voltra/VoltraModule.kt index ed617023..bebebea4 100644 --- a/android/src/main/java/voltra/VoltraModule.kt +++ b/android/src/main/java/voltra/VoltraModule.kt @@ -328,6 +328,39 @@ class VoltraModule : Module() { imageManager.clearPreloadedImages(keys) } + // Widget Server Credentials APIs + + AsyncFunction("setWidgetServerCredentials") { credentials: Map -> + Log.d(TAG, "setWidgetServerCredentials called") + + val context = appContext.reactContext!! + val token = credentials["token"] as? String + ?: throw IllegalArgumentException("token is required in credentials") + + @Suppress("UNCHECKED_CAST") + val headers = credentials["headers"] as? Map + + runBlocking { + voltra.widget.VoltraWidgetCredentialStore.saveToken(context, token) + if (headers != null && headers.isNotEmpty()) { + voltra.widget.VoltraWidgetCredentialStore.saveHeaders(context, headers) + } + } + + Log.d(TAG, "Widget server credentials saved") + } + + AsyncFunction("clearWidgetServerCredentials") { + Log.d(TAG, "clearWidgetServerCredentials called") + + val context = appContext.reactContext!! + runBlocking { + voltra.widget.VoltraWidgetCredentialStore.clearAll(context) + } + + Log.d(TAG, "Widget server credentials cleared") + } + AsyncFunction("reloadLiveActivities") { activityNames: List? -> // On Android, we don't have "Live Activities" in the same sense as iOS, // but we might want to refresh widgets or notifications. diff --git a/android/src/main/java/voltra/widget/VoltraWidgetCredentialStore.kt b/android/src/main/java/voltra/widget/VoltraWidgetCredentialStore.kt new file mode 100644 index 00000000..a9c23c99 --- /dev/null +++ b/android/src/main/java/voltra/widget/VoltraWidgetCredentialStore.kt @@ -0,0 +1,168 @@ +package voltra.widget + +import android.content.Context +import android.util.Log +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.stringPreferencesKey +import androidx.datastore.preferences.core.stringSetPreferencesKey +import androidx.datastore.preferences.preferencesDataStore +import kotlinx.coroutines.flow.firstOrNull +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.runBlocking + +/** + * Secure credential storage for widget server-driven updates. + * Uses Jetpack DataStore with Preferences for storing auth tokens and custom headers. + * + * Note: DataStore provides file-based atomic writes and coroutine-safe access. + * For additional encryption at rest, consider combining with Tink. + * + * Since Android widgets are part of the main app binary, they inherently share + * this storage; no special grouping or sharing configuration is required. + */ + +private val Context.voltraCredentialsDataStore: DataStore by preferencesDataStore( + name = "voltra_widget_credentials" +) + +object VoltraWidgetCredentialStore { + private const val TAG = "VoltraWidgetCredStore" + + private val KEY_TOKEN = stringPreferencesKey("auth_token") + private val KEY_HEADER_KEYS = stringSetPreferencesKey("header_keys") + private const val KEY_HEADERS_PREFIX = "header_" + + /** + * Save an auth token. + * Called from the main app after user login. + */ + suspend fun saveToken(context: Context, token: String): Boolean { + return try { + context.voltraCredentialsDataStore.edit { prefs -> + prefs[KEY_TOKEN] = token + } + Log.d(TAG, "Token saved") + true + } catch (e: Exception) { + Log.e(TAG, "Failed to save token: ${e.message}", e) + false + } + } + + /** + * Read the auth token. + * Called from the WorkManager Worker during background fetch. + */ + suspend fun readToken(context: Context): String? { + return try { + context.voltraCredentialsDataStore.data + .map { prefs -> prefs[KEY_TOKEN] } + .firstOrNull() + } catch (e: Exception) { + Log.e(TAG, "Failed to read token: ${e.message}", e) + null + } + } + + /** + * Read the auth token synchronously (blocking). + * Use only from contexts where a suspend function is not available. + */ + fun readTokenBlocking(context: Context): String? { + return runBlocking { readToken(context) } + } + + /** + * Save custom headers. + */ + suspend fun saveHeaders(context: Context, headers: Map): Boolean { + return try { + context.voltraCredentialsDataStore.edit { prefs -> + // Clear existing headers + val existingKeys = prefs[KEY_HEADER_KEYS] ?: emptySet() + existingKeys.forEach { key -> + prefs.remove(stringPreferencesKey("$KEY_HEADERS_PREFIX$key")) + } + + // Save new headers + val headerKeys = mutableSetOf() + headers.forEach { (key, value) -> + prefs[stringPreferencesKey("$KEY_HEADERS_PREFIX$key")] = value + headerKeys.add(key) + } + prefs[KEY_HEADER_KEYS] = headerKeys + } + Log.d(TAG, "Headers saved (${headers.size} headers)") + true + } catch (e: Exception) { + Log.e(TAG, "Failed to save headers: ${e.message}", e) + false + } + } + + /** + * Read custom headers. + * Called from the WorkManager Worker during background fetch. + */ + suspend fun readHeaders(context: Context): Map { + return try { + context.voltraCredentialsDataStore.data + .map { prefs -> + val headerKeys = prefs[KEY_HEADER_KEYS] ?: emptySet() + val headers = mutableMapOf() + headerKeys.forEach { key -> + val value = prefs[stringPreferencesKey("$KEY_HEADERS_PREFIX$key")] + if (value != null) { + headers[key] = value + } + } + headers as Map + } + .firstOrNull() ?: emptyMap() + } catch (e: Exception) { + Log.e(TAG, "Failed to read headers: ${e.message}", e) + emptyMap() + } + } + + /** + * Read custom headers synchronously (blocking). + * Use only from contexts where a suspend function is not available. + */ + fun readHeadersBlocking(context: Context): Map { + return runBlocking { readHeaders(context) } + } + + /** + * Delete the auth token. + */ + suspend fun deleteToken(context: Context): Boolean { + return try { + context.voltraCredentialsDataStore.edit { prefs -> + prefs.remove(KEY_TOKEN) + } + true + } catch (e: Exception) { + Log.e(TAG, "Failed to delete token: ${e.message}", e) + false + } + } + + /** + * Clear all stored credentials. + */ + suspend fun clearAll(context: Context): Boolean { + return try { + context.voltraCredentialsDataStore.edit { prefs -> + prefs.clear() + } + Log.d(TAG, "All credentials cleared") + true + } catch (e: Exception) { + Log.e(TAG, "Failed to clear credentials: ${e.message}", e) + false + } + } +} diff --git a/android/src/main/java/voltra/widget/VoltraWidgetUpdateScheduler.kt b/android/src/main/java/voltra/widget/VoltraWidgetUpdateScheduler.kt new file mode 100644 index 00000000..3b94da7a --- /dev/null +++ b/android/src/main/java/voltra/widget/VoltraWidgetUpdateScheduler.kt @@ -0,0 +1,90 @@ +package voltra.widget + +import android.content.Context +import android.util.Log +import androidx.work.Constraints +import androidx.work.Data +import androidx.work.ExistingPeriodicWorkPolicy +import androidx.work.NetworkType +import androidx.work.PeriodicWorkRequestBuilder +import androidx.work.WorkManager +import java.util.concurrent.TimeUnit + +/** + * Schedules and manages periodic WorkManager tasks for server-driven widget updates. + * + * Each widget with a serverUpdate configuration gets its own periodic work request + * that runs at the configured interval to fetch new content from the server. + */ +object VoltraWidgetUpdateScheduler { + private const val TAG = "VoltraWidgetScheduler" + + /** + * Schedule periodic server updates for a widget. + * + * @param context Application context + * @param widgetId The widget identifier + * @param serverUrl The Voltra SSR server URL + * @param intervalMinutes How often to fetch updates (minimum 15 minutes per WorkManager) + */ + fun schedulePeriodicUpdate( + context: Context, + widgetId: String, + serverUrl: String, + intervalMinutes: Long = 15, + ) { + val workName = "${VoltraWidgetUpdateWorker.WORK_NAME_PREFIX}$widgetId" + + // Ensure minimum interval is 15 minutes (WorkManager requirement) + val effectiveInterval = maxOf(intervalMinutes, 15L) + + val inputData = + Data.Builder() + .putString(VoltraWidgetUpdateWorker.KEY_WIDGET_ID, widgetId) + .putString(VoltraWidgetUpdateWorker.KEY_SERVER_URL, serverUrl) + .build() + + val constraints = + Constraints.Builder() + .setRequiredNetworkType(NetworkType.CONNECTED) + .build() + + val workRequest = + PeriodicWorkRequestBuilder( + effectiveInterval, + TimeUnit.MINUTES, + ) + .setInputData(inputData) + .setConstraints(constraints) + .build() + + WorkManager.getInstance(context) + .enqueueUniquePeriodicWork( + workName, + ExistingPeriodicWorkPolicy.UPDATE, + workRequest, + ) + + Log.d(TAG, "Scheduled periodic update for widget '$widgetId' every ${effectiveInterval}min from $serverUrl") + } + + /** + * Cancel periodic server updates for a widget. + */ + fun cancelPeriodicUpdate( + context: Context, + widgetId: String, + ) { + val workName = "${VoltraWidgetUpdateWorker.WORK_NAME_PREFIX}$widgetId" + WorkManager.getInstance(context).cancelUniqueWork(workName) + Log.d(TAG, "Cancelled periodic update for widget '$widgetId'") + } + + /** + * Cancel all periodic widget updates. + */ + fun cancelAllPeriodicUpdates(context: Context) { + WorkManager.getInstance(context).cancelAllWorkByTag(VoltraWidgetUpdateWorker.TAG) + Log.d(TAG, "Cancelled all periodic widget updates") + } +} diff --git a/android/src/main/java/voltra/widget/VoltraWidgetUpdateWorker.kt b/android/src/main/java/voltra/widget/VoltraWidgetUpdateWorker.kt new file mode 100644 index 00000000..2ee11b85 --- /dev/null +++ b/android/src/main/java/voltra/widget/VoltraWidgetUpdateWorker.kt @@ -0,0 +1,156 @@ +package voltra.widget + +import android.appwidget.AppWidgetManager +import android.content.ComponentName +import android.content.Context +import android.util.Log +import android.widget.RemoteViews +import androidx.work.CoroutineWorker +import androidx.work.WorkerParameters +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import voltra.glance.RemoteViewsGenerator +import voltra.parsing.VoltraPayloadParser +import java.io.BufferedReader +import java.io.InputStreamReader +import java.net.HttpURLConnection +import java.net.URL + +/** + * Background Worker that fetches widget content from a remote Voltra SSR server + * and pushes updates to the widget via AppWidgetManager. + * + * This worker: + * 1. Reads auth credentials from DataStore + * 2. Performs an HTTP GET request to the configured server URL + * 3. Parses the response JSON (Voltra widget payload) + * 4. Generates RemoteViews and updates the widget directly + * + * Scheduled via WorkManager PeriodicWorkRequest from the widget receiver. + */ +class VoltraWidgetUpdateWorker( + context: Context, + params: WorkerParameters, +) : CoroutineWorker(context, params) { + companion object { + const val TAG = "VoltraWidgetUpdateWorker" + const val KEY_WIDGET_ID = "widget_id" + const val KEY_SERVER_URL = "server_url" + const val WORK_NAME_PREFIX = "voltra_widget_update_" + } + + override suspend fun doWork(): Result = + withContext(Dispatchers.IO) { + val widgetId = inputData.getString(KEY_WIDGET_ID) + val serverUrl = inputData.getString(KEY_SERVER_URL) + + if (widgetId == null || serverUrl == null) { + Log.e(TAG, "Missing required input data: widgetId=$widgetId, serverUrl=$serverUrl") + return@withContext Result.failure() + } + + Log.d(TAG, "Starting server update for widget '$widgetId' from $serverUrl") + + try { + // 1. Build URL with query parameters + val urlBuilder = StringBuilder(serverUrl) + urlBuilder.append(if (serverUrl.contains("?")) "&" else "?") + urlBuilder.append("widgetId=").append(widgetId) + + val url = URL(urlBuilder.toString()) + val connection = url.openConnection() as HttpURLConnection + + try { + connection.requestMethod = "GET" + connection.connectTimeout = 15000 + connection.readTimeout = 15000 + connection.setRequestProperty("Accept", "application/json") + connection.setRequestProperty("User-Agent", "VoltraWidget/1.0") + + // 2. Add auth token from encrypted storage + val token = VoltraWidgetCredentialStore.readToken(applicationContext) + if (token != null) { + connection.setRequestProperty("Authorization", "Bearer $token") + } + + // 3. Add custom headers from encrypted storage + val headers = VoltraWidgetCredentialStore.readHeaders(applicationContext) + headers.forEach { (key, value) -> + connection.setRequestProperty(key, value) + } + + // 4. Execute request + val responseCode = connection.responseCode + if (responseCode !in 200..299) { + Log.e(TAG, "Server returned HTTP $responseCode for widget '$widgetId'") + return@withContext Result.retry() + } + + // 5. Read response + val reader = BufferedReader(InputStreamReader(connection.inputStream)) + val jsonString = reader.readText() + reader.close() + + if (jsonString.isEmpty()) { + Log.e(TAG, "Empty response from server for widget '$widgetId'") + return@withContext Result.retry() + } + + Log.d(TAG, "Received ${jsonString.length} bytes for widget '$widgetId'") + + // 6. Store the fetched data in SharedPreferences (for Glance fallback) + val widgetManager = VoltraWidgetManager(applicationContext) + widgetManager.writeWidgetData(widgetId, jsonString, null) + + // 7. Parse and generate RemoteViews for direct update + val payload = + try { + VoltraPayloadParser.parse(jsonString) + } catch (e: Exception) { + Log.e(TAG, "Failed to parse widget payload: ${e.message}", e) + // Data is stored, so Glance can still use it. Return success. + return@withContext Result.success() + } + + if (payload.variants.isNullOrEmpty()) { + Log.w(TAG, "No variants in payload for widget '$widgetId'") + return@withContext Result.success() + } + + // 8. Push update to widget via AppWidgetManager + val receiverClassName = + "${applicationContext.packageName}.widget.VoltraWidget_${widgetId}Receiver" + val componentName = ComponentName(applicationContext.packageName, receiverClassName) + val appWidgetManager = AppWidgetManager.getInstance(applicationContext) + val appWidgetIds = appWidgetManager.getAppWidgetIds(componentName) + + if (appWidgetIds.isEmpty()) { + Log.w(TAG, "No widget instances found on home screen for '$widgetId'") + return@withContext Result.success() + } + + val sizeMapping = RemoteViewsGenerator.generateWidgetRemoteViews(applicationContext, payload) + + if (sizeMapping.isNotEmpty()) { + for (appWidgetId in appWidgetIds) { + try { + val responsiveRemoteViews = RemoteViews(sizeMapping) + appWidgetManager.updateAppWidget(appWidgetId, responsiveRemoteViews) + Log.d(TAG, "Updated widget instance $appWidgetId with server data") + } catch (e: Exception) { + Log.e(TAG, "Failed to update widget instance $appWidgetId: ${e.message}", e) + } + } + } + + Log.d(TAG, "Server update completed successfully for widget '$widgetId'") + Result.success() + } finally { + connection.disconnect() + } + } catch (e: Exception) { + Log.e(TAG, "Server update failed for widget '$widgetId': ${e.message}", e) + Result.retry() + } + } +} diff --git a/example/__tests__/ios/widget-snapshots.harness.tsx b/example/__tests__/ios/widget-snapshots.harness.tsx index 2941b464..00a10a9c 100644 --- a/example/__tests__/ios/widget-snapshots.harness.tsx +++ b/example/__tests__/ios/widget-snapshots.harness.tsx @@ -4,7 +4,7 @@ import { afterAll, beforeAll, describe, expect, Mock, render, spyOn, test } from import { VoltraWidgetPreview } from 'voltra/client' import { SAMPLE_WEATHER_DATA } from '../../widgets/weather-types' -import { WeatherWidget } from '../../widgets/WeatherWidget' +import { WeatherWidget } from '../../widgets/ios/IosWeatherWidget' describe('Widget snapshots', () => { const mockDate = new Date('2026-01-20T08:00:00Z') diff --git a/example/app.json b/example/app.json index 0fa6653c..9d9689e6 100644 --- a/example/app.json +++ b/example/app.json @@ -34,6 +34,7 @@ "../app.plugin.js", { "groupIdentifier": "group.callstackincubator.voltraexample", + "keychainGroup": "$(AppIdentifierPrefix)group.callstackincubator.voltraexample", "enablePushNotifications": true, "liveActivity": { "supplementalActivityFamilies": ["small"] @@ -44,7 +45,18 @@ "displayName": "Weather Widget", "description": "Shows current weather conditions", "supportedFamilies": ["systemSmall", "systemMedium", "systemLarge"], - "initialStatePath": "./widgets/weather-initial.tsx" + "initialStatePath": "./widgets/ios/ios-weather-initial.tsx" + }, + { + "id": "dynamic_weather", + "displayName": "Dynamic Weather Widget", + "description": "Shows current weather conditions with server-driven updates", + "supportedFamilies": ["systemSmall", "systemMedium", "systemLarge"], + "initialStatePath": "./widgets/ios/ios-weather-dynamic-initial.tsx", + "serverUpdate": { + "url": "http://localhost:3333", + "intervalMinutes": 15 + } } ], "android": { @@ -59,7 +71,7 @@ "targetCellHeight": 2, "resizeMode": "horizontal|vertical", "widgetCategory": "home_screen", - "initialStatePath": "./widgets/android-voltra-widget-initial.tsx", + "initialStatePath": "./widgets/android/android-voltra-widget-initial.tsx", "previewImage": "./assets/voltra-icon.jpg" }, { @@ -89,7 +101,21 @@ "targetCellHeight": 2, "resizeMode": "horizontal|vertical", "widgetCategory": "home_screen", - "initialStatePath": "./widgets/android-image-fallback-initial.tsx" + "initialStatePath": "./widgets/android/android-image-fallback-initial.tsx" + }, + { + "id": "dynamic_weather", + "displayName": "Dynamic Weather Widget", + "description": "Shows current weather conditions with server-driven updates", + "targetCellWidth": 2, + "targetCellHeight": 1, + "resizeMode": "horizontal|vertical", + "widgetCategory": "home_screen", + "initialStatePath": "./widgets/android/android-dynamic-weather-initial.tsx", + "serverUpdate": { + "url": "http://10.0.2.2:3333", + "intervalMinutes": 15 + } } ] }, diff --git a/example/app/_layout.tsx b/example/app/_layout.tsx index c421c6e7..e9aa16de 100644 --- a/example/app/_layout.tsx +++ b/example/app/_layout.tsx @@ -3,7 +3,7 @@ import { SafeAreaProvider } from 'react-native-safe-area-context' import { BackgroundWrapper } from '~/components/BackgroundWrapper' import { useVoltraEvents } from '~/hooks/useVoltraEvents' -import { updateAndroidVoltraWidget } from '~/widgets/updateAndroidVoltraWidget' +import { updateAndroidVoltraWidget } from '~/widgets/android/updateAndroidVoltraWidget' updateAndroidVoltraWidget({ width: 300, height: 200 }) diff --git a/example/app/testing-grounds/server-driven-widgets.tsx b/example/app/testing-grounds/server-driven-widgets.tsx new file mode 100644 index 00000000..328d4e1f --- /dev/null +++ b/example/app/testing-grounds/server-driven-widgets.tsx @@ -0,0 +1,5 @@ +import ServerDrivenWidgetsScreen from '~/screens/testing-grounds/ServerDrivenWidgetsScreen' + +export default function ServerDrivenWidgetsIndex() { + return +} diff --git a/example/package-lock.json b/example/package-lock.json index 56165879..3a40b4dc 100644 --- a/example/package-lock.json +++ b/example/package-lock.json @@ -40,6 +40,7 @@ "babel-plugin-module-resolver": "^5.0.2", "patch-package": "^8.0.1", "react-native-harness": "1.0.0-alpha.23", + "tsx": "^4.19.0", "typescript": "~5.8.3" } }, @@ -1599,6 +1600,448 @@ "tslib": "^2.4.0" } }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz", + "integrity": "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.3.tgz", + "integrity": "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.3.tgz", + "integrity": "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.3.tgz", + "integrity": "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.3.tgz", + "integrity": "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.3.tgz", + "integrity": "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.3.tgz", + "integrity": "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.3.tgz", + "integrity": "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.3.tgz", + "integrity": "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.3.tgz", + "integrity": "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.3.tgz", + "integrity": "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.3.tgz", + "integrity": "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.3.tgz", + "integrity": "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.3.tgz", + "integrity": "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.3.tgz", + "integrity": "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.3.tgz", + "integrity": "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.3.tgz", + "integrity": "sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.3.tgz", + "integrity": "sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.3.tgz", + "integrity": "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.3.tgz", + "integrity": "sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.3.tgz", + "integrity": "sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.3.tgz", + "integrity": "sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.3.tgz", + "integrity": "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.3.tgz", + "integrity": "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.3.tgz", + "integrity": "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.3.tgz", + "integrity": "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, "node_modules/@expo-google-fonts/merriweather": { "version": "0.4.2", "resolved": "https://registry.npmjs.org/@expo-google-fonts/merriweather/-/merriweather-0.4.2.tgz", @@ -6929,6 +7372,48 @@ "node": ">= 0.4" } }, + "node_modules/esbuild": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz", + "integrity": "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.3", + "@esbuild/android-arm": "0.27.3", + "@esbuild/android-arm64": "0.27.3", + "@esbuild/android-x64": "0.27.3", + "@esbuild/darwin-arm64": "0.27.3", + "@esbuild/darwin-x64": "0.27.3", + "@esbuild/freebsd-arm64": "0.27.3", + "@esbuild/freebsd-x64": "0.27.3", + "@esbuild/linux-arm": "0.27.3", + "@esbuild/linux-arm64": "0.27.3", + "@esbuild/linux-ia32": "0.27.3", + "@esbuild/linux-loong64": "0.27.3", + "@esbuild/linux-mips64el": "0.27.3", + "@esbuild/linux-ppc64": "0.27.3", + "@esbuild/linux-riscv64": "0.27.3", + "@esbuild/linux-s390x": "0.27.3", + "@esbuild/linux-x64": "0.27.3", + "@esbuild/netbsd-arm64": "0.27.3", + "@esbuild/netbsd-x64": "0.27.3", + "@esbuild/openbsd-arm64": "0.27.3", + "@esbuild/openbsd-x64": "0.27.3", + "@esbuild/openharmony-arm64": "0.27.3", + "@esbuild/sunos-x64": "0.27.3", + "@esbuild/win32-arm64": "0.27.3", + "@esbuild/win32-ia32": "0.27.3", + "@esbuild/win32-x64": "0.27.3" + } + }, "node_modules/escalade": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", @@ -7890,6 +8375,19 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/get-tsconfig": { + "version": "4.13.6", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.6.tgz", + "integrity": "sha512-shZT/QMiSHc/YBLxxOkMtgSid5HFoauqCE3/exfsEcwg1WkeqjG+V40yBbBrsD+jW2HDXcs28xOfcbm2jI8Ddw==", + "dev": true, + "license": "MIT", + "dependencies": { + "resolve-pkg-maps": "^1.0.0" + }, + "funding": { + "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" + } + }, "node_modules/getenv": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/getenv/-/getenv-2.0.0.tgz", @@ -14363,6 +14861,16 @@ "node": ">=8" } }, + "node_modules/resolve-pkg-maps": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", + "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" + } + }, "node_modules/resolve-workspace-root": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/resolve-workspace-root/-/resolve-workspace-root-2.0.0.tgz", @@ -15417,6 +15925,26 @@ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "license": "0BSD" }, + "node_modules/tsx": { + "version": "4.21.0", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz", + "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.27.0", + "get-tsconfig": "^4.7.5" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, "node_modules/type-detect": { "version": "4.0.8", "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.0.8.tgz", diff --git a/example/package.json b/example/package.json index a032f921..a41d3ade 100644 --- a/example/package.json +++ b/example/package.json @@ -5,8 +5,10 @@ "scripts": { "clean": "rm -rf .expo ios", "start": "expo start --dev-client --clear", + "prebuild": "expo prebuild --clean", "android": "expo run:android", "ios": "expo run:ios", + "server": "tsx server/widget-server.tsx", "web": "expo start --web", "harness:ios": "react-native-harness ./__tests__/ios --harnessRunner=ios", "harness:android": "react-native-harness ./__tests__/android --harnessRunner=android", @@ -44,6 +46,7 @@ "babel-plugin-module-resolver": "^5.0.2", "patch-package": "^8.0.1", "react-native-harness": "1.0.0-alpha.23", + "tsx": "^4.19.0", "typescript": "~5.8.3" }, "private": true, diff --git a/example/screens/android/AndroidImageFallbackScreen.tsx b/example/screens/android/AndroidImageFallbackScreen.tsx index acc2c9d6..895181cd 100644 --- a/example/screens/android/AndroidImageFallbackScreen.tsx +++ b/example/screens/android/AndroidImageFallbackScreen.tsx @@ -5,7 +5,7 @@ import { requestPinAndroidWidget, updateAndroidWidget } from 'voltra/android/cli import { Button } from '~/components/Button' import { Card } from '~/components/Card' -import { AndroidImageFallbackWidget } from '~/widgets/AndroidImageFallbackWidget' +import { AndroidImageFallbackWidget } from '~/widgets/android/AndroidImageFallbackWidget' const WIDGET_ID = 'image_fallback' diff --git a/example/screens/testing-grounds/ServerDrivenWidgetsScreen.tsx b/example/screens/testing-grounds/ServerDrivenWidgetsScreen.tsx new file mode 100644 index 00000000..f63daec0 --- /dev/null +++ b/example/screens/testing-grounds/ServerDrivenWidgetsScreen.tsx @@ -0,0 +1,339 @@ +import React, { useState } from 'react' +import { Alert, Platform, ScrollView, StyleSheet, Text, TextInput, View } from 'react-native' +import { Voltra } from 'voltra' +import { + clearWidgetServerCredentials, + reloadWidgets, + setWidgetServerCredentials, + VoltraWidgetPreview, +} from 'voltra/client' + +import { Button } from '~/components/Button' +import { Card } from '~/components/Card' + +let reloadAndroidWidgetsFn: ((widgetIds?: string[]) => Promise) | undefined +if (Platform.OS === 'android') { + try { + const androidModule = require('voltra/android') + reloadAndroidWidgetsFn = androidModule.reloadAndroidWidgets + } catch {} +} + +export default function ServerDrivenWidgetsScreen() { + const [serverUrl, setServerUrl] = useState('http://localhost:3333') + const [token, setToken] = useState('demo-token') + const [credentialsSet, setCredentialsSet] = useState(false) + const [isLoading, setIsLoading] = useState(false) + + const handleSetCredentials = async () => { + setIsLoading(true) + try { + await setWidgetServerCredentials({ + token, + headers: { + 'X-Widget-Source': 'voltra-example', + }, + }) + setCredentialsSet(true) + Alert.alert( + 'Success', + 'Widget server credentials saved securely.\n\nOn iOS: stored in Shared Keychain\nOn Android: stored in EncryptedSharedPreferences' + ) + } catch (error) { + Alert.alert('Error', `Failed to set credentials: ${error}`) + } finally { + setIsLoading(false) + } + } + + const handleClearCredentials = async () => { + setIsLoading(true) + try { + await clearWidgetServerCredentials() + setCredentialsSet(false) + Alert.alert('Success', 'Widget server credentials cleared.') + } catch (error) { + Alert.alert('Error', `Failed to clear credentials: ${error}`) + } finally { + setIsLoading(false) + } + } + + const handleReloadWidgets = async () => { + try { + if (Platform.OS === 'android' && reloadAndroidWidgetsFn) { + await reloadAndroidWidgetsFn(['dynamic_weather']) + Alert.alert('Success', 'Android widgets reloaded. WorkManager will fetch fresh content from the server.') + } else { + await reloadWidgets(['dynamic_weather']) + Alert.alert('Success', 'Widget timelines reloaded. The widget will fetch fresh content from the server.') + } + } catch (error) { + Alert.alert('Error', `Failed to reload: ${error}`) + } + } + + return ( + + + Server-Driven Widgets + + Widgets can fetch content from a remote server without the user opening the app. This is configured via the{' '} + serverUpdate option in the plugin config. + + + {/* How it works */} + + How it works + + 1. Configure serverUpdate.url in your widget config{'\n\n'} + 2. Call setWidgetServerCredentials() after user login{'\n\n'} + 3. iOS WidgetKit / Android WorkManager periodically fetches from your server URL{'\n\n'} + 4. Your server renders JSX → JSON using createWidgetUpdateHandler() + {'\n\n'} + 5. The widget updates automatically — no app launch needed! + + {Platform.OS === 'android' ? ( + + + ⚠️ Android emulator: use 10.0.2.2 instead of localhost to reach the host machine. Real devices need the + host's LAN IP. + + + ) : null} + + + {/* Plugin config */} + + Plugin Configuration + + In app.json, add serverUpdate to your + widget: + + + + {Platform.OS === 'android' + ? `// android.widgets in app.json +{ + "android": { + "widgets": [{ + "id": "dynamic_weather", + "serverUpdate": { + "url": "${serverUrl}", + "intervalMinutes": 15 + } + }] + } +}` + : `// widgets in app.json (iOS) +{ + "widgets": [{ + "id": "dynamic_weather", + "serverUpdate": { + "url": "${serverUrl}", + "intervalMinutes": 15 + } + }] +}`} + + + + + {/* Credentials */} + + Server Credentials + + Store auth tokens securely so the widget extension can authenticate with your server in the background. + + + Server URL + + + Auth Token + + + +