diff --git a/.changeset/quiet-bears-breathe.md b/.changeset/quiet-bears-breathe.md new file mode 100644 index 00000000..842dcd85 --- /dev/null +++ b/.changeset/quiet-bears-breathe.md @@ -0,0 +1,5 @@ +--- +'voltra': minor +--- + +Add support for server-driven Home Screen widgets on iOS and Android, so widgets can refresh with content from your backend even when the app is closed. diff --git a/android/build.gradle b/android/build.gradle index ebf71060..15c3bf49 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -51,11 +51,20 @@ if (useManagedAndroidSdkVersions) { } } +// Read version from package.json for BuildConfig +def packageJsonFile = new File(project.projectDir.parentFile, "package.json") +def packageJson = new groovy.json.JsonSlurper().parseText(packageJsonFile.text) +def voltraVersion = packageJson.version + android { namespace "voltra" defaultConfig { versionCode 1 versionName "0.1.0" + buildConfigField "String", "VOLTRA_VERSION", "\"${voltraVersion}\"" + } + buildFeatures { + buildConfig true } lintOptions { abortOnError false @@ -84,6 +93,15 @@ 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 (credential storage for widget server updates) + api "androidx.datastore:datastore-preferences:1.1.4" + + // Google Tink (encryption for credential storage) + implementation "com.google.crypto.tink:tink-android:1.19.0" + // 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..13245da4 100644 --- a/android/src/main/java/voltra/VoltraModule.kt +++ b/android/src/main/java/voltra/VoltraModule.kt @@ -328,6 +328,50 @@ 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") + + val widgetManager = voltra.widget.VoltraWidgetManager(context) + runBlocking { + widgetManager.reloadAllWidgets() + } + } + + AsyncFunction("clearWidgetServerCredentials") { + Log.d(TAG, "clearWidgetServerCredentials called") + + val context = appContext.reactContext!! + runBlocking { + voltra.widget.VoltraWidgetCredentialStore.clearAll(context) + } + + Log.d(TAG, "Widget server credentials cleared") + + val widgetManager = voltra.widget.VoltraWidgetManager(context) + runBlocking { + widgetManager.reloadAllWidgets() + } + } + 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/VoltraCryptoManager.kt b/android/src/main/java/voltra/widget/VoltraCryptoManager.kt new file mode 100644 index 00000000..76aff5cd --- /dev/null +++ b/android/src/main/java/voltra/widget/VoltraCryptoManager.kt @@ -0,0 +1,99 @@ +package voltra.widget + +import android.content.Context +import android.util.Base64 +import android.util.Log +import com.google.crypto.tink.Aead +import com.google.crypto.tink.KeyTemplates +import com.google.crypto.tink.KeysetHandle +import com.google.crypto.tink.aead.AeadConfig +import com.google.crypto.tink.integration.android.AndroidKeysetManager + +/** + * Encryption helper using Google Tink's AEAD primitive. + * + * Uses AES-256-GCM backed by the Android Keystore for key management. + * The keyset is stored encrypted in SharedPreferences and the master key + * never leaves the hardware-backed Keystore. + * + * This replaces the deprecated EncryptedSharedPreferences approach with + * the modern DataStore + Tink architecture. + */ +object VoltraCryptoManager { + private const val TAG = "VoltraCryptoManager" + private const val KEYSET_NAME = "voltra_widget_keyset" + private const val PREF_FILE_NAME = "voltra_widget_keyset_prefs" + private const val MASTER_KEY_URI = "android-keystore://voltra_widget_master_key" + + @Volatile + private var aead: Aead? = null + + /** + * Get or initialize the AEAD primitive. + * Thread-safe via double-checked locking. + */ + private fun getAead(context: Context): Aead = + aead ?: synchronized(this) { + aead ?: initAead(context).also { aead = it } + } + + private fun initAead(context: Context): Aead { + AeadConfig.register() + + val keysetHandle: KeysetHandle = + AndroidKeysetManager + .Builder() + .withSharedPref(context, KEYSET_NAME, PREF_FILE_NAME) + .withKeyTemplate(KeyTemplates.get("AES256_GCM")) + .withMasterKeyUri(MASTER_KEY_URI) + .build() + .keysetHandle + + return keysetHandle.getPrimitive(Aead::class.java) + } + + /** + * Encrypt a plaintext string. + * Returns a Base64-encoded ciphertext, or null on failure. + * + * @param context Application context + * @param plaintext The string to encrypt + * @param associatedData Optional associated data for AEAD authentication + */ + fun encrypt( + context: Context, + plaintext: String, + associatedData: ByteArray = ByteArray(0), + ): String? = + try { + val aead = getAead(context) + val ciphertext = aead.encrypt(plaintext.toByteArray(Charsets.UTF_8), associatedData) + Base64.encodeToString(ciphertext, Base64.NO_WRAP) + } catch (e: Exception) { + Log.e(TAG, "Encryption failed: ${e.message}", e) + null + } + + /** + * Decrypt a Base64-encoded ciphertext string. + * Returns the plaintext string, or null on failure. + * + * @param context Application context + * @param encryptedBase64 The Base64-encoded ciphertext to decrypt + * @param associatedData Optional associated data for AEAD authentication (must match encryption) + */ + fun decrypt( + context: Context, + encryptedBase64: String, + associatedData: ByteArray = ByteArray(0), + ): String? = + try { + val aead = getAead(context) + val ciphertext = Base64.decode(encryptedBase64, Base64.NO_WRAP) + val plaintext = aead.decrypt(ciphertext, associatedData) + String(plaintext, Charsets.UTF_8) + } catch (e: Exception) { + Log.e(TAG, "Decryption failed: ${e.message}", e) + null + } +} 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..d3d848b8 --- /dev/null +++ b/android/src/main/java/voltra/widget/VoltraWidgetCredentialStore.kt @@ -0,0 +1,174 @@ +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 persistence, and Google Tink AEAD + * for encrypting sensitive values (tokens and headers) at rest. + * + * 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 (encrypted via Tink AEAD). + * Called from the main app after user login. + */ + suspend fun saveToken( + context: Context, + token: String, + ): Boolean = + try { + val encrypted = + VoltraCryptoManager.encrypt(context, token) + ?: throw IllegalStateException("Failed to encrypt token") + context.voltraCredentialsDataStore.edit { prefs -> + prefs[KEY_TOKEN] = encrypted + } + Log.d(TAG, "Token saved (encrypted)") + true + } catch (e: Exception) { + Log.e(TAG, "Failed to save token: ${e.message}", e) + false + } + + /** + * Read the auth token (decrypted via Tink AEAD). + * Called from the WorkManager Worker during background fetch. + */ + suspend fun readToken(context: Context): String? = + try { + val encrypted = + context.voltraCredentialsDataStore.data + .map { prefs -> prefs[KEY_TOKEN] } + .firstOrNull() + encrypted?.let { VoltraCryptoManager.decrypt(context, it) } + } 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? = runBlocking { readToken(context) } + + /** + * Save custom headers (values encrypted via Tink AEAD). + */ + suspend fun saveHeaders( + context: Context, + headers: Map, + ): Boolean = + 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 with encrypted values + val headerKeys = mutableSetOf() + headers.forEach { (key, value) -> + val encrypted = + VoltraCryptoManager.encrypt(context, value) + ?: throw IllegalStateException("Failed to encrypt header value for key: $key") + prefs[stringPreferencesKey("$KEY_HEADERS_PREFIX$key")] = encrypted + headerKeys.add(key) + } + prefs[KEY_HEADER_KEYS] = headerKeys + } + Log.d(TAG, "Headers saved (${headers.size} headers, encrypted)") + true + } catch (e: Exception) { + Log.e(TAG, "Failed to save headers: ${e.message}", e) + false + } + + /** + * Read custom headers (values decrypted via Tink AEAD). + * Called from the WorkManager Worker during background fetch. + */ + suspend fun readHeaders(context: Context): Map = + try { + context.voltraCredentialsDataStore.data + .map { prefs -> + val headerKeys = prefs[KEY_HEADER_KEYS] ?: emptySet() + val headers = mutableMapOf() + headerKeys.forEach { key -> + val encrypted = prefs[stringPreferencesKey("$KEY_HEADERS_PREFIX$key")] + if (encrypted != null) { + val decrypted = VoltraCryptoManager.decrypt(context, encrypted) + if (decrypted != null) { + headers[key] = decrypted + } else { + Log.w(TAG, "Failed to decrypt header value for key: $key") + } + } + } + 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 = runBlocking { readHeaders(context) } + + /** + * Delete the auth token. + */ + suspend fun deleteToken(context: Context): Boolean = + 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 = + 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/VoltraWidgetManager.kt b/android/src/main/java/voltra/widget/VoltraWidgetManager.kt index 6379ee51..3604842d 100644 --- a/android/src/main/java/voltra/widget/VoltraWidgetManager.kt +++ b/android/src/main/java/voltra/widget/VoltraWidgetManager.kt @@ -267,7 +267,12 @@ class VoltraWidgetManager( } /** - * Reload specific widgets or all widgets + * Reload specific widgets or all widgets. + * + * For server-driven widgets (those with a registered server URL), + * this enqueues an immediate WorkManager fetch so the widget gets + * fresh content from the server. For local-only widgets it + * re-renders from the cached SharedPreferences data. */ suspend fun reloadWidgets(widgetIds: List?) = withContext(Dispatchers.Main) { @@ -275,7 +280,7 @@ class VoltraWidgetManager( Log.d(TAG, "reloadWidgets: specific widgets ${widgetIds.joinToString()}") for (widgetId in widgetIds) { try { - updateWidget(widgetId) + reloadSingleWidget(widgetId) } catch (e: Exception) { Log.e(TAG, "Failed to reload widget $widgetId: ${e.message}") } @@ -286,6 +291,21 @@ class VoltraWidgetManager( } } + /** + * Reload a single widget. + * If the widget is server-driven, enqueues an immediate server fetch. + * Otherwise, re-renders from cached data. + */ + private suspend fun reloadSingleWidget(widgetId: String) { + val didEnqueue = VoltraWidgetUpdateScheduler.requestImmediateUpdate(context, widgetId) + if (didEnqueue) { + Log.d(TAG, "reloadSingleWidget: enqueued immediate server fetch for $widgetId") + } else { + Log.d(TAG, "reloadSingleWidget: no server URL for $widgetId, updating from cache") + updateWidget(widgetId) + } + } + /** * Reload all widgets by finding all saved widget data */ @@ -301,11 +321,18 @@ class VoltraWidgetManager( .map { it.removePrefix(KEY_JSON_PREFIX) } .toSet() - Log.d(TAG, "Found ${widgetIds.size} widgets with saved data: $widgetIds") + // Also include server-driven widgets that may not have cached data yet + val serverDrivenIds = VoltraWidgetUpdateScheduler.getAllServerDrivenWidgetIds(context) + val allWidgetIds = widgetIds + serverDrivenIds + + Log.d( + TAG, + "Found ${allWidgetIds.size} widgets to reload (${widgetIds.size} cached, ${serverDrivenIds.size} server-driven)", + ) - for (widgetId in widgetIds) { + for (widgetId in allWidgetIds) { try { - updateWidget(widgetId) + reloadSingleWidget(widgetId) } catch (e: Exception) { Log.e(TAG, "Failed to update widget $widgetId: ${e.message}") } 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..38ba5e90 --- /dev/null +++ b/android/src/main/java/voltra/widget/VoltraWidgetUpdateScheduler.kt @@ -0,0 +1,233 @@ +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 androidx.work.Constraints +import androidx.work.Data +import androidx.work.ExistingPeriodicWorkPolicy +import androidx.work.NetworkType +import androidx.work.OneTimeWorkRequestBuilder +import androidx.work.PeriodicWorkRequestBuilder +import androidx.work.WorkManager +import kotlinx.coroutines.flow.firstOrNull +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.runBlocking +import java.util.concurrent.TimeUnit + +private val Context.voltraServerUrlsDataStore: DataStore by preferencesDataStore( + name = "voltra_widget_server_urls", +) + +/** + * 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. + * + * Server URLs are persisted in Jetpack DataStore so that [requestImmediateUpdate] + * can trigger an on-demand fetch without needing the generated receiver code. + */ +object VoltraWidgetUpdateScheduler { + private const val TAG = "VoltraWidgetScheduler" + + /** DataStore key that holds the set of all registered widget IDs. */ + private val KEY_WIDGET_IDS = stringSetPreferencesKey("registered_widget_ids") + + /** Prefix used to build per-widget server URL keys. */ + private const val KEY_SERVER_URL_PREFIX = "server_url_" + + /** + * 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, + ) { + // Persist the server URL so requestImmediateUpdate can look it up later + runBlocking { saveServerUrl(context, widgetId, serverUrl) } + + 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) + .addTag(VoltraWidgetUpdateWorker.TAG) + .build() + + WorkManager + .getInstance(context) + .enqueueUniquePeriodicWork( + workName, + ExistingPeriodicWorkPolicy.UPDATE, + workRequest, + ) + + Log.d(TAG, "Scheduled periodic update for widget '$widgetId' every ${effectiveInterval}min from $serverUrl") + } + + /** + * Enqueue a one-time WorkManager request to immediately fetch fresh content + * from the server for the given widget. + * + * @return true if the request was enqueued, false if no server URL is known for this widget. + */ + suspend fun requestImmediateUpdate( + context: Context, + widgetId: String, + ): Boolean { + val serverUrl = readServerUrl(context, widgetId) + if (serverUrl == null) { + Log.d(TAG, "No server URL registered for widget '$widgetId', skipping immediate update") + return false + } + + 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 = + OneTimeWorkRequestBuilder() + .setInputData(inputData) + .setConstraints(constraints) + .addTag(VoltraWidgetUpdateWorker.TAG) + .build() + + WorkManager.getInstance(context).enqueue(workRequest) + + Log.d(TAG, "Enqueued immediate update for widget '$widgetId' from $serverUrl") + return true + } + + /** + * Check whether a widget has a server URL registered (i.e. is server-driven). + */ + suspend fun hasServerUrl( + context: Context, + widgetId: String, + ): Boolean = readServerUrl(context, widgetId) != null + + /** + * 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) + runBlocking { removeServerUrl(context, widgetId) } + Log.d(TAG, "Cancelled periodic update for widget '$widgetId'") + } + + /** + * Cancel all periodic widget updates. + */ + fun cancelAllPeriodicUpdates(context: Context) { + WorkManager.getInstance(context).cancelAllWorkByTag(VoltraWidgetUpdateWorker.TAG) + runBlocking { clearAllServerUrls(context) } + Log.d(TAG, "Cancelled all periodic widget updates") + } + + // -- DataStore helpers for server URL persistence -- + + private suspend fun saveServerUrl( + context: Context, + widgetId: String, + serverUrl: String, + ) { + val urlKey = stringPreferencesKey("$KEY_SERVER_URL_PREFIX$widgetId") + context.voltraServerUrlsDataStore.edit { prefs -> + prefs[urlKey] = serverUrl + // Also track the widget ID in the index set + val currentIds = prefs[KEY_WIDGET_IDS] ?: emptySet() + prefs[KEY_WIDGET_IDS] = currentIds + widgetId + } + } + + suspend fun readServerUrl( + context: Context, + widgetId: String, + ): String? { + val urlKey = stringPreferencesKey("$KEY_SERVER_URL_PREFIX$widgetId") + return try { + context.voltraServerUrlsDataStore.data + .map { prefs -> prefs[urlKey] } + .firstOrNull() + } catch (e: Exception) { + Log.e(TAG, "Failed to read server URL for widget '$widgetId': ${e.message}", e) + null + } + } + + /** + * Return all widget IDs that have a server URL registered. + */ + suspend fun getAllServerDrivenWidgetIds(context: Context): Set = + try { + context.voltraServerUrlsDataStore.data + .map { prefs -> prefs[KEY_WIDGET_IDS] ?: emptySet() } + .firstOrNull() ?: emptySet() + } catch (e: Exception) { + Log.e(TAG, "Failed to read server-driven widget IDs: ${e.message}", e) + emptySet() + } + + private suspend fun removeServerUrl( + context: Context, + widgetId: String, + ) { + val urlKey = stringPreferencesKey("$KEY_SERVER_URL_PREFIX$widgetId") + context.voltraServerUrlsDataStore.edit { prefs -> + prefs.remove(urlKey) + val currentIds = prefs[KEY_WIDGET_IDS] ?: emptySet() + prefs[KEY_WIDGET_IDS] = currentIds - widgetId + } + } + + private suspend fun clearAllServerUrls(context: Context) { + context.voltraServerUrlsDataStore.edit { prefs -> + prefs.clear() + } + } +} 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..51af2aa1 --- /dev/null +++ b/android/src/main/java/voltra/widget/VoltraWidgetUpdateWorker.kt @@ -0,0 +1,179 @@ +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.BuildConfig +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_" + + /** Stop retrying after this many consecutive failures to avoid infinite retry loops. */ + const val MAX_RETRIES = 3 + } + + 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) + urlBuilder.append("&platform=android") + + 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/${BuildConfig.VOLTRA_VERSION}") + + // 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' (attempt $runAttemptCount)", + ) + return@withContext if (runAttemptCount >= MAX_RETRIES) { + Log.w(TAG, "Max retries ($MAX_RETRIES) reached for widget '$widgetId', giving up") + Result.failure() + } else { + 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' (attempt $runAttemptCount)") + return@withContext if (runAttemptCount >= MAX_RETRIES) { + Log.w(TAG, "Max retries ($MAX_RETRIES) reached for widget '$widgetId', giving up") + Result.failure() + } else { + 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' (attempt $runAttemptCount): ${e.message}", e) + if (runAttemptCount >= MAX_RETRIES) { + Log.w(TAG, "Max retries ($MAX_RETRIES) reached for widget '$widgetId', giving up") + Result.failure() + } else { + Result.retry() + } + } + } +} diff --git a/example/__tests__/ios/widget-snapshots.harness.tsx b/example/__tests__/ios/widget-snapshots.harness.tsx index 2941b464..3abec09f 100644 --- a/example/__tests__/ios/widget-snapshots.harness.tsx +++ b/example/__tests__/ios/widget-snapshots.harness.tsx @@ -3,8 +3,8 @@ import { View } from 'react-native' import { afterAll, beforeAll, describe, expect, Mock, render, spyOn, test } from 'react-native-harness' import { VoltraWidgetPreview } from 'voltra/client' +import { IosWeatherWidget } from '../../widgets/ios/IosWeatherWidget' import { SAMPLE_WEATHER_DATA } from '../../widgets/weather-types' -import { WeatherWidget } from '../../widgets/WeatherWidget' describe('Widget snapshots', () => { const mockDate = new Date('2026-01-20T08:00:00Z') @@ -34,7 +34,7 @@ describe('Widget snapshots', () => { - + @@ -52,7 +52,7 @@ describe('Widget snapshots', () => { - + @@ -70,7 +70,7 @@ describe('Widget snapshots', () => { - + @@ -88,7 +88,7 @@ describe('Widget snapshots', () => { - + 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/android-widgets/server-driven.tsx b/example/app/android-widgets/server-driven.tsx new file mode 100644 index 00000000..d27e0b93 --- /dev/null +++ b/example/app/android-widgets/server-driven.tsx @@ -0,0 +1,5 @@ +import ServerDrivenWidgetsScreen from '~/screens/shared/ServerDrivenWidgetsScreen' + +export default function ServerDrivenWidgetsIndex() { + return +} 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..d27e0b93 --- /dev/null +++ b/example/app/testing-grounds/server-driven-widgets.tsx @@ -0,0 +1,5 @@ +import ServerDrivenWidgetsScreen from '~/screens/shared/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..8f38d9e7 100644 --- a/example/package.json +++ b/example/package.json @@ -5,9 +5,11 @@ "scripts": { "clean": "rm -rf .expo ios", "start": "expo start --dev-client --clear", + "prebuild": "expo prebuild --clean", "android": "expo run:android", "ios": "expo run:ios", "web": "expo start --web", + "widget:server": "tsx server/widget-server.tsx", "harness:ios": "react-native-harness ./__tests__/ios --harnessRunner=ios", "harness:android": "react-native-harness ./__tests__/android --harnessRunner=android", "postinstall": "patch-package" @@ -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..413f9ed7 100644 --- a/example/screens/android/AndroidImageFallbackScreen.tsx +++ b/example/screens/android/AndroidImageFallbackScreen.tsx @@ -5,13 +5,13 @@ 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' type ExampleType = 'colors' | 'styled' | 'transparent' | 'custom' | 'mixed' -const EXAMPLES: Array<{ id: ExampleType; title: string; description: string }> = [ +const EXAMPLES: { id: ExampleType; title: string; description: string }[] = [ { id: 'colors', title: 'Background Colors', diff --git a/example/screens/android/AndroidScreen.tsx b/example/screens/android/AndroidScreen.tsx index 6423dfba..b088c9a0 100644 --- a/example/screens/android/AndroidScreen.tsx +++ b/example/screens/android/AndroidScreen.tsx @@ -34,6 +34,13 @@ const ANDROID_SECTIONS = [ description: 'Preview your Android widget layouts directly within the app using VoltraWidgetPreview.', route: '/android-widgets/preview', }, + { + id: 'server-driven-widgets', + title: 'Server-Driven Widgets', + description: + 'Serve dynamic widget content from a remote server using Voltra SSR. This example includes a sample widget server implementation.', + route: '/android-widgets/server-driven', + }, // Add more Android-specific sections here as they are implemented ] diff --git a/example/screens/shared/ServerDrivenWidgetsScreen.tsx b/example/screens/shared/ServerDrivenWidgetsScreen.tsx new file mode 100644 index 00000000..01616d86 --- /dev/null +++ b/example/screens/shared/ServerDrivenWidgetsScreen.tsx @@ -0,0 +1,316 @@ +import React, { useState } from 'react' +import { Alert, Platform, ScrollView, StyleSheet, Text, TextInput, View } from 'react-native' +import { reloadAndroidWidgets, VoltraWidgetPreview as AndroidVoltraWidgetPreview } from 'voltra/android/client' +import { + clearWidgetServerCredentials, + reloadWidgets, + setWidgetServerCredentials, + VoltraWidgetPreview, +} from 'voltra/client' + +import { Button } from '~/components/Button' +import { Card } from '~/components/Card' +import { AndroidDynamicWeatherWidget } from '~/widgets/android/AndroidDynamicWeatherWidget' +import { IosDynamicWeatherWidget } from '~/widgets/ios/IosDynamicWeatherWidget' + +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: encrypted via Tink in DataStore' + ) + } 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') { + await reloadAndroidWidgets(['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 + + + +