diff --git a/.changeset/server-driven-dynamic-widgets.md b/.changeset/server-driven-dynamic-widgets.md new file mode 100644 index 00000000..19ff14d1 --- /dev/null +++ b/.changeset/server-driven-dynamic-widgets.md @@ -0,0 +1,24 @@ +--- +'@use-voltra/android-client': minor +'@use-voltra/ios-client': minor +'@use-voltra/expo-plugin': minor +'@use-voltra/android': minor +'@use-voltra/ios': minor +'@use-voltra/core': minor +'voltra': minor +--- + +Dynamic Widgets can now be server-driven: give a widget both `entry` and `serverUpdate` and the device fetches a plain JSON object from your endpoint and hands it to the bundled JS as props, instead of your server having to run Voltra's renderer and return UI (issue #176). The backend can be written in any language. + +- `serverUpdate.url` is now optional. `"serverUpdate": {}` marks a widget server-driven with the URL supplied at runtime, which covers per-tenant backends whose URL is only known after login. +- New `setWidgetServerUpdate(settings, { widgetId })` and `clearWidgetServerUpdate({ widgetId })` on both platforms let an app change a server-driven widget's `url`, `intervalMinutes`, `method`, `query`, `headers` and `body` at runtime, or set `enabled: false` to stop fetching and drive the widget itself. Settings apply to both render engines, so payload widgets gain runtime URLs and non-GET requests too. +- `setWidgetServerCredentials` and `clearWidgetServerCredentials` are deprecated in favour of `setWidgetServerUpdate` with an `Authorization` header. They keep their signatures and read and write the same stored records, so nothing migrates on device; they will be removed in a later major. +- Widgets rendered from fetched props get `env.serverUpdate` with `status`, `fetchedAt`, `error` and `httpStatus`, so a widget can show "updated 3 min ago" or dim itself when the data is stale. It is `undefined` on widgets without a `serverUpdate`. +- Every server request now also carries a `locale` query parameter, and redirects are followed only within the host the app configured. A widget with an `entry` also sends `If-None-Match` when the previous response had an `ETag`, and honours `Cache-Control: max-age` and `Retry-After` when scheduling its next fetch; a payload widget's request stays unconditional. Dynamic Widgets do not send `family`: one fetch serves every size, so props must be size-agnostic and the entry picks its layout from `env.widgetFamily`. +- A widget with `entry` and `serverUpdate` defaults to a 15 minute interval on both platforms, and a shorter one is raised to 15 with a warning rather than failing the build. On iOS such a widget requires `ios.groupIdentifier`, because the fetched props are shared with the widget extension through the App Group. +- `serverUpdate.url` values that are not absolute `http(s)` URLs are now rejected when the native project is generated; plain `http` to a non-local host is reported as a warning, because release builds block cleartext traffic. +- `clearWidgetServerUpdate()` with no `widgetId` is the logout gesture: it drops the runtime settings and everything the server last sent, so a Dynamic Widget goes back to `{}` with `env.serverUpdate.status` of `never` rather than showing the previous account's data. + +The one behaviour change: `entry` plus `serverUpdate` used to be accepted and ignore the URL. Apps with that config now fetch. Until the endpoint returns props the widget shows its initial state as before, and a payload-shaped response is rejected with a log line naming the mismatch. + +Android widget receivers no longer inline the server URL and interval; they come from a generated `assets/voltra/widget_server_defaults.json`. Run `expo prebuild` or `voltra apply` to regenerate them, as with any generator change. diff --git a/docs/adr/0002-server-driven-dynamic-widgets.md b/docs/adr/0002-server-driven-dynamic-widgets.md index c1a6847f..46ce304e 100644 --- a/docs/adr/0002-server-driven-dynamic-widgets.md +++ b/docs/adr/0002-server-driven-dynamic-widgets.md @@ -1,6 +1,6 @@ # ADR 0002: Server-driven Dynamic Widgets -Status: Accepted — not yet implemented +Status: Accepted Tracks [#176](https://github.com/callstackincubator/voltra/issues/176). @@ -197,6 +197,11 @@ type WidgetServerUpdateSettings = { setWidgetServerUpdate(settings: WidgetServerUpdateSettings, options?: { widgetId?: string }): Promise clearWidgetServerUpdate(options?: { widgetId?: string }): Promise +// widgetId given: fully resolved (defaults applied), or null if the widget is not server-driven. +getWidgetServerUpdate(options: { widgetId: string }): Promise +// no widgetId: raw GLOBAL layer contents only (no defaults applied), or null if nothing set globally. +getWidgetServerUpdate(options?: undefined): Promise + /** @deprecated use setWidgetServerUpdate with an Authorization header */ setWidgetServerCredentials({ token, headers? }): Promise /** @deprecated */ diff --git a/docs/adr/README.md b/docs/adr/README.md index 8064ee1a..cac86405 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -18,8 +18,8 @@ Status values: toward it should treat the documentation as the specification. - **Superseded by NNNN** — no longer binding; read the replacement. -| ADR | Title | Status | -| ---------------------------------------------- | -------------------------------------------------------- | ------------------------------ | -| [0000](0000-android-widget-kind-separation.md) | Separate payload-driven and Dynamic Android widget paths | Accepted | -| [0001](0001-dynamic-live-activities.md) | Dynamic Live Activities rendering | Accepted | -| [0002](0002-server-driven-dynamic-widgets.md) | Server-driven Dynamic Widgets | Accepted — not yet implemented | +| ADR | Title | Status | +| ---------------------------------------------- | -------------------------------------------------------- | -------- | +| [0000](0000-android-widget-kind-separation.md) | Separate payload-driven and Dynamic Android widget paths | Accepted | +| [0001](0001-dynamic-live-activities.md) | Dynamic Live Activities rendering | Accepted | +| [0002](0002-server-driven-dynamic-widgets.md) | Server-driven Dynamic Widgets | Accepted | diff --git a/packages/android-client/android/src/main/java/voltra/VoltraModule.kt b/packages/android-client/android/src/main/java/voltra/VoltraModule.kt index 53317b85..7ee8898d 100644 --- a/packages/android-client/android/src/main/java/voltra/VoltraModule.kt +++ b/packages/android-client/android/src/main/java/voltra/VoltraModule.kt @@ -35,8 +35,12 @@ import voltra.widget.VoltraWidgetReceivers import voltra.widget.payload.PayloadWidgetUpdateRejection import voltra.widget.payload.PayloadWidgetUpdater import voltra.widget.payload.VoltraGlanceWidget -import voltra.widget.payload.VoltraWidgetCredentialStore import voltra.widget.payload.VoltraWidgetManager +import voltra.widget.server.VoltraWidgetCredentialStore +import voltra.widget.server.VoltraWidgetServer +import voltra.widget.server.WidgetScope +import voltra.widget.server.WidgetServerUpdateSettings +import voltra.widget.server.WidgetServerUpdateSettingsJson class VoltraModule( reactContext: ReactApplicationContext, @@ -53,6 +57,10 @@ class VoltraModule( VoltraWidgetManager(reactApplicationContext) } + private val widgetServerUpdateCoordinator by lazy { + WidgetServerUpdateCoordinator(reactApplicationContext) + } + private val widgetOrchestrator by lazy { WidgetOrchestrator(reactApplicationContext, widgetManager) } @@ -371,6 +379,7 @@ class VoltraModule( Log.d(TAG, "clearAndroidWidget called with widgetId=$widgetId") widgetManager.clearWidgetData(widgetId) dynamicWidgetPropsStore.clearDynamicWidgetProps(widgetId) + runBlocking { widgetServerUpdateCoordinator.dropWidgetLayer(widgetId) } runBlocking { when (val resolution = VoltraWidgetKindResolver.resolve(reactApplicationContext, widgetId)) { is VoltraWidgetKindResolution.Resolved -> { @@ -398,6 +407,11 @@ class VoltraModule( Log.d(TAG, "clearAllAndroidWidgets called") widgetManager.clearAllWidgetData() dynamicWidgetPropsStore.clearAllDynamicWidgetProps() + runBlocking { + for (widgetId in VoltraWidgetServer.serverDrivenWidgetIds(reactApplicationContext)) { + widgetServerUpdateCoordinator.dropWidgetLayer(widgetId) + } + } runBlocking { widgetOrchestrator.reloadAllWidgets() } Log.d(TAG, "clearAllAndroidWidgets completed") promise.resolve(null) @@ -567,6 +581,97 @@ class VoltraModule( promise.resolve(null) } + override fun setWidgetServerUpdate( + settingsJson: String, + widgetId: String?, + promise: Promise, + ) { + Log.d(TAG, "setWidgetServerUpdate called for widgetId=${widgetId ?: ""}") + + runBlocking { + when (val result = widgetServerUpdateCoordinator.set(settingsJson, widgetId)) { + is WidgetServerUpdateCoordinator.Result.Applied -> { + promise.resolve(null) + } + + is WidgetServerUpdateCoordinator.Result.Rejected -> { + promise.reject("VOLTRA_INVALID_SERVER_UPDATE_SETTINGS", result.reason) + } + } + } + } + + override fun clearWidgetServerUpdate( + widgetId: String?, + promise: Promise, + ) { + Log.d(TAG, "clearWidgetServerUpdate called for widgetId=${widgetId ?: ""}") + + runBlocking { + when (val result = widgetServerUpdateCoordinator.clear(widgetId)) { + is WidgetServerUpdateCoordinator.Result.Applied -> { + promise.resolve(null) + } + + is WidgetServerUpdateCoordinator.Result.Rejected -> { + promise.reject("VOLTRA_INVALID_SERVER_UPDATE_SETTINGS", result.reason) + } + } + } + } + + /** + * Reads settings back rather than reasoning about what was set: with [widgetId] given, the + * fully resolved settings that widget would fetch with right now (or null if it is not + * server-driven); with none, the raw global layer only, no defaults applied. + */ + override fun getWidgetServerUpdate( + widgetId: String?, + promise: Promise, + ) { + Log.d(TAG, "getWidgetServerUpdate called for widgetId=${widgetId ?: ""}") + + runBlocking { + try { + val resolver = VoltraWidgetServer.resolver(reactApplicationContext) + + if (widgetId != null) { + val scope = WidgetScope.of(widgetId) + + if (!resolver.isServerDriven(scope)) { + promise.resolve(null) + return@runBlocking + } + + val resolved = resolver.resolve(scope) + val settings = + WidgetServerUpdateSettings( + url = resolved.url, + intervalMinutes = resolved.intervalMinutes, + enabled = resolved.enabled, + method = resolved.method, + query = resolved.query, + headers = resolved.headers, + body = resolved.body, + ) + + promise.resolve(WidgetServerUpdateSettingsJson.stringify(settings)) + } else { + val global = resolver.globalSettings() + promise.resolve(global?.let { WidgetServerUpdateSettingsJson.stringify(it) }) + } + } catch (e: Exception) { + Log.e(TAG, "Failed to read widget server settings: ${e.message}", e) + promise.reject("VOLTRA_GET_SERVER_UPDATE_FAILED", e.message, e) + } + } + } + + /** + * Deprecated in favour of [setWidgetServerUpdate] with an `Authorization` header. Kept as a + * wrapper over the same encrypted records, so an app that has not migrated keeps working and + * nothing has to be moved on device. + */ override fun setWidgetServerCredentials( credentials: ReadableMap, promise: Promise, @@ -594,15 +699,18 @@ class VoltraModule( } } - runBlocking { widgetOrchestrator.reloadAllWidgets() } + runBlocking { widgetServerUpdateCoordinator.onCredentialsChanged() } Log.d(TAG, "Widget server credentials saved") promise.resolve(null) } + /** Deprecated alongside [setWidgetServerCredentials]. */ override fun clearWidgetServerCredentials(promise: Promise) { Log.d(TAG, "clearWidgetServerCredentials called") - runBlocking { VoltraWidgetCredentialStore.clearAll(reactApplicationContext) } - runBlocking { widgetOrchestrator.reloadAllWidgets() } + runBlocking { + VoltraWidgetCredentialStore.clearAll(reactApplicationContext) + widgetServerUpdateCoordinator.onCredentialsChanged() + } Log.d(TAG, "Widget server credentials cleared") promise.resolve(null) } diff --git a/packages/android-client/android/src/main/java/voltra/WidgetOrchestrator.kt b/packages/android-client/android/src/main/java/voltra/WidgetOrchestrator.kt index c0fd263a..7ef3bf01 100644 --- a/packages/android-client/android/src/main/java/voltra/WidgetOrchestrator.kt +++ b/packages/android-client/android/src/main/java/voltra/WidgetOrchestrator.kt @@ -5,6 +5,7 @@ import android.content.Context import android.util.Log import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext +import voltra.dynamicwidget.serverupdate.DynamicWidgetServerUpdateScheduler import voltra.dynamicwidget.triggerDynamicWidgetGlanceUpdate import voltra.widget.VoltraWidgetKind import voltra.widget.VoltraWidgetKindResolution @@ -13,6 +14,8 @@ import voltra.widget.VoltraWidgetReceiver import voltra.widget.VoltraWidgetReceivers import voltra.widget.payload.VoltraWidgetManager import voltra.widget.payload.VoltraWidgetUpdateScheduler +import voltra.widget.server.VoltraWidgetServer +import voltra.widget.server.WidgetScope /** * Resolves a widget id's [VoltraWidgetKind], or null if it can't be resolved. Injectable so @@ -58,6 +61,20 @@ internal class WidgetOrchestrator( // were Dynamic Widgets, without a real registered Glance receiver for the id to update. private val dynamicWidgetGlanceUpdateTrigger: suspend (String) -> Unit = { dynamicWidgetId -> triggerDynamicWidgetGlanceUpdate(context, dynamicWidgetId) }, + // Injectable for the same reason: a test can observe which Dynamic Widgets were asked to + // refetch without WorkManager being initialised. + private val dynamicWidgetServerFetchTrigger: (String) -> Boolean = + { dynamicWidgetId -> + if (VoltraWidgetServer.defaults(context).isServerDriven(dynamicWidgetId)) { + DynamicWidgetServerUpdateScheduler.requestImmediateUpdate( + context, + WidgetScope.of(dynamicWidgetId), + ) + true + } else { + false + } + }, private val clientWidgetGlanceUpdateTrigger: suspend (String) -> Unit = { widgetId -> VoltraWidgetReceiver.triggerGlanceUpdate(context, widgetId) }, ) { @@ -96,7 +113,7 @@ internal class WidgetOrchestrator( */ private suspend fun reloadSingleWidget(widgetId: String) { if (widgetKindClassifier.classify(widgetId) == VoltraWidgetKind.Dynamic) { - dynamicWidgetGlanceUpdateTrigger(widgetId) + reloadDynamicWidget(widgetId) return } @@ -131,13 +148,16 @@ internal class WidgetOrchestrator( for (widgetId in cachedAndServerIds) { when (widgetKindClassifier.classify(widgetId)) { VoltraWidgetKind.Dynamic -> { - Log.w( - TAG, - "reloadAllWidgets: $widgetId has a cached payload but resolves as " + - "a Dynamic Widget; purging the stale payload instead of " + - "pushing it onto the widget", - ) + // Server-driven Dynamic Widgets are in this set too, and they have no + // cached payload; only a widget that actually has one is worth warning + // about, because that payload was left by an older app version. if (widgetId in cachedPayloadIds) { + Log.w( + TAG, + "reloadAllWidgets: $widgetId has a cached payload but resolves as " + + "a Dynamic Widget; purging the stale payload instead of " + + "pushing it onto the widget", + ) payloadWidgetManager.clearWidgetData(widgetId) } dynamicIds.add(widgetId) @@ -195,7 +215,7 @@ internal class WidgetOrchestrator( } for (widgetId in dynamicIds) { try { - dynamicWidgetGlanceUpdateTrigger(widgetId) + reloadDynamicWidget(widgetId) } catch (e: Exception) { Log.e(TAG, "Failed to update client widget $widgetId: ${e.message}") } @@ -203,6 +223,18 @@ internal class WidgetOrchestrator( } } + /** + * Re-renders a Dynamic Widget, and refetches first when it is server-driven. + * + * A reload means "show me the current state", and for a server-driven widget that includes + * asking the server. The render still happens straight away so the widget reflects whatever it + * already has rather than waiting for the network. + */ + private suspend fun reloadDynamicWidget(widgetId: String) { + dynamicWidgetServerFetchTrigger(widgetId) + dynamicWidgetGlanceUpdateTrigger(widgetId) + } + /** * Re-render only Dynamic Widgets. Used to react to environment changes that affect `env` but * not server payloads, e.g. a light/dark (color scheme) toggle. diff --git a/packages/android-client/android/src/main/java/voltra/WidgetServerUpdateCoordinator.kt b/packages/android-client/android/src/main/java/voltra/WidgetServerUpdateCoordinator.kt new file mode 100644 index 00000000..50a56049 --- /dev/null +++ b/packages/android-client/android/src/main/java/voltra/WidgetServerUpdateCoordinator.kt @@ -0,0 +1,197 @@ +package voltra + +import android.content.Context +import android.util.Log +import voltra.dynamicwidget.DynamicWidgetPropsStore +import voltra.dynamicwidget.serverupdate.DynamicWidgetServerPropsStore +import voltra.dynamicwidget.serverupdate.DynamicWidgetServerUpdateScheduler +import voltra.widget.VoltraWidgetKind +import voltra.widget.VoltraWidgetKindResolution +import voltra.widget.VoltraWidgetKindResolver +import voltra.widget.payload.VoltraWidgetUpdateScheduler +import voltra.widget.server.VoltraWidgetServer +import voltra.widget.server.WidgetScope +import voltra.widget.server.WidgetServerEtagStore +import voltra.widget.server.WidgetServerSettingsValidator +import voltra.widget.server.WidgetServerUpdateSettings +import voltra.widget.server.WidgetServerUpdateSettingsJson + +/** + * Applies runtime server-update settings and makes the widgets they affect act on them. + * + * Writing a setting is only half of what an app expects from `setWidgetServerUpdate`: a new URL + * should be fetched from now, a new interval should reschedule the work, and `enabled: false` + * should actually stop it. Because that means talking to both engines' schedulers, this + * coordinator lives at `voltra` rather than inside `voltra.widget.server`, which depends on + * neither engine (ADR 0000). + */ +internal class WidgetServerUpdateCoordinator( + private val context: Context, + private val classifyKind: (String) -> VoltraWidgetKind? = { widgetId -> + when (val resolution = VoltraWidgetKindResolver.resolve(context, widgetId)) { + is VoltraWidgetKindResolution.Resolved -> resolution.kind + is VoltraWidgetKindResolution.Unresolved -> null + } + }, +) { + sealed class Result { + object Applied : Result() + + data class Rejected( + val reason: String, + ) : Result() + } + + /** + * @param widgetId the widget to scope the settings to, or null for every server-driven widget. + */ + suspend fun set( + settingsJson: String, + widgetId: String?, + ): Result { + val parsed = + when (val result = WidgetServerUpdateSettingsJson.parse(settingsJson)) { + is WidgetServerUpdateSettingsJson.Result.Invalid -> return Result.Rejected(result.reason) + is WidgetServerUpdateSettingsJson.Result.Parsed -> result.settings + } + + validate(parsed, widgetId)?.let { return Result.Rejected(it) } + + VoltraWidgetServer.store(context).set(parsed, widgetId?.let { WidgetScope.of(it) }) + applyToAffectedWidgets(widgetId) + + return Result.Applied + } + + suspend fun clear(widgetId: String?): Result { + widgetId?.let { id -> + rejectIfNotServerDriven(id)?.let { return Result.Rejected(it) } + } + + VoltraWidgetServer.store(context).clear(widgetId?.let { WidgetScope.of(it) }) + + // Clearing the global layer is logout: what the previous account's server sent has to go + // with it, or the widget keeps showing their data. A widget-scoped clear only drops that + // widget's overrides, so its props are left alone. + if (widgetId == null) { + clearFetchedState() + } + + applyToAffectedWidgets(widgetId) + + return Result.Applied + } + + /** + * Drops what the server last sent for every server-driven Dynamic Widget, so they fall back to + * `{}` with `env.serverUpdate.status` of `never` — the state a widget is in before its first + * fetch. + */ + private fun clearFetchedState() { + val propsStore = DynamicWidgetPropsStore(context) + val statusStore = DynamicWidgetServerPropsStore(context) + val etags = WidgetServerEtagStore(context) + + for (widgetId in VoltraWidgetServer.serverDrivenWidgetIds(context)) { + if (classifyKind(widgetId) != VoltraWidgetKind.Dynamic) continue + + val scope = WidgetScope.of(widgetId) + + try { + propsStore.clearDynamicWidgetProps(widgetId) + } catch (e: Exception) { + Log.e(TAG, "Failed to clear fetched props for '$widgetId': ${e.message}", e) + } + + statusStore.clear(scope) + etags.clear(scope) + } + } + + /** + * Rescheduling and refetching after the deprecated credential API writes its layer. It does not + * go through [set], but a new token is exactly the thing a widget stuck on a `401` is waiting + * for. + */ + suspend fun onCredentialsChanged() { + VoltraWidgetServer.store(context).bumpRevision() + applyToAffectedWidgets(widgetId = null) + } + + /** + * Drops one widget's runtime settings and everything the server left behind for it, so + * `clearWidget` really clears it rather than leaving a stored ETag that turns the next fetch + * into a `304` against content that is no longer there. + */ + suspend fun dropWidgetLayer(widgetId: String) { + if (!VoltraWidgetServer.defaults(context).isServerDriven(widgetId)) return + + val scope = WidgetScope.of(widgetId) + + VoltraWidgetServer.store(context).clear(scope) + DynamicWidgetServerPropsStore(context).clear(scope) + WidgetServerEtagStore(context).clear(scope) + } + + private fun validate( + settings: WidgetServerUpdateSettings, + widgetId: String?, + ): String? { + widgetId?.let { id -> + rejectIfNotServerDriven(id)?.let { return it } + } + + return WidgetServerSettingsValidator.validate(settings, VoltraWidgetServer.isDebugBuild(context)) + } + + /** + * The engine is chosen at generate time, so a runtime URL cannot turn a locally-driven widget + * into a server-driven one. Saying so at call time is much easier to act on than a widget that + * quietly never fetches. + */ + private fun rejectIfNotServerDriven(widgetId: String): String? { + if (VoltraWidgetServer.defaults(context).isServerDriven(widgetId)) return null + + return "Widget '$widgetId' is not server-driven. Add a serverUpdate entry for it in app.json " + + "and rebuild; a runtime url does not change how a widget is rendered." + } + + /** Reschedules and refetches the widgets a settings change reaches: one, or all of them. */ + private suspend fun applyToAffectedWidgets(widgetId: String?) { + val affected = + if (widgetId != null) setOf(widgetId) else VoltraWidgetServer.serverDrivenWidgetIds(context) + + for (id in affected) { + try { + applyToWidget(id) + } catch (e: Exception) { + Log.e(TAG, "Failed to apply server update settings to '$id': ${e.message}", e) + } + } + } + + private suspend fun applyToWidget(widgetId: String) { + val scope = WidgetScope.of(widgetId) + + when (classifyKind(widgetId)) { + VoltraWidgetKind.Dynamic -> { + DynamicWidgetServerUpdateScheduler.schedule(context, scope) + } + + VoltraWidgetKind.Payload -> { + // schedulePeriodicUpdate cancels the work when there is nothing to fetch, so this + // one call covers a changed interval, a changed url and enabled: false alike. + VoltraWidgetUpdateScheduler.schedulePeriodicUpdate(context, widgetId) + VoltraWidgetUpdateScheduler.requestImmediateUpdate(context, widgetId) + } + + null -> { + Log.w(TAG, "Could not resolve the kind of '$widgetId'; its settings were stored but not applied") + } + } + } + + private companion object { + private const val TAG = "VoltraServerSettings" + } +} diff --git a/packages/android-client/android/src/main/java/voltra/dynamicwidget/DynamicWidgetEnvironmentSource.kt b/packages/android-client/android/src/main/java/voltra/dynamicwidget/DynamicWidgetEnvironmentSource.kt new file mode 100644 index 00000000..585917f3 --- /dev/null +++ b/packages/android-client/android/src/main/java/voltra/dynamicwidget/DynamicWidgetEnvironmentSource.kt @@ -0,0 +1,34 @@ +package voltra.dynamicwidget + +import android.content.Context +import androidx.glance.action.Action + +/** + * Extra `env` fields contributed by whatever is driving a Dynamic Widget's props. + * + * This is the only seam ADR 0002 opens in the existing Dynamic render path. A plain Dynamic + * Widget has no source and its `env` is unchanged; a server-driven one is given a source that + * contributes `env.serverUpdate`, so the widget can say "updated 3 min ago" or "offline" without + * the server having to tell it. + * + * Values are written straight into the env JSON, so they must be things `org.json` understands: + * a `JSONObject`, a `String`, a number, or a boolean. + */ +interface DynamicWidgetEnvironmentSource { + fun environmentFields( + context: Context, + dynamicWidgetId: String, + ): Map + + /** + * The action a refresh button should run, or null when the widget draws no button. + * + * Only something that can actually refresh the widget can answer this, which is why it lives + * next to the env fields rather than on the Glance widget. A plain Dynamic Widget has nothing + * to refresh from, so it never draws one. + */ + fun refreshAction( + context: Context, + dynamicWidgetId: String, + ): Action? = null +} diff --git a/packages/android-client/android/src/main/java/voltra/dynamicwidget/DynamicWidgetInstanceSizes.kt b/packages/android-client/android/src/main/java/voltra/dynamicwidget/DynamicWidgetInstanceSizes.kt new file mode 100644 index 00000000..af79e967 --- /dev/null +++ b/packages/android-client/android/src/main/java/voltra/dynamicwidget/DynamicWidgetInstanceSizes.kt @@ -0,0 +1,60 @@ +package voltra.dynamicwidget + +import android.appwidget.AppWidgetManager +import android.content.Context +import android.os.Build +import android.util.Log +import androidx.compose.ui.unit.DpSize +import androidx.compose.ui.unit.dp +import voltra.widget.VoltraWidgetReceivers + +/** + * Sizes the launcher has actually given a Dynamic Widget's instances. + * + * Used by the trial render to pick an environment the user will really see. The smallest instance + * is chosen because it is the one most likely to exercise a widget's compact layout branch, which + * is where a size-dependent render error tends to live. + */ +internal object DynamicWidgetInstanceSizes { + private const val TAG = "VoltraDynamicSizes" + + fun smallestPlacedSize( + context: Context, + dynamicWidgetId: String, + ): DpSize? = + try { + val manager = AppWidgetManager.getInstance(context) + val component = VoltraWidgetReceivers.componentName(context, dynamicWidgetId) + + manager + .getAppWidgetIds(component) + .toList() + .mapNotNull { appWidgetId -> minimumSize(manager, appWidgetId) } + .minByOrNull { size -> size.width.value * size.height.value } + } catch (e: Exception) { + Log.w(TAG, "Could not read placed sizes for '$dynamicWidgetId': ${e.message}") + null + } + + private fun minimumSize( + manager: AppWidgetManager, + appWidgetId: Int, + ): DpSize? { + val options = manager.getAppWidgetOptions(appWidgetId) ?: return null + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + val sizes = options.getParcelableArrayList(AppWidgetManager.OPTION_APPWIDGET_SIZES) + + sizes + ?.minByOrNull { it.width * it.height } + ?.let { return DpSize(it.width.dp, it.height.dp) } + } + + val width = options.getInt(AppWidgetManager.OPTION_APPWIDGET_MIN_WIDTH, 0) + val height = options.getInt(AppWidgetManager.OPTION_APPWIDGET_MIN_HEIGHT, 0) + + if (width <= 0 || height <= 0) return null + + return DpSize(width.dp, height.dp) + } +} diff --git a/packages/android-client/android/src/main/java/voltra/dynamicwidget/DynamicWidgetTrialRenderBoundary.kt b/packages/android-client/android/src/main/java/voltra/dynamicwidget/DynamicWidgetTrialRenderBoundary.kt new file mode 100644 index 00000000..f78ba693 --- /dev/null +++ b/packages/android-client/android/src/main/java/voltra/dynamicwidget/DynamicWidgetTrialRenderBoundary.kt @@ -0,0 +1,61 @@ +package voltra.dynamicwidget + +import android.content.Context +import androidx.compose.ui.unit.DpSize +import androidx.compose.ui.unit.dp +import voltra.models.VoltraNode + +/** + * Renders a Dynamic Widget once, outside any composition, to find out whether some props render at + * all. + * + * It lives here rather than in `voltra.dynamicwidget.serverupdate` because it needs the same + * bundle evaluation and env construction the on-screen render uses, and neither is public. The + * server-update engine calls it before committing fetched props. + * + * @return the rendered node, or null when the bundle is not available or the render failed. + */ +internal suspend fun renderDynamicWidgetForTrial( + context: Context, + dynamicWidgetId: String, + dynamicWidgetPropsJson: String, +): VoltraNode? { + if (!VoltraClientGlanceWidget.ensureBundleEvaluated(context, dynamicWidgetId)) { + // No bundle means every render fails, including the one already on screen. Rejecting the + // props here would be blaming them for something they did not cause, and the widget falls + // back to its prerendered initial state either way. + return null + } + + val environmentJson = + VoltraClientGlanceWidget.buildTrialEnvJson( + context = context, + widgetId = dynamicWidgetId, + size = trialSize(context, dynamicWidgetId), + configuration = VoltraConfigurationStore(context).get(dynamicWidgetId), + ) + + return DynamicWidgetRenderCoordinator().renderDynamicWidget( + dynamicWidgetId = dynamicWidgetId, + dynamicWidgetRenderInput = DynamicWidgetRenderInput(propsRevision = 0L, propsJson = dynamicWidgetPropsJson), + dynamicWidgetEnvironmentJson = environmentJson, + ) +} + +/** + * The size the trial render uses: the smallest placed instance if there is one, otherwise a + * middling home screen widget. Picking a real placement matters because `env.widgetFamily` is how + * a widget chooses its layout, and rendering a size the user does not have would test the wrong + * branch. + */ +private fun trialSize( + context: Context, + dynamicWidgetId: String, +): DpSize { + val placed = DynamicWidgetInstanceSizes.smallestPlacedSize(context, dynamicWidgetId) + + return placed ?: DpSize(FALLBACK_WIDTH_DP.dp, FALLBACK_HEIGHT_DP.dp) +} + +private const val FALLBACK_WIDTH_DP = 180 +private const val FALLBACK_HEIGHT_DP = 110 diff --git a/packages/android-client/android/src/main/java/voltra/dynamicwidget/VoltraClientGlanceWidget.kt b/packages/android-client/android/src/main/java/voltra/dynamicwidget/VoltraClientGlanceWidget.kt index 922bb40d..98c92cdb 100644 --- a/packages/android-client/android/src/main/java/voltra/dynamicwidget/VoltraClientGlanceWidget.kt +++ b/packages/android-client/android/src/main/java/voltra/dynamicwidget/VoltraClientGlanceWidget.kt @@ -8,18 +8,26 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.DpSize import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp import androidx.glance.GlanceId import androidx.glance.GlanceModifier import androidx.glance.LocalContext import androidx.glance.LocalSize +import androidx.glance.action.Action +import androidx.glance.action.clickable import androidx.glance.appwidget.GlanceAppWidget import androidx.glance.appwidget.SizeMode +import androidx.glance.appwidget.cornerRadius import androidx.glance.appwidget.provideContent +import androidx.glance.background import androidx.glance.layout.Alignment import androidx.glance.layout.Box import androidx.glance.layout.fillMaxSize import androidx.glance.layout.padding +import androidx.glance.layout.size +import androidx.glance.text.FontWeight import androidx.glance.text.Text +import androidx.glance.text.TextAlign import androidx.glance.unit.ColorProvider import com.facebook.react.modules.systeminfo.AndroidInfoHelpers import kotlinx.coroutines.Dispatchers @@ -50,6 +58,7 @@ import java.net.URL */ class VoltraClientGlanceWidget( private val widgetId: String = "default", + private val environmentSource: DynamicWidgetEnvironmentSource? = null, ) : GlanceAppWidget() { companion object { private const val TAG = "VoltraClientGlanceWidget" @@ -124,14 +133,49 @@ class VoltraClientGlanceWidget( private fun isDev(context: Context): Boolean = (context.applicationInfo.flags and ApplicationInfo.FLAG_DEBUGGABLE) != 0 + /** + * Loads the widget's bundle and evaluates it into the shared Hermes runtime, the same way + * an on-screen render does — reading Metro in a debug build and the baked asset otherwise. + * + * Exposed for the server-update engine, which has to render fetched props before + * committing them. It costs what a render already costs, including the Metro round trip in + * a debug build. + */ + internal suspend fun ensureBundleEvaluated( + context: Context, + widgetId: String, + ): Boolean { + val source = + if (isDev(context)) fetchDevBundle(context, widgetId) else loadBakedBundle(context, widgetId) + + return source != null && VoltraJSRenderer.evaluateBundle(source, widgetId) + } + + /** + * The env a trial render runs with: the real theme, locale and configuration, and a size + * the caller picked from the widget's placements. + * + * `env.serverUpdate` is deliberately absent. The trial is asking whether the props render, + * and a widget that only fails when it is told the fetch went badly is a different problem + * from props that cannot be drawn. + */ + internal fun buildTrialEnvJson( + context: Context, + widgetId: String, + size: DpSize, + configuration: Map, + ): String = buildEnvJson(context, widgetId, size, configuration, environmentSource = null) + /** * Build the WidgetEnvironment JSON (see packages/core/src/widget-environment.ts) for the * current render. */ private fun buildEnvJson( context: Context, + widgetId: String, size: DpSize, configuration: Map, + environmentSource: DynamicWidgetEnvironmentSource?, ): String { val family = "${size.width.value.toInt()}x${size.height.value.toInt()}" val nightMode = @@ -160,14 +204,22 @@ class VoltraClientGlanceWidget( val configObject = JSONObject() configuration.forEach { (key, value) -> configObject.put(key, value) } - return JSONObject() - .put("date", System.currentTimeMillis()) - .put("widgetFamily", family) - .put("colorScheme", colorScheme) - .put("locale", locale) - .put("configuration", configObject) - .put("build", build) - .toString() + val env = + JSONObject() + .put("date", System.currentTimeMillis()) + .put("widgetFamily", family) + .put("colorScheme", colorScheme) + .put("locale", locale) + .put("configuration", configObject) + .put("build", build) + + // Whatever drives this widget's props gets to describe itself. A plain Dynamic Widget + // has no source and its env is exactly what it was before ADR 0002. + environmentSource?.environmentFields(context, widgetId)?.forEach { (key, value) -> + env.put(key, value) + } + + return env.toString() } } @@ -219,6 +271,54 @@ class VoltraClientGlanceWidget( } else { Fallback() } + + // Drawn over the widget's own content, so an entry does not have to leave room for it. + // Only a server-driven widget configured with `refresh: true` has a source that offers one. + environmentSource?.refreshAction(context, widgetId)?.let { action -> + RefreshButton(action) + } + } + + /** + * The same overlay the payload engine draws, so the two engines' refresh buttons look and sit + * identically. Tapping it enqueues a fetch rather than running one inline: the tap then + * survives a moment without connectivity instead of failing silently. + */ + @Composable + private fun RefreshButton(action: Action) { + Box( + modifier = GlanceModifier.fillMaxSize().padding(12.dp), + contentAlignment = Alignment.TopEnd, + ) { + Box( + modifier = + GlanceModifier + .size(28.dp) + .cornerRadius(14.dp) + .background( + androidx.glance.color.ColorProvider( + day = Color(0x32787880), + night = Color(0x32787880), + ), + ).clickable(action), + contentAlignment = Alignment.Center, + ) { + Text( + text = "↻", + style = + androidx.glance.text.TextStyle( + fontSize = 18.sp, + fontWeight = FontWeight.Bold, + textAlign = TextAlign.Center, + color = + androidx.glance.color.ColorProvider( + day = Color(0x993C3C43), + night = Color(0x99EBEBF5), + ), + ), + ) + } + } } private fun renderNode( @@ -227,7 +327,7 @@ class VoltraClientGlanceWidget( configuration: Map, dynamicWidgetRenderInput: DynamicWidgetRenderInput, ): VoltraNode? { - val envJson = buildEnvJson(context, size, configuration) + val envJson = buildEnvJson(context, widgetId, size, configuration, environmentSource) val dynamicWidgetRenderCoordinator = DynamicWidgetRenderCoordinator() return dynamicWidgetRenderCoordinator.renderDynamicWidget( dynamicWidgetId = widgetId, diff --git a/packages/android-client/android/src/main/java/voltra/dynamicwidget/serverupdate/DynamicWidgetRefreshActionCallback.kt b/packages/android-client/android/src/main/java/voltra/dynamicwidget/serverupdate/DynamicWidgetRefreshActionCallback.kt new file mode 100644 index 00000000..8bb0164c --- /dev/null +++ b/packages/android-client/android/src/main/java/voltra/dynamicwidget/serverupdate/DynamicWidgetRefreshActionCallback.kt @@ -0,0 +1,42 @@ +package voltra.dynamicwidget.serverupdate + +import android.content.Context +import android.util.Log +import androidx.glance.GlanceId +import androidx.glance.action.ActionParameters +import androidx.glance.appwidget.action.ActionCallback +import voltra.widget.server.WidgetScope + +/** + * The refresh button on a server-driven Dynamic Widget. + * + * Unlike the payload engine's button, which fetches inline, this enqueues expedited work. A tap + * with no signal then waits for connectivity and retries with backoff instead of failing silently, + * and the fetch runs under the same constraints and through the same code path as every other + * update — so a refresh cannot produce props a scheduled run would have rejected. + * + * Glance writes this class name into the `RemoteViews` the launcher holds for a placed widget, so + * it must not move or be renamed once a release ships it. + */ +class DynamicWidgetRefreshActionCallback : ActionCallback { + override suspend fun onAction( + context: Context, + glanceId: GlanceId, + parameters: ActionParameters, + ) { + val widgetId = parameters[KEY_WIDGET_ID] + + if (widgetId == null) { + Log.e(TAG, "No widget id in the refresh action parameters") + return + } + + DynamicWidgetServerUpdateScheduler.requestImmediateUpdate(context, WidgetScope.of(widgetId)) + } + + companion object { + val KEY_WIDGET_ID = ActionParameters.Key("voltra_widget_id") + + private const val TAG = "VoltraDynamicRefresh" + } +} diff --git a/packages/android-client/android/src/main/java/voltra/dynamicwidget/serverupdate/DynamicWidgetServerEnvironmentSource.kt b/packages/android-client/android/src/main/java/voltra/dynamicwidget/serverupdate/DynamicWidgetServerEnvironmentSource.kt new file mode 100644 index 00000000..b34e5684 --- /dev/null +++ b/packages/android-client/android/src/main/java/voltra/dynamicwidget/serverupdate/DynamicWidgetServerEnvironmentSource.kt @@ -0,0 +1,40 @@ +package voltra.dynamicwidget.serverupdate + +import android.content.Context +import androidx.glance.action.Action +import androidx.glance.action.actionParametersOf +import androidx.glance.appwidget.action.actionRunCallback +import voltra.dynamicwidget.DynamicWidgetEnvironmentSource +import voltra.widget.server.VoltraWidgetServer +import voltra.widget.server.WidgetScope + +/** + * Contributes `env.serverUpdate` to a server-driven Dynamic Widget's render. + * + * This is the whole of what ADR 0002 adds to the render path: the widget is told how the last + * fetch went, so it can show "updated 3 min ago", dim itself when the data is stale, or hide its + * freshness line entirely while the app has taken it over. + */ +internal class DynamicWidgetServerEnvironmentSource : DynamicWidgetEnvironmentSource { + override fun environmentFields( + context: Context, + dynamicWidgetId: String, + ): Map { + val status = DynamicWidgetServerPropsStore(context).status(WidgetScope.of(dynamicWidgetId)) + + return mapOf("serverUpdate" to status.toJson()) + } + + override fun refreshAction( + context: Context, + dynamicWidgetId: String, + ): Action? { + if (VoltraWidgetServer.defaults(context).defaults(dynamicWidgetId)?.refresh != true) { + return null + } + + return actionRunCallback( + actionParametersOf(DynamicWidgetRefreshActionCallback.KEY_WIDGET_ID to dynamicWidgetId), + ) + } +} diff --git a/packages/android-client/android/src/main/java/voltra/dynamicwidget/serverupdate/DynamicWidgetServerProps.kt b/packages/android-client/android/src/main/java/voltra/dynamicwidget/serverupdate/DynamicWidgetServerProps.kt new file mode 100644 index 00000000..2008aa7f --- /dev/null +++ b/packages/android-client/android/src/main/java/voltra/dynamicwidget/serverupdate/DynamicWidgetServerProps.kt @@ -0,0 +1,69 @@ +package voltra.dynamicwidget.serverupdate + +import org.json.JSONArray +import org.json.JSONObject + +/** What a `200` body turned out to be. */ +internal sealed class DynamicWidgetPropsParseResult { + data class Props( + val json: String, + ) : DynamicWidgetPropsParseResult() + + data class Invalid( + val reason: String, + ) : DynamicWidgetPropsParseResult() +} + +/** + * Reads a server response as Dynamic Widget props. + * + * The whole point of ADR 0002 is that the server returns data, not UI, so the only thing accepted + * here is a JSON object. The one shape called out specially is a Voltra payload: `serverUpdate` is + * the same config key for both engines, so pointing a widget with an `entry` at a payload endpoint + * is the easy mistake to make, and it has to fail loudly rather than look like unusable props. + */ +internal object DynamicWidgetServerProps { + fun parse(body: String): DynamicWidgetPropsParseResult { + val trimmed = body.trim() + + if (trimmed.isEmpty()) { + return DynamicWidgetPropsParseResult.Invalid("response body was empty") + } + + if (trimmed.startsWith("[")) { + return DynamicWidgetPropsParseResult.Invalid( + "response body is a JSON array; a Dynamic Widget's props must be a JSON object", + ) + } + + val parsed = + try { + JSONObject(trimmed) + } catch (_: Exception) { + return DynamicWidgetPropsParseResult.Invalid( + "response body is not a JSON object; a Dynamic Widget's props must be a JSON object", + ) + } + + if (looksLikeVoltraPayload(parsed)) { + return DynamicWidgetPropsParseResult.Invalid( + "response body looks like a Voltra payload (top-level 'v' with 'variants' or 'e'). " + + "This widget has an entry, so it renders on the device: return the props it should " + + "render, not a rendered payload.", + ) + } + + return DynamicWidgetPropsParseResult.Props(parsed.toString()) + } + + /** + * A Voltra payload always carries a version under `v` alongside either the size variants a + * widget renders or the shared element table. Props that happen to have a `v` key are not + * mistaken for one. + */ + private fun looksLikeVoltraPayload(body: JSONObject): Boolean { + if (body.opt("v") !is Int) return false + + return body.opt("variants") is JSONObject || body.opt("e") is JSONArray + } +} diff --git a/packages/android-client/android/src/main/java/voltra/dynamicwidget/serverupdate/DynamicWidgetServerPropsStore.kt b/packages/android-client/android/src/main/java/voltra/dynamicwidget/serverupdate/DynamicWidgetServerPropsStore.kt new file mode 100644 index 00000000..4b47a0f0 --- /dev/null +++ b/packages/android-client/android/src/main/java/voltra/dynamicwidget/serverupdate/DynamicWidgetServerPropsStore.kt @@ -0,0 +1,161 @@ +package voltra.dynamicwidget.serverupdate + +import android.content.Context +import org.json.JSONObject +import voltra.widget.server.WidgetScope + +/** + * What the widget is told about the server side of its props, as `env.serverUpdate`. + * + * Deliberately not the props themselves: fetched props are committed to the Dynamic Widget's + * existing props slot, so the render path cannot tell whether they came from a fetch or from + * `updateDynamicWidget`. This record is only the story around them. + */ +data class DynamicWidgetServerStatus( + val status: String, + val fetchedAt: Long? = null, + val error: String? = null, + val httpStatus: Int? = null, +) { + fun toJson(): JSONObject { + val json = JSONObject().put("status", status) + + fetchedAt?.let { json.put("fetchedAt", it) } + error?.let { json.put("error", it) } + httpStatus?.let { json.put("httpStatus", it) } + + return json + } + + companion object { + const val STATUS_FRESH = "fresh" + const val STATUS_STALE = "stale" + const val STATUS_NEVER = "never" + const val STATUS_DISABLED = "disabled" + + const val ERROR_NETWORK = "network" + const val ERROR_HTTP = "http" + const val ERROR_UNAUTHORIZED = "unauthorized" + const val ERROR_PARSE = "parse" + const val ERROR_RENDER = "render" + + /** What a widget sees before any fetch has succeeded. */ + val NEVER = DynamicWidgetServerStatus(STATUS_NEVER) + } +} + +/** + * Per-scope record of how the last fetch went. + * + * Kept in its own preferences file rather than alongside the props so that clearing a widget's + * props — logout, `clearWidget` — and clearing its fetch history stay separate decisions. + */ +internal class DynamicWidgetServerPropsStore( + context: Context, +) : DynamicWidgetServerStatusSink { + private val preferences = + context.applicationContext.getSharedPreferences(PREFERENCES_NAME, Context.MODE_PRIVATE) + + fun status(scope: WidgetScope): DynamicWidgetServerStatus { + val raw = preferences.getString(key(scope), null) ?: return DynamicWidgetServerStatus.NEVER + + return try { + val json = JSONObject(raw) + + DynamicWidgetServerStatus( + status = json.optString("status", DynamicWidgetServerStatus.STATUS_NEVER), + fetchedAt = if (json.has("fetchedAt")) json.optLong("fetchedAt") else null, + error = if (json.has("error")) json.optString("error") else null, + httpStatus = if (json.has("httpStatus")) json.optInt("httpStatus") else null, + ) + } catch (_: Exception) { + DynamicWidgetServerStatus.NEVER + } + } + + fun put( + scope: WidgetScope, + status: DynamicWidgetServerStatus, + ) { + preferences.edit().putString(key(scope), status.toJson().toString()).apply() + } + + /** + * Records a failure without losing the fact that a fetch once worked. `stale` is only + * meaningful next to the `fetchedAt` of the last success, so that is carried forward. + */ + override fun recordFailure( + scope: WidgetScope, + error: String, + httpStatus: Int?, + ) { + val previous = status(scope) + + put( + scope, + DynamicWidgetServerStatus( + status = + if (previous.fetchedAt == null) { + DynamicWidgetServerStatus.STATUS_NEVER + } else { + DynamicWidgetServerStatus.STATUS_STALE + }, + fetchedAt = previous.fetchedAt, + error = error, + httpStatus = httpStatus, + ), + ) + } + + override fun recordSuccess( + scope: WidgetScope, + fetchedAt: Long, + httpStatus: Int, + ) { + put( + scope, + DynamicWidgetServerStatus( + status = DynamicWidgetServerStatus.STATUS_FRESH, + fetchedAt = fetchedAt, + httpStatus = httpStatus, + ), + ) + } + + /** + * Reports `disabled` once, keeping the last `fetchedAt` so a widget that comes back under app + * control can still say when the server last spoke. + */ + override fun markDisabledIfNeeded( + scope: WidgetScope, + enabled: Boolean, + ) { + if (enabled) return + + val previous = status(scope) + + if (previous.status == DynamicWidgetServerStatus.STATUS_DISABLED) return + + put( + scope, + DynamicWidgetServerStatus( + status = DynamicWidgetServerStatus.STATUS_DISABLED, + fetchedAt = previous.fetchedAt, + ), + ) + } + + fun clear(scope: WidgetScope) { + preferences.edit().remove(key(scope)).apply() + } + + fun clearAll() { + preferences.edit().clear().apply() + } + + private fun key(scope: WidgetScope) = "server_status.${scope.storageKey}" + + companion object { + private const val PREFERENCES_NAME = "voltra_dynamic_widget_server" + } +} diff --git a/packages/android-client/android/src/main/java/voltra/dynamicwidget/serverupdate/DynamicWidgetServerUpdateOutcome.kt b/packages/android-client/android/src/main/java/voltra/dynamicwidget/serverupdate/DynamicWidgetServerUpdateOutcome.kt new file mode 100644 index 00000000..2cedae2a --- /dev/null +++ b/packages/android-client/android/src/main/java/voltra/dynamicwidget/serverupdate/DynamicWidgetServerUpdateOutcome.kt @@ -0,0 +1,45 @@ +package voltra.dynamicwidget.serverupdate + +/** + * What one server-update run decided, before it is turned into a WorkManager result. + * + * Splitting the decision from the plumbing is what makes the ADR 0002 failure table testable: the + * rules for "retry", "give up", and "keep what we have" are the interesting part, and none of them + * need WorkManager to be exercised. + */ +internal data class DynamicWidgetServerUpdateResult( + val outcome: DynamicWidgetServerUpdateOutcome, + /** + * What the server asked for about the next fetch, in minutes: `Cache-Control: max-age` on a + * success, `Retry-After` on a `429` or `503`. Already clamped to what the platform can honour. + * Null when the server said nothing and the widget's own interval stands. + */ + val nextIntervalMinutes: Long? = null, +) + +internal enum class DynamicWidgetServerUpdateOutcome { + /** Props were committed, or the server said `304` and what we have is still current. */ + Committed, + + /** Nothing to do: the widget has no URL, or the app turned fetching off. */ + Skipped, + + /** + * Something went wrong that waiting could fix — no connectivity, a `5xx`, a `429`. The + * previous props stay on screen and the run is retried with backoff. + */ + Retry, + + /** + * Something went wrong that waiting will not fix — a `401`, a `404`, a body that is not props. + * The previous props stay on screen and the next periodic run tries again at the normal + * interval rather than immediately. + */ + Failed, + + /** + * The response arrived and parsed, but the settings it was built from are no longer current. + * The result is dropped; the reload queued by whatever changed the settings fetches again. + */ + Dropped, +} diff --git a/packages/android-client/android/src/main/java/voltra/dynamicwidget/serverupdate/DynamicWidgetServerUpdateRunner.kt b/packages/android-client/android/src/main/java/voltra/dynamicwidget/serverupdate/DynamicWidgetServerUpdateRunner.kt new file mode 100644 index 00000000..c4927050 --- /dev/null +++ b/packages/android-client/android/src/main/java/voltra/dynamicwidget/serverupdate/DynamicWidgetServerUpdateRunner.kt @@ -0,0 +1,189 @@ +package voltra.dynamicwidget.serverupdate + +import android.content.Context +import android.util.Log +import voltra.dynamicwidget.DynamicWidgetPropsPersistence +import voltra.widget.VoltraWidgetKind +import voltra.widget.VoltraWidgetKindResolution +import voltra.widget.server.ResolvedWidgetServerSettings +import voltra.widget.server.WidgetScope +import voltra.widget.server.WidgetServerFetchResult +import voltra.widget.server.WidgetServerUpdateDefaults + +/** + * Fetch, parse, trial-render, commit — the four steps ADR 0002 requires of every server-driven + * widget, with every collaborator injected so the failure table can be tested without a network, + * a Hermes runtime, or WorkManager. + * + * The rule the ordering exists for: props that do not render are never committed. A server that + * starts returning a shape the widget throws on leaves the last good props on screen instead of + * replacing them with an error box. + */ +internal class DynamicWidgetServerUpdateRunner( + private val resolveKind: suspend (String) -> VoltraWidgetKindResolution, + private val resolveSettings: suspend (WidgetScope) -> ResolvedWidgetServerSettings, + private val currentRevision: suspend (WidgetScope) -> Long, + private val readEtag: (WidgetScope, String?) -> String?, + private val fetch: suspend (WidgetScope, ResolvedWidgetServerSettings, String?) -> WidgetServerFetchResult, + private val writeEtag: (WidgetScope, String, String?) -> Unit, + private val trialRender: suspend (WidgetScope, String) -> Boolean, + private val commitProps: DynamicWidgetPropsPersistence, + private val statusStore: DynamicWidgetServerStatusSink, + private val notifyWidget: suspend (WidgetScope) -> Unit, + private val now: () -> Long = System::currentTimeMillis, +) { + suspend fun run(scope: WidgetScope): DynamicWidgetServerUpdateResult { + // Kind first, before anything opens a connection (ADR 0000). A release that turns a + // Dynamic Widget back into a payload widget leaves this work scheduled, and it has to + // notice rather than write props into a widget that does not read them. + val kind = resolveKind(scope.widgetId) + + if (kind !is VoltraWidgetKindResolution.Resolved || kind.kind != VoltraWidgetKind.Dynamic) { + Log.w(TAG, "Widget '${scope.widgetId}' is not a Dynamic Widget; cancelling its server updates") + return DynamicWidgetServerUpdateResult(DynamicWidgetServerUpdateOutcome.Skipped) + } + + val settings = resolveSettings(scope) + + if (!settings.shouldFetch) { + statusStore.markDisabledIfNeeded(scope, settings.enabled) + return DynamicWidgetServerUpdateResult(DynamicWidgetServerUpdateOutcome.Skipped) + } + + val revision = currentRevision(scope) + val url = settings.url!! + val result = fetch(scope, settings, readEtag(scope, url)) + + // Settings that moved while we were on the network make this response answer a question + // nobody is asking any more. + if (currentRevision(scope) != revision) { + Log.d(TAG, "Dropping server update for '${scope.widgetId}': settings changed mid-fetch") + return DynamicWidgetServerUpdateResult(DynamicWidgetServerUpdateOutcome.Dropped) + } + + return when (result) { + is WidgetServerFetchResult.NotModified -> { + statusStore.recordSuccess(scope, now(), HTTP_NOT_MODIFIED) + notifyWidget(scope) + DynamicWidgetServerUpdateResult( + DynamicWidgetServerUpdateOutcome.Committed, + clampServerInterval(result.nextIntervalMinutes), + ) + } + + is WidgetServerFetchResult.NetworkFailure -> { + Log.w(TAG, "Server update for '${scope.widgetId}' failed: ${result.message}") + statusStore.recordFailure(scope, DynamicWidgetServerStatus.ERROR_NETWORK) + notifyWidget(scope) + DynamicWidgetServerUpdateResult(DynamicWidgetServerUpdateOutcome.Retry) + } + + is WidgetServerFetchResult.TooLarge -> { + // The server answered, with a body the device will not hold. Asking again returns + // the same one, so this is a parse failure rather than something to back off from. + Log.e(TAG, "Server update for '${scope.widgetId}' returned a body that is too large") + statusStore.recordFailure(scope, DynamicWidgetServerStatus.ERROR_PARSE, result.httpStatus) + notifyWidget(scope) + DynamicWidgetServerUpdateResult(DynamicWidgetServerUpdateOutcome.Failed) + } + + is WidgetServerFetchResult.HttpFailure -> { + val error = + if (result.isUnauthorized) { + DynamicWidgetServerStatus.ERROR_UNAUTHORIZED + } else { + DynamicWidgetServerStatus.ERROR_HTTP + } + + Log.w(TAG, "Server update for '${scope.widgetId}' got HTTP ${result.httpStatus}") + statusStore.recordFailure(scope, error, result.httpStatus) + notifyWidget(scope) + + // A 401 will keep being a 401 until the app sets a fresh token, and setting one + // reloads the widget. Backing off would only burn battery. + if (result.isTransient) { + DynamicWidgetServerUpdateResult( + DynamicWidgetServerUpdateOutcome.Retry, + clampServerInterval(result.retryAfterMinutes), + ) + } else { + DynamicWidgetServerUpdateResult(DynamicWidgetServerUpdateOutcome.Failed) + } + } + + is WidgetServerFetchResult.Success -> { + commit(scope, url, result) + } + } + } + + private suspend fun commit( + scope: WidgetScope, + url: String, + result: WidgetServerFetchResult.Success, + ): DynamicWidgetServerUpdateResult { + val nextIntervalMinutes = clampServerInterval(result.nextIntervalMinutes) + + when (val parsed = DynamicWidgetServerProps.parse(result.body)) { + is DynamicWidgetPropsParseResult.Invalid -> { + Log.e(TAG, "Server update for '${scope.widgetId}' rejected: ${parsed.reason}") + statusStore.recordFailure(scope, DynamicWidgetServerStatus.ERROR_PARSE, result.httpStatus) + notifyWidget(scope) + // Asking again returns the same body, so this is not something to retry. + return DynamicWidgetServerUpdateResult(DynamicWidgetServerUpdateOutcome.Failed) + } + + is DynamicWidgetPropsParseResult.Props -> { + if (!trialRender(scope, parsed.json)) { + Log.e(TAG, "Server update for '${scope.widgetId}' did not render; keeping the previous props") + statusStore.recordFailure(scope, DynamicWidgetServerStatus.ERROR_RENDER, result.httpStatus) + notifyWidget(scope) + return DynamicWidgetServerUpdateResult(DynamicWidgetServerUpdateOutcome.Failed) + } + + commitProps.persistDynamicWidgetProps(scope.widgetId, parsed.json) + writeEtag(scope, url, result.etag) + statusStore.recordSuccess(scope, now(), result.httpStatus) + notifyWidget(scope) + + return DynamicWidgetServerUpdateResult( + DynamicWidgetServerUpdateOutcome.Committed, + nextIntervalMinutes, + ) + } + } + } + + /** + * What the server asked for, held to what the platform can honour: never sooner than + * WorkManager will run periodic work, never further out than a day. + */ + private fun clampServerInterval(minutes: Long?): Long? = + minutes?.let { WidgetServerUpdateDefaults.clampIntervalMinutes(it) } + + private companion object { + private const val TAG = "VoltraDynamicServerUpdate" + private const val HTTP_NOT_MODIFIED = 304 + } +} + +/** The slice of [DynamicWidgetServerPropsStore] the runner needs, so tests can supply a fake. */ +internal interface DynamicWidgetServerStatusSink { + fun recordSuccess( + scope: WidgetScope, + fetchedAt: Long, + httpStatus: Int, + ) + + fun recordFailure( + scope: WidgetScope, + error: String, + httpStatus: Int? = null, + ) + + /** Reports `disabled` when the app turned fetching off, so the widget can hide its freshness line. */ + fun markDisabledIfNeeded( + scope: WidgetScope, + enabled: Boolean, + ) +} diff --git a/packages/android-client/android/src/main/java/voltra/dynamicwidget/serverupdate/DynamicWidgetServerUpdateScheduler.kt b/packages/android-client/android/src/main/java/voltra/dynamicwidget/serverupdate/DynamicWidgetServerUpdateScheduler.kt new file mode 100644 index 00000000..3e6c7ed1 --- /dev/null +++ b/packages/android-client/android/src/main/java/voltra/dynamicwidget/serverupdate/DynamicWidgetServerUpdateScheduler.kt @@ -0,0 +1,168 @@ +package voltra.dynamicwidget.serverupdate + +import android.content.Context +import android.util.Log +import androidx.work.BackoffPolicy +import androidx.work.Constraints +import androidx.work.Data +import androidx.work.ExistingPeriodicWorkPolicy +import androidx.work.ExistingWorkPolicy +import androidx.work.NetworkType +import androidx.work.OneTimeWorkRequestBuilder +import androidx.work.OutOfQuotaPolicy +import androidx.work.PeriodicWorkRequestBuilder +import androidx.work.WorkManager +import voltra.widget.server.VoltraWidgetServer +import voltra.widget.server.WidgetScope +import java.util.concurrent.TimeUnit + +/** + * WorkManager scheduling for server-driven Dynamic Widgets. + * + * Its own worker class and its own unique work names, deliberately: the payload engine's work is + * pinned on devices by class name, and reusing it would mean one release could hand a Dynamic + * Widget's job to a worker that writes payloads. + */ +object DynamicWidgetServerUpdateScheduler { + private const val TAG = "VoltraDynamicServerSched" + + internal const val WORK_NAME_PREFIX = "voltra_dynamic_widget_server_" + internal const val WORK_TAG = "voltra_dynamic_widget_server_update" + internal const val KEY_WIDGET_ID = "widgetId" + + private const val BACKOFF_SECONDS = 30L + + /** + * Schedules — or reschedules — periodic fetches from the widget's resolved interval, and runs + * one now so a freshly placed widget does not sit on its placeholder for 15 minutes. + * + * Cancels instead when the widget has nothing to fetch, which is what makes + * `setWidgetServerUpdate({ enabled: false })` actually stop the work rather than just ignore + * its results. + */ + suspend fun schedule( + context: Context, + scope: WidgetScope, + runImmediately: Boolean = true, + ) { + val settings = VoltraWidgetServer.resolver(context).resolve(scope) + + if (!settings.shouldFetch) { + cancel(context, scope) + Log.d(TAG, "Not scheduling '${scope.widgetId}': no url, or fetching is disabled") + return + } + + enqueuePeriodic(context, scope, settings.intervalMinutes) + + if (runImmediately) { + requestImmediateUpdate(context, scope) + } + } + + /** + * Moves the periodic schedule to what the server asked for with `Cache-Control: max-age`, + * without re-resolving settings or running a fetch. + */ + fun reschedule( + context: Context, + scope: WidgetScope, + intervalMinutes: Long, + ) { + enqueuePeriodic(context, scope, intervalMinutes) + Log.d(TAG, "Server asked '${scope.widgetId}' to come back in ${intervalMinutes}min") + } + + private fun enqueuePeriodic( + context: Context, + scope: WidgetScope, + intervalMinutes: Long, + ) { + val request = + PeriodicWorkRequestBuilder( + intervalMinutes, + TimeUnit.MINUTES, + ).setInputData(inputData(scope)) + .setConstraints(networkConstraints()) + .setBackoffCriteria(BackoffPolicy.EXPONENTIAL, BACKOFF_SECONDS, TimeUnit.SECONDS) + .addTag(WORK_TAG) + .build() + + WorkManager + .getInstance(context) + .enqueueUniquePeriodicWork(workName(scope), ExistingPeriodicWorkPolicy.UPDATE, request) + + Log.d(TAG, "Scheduled server updates for '${scope.widgetId}' every ${intervalMinutes}min") + } + + /** + * Runs a fetch as soon as the device allows. Used by the refresh button, by + * `reloadAndroidWidgets`, and by every settings change, none of which should wait out the + * remainder of a 15 minute period. + */ + fun requestImmediateUpdate( + context: Context, + scope: WidgetScope, + ) { + enqueueOneTime(context, scope, delayMinutes = 0L, expedited = true) + } + + /** + * Runs a fetch after the delay the server asked for with `Retry-After`. Used instead of + * WorkManager's own backoff, which starts at 30 seconds and would ignore what the server said. + */ + fun requestDelayedUpdate( + context: Context, + scope: WidgetScope, + delayMinutes: Long, + ) { + enqueueOneTime(context, scope, delayMinutes = delayMinutes, expedited = false) + } + + private fun enqueueOneTime( + context: Context, + scope: WidgetScope, + delayMinutes: Long, + expedited: Boolean, + ) { + val builder = + OneTimeWorkRequestBuilder() + .setInputData(inputData(scope)) + .setConstraints(networkConstraints()) + .setBackoffCriteria(BackoffPolicy.EXPONENTIAL, BACKOFF_SECONDS, TimeUnit.SECONDS) + .addTag(WORK_TAG) + + if (delayMinutes > 0) { + builder.setInitialDelay(delayMinutes, TimeUnit.MINUTES) + } else if (expedited) { + builder.setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST) + } + + // Unique per scope, so a settings change plus a reload plus a refresh tap collapse into + // one fetch rather than three. REPLACE rather than KEEP because the newest request is the + // one carrying the caller's intent -- a fresh URL, or a delay the server asked for. + WorkManager + .getInstance(context) + .enqueueUniqueWork(oneTimeWorkName(scope), ExistingWorkPolicy.REPLACE, builder.build()) + } + + fun cancel( + context: Context, + scope: WidgetScope, + ) { + WorkManager.getInstance(context).cancelUniqueWork(workName(scope)) + WorkManager.getInstance(context).cancelUniqueWork(oneTimeWorkName(scope)) + } + + internal fun workName(scope: WidgetScope): String = "$WORK_NAME_PREFIX${scope.storageKey}" + + private fun oneTimeWorkName(scope: WidgetScope): String = "$WORK_NAME_PREFIX${scope.storageKey}_once" + + private fun inputData(scope: WidgetScope): Data = Data.Builder().putString(KEY_WIDGET_ID, scope.widgetId).build() + + private fun networkConstraints(): Constraints = + Constraints + .Builder() + .setRequiredNetworkType(NetworkType.CONNECTED) + .build() +} diff --git a/packages/android-client/android/src/main/java/voltra/dynamicwidget/serverupdate/DynamicWidgetServerUpdateWorker.kt b/packages/android-client/android/src/main/java/voltra/dynamicwidget/serverupdate/DynamicWidgetServerUpdateWorker.kt new file mode 100644 index 00000000..41b6bd91 --- /dev/null +++ b/packages/android-client/android/src/main/java/voltra/dynamicwidget/serverupdate/DynamicWidgetServerUpdateWorker.kt @@ -0,0 +1,126 @@ +package voltra.dynamicwidget.serverupdate + +import android.content.Context +import android.util.Log +import androidx.work.CoroutineWorker +import androidx.work.WorkerParameters +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import voltra.dynamicwidget.DynamicWidgetPropsStore +import voltra.dynamicwidget.triggerDynamicWidgetGlanceUpdate +import voltra.widget.VoltraWidgetKindResolver +import voltra.widget.server.VoltraWidgetServer +import voltra.widget.server.WidgetScope +import voltra.widget.server.WidgetServerEtagStore +import voltra.widget.server.WidgetServerFetchResult +import voltra.widget.server.WidgetServerFetcher +import voltra.widget.server.WidgetServerRequestBuilder + +/** + * Fetches props for one server-driven Dynamic Widget in the background. + * + * It never pushes `RemoteViews`. Drawing stays where it already is, in `VoltraClientGlanceWidget`: + * this worker only commits props and asks Glance to re-render, so a widget looks the same whether + * its props arrived from the server or from `updateDynamicWidget`. + */ +class DynamicWidgetServerUpdateWorker( + context: Context, + parameters: WorkerParameters, +) : CoroutineWorker(context, parameters) { + override suspend fun doWork(): Result { + val widgetId = + inputData.getString(DynamicWidgetServerUpdateScheduler.KEY_WIDGET_ID) + ?: return Result.failure() + + val scope = WidgetScope.of(widgetId) + val result = runner(applicationContext).run(scope) + + return when (result.outcome) { + DynamicWidgetServerUpdateOutcome.Committed -> { + // Cache-Control: max-age moves the next fetch. Rescheduling the periodic work is + // how that reaches WorkManager; the UPDATE policy keeps the same unique work. + result.nextIntervalMinutes?.let { minutes -> + DynamicWidgetServerUpdateScheduler.reschedule(applicationContext, scope, minutes) + } + Result.success() + } + + DynamicWidgetServerUpdateOutcome.Dropped -> { + Result.success() + } + + // The server answered with something no retry will change: a body that is not props, + // an oversized one, or a 4xx. The periodic run continues at the normal interval, so + // this run reports success rather than spending the chain's retry budget. + DynamicWidgetServerUpdateOutcome.Failed -> { + Result.success() + } + + DynamicWidgetServerUpdateOutcome.Retry -> { + // Retry-After on a 429 or 503 is longer than WorkManager's backoff would be, so it + // is honoured with an explicitly delayed run instead of the default 30s chain. + val retryAfterMinutes = result.nextIntervalMinutes + + if (retryAfterMinutes != null) { + DynamicWidgetServerUpdateScheduler.requestDelayedUpdate( + applicationContext, + scope, + retryAfterMinutes, + ) + Result.success() + } else { + Result.retry() + } + } + + DynamicWidgetServerUpdateOutcome.Skipped -> { + // Either the widget has nothing to fetch, or it is no longer a Dynamic Widget. + // Cancelling here is how work left behind by an older release stops itself. + DynamicWidgetServerUpdateScheduler.cancel(applicationContext, scope) + Result.success() + } + } + } + + private fun runner(context: Context): DynamicWidgetServerUpdateRunner { + val resolver = VoltraWidgetServer.resolver(context) + val etags = WidgetServerEtagStore(context) + val statuses = DynamicWidgetServerPropsStore(context) + + return DynamicWidgetServerUpdateRunner( + resolveKind = { id -> VoltraWidgetKindResolver.resolve(context, id) }, + resolveSettings = { resolver.resolve(it) }, + currentRevision = { resolver.revision(it) }, + readEtag = { widgetScope, url -> etags.etag(widgetScope, url) }, + fetch = { widgetScope, settings, etag -> + withContext(Dispatchers.IO) { + val request = WidgetServerRequestBuilder.build(context, widgetScope, settings, etag) + + if (request == null) { + // The runner checks shouldFetch before calling, so this only happens if the + // two ever disagree. Reporting it as a network failure keeps the previous + // props on screen and retries rather than committing anything. + WidgetServerFetchResult.NetworkFailure("Could not build a request") + } else { + WidgetServerFetcher.fetch(request) + } + } + }, + writeEtag = { widgetScope, url, etag -> etags.put(widgetScope, url, etag) }, + trialRender = { widgetScope, props -> DynamicWidgetTrialRender.canRender(context, widgetScope, props) }, + commitProps = DynamicWidgetPropsStore(context), + statusStore = statuses, + notifyWidget = { widgetScope -> + try { + triggerDynamicWidgetGlanceUpdate(context, widgetScope.widgetId) + } catch (e: Exception) { + Log.w(TAG, "Failed to refresh '${widgetScope.widgetId}' after a server update: ${e.message}") + } + }, + ) + } + + private companion object { + private const val TAG = "VoltraDynamicServerWorker" + } +} diff --git a/packages/android-client/android/src/main/java/voltra/dynamicwidget/serverupdate/DynamicWidgetTrialRender.kt b/packages/android-client/android/src/main/java/voltra/dynamicwidget/serverupdate/DynamicWidgetTrialRender.kt new file mode 100644 index 00000000..d7cf463a --- /dev/null +++ b/packages/android-client/android/src/main/java/voltra/dynamicwidget/serverupdate/DynamicWidgetTrialRender.kt @@ -0,0 +1,32 @@ +package voltra.dynamicwidget.serverupdate + +import android.content.Context +import android.util.Log +import voltra.dynamicwidget.renderDynamicWidgetForTrial +import voltra.widget.server.WidgetScope + +/** + * Renders fetched props once, off screen, before they are allowed anywhere near the widget. + * + * Props that do not render are never committed: a server that starts returning a shape the widget + * throws on leaves the last good props on screen rather than replacing them with an error box. + * + * The trial uses one environment — the widget's target cell size, in the current theme and locale. + * A widget that only throws for another size slips through, and ADR 0002 accepts that: rendering + * every possible size on every fetch would cost more than the failure it prevents. + */ +internal object DynamicWidgetTrialRender { + private const val TAG = "VoltraDynamicTrialRender" + + suspend fun canRender( + context: Context, + scope: WidgetScope, + propsJson: String, + ): Boolean = + try { + renderDynamicWidgetForTrial(context, scope.widgetId, propsJson) != null + } catch (e: Throwable) { + Log.e(TAG, "Trial render for '${scope.widgetId}' threw: ${e.message}") + false + } +} diff --git a/packages/android-client/android/src/main/java/voltra/dynamicwidget/serverupdate/VoltraServerDrivenClientWidgetReceiver.kt b/packages/android-client/android/src/main/java/voltra/dynamicwidget/serverupdate/VoltraServerDrivenClientWidgetReceiver.kt new file mode 100644 index 00000000..2fb76f24 --- /dev/null +++ b/packages/android-client/android/src/main/java/voltra/dynamicwidget/serverupdate/VoltraServerDrivenClientWidgetReceiver.kt @@ -0,0 +1,79 @@ +package voltra.dynamicwidget.serverupdate + +import android.appwidget.AppWidgetManager +import android.content.ComponentName +import android.content.Context +import android.util.Log +import androidx.glance.appwidget.GlanceAppWidget +import kotlinx.coroutines.runBlocking +import voltra.dynamicwidget.VoltraClientGlanceWidget +import voltra.dynamicwidget.VoltraClientWidgetReceiver +import voltra.widget.server.WidgetScope + +/** + * Receiver generated for a widget that has both an `entry` and a `serverUpdate`. + * + * It is still a Dynamic Widget in every way that matters — same kind, same Glance widget, same + * render path — with two additions: its props are fetched in the background, and its `env` carries + * how that fetch went. + */ +abstract class VoltraServerDrivenClientWidgetReceiver : VoltraClientWidgetReceiver() { + override fun createGlanceAppWidget(): GlanceAppWidget = + VoltraClientGlanceWidget(widgetId, DynamicWidgetServerEnvironmentSource()) + + override fun onUpdate( + context: Context, + appWidgetManager: AppWidgetManager, + appWidgetIds: IntArray, + ) { + super.onUpdate(context, appWidgetManager, appWidgetIds) + + // Scheduling on every onUpdate rather than only onEnabled: WorkManager's UPDATE policy + // makes it idempotent, and it is how a widget picks up an interval the app changed while + // the widget was not being drawn. + // + // Blocking rather than launching: onReceive must not return before the work is enqueued, + // or a widget added while the app is not running can lose its schedule entirely -- the + // process is reclaimed and updatePeriodMillis is 0, so nothing asks again until a reboot. + // goAsync() is not an option here: GlanceAppWidgetReceiver already consumed it in its own + // onReceive, and a second call returns null. + try { + runBlocking { + DynamicWidgetServerUpdateScheduler.schedule(context.applicationContext, WidgetScope.of(widgetId)) + } + } catch (e: Exception) { + Log.e(TAG, "Failed to schedule server updates for '$widgetId': ${e.message}", e) + } + } + + override fun onDeleted( + context: Context, + appWidgetIds: IntArray, + ) { + super.onDeleted(context, appWidgetIds) + + if (remainingInstanceCount(context, appWidgetIds) == 0) { + DynamicWidgetServerUpdateScheduler.cancel(context.applicationContext, WidgetScope.of(widgetId)) + } + } + + private fun remainingInstanceCount( + context: Context, + deletedIds: IntArray, + ): Int = + try { + AppWidgetManager + .getInstance(context) + .getAppWidgetIds(ComponentName(context, this::class.java)) + .count { it !in deletedIds } + } catch (e: Exception) { + Log.w(TAG, "Could not count remaining instances of '$widgetId': ${e.message}") + // Leaving the work scheduled is the safer guess: the worker cancels itself when it + // finds nothing to do, whereas cancelling here would silently stop a live widget. + 1 + } + + private companion object { + private const val TAG = "VoltraServerDrivenClient" + } +} diff --git a/packages/android-client/android/src/main/java/voltra/widget/payload/PayloadRefreshActionCallback.kt b/packages/android-client/android/src/main/java/voltra/widget/payload/PayloadRefreshActionCallback.kt index 146d9bbf..88ca2e62 100644 --- a/packages/android-client/android/src/main/java/voltra/widget/payload/PayloadRefreshActionCallback.kt +++ b/packages/android-client/android/src/main/java/voltra/widget/payload/PayloadRefreshActionCallback.kt @@ -7,16 +7,17 @@ import androidx.glance.GlanceId import androidx.glance.action.ActionParameters import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext -import voltra.BuildConfig import voltra.parsing.VoltraPayloadParser import voltra.widget.VoltraRefreshActionCallback import voltra.widget.VoltraWidgetKind import voltra.widget.VoltraWidgetKindResolution import voltra.widget.VoltraWidgetKindResolver import voltra.widget.VoltraWidgetReceivers -import java.io.BufferedReader -import java.io.InputStreamReader -import java.net.HttpURLConnection +import voltra.widget.server.VoltraWidgetServer +import voltra.widget.server.WidgetScope +import voltra.widget.server.WidgetServerFetchResult +import voltra.widget.server.WidgetServerFetcher +import voltra.widget.server.WidgetServerRequestBuilder /** * Real implementation behind the pinned [voltra.widget.VoltraRefreshActionCallback] (ADR 0000): @@ -27,6 +28,11 @@ import java.net.HttpURLConnection * provideGlance() from ActionCallbacks. Instead we use GlanceRemoteViews.compose() (via * [RemoteViewsGenerator]) to generate RemoteViews that include both the widget content and the * refresh button overlay, then push them directly. + * + * The fetch is inline rather than enqueued, so a tap redraws the widget as fast as the network + * allows and a failed tap simply leaves what is on screen. That is unchanged; what ADR 0002 + * changes is that the request comes from the shared settings resolver, so a runtime URL, method + * or header applies to the refresh button too. */ internal class PayloadRefreshActionCallback { companion object { @@ -48,12 +54,6 @@ internal class PayloadRefreshActionCallback { Log.d(TAG, "Refresh requested for widget '$widgetId'") - val serverUrl = VoltraWidgetUpdateScheduler.readServerUrl(context, widgetId) - if (serverUrl == null) { - Log.w(TAG, "No server URL registered for widget '$widgetId', skipping refresh") - return - } - // Resolve the widget's kind before opening any connection (ADR 0000, mirroring // VoltraWidgetUpdateWorker): a Dynamic Widget's placeholder reader never consults this // payload store, so fetching for the wrong kind is wasted work. @@ -74,54 +74,47 @@ internal class PayloadRefreshActionCallback { } } + val scope = WidgetScope.of(widgetId) + val settings = VoltraWidgetServer.resolver(context).resolve(scope) + + if (!settings.shouldFetch) { + Log.w(TAG, "No server url for widget '$widgetId', skipping refresh") + return + } + + // A tap is an explicit "give me the current data", so the stored ETag is deliberately not + // sent: a 304 would leave the user staring at an unchanged widget with no way to tell + // whether the tap did anything. + val request = WidgetServerRequestBuilder.build(context, scope, settings) ?: return + val jsonString = withContext(Dispatchers.IO) { - try { - val url = VoltraWidgetUpdateRequest.buildUrl(serverUrl, widgetId, context) - val connection = url.openConnection() as HttpURLConnection - - try { - connection.requestMethod = "GET" - connection.connectTimeout = 10000 - connection.readTimeout = 10000 - connection.setRequestProperty("Accept", "application/json") - val androidVersion = android.os.Build.VERSION.RELEASE - connection.setRequestProperty( - "User-Agent", - "VoltraWidget/${BuildConfig.VOLTRA_VERSION} (Android/$androidVersion)", - ) - - val token = VoltraWidgetCredentialStore.readToken(context) - if (token != null) { - connection.setRequestProperty("Authorization", "Bearer $token") - } - VoltraWidgetCredentialStore.readHeaders(context).forEach { (key, value) -> - connection.setRequestProperty(key, value) - } - - val responseCode = connection.responseCode - if (responseCode !in 200..299) { - Log.e(TAG, "Server returned HTTP $responseCode for widget '$widgetId'") - return@withContext null - } - - val reader = BufferedReader(InputStreamReader(connection.inputStream)) - val json = reader.readText() - reader.close() - - if (json.isEmpty()) { - Log.e(TAG, "Empty response from server for widget '$widgetId'") - return@withContext null - } - - Log.d(TAG, "Received ${json.length} bytes for widget '$widgetId'") - json - } finally { - connection.disconnect() + when (val result = WidgetServerFetcher.fetch(request)) { + is WidgetServerFetchResult.Success -> { + result.body + } + + is WidgetServerFetchResult.HttpFailure -> { + Log.e(TAG, "Server returned HTTP ${result.httpStatus} for widget '$widgetId'") + null + } + + is WidgetServerFetchResult.NetworkFailure -> { + Log.e(TAG, "Refresh failed for widget '$widgetId': ${result.message}") + null + } + + is WidgetServerFetchResult.NotModified -> { + // Only reachable if the server answers 304 unprompted; a refresh tap never + // sends a conditional request. + Log.d(TAG, "Widget '$widgetId' is unchanged") + null + } + + is WidgetServerFetchResult.TooLarge -> { + Log.e(TAG, "Response for widget '$widgetId' is too large to render") + null } - } catch (e: Exception) { - Log.e(TAG, "Refresh failed for widget '$widgetId': ${e.message}", e) - null } } ?: return diff --git a/packages/android-client/android/src/main/java/voltra/widget/payload/PayloadWidgetUpdateWorker.kt b/packages/android-client/android/src/main/java/voltra/widget/payload/PayloadWidgetUpdateWorker.kt index e2e9cb2f..11e9c883 100644 --- a/packages/android-client/android/src/main/java/voltra/widget/payload/PayloadWidgetUpdateWorker.kt +++ b/packages/android-client/android/src/main/java/voltra/widget/payload/PayloadWidgetUpdateWorker.kt @@ -7,22 +7,27 @@ import androidx.work.Data import androidx.work.ListenableWorker.Result import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext -import voltra.BuildConfig import voltra.parsing.VoltraPayloadParser import voltra.widget.VoltraWidgetKind import voltra.widget.VoltraWidgetKindResolution import voltra.widget.VoltraWidgetKindResolver import voltra.widget.VoltraWidgetReceivers import voltra.widget.VoltraWidgetUpdateWorker -import java.io.BufferedReader -import java.io.InputStreamReader -import java.net.HttpURLConnection +import voltra.widget.server.VoltraWidgetServer +import voltra.widget.server.WidgetScope +import voltra.widget.server.WidgetServerFetchResult +import voltra.widget.server.WidgetServerFetcher +import voltra.widget.server.WidgetServerRequestBuilder /** - * Real implementation behind the pinned [voltra.widget.VoltraWidgetUpdateWorker] (ADR 0000): - * fetches widget content from a remote Voltra SSR server and pushes updates to the widget via + * Fetches a Voltra payload from the server, stores it, and pushes fresh `RemoteViews` through * AppWidgetManager. Split out so the pinned worker class stays a thin delegate, and this * payload-only logic can live in `voltra.widget.payload` with the rest of the payload engine. + * + * Since ADR 0002 the request itself is built by `voltra.widget.server`, the same code the Dynamic + * engine uses. With no runtime settings set, that produces the request this worker always sent, + * plus `locale` and a conditional `If-None-Match`; with settings set, this widget gains the + * runtime URL, method, headers, query and body too. What it does with the response is unchanged. */ internal object PayloadWidgetUpdateWorker { // Kept as the original class name (ADR 0000): this TAG predates the package split and is @@ -36,14 +41,13 @@ internal object PayloadWidgetUpdateWorker { ): Result = withContext(Dispatchers.IO) { val widgetId = inputData.getString(VoltraWidgetUpdateWorker.KEY_WIDGET_ID) - val serverUrl = inputData.getString(VoltraWidgetUpdateWorker.KEY_SERVER_URL) - if (widgetId == null || serverUrl == null) { - Log.e(TAG, "Missing required input data: widgetId=$widgetId, serverUrl=$serverUrl") + if (widgetId == null) { + Log.e(TAG, "Missing required input data: widgetId") return@withContext Result.failure() } - Log.d(TAG, "Starting server update for widget '$widgetId' from $serverUrl") + val scope = WidgetScope.of(widgetId) // Resolve the widget's kind before opening any connection (ADR 0000): a Dynamic // Widget's placeholder reader never consults this payload store, so fetching for the @@ -68,139 +72,137 @@ internal object PayloadWidgetUpdateWorker { } } - try { - // 1. Build URL with query parameters - val url = VoltraWidgetUpdateRequest.buildUrl(serverUrl, widgetId, applicationContext) - val connection = url.openConnection() as HttpURLConnection - - try { - connection.requestMethod = "GET" - connection.connectTimeout = 15000 - connection.readTimeout = 15000 - connection.setRequestProperty("Accept", "application/json") - val androidVersion = android.os.Build.VERSION.RELEASE - connection.setRequestProperty( - "User-Agent", - "VoltraWidget/${BuildConfig.VOLTRA_VERSION} (Android/$androidVersion)", - ) + val resolver = VoltraWidgetServer.resolver(applicationContext) + val settings = resolver.resolve(scope) - // 2. Add auth token from encrypted storage - val token = VoltraWidgetCredentialStore.readToken(applicationContext) - if (token != null) { - connection.setRequestProperty("Authorization", "Bearer $token") - } + if (!settings.shouldFetch) { + Log.d(TAG, "Nothing to fetch for widget '$widgetId': no url, or fetching is disabled") + return@withContext Result.success() + } - // 3. Add custom headers from encrypted storage - val headers = VoltraWidgetCredentialStore.readHeaders(applicationContext) - headers.forEach { (key, value) -> - connection.setRequestProperty(key, value) - } + val revision = resolver.revision(scope) + val url = settings.url!! - // 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 >= VoltraWidgetUpdateWorker.MAX_RETRIES) { - Log.w( - TAG, - "Max retries (${VoltraWidgetUpdateWorker.MAX_RETRIES}) reached for widget " + - "'$widgetId', giving up", - ) - Result.failure() - } else { - Result.retry() - } - } + Log.d(TAG, "Starting server update for widget '$widgetId' from $url") - // 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 >= VoltraWidgetUpdateWorker.MAX_RETRIES) { - Log.w( - TAG, - "Max retries (${VoltraWidgetUpdateWorker.MAX_RETRIES}) reached for widget " + - "'$widgetId', giving up", - ) - Result.failure() - } else { - Result.retry() - } - } + // No If-None-Match: a payload widget sends the request it always sent, plus locale. A + // 304 would mean committing nothing, and the payload store is cleared by clearWidget + // and by an app upgrade, so there is no way to be sure "unchanged" still matches what + // is on screen. + val request = + WidgetServerRequestBuilder.build(applicationContext, scope, settings) + ?: return@withContext Result.success() - Log.d(TAG, "Received ${jsonString.length} bytes for widget '$widgetId'") + when (val result = WidgetServerFetcher.fetch(request)) { + is WidgetServerFetchResult.NotModified -> { + // Only reachable if the server answers 304 unprompted; nothing was requested + // conditionally, so there is nothing to commit. + Log.d(TAG, "Widget '$widgetId' is unchanged since the last fetch") + Result.success() + } - // 6. Store the fetched data in SharedPreferences (for Glance fallback) - val widgetManager = VoltraWidgetManager(applicationContext) - widgetManager.writeWidgetData(widgetId, jsonString, null) + is WidgetServerFetchResult.TooLarge -> { + Log.e(TAG, "Response for widget '$widgetId' is too large to render") + Result.failure() + } - // 7. Parse payload to validate it (also needed for non-Glance RemoteViews path) - 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() - } + is WidgetServerFetchResult.NetworkFailure -> { + Log.e( + TAG, + "Server update failed for widget '$widgetId' (attempt $runAttemptCount): ${result.message}", + ) + retryOrGiveUp(widgetId, runAttemptCount) + } - if (payload.variants.isNullOrEmpty()) { - Log.w(TAG, "No variants in payload for widget '$widgetId'") - return@withContext Result.success() - } + is WidgetServerFetchResult.HttpFailure -> { + Log.e( + TAG, + "Server returned HTTP ${result.httpStatus} for widget '$widgetId' (attempt $runAttemptCount)", + ) + + // A 4xx that is not a 429 will not change by asking again, so it is not worth + // the retry budget; the next periodic run tries at the normal interval. + if (result.isTransient) retryOrGiveUp(widgetId, runAttemptCount) else Result.failure() + } - // 8. Check if this widget uses the Glance refresh overlay - val refreshEnabled = VoltraWidgetUpdateScheduler.isRefreshEnabled(applicationContext, widgetId) - - val sizeMapping = - if (refreshEnabled) { - // Generate RemoteViews with refresh button overlay - RemoteViewsGenerator.generateWidgetRemoteViewsWithRefresh( - applicationContext, - payload, - widgetId, - ) - } else { - // Generate plain RemoteViews without refresh overlay - RemoteViewsGenerator.generateWidgetRemoteViews(applicationContext, payload) - } - - val componentName = VoltraWidgetReceivers.componentName(applicationContext, widgetId) - 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'") - } else if (sizeMapping.isNotEmpty()) { - for (appWidgetId in appWidgetIds) { - appWidgetManager.updateResponsiveAppWidget(appWidgetId, sizeMapping) - Log.d(TAG, "Updated widget instance $appWidgetId with server data") - } + is WidgetServerFetchResult.Success -> { + if (resolver.revision(scope) != revision) { + Log.d(TAG, "Dropping server update for '$widgetId': settings changed mid-fetch") + return@withContext Result.success() } - Log.d(TAG, "Server update completed successfully for widget '$widgetId'") + commit(applicationContext, widgetId, result.body) Result.success() - } finally { - connection.disconnect() } + } + } + + /** + * Stores the payload first, then tries to draw it. Storing first is deliberate: Glance reads + * the stored payload on its own, so a payload that parses but fails to draw here is still the + * widget's content rather than being thrown away. + */ + private suspend fun commit( + applicationContext: Context, + widgetId: String, + body: String, + ) { + Log.d(TAG, "Received ${body.length} bytes for widget '$widgetId'") + + VoltraWidgetManager(applicationContext).writeWidgetData(widgetId, body, null) + + val payload = + try { + VoltraPayloadParser.parse(body) } catch (e: Exception) { - Log.e(TAG, "Server update failed for widget '$widgetId' (attempt $runAttemptCount): ${e.message}", e) - if (runAttemptCount >= VoltraWidgetUpdateWorker.MAX_RETRIES) { - Log.w( - TAG, - "Max retries (${VoltraWidgetUpdateWorker.MAX_RETRIES}) reached for widget " + - "'$widgetId', giving up", - ) - Result.failure() - } else { - Result.retry() - } + Log.e(TAG, "Failed to parse widget payload: ${e.message}", e) + return + } + + if (payload.variants.isNullOrEmpty()) { + Log.w(TAG, "No variants in payload for widget '$widgetId'") + return + } + + val sizeMapping = + if (VoltraWidgetUpdateScheduler.isRefreshEnabled(applicationContext, widgetId)) { + RemoteViewsGenerator.generateWidgetRemoteViewsWithRefresh(applicationContext, payload, widgetId) + } else { + RemoteViewsGenerator.generateWidgetRemoteViews(applicationContext, payload) } + + val componentName = VoltraWidgetReceivers.componentName(applicationContext, widgetId) + 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 + } + + if (sizeMapping.isEmpty()) { + return + } + + for (appWidgetId in appWidgetIds) { + appWidgetManager.updateResponsiveAppWidget(appWidgetId, sizeMapping) + Log.d(TAG, "Updated widget instance $appWidgetId with server data") + } + + Log.d(TAG, "Server update completed successfully for widget '$widgetId'") + } + + private fun retryOrGiveUp( + widgetId: String, + runAttemptCount: Int, + ): Result = + if (runAttemptCount >= VoltraWidgetUpdateWorker.MAX_RETRIES) { + Log.w( + TAG, + "Max retries (${VoltraWidgetUpdateWorker.MAX_RETRIES}) reached for widget '$widgetId', giving up", + ) + Result.failure() + } else { + Result.retry() } } diff --git a/packages/android-client/android/src/main/java/voltra/widget/payload/VoltraWidgetUpdateRequest.kt b/packages/android-client/android/src/main/java/voltra/widget/payload/VoltraWidgetUpdateRequest.kt deleted file mode 100644 index 79060911..00000000 --- a/packages/android-client/android/src/main/java/voltra/widget/payload/VoltraWidgetUpdateRequest.kt +++ /dev/null @@ -1,30 +0,0 @@ -package voltra.widget.payload - -import android.content.Context -import android.content.res.Configuration -import android.net.Uri -import java.net.URL - -object VoltraWidgetUpdateRequest { - fun currentTheme(context: Context): String { - val nightModeFlags = context.resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK - return if (nightModeFlags == Configuration.UI_MODE_NIGHT_YES) "dark" else "light" - } - - fun buildUrl( - serverUrl: String, - widgetId: String, - context: Context, - ): URL { - val uri = - Uri - .parse(serverUrl) - .buildUpon() - .appendQueryParameter("widgetId", widgetId) - .appendQueryParameter("platform", "android") - .appendQueryParameter("theme", currentTheme(context)) - .build() - - return URL(uri.toString()) - } -} diff --git a/packages/android-client/android/src/main/java/voltra/widget/payload/VoltraWidgetUpdateScheduler.kt b/packages/android-client/android/src/main/java/voltra/widget/payload/VoltraWidgetUpdateScheduler.kt index 0f72a44a..c7ab7c4b 100644 --- a/packages/android-client/android/src/main/java/voltra/widget/payload/VoltraWidgetUpdateScheduler.kt +++ b/packages/android-client/android/src/main/java/voltra/widget/payload/VoltraWidgetUpdateScheduler.kt @@ -2,13 +2,6 @@ package voltra.widget.payload import android.content.Context import android.util.Log -import androidx.datastore.core.DataStore -import androidx.datastore.preferences.core.Preferences -import androidx.datastore.preferences.core.booleanPreferencesKey -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 @@ -16,259 +9,126 @@ 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 voltra.widget.VoltraWidgetUpdateWorker +import voltra.widget.server.VoltraWidgetServer +import voltra.widget.server.WidgetScope 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. + * Schedules periodic WorkManager tasks for payload-driven widgets that fetch from a server. * - * Server URLs are persisted in Jetpack DataStore so that [requestImmediateUpdate] - * can trigger an on-demand fetch without needing the generated receiver code. + * The URL and interval used to be inlined into each generated receiver and copied into a + * DataStore. They now come from the settings resolver, which is what lets an app change either at + * runtime with `setWidgetServerUpdate`: a receiver compiled months ago cannot be asked what the + * interval is now. */ 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_" - - /** Prefix used to build per-widget refresh-enabled keys. */ - private const val KEY_REFRESH_ENABLED_PREFIX = "refresh_enabled_" - /** - * 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) - * @param refreshEnabled Whether the native refresh button should be shown + * Schedules — or reschedules — periodic updates from the widget's resolved settings, and + * cancels instead when it has nothing to fetch. Called from the generated receiver's + * `onUpdate`, and again whenever settings change. */ - fun schedulePeriodicUpdate( + suspend fun schedulePeriodicUpdate( context: Context, widgetId: String, - serverUrl: String, - intervalMinutes: Long = 15, - refreshEnabled: Boolean = false, ) { - // Persist the server URL and refresh flag - runBlocking { - saveServerUrl(context, widgetId, serverUrl) - saveRefreshEnabled(context, widgetId, refreshEnabled) - } + val scope = WidgetScope.of(widgetId) + val settings = VoltraWidgetServer.resolver(context).resolve(scope) - 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() + if (!settings.shouldFetch) { + cancelPeriodicUpdate(context, widgetId) + Log.d(TAG, "Not scheduling '$widgetId': no url, or fetching is disabled") + return + } - val workRequest = - PeriodicWorkRequestBuilder( - effectiveInterval, - TimeUnit.MINUTES, - ).setInputData(inputData) - .setConstraints(constraints) + val request = + PeriodicWorkRequestBuilder(settings.intervalMinutes, TimeUnit.MINUTES) + .setInputData(inputData(widgetId)) + .setConstraints(networkConstraints()) .addTag(VoltraWidgetUpdateWorker.TAG) .build() WorkManager .getInstance(context) - .enqueueUniquePeriodicWork( - workName, - ExistingPeriodicWorkPolicy.UPDATE, - workRequest, - ) + .enqueueUniquePeriodicWork(workName(widgetId), ExistingPeriodicWorkPolicy.UPDATE, request) - Log.d(TAG, "Scheduled periodic update for widget '$widgetId' every ${effectiveInterval}min from $serverUrl") + Log.d(TAG, "Scheduled periodic update for widget '$widgetId' every ${settings.intervalMinutes}min") } /** - * Enqueue a one-time WorkManager request to immediately fetch fresh content - * from the server for the given widget. + * Enqueues a one-time fetch. * - * @return true if the request was enqueued, false if no server URL is known for this widget. + * @return false when the widget has nothing to fetch, so the caller can fall back to + * re-rendering whatever payload it already has. */ 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") + val settings = VoltraWidgetServer.resolver(context).resolve(WidgetScope.of(widgetId)) + + if (!settings.shouldFetch) { + Log.d(TAG, "No server url 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 = + val request = OneTimeWorkRequestBuilder() - .setInputData(inputData) - .setConstraints(constraints) + .setInputData(inputData(widgetId)) + .setConstraints(networkConstraints()) .addTag(VoltraWidgetUpdateWorker.TAG) .build() - WorkManager.getInstance(context).enqueue(workRequest) + WorkManager.getInstance(context).enqueue(request) - Log.d(TAG, "Enqueued immediate update for widget '$widgetId' from $serverUrl") + Log.d(TAG, "Enqueued immediate update for widget '$widgetId'") return true } - /** - * Check whether a widget has a server URL registered (i.e. is server-driven). - */ + /** Whether this widget has somewhere to fetch from and permission to do it. */ suspend fun hasServerUrl( context: Context, widgetId: String, - ): Boolean = readServerUrl(context, widgetId) != null + ): Boolean = VoltraWidgetServer.resolver(context).resolve(WidgetScope.of(widgetId)).shouldFetch - /** - * 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) } + WorkManager.getInstance(context).cancelUniqueWork(workName(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 - } - } - - private suspend fun saveRefreshEnabled( - context: Context, - widgetId: String, - enabled: Boolean, - ) { - val key = booleanPreferencesKey("$KEY_REFRESH_ENABLED_PREFIX$widgetId") - context.voltraServerUrlsDataStore.edit { prefs -> - prefs[key] = enabled - } - } - /** - * Check whether the native refresh button is enabled for this widget. + * Whether the widget draws a refresh button. Build-time only: the button is generated UI + * structure, so unlike the URL and the interval it cannot be changed at runtime. */ - suspend fun isRefreshEnabled( + fun isRefreshEnabled( context: Context, widgetId: String, - ): Boolean { - val key = booleanPreferencesKey("$KEY_REFRESH_ENABLED_PREFIX$widgetId") - return try { - context.voltraServerUrlsDataStore.data - .map { prefs -> prefs[key] ?: false } - .firstOrNull() ?: false - } catch (e: Exception) { - Log.e(TAG, "Failed to read refresh flag for widget '$widgetId': ${e.message}", e) - false - } - } + ): Boolean = VoltraWidgetServer.defaults(context).defaults(widgetId)?.refresh ?: false - /** - * 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() - } + /** Every widget app.json marked server-driven, whichever engine renders it. */ + fun getAllServerDrivenWidgetIds(context: Context): Set = VoltraWidgetServer.serverDrivenWidgetIds(context) - private suspend fun removeServerUrl( - context: Context, - widgetId: String, - ) { - val urlKey = stringPreferencesKey("$KEY_SERVER_URL_PREFIX$widgetId") - val refreshKey = booleanPreferencesKey("$KEY_REFRESH_ENABLED_PREFIX$widgetId") - context.voltraServerUrlsDataStore.edit { prefs -> - prefs.remove(urlKey) - prefs.remove(refreshKey) - val currentIds = prefs[KEY_WIDGET_IDS] ?: emptySet() - prefs[KEY_WIDGET_IDS] = currentIds - widgetId - } - } + private fun workName(widgetId: String) = "${VoltraWidgetUpdateWorker.WORK_NAME_PREFIX}$widgetId" - private suspend fun clearAllServerUrls(context: Context) { - context.voltraServerUrlsDataStore.edit { prefs -> - prefs.clear() - } - } + private fun inputData(widgetId: String): Data = + Data + .Builder() + .putString(VoltraWidgetUpdateWorker.KEY_WIDGET_ID, widgetId) + .build() + + private fun networkConstraints(): Constraints = + Constraints + .Builder() + .setRequiredNetworkType(NetworkType.CONNECTED) + .build() } diff --git a/packages/android-client/android/src/main/java/voltra/widget/server/CredentialsWidgetServerSettingsLayer.kt b/packages/android-client/android/src/main/java/voltra/widget/server/CredentialsWidgetServerSettingsLayer.kt new file mode 100644 index 00000000..fcf5a1c9 --- /dev/null +++ b/packages/android-client/android/src/main/java/voltra/widget/server/CredentialsWidgetServerSettingsLayer.kt @@ -0,0 +1,29 @@ +package voltra.widget.server + +import android.content.Context + +/** + * The deprecated `setWidgetServerCredentials` API, expressed as a settings layer. + * + * It reads the same encrypted token and header records it always has, which is why nothing + * migrates. It sits below the global layer so an app that has moved to + * `setWidgetServerUpdate({ headers: { Authorization: ... } })` overrides whatever an older call + * left behind, rather than the other way round. + */ +class CredentialsWidgetServerSettingsLayer( + private val context: Context, +) : WidgetServerSettingsLayer { + override val name: String = "credentials" + + override suspend fun settings(scope: WidgetScope): WidgetServerUpdateSettings? { + val headers = mutableMapOf() + + VoltraWidgetCredentialStore.readToken(context)?.takeIf { it.isNotBlank() }?.let { token -> + headers["Authorization"] = "Bearer $token" + } + + headers.putAll(VoltraWidgetCredentialStore.readHeaders(context)) + + return if (headers.isEmpty()) null else WidgetServerUpdateSettings(headers = headers) + } +} diff --git a/packages/android-client/android/src/main/java/voltra/widget/payload/VoltraCryptoManager.kt b/packages/android-client/android/src/main/java/voltra/widget/server/VoltraCryptoManager.kt similarity index 99% rename from packages/android-client/android/src/main/java/voltra/widget/payload/VoltraCryptoManager.kt rename to packages/android-client/android/src/main/java/voltra/widget/server/VoltraCryptoManager.kt index 737464bf..57cc77dd 100644 --- a/packages/android-client/android/src/main/java/voltra/widget/payload/VoltraCryptoManager.kt +++ b/packages/android-client/android/src/main/java/voltra/widget/server/VoltraCryptoManager.kt @@ -1,4 +1,4 @@ -package voltra.widget.payload +package voltra.widget.server import android.content.Context import android.util.Base64 diff --git a/packages/android-client/android/src/main/java/voltra/widget/payload/VoltraWidgetCredentialStore.kt b/packages/android-client/android/src/main/java/voltra/widget/server/VoltraWidgetCredentialStore.kt similarity index 88% rename from packages/android-client/android/src/main/java/voltra/widget/payload/VoltraWidgetCredentialStore.kt rename to packages/android-client/android/src/main/java/voltra/widget/server/VoltraWidgetCredentialStore.kt index 5f3b85e6..85359a81 100644 --- a/packages/android-client/android/src/main/java/voltra/widget/payload/VoltraWidgetCredentialStore.kt +++ b/packages/android-client/android/src/main/java/voltra/widget/server/VoltraWidgetCredentialStore.kt @@ -1,4 +1,4 @@ -package voltra.widget.payload +package voltra.widget.server import android.content.Context import android.util.Log @@ -21,7 +21,7 @@ import kotlinx.coroutines.runBlocking * this storage; no special grouping or sharing configuration is required. */ -private val Context.voltraCredentialsDataStore: DataStore by preferencesDataStore( +internal val Context.voltraCredentialsDataStore: DataStore by preferencesDataStore( name = "voltra_widget_credentials", ) @@ -158,12 +158,22 @@ object VoltraWidgetCredentialStore { } /** - * Clear all stored credentials. + * Clear the stored token and headers. + * + * Only those: this DataStore is shared with the runtime server-update settings layers, so + * clearing it wholesale would silently drop a URL or an interval the app set through + * `setWidgetServerUpdate` — and reset the revision counter that tells an in-flight fetch its + * settings have moved. */ suspend fun clearAll(context: Context): Boolean = try { context.voltraCredentialsDataStore.edit { prefs -> - prefs.clear() + val headerKeys = prefs[KEY_HEADER_KEYS] ?: emptySet() + headerKeys.forEach { key -> + prefs.remove(stringPreferencesKey("$KEY_HEADERS_PREFIX$key")) + } + prefs.remove(KEY_HEADER_KEYS) + prefs.remove(KEY_TOKEN) } Log.d(TAG, "All credentials cleared") true diff --git a/packages/android-client/android/src/main/java/voltra/widget/server/VoltraWidgetServer.kt b/packages/android-client/android/src/main/java/voltra/widget/server/VoltraWidgetServer.kt new file mode 100644 index 00000000..4fd5e2ee --- /dev/null +++ b/packages/android-client/android/src/main/java/voltra/widget/server/VoltraWidgetServer.kt @@ -0,0 +1,80 @@ +package voltra.widget.server + +import android.content.Context +import android.content.pm.ApplicationInfo + +/** + * Assembles the settings stack for the process. + * + * Everything that needs server-update settings goes through here: the payload worker, the payload + * refresh button, the Dynamic Widget worker, and the bridge methods. Neither engine reads the + * generated asset, the DataStore, or the credential records on its own, which is what keeps the + * layer order and the merge rule in one place. + */ +object VoltraWidgetServer { + @Volatile + private var resolverCache: WidgetServerSettingsResolver? = null + + @Volatile + private var storeCache: WidgetServerSettingsStore? = null + + @Volatile + private var defaultsCache: WidgetServerDefaultsStore? = null + + fun resolver(context: Context): WidgetServerSettingsResolver { + resolverCache?.let { return it } + + synchronized(this) { + resolverCache?.let { return it } + + val applicationContext = context.applicationContext + val store = store(applicationContext) + + // Fixed order, lowest priority first. An instance layer will slot in above `widget`. + val resolver = + WidgetServerSettingsResolver( + layers = + listOf( + ConfigWidgetServerSettingsLayer(defaults(applicationContext)), + CredentialsWidgetServerSettingsLayer(applicationContext), + GlobalWidgetServerSettingsLayer(store), + WidgetWidgetServerSettingsLayer(store), + ), + revisionSource = { store.revision() }, + ) + + resolverCache = resolver + return resolver + } + } + + fun store(context: Context): WidgetServerSettingsStore { + storeCache?.let { return it } + + synchronized(this) { + storeCache?.let { return it } + + return WidgetServerSettingsStore(context.applicationContext).also { storeCache = it } + } + } + + fun defaults(context: Context): WidgetServerDefaultsStore { + defaultsCache?.let { return it } + + synchronized(this) { + defaultsCache?.let { return it } + + return WidgetServerDefaultsStore(context.applicationContext).also { defaultsCache = it } + } + } + + /** Every widget app.json marked server-driven, whichever engine renders it. */ + fun serverDrivenWidgetIds(context: Context): Set = defaults(context).serverDrivenWidgetIds() + + /** + * Whether plain http to a local dev host is allowed. Release builds block cleartext traffic, so + * accepting such a URL there would only move the failure to fetch time. + */ + fun isDebugBuild(context: Context): Boolean = + (context.applicationInfo.flags and ApplicationInfo.FLAG_DEBUGGABLE) != 0 +} diff --git a/packages/android-client/android/src/main/java/voltra/widget/server/WidgetScope.kt b/packages/android-client/android/src/main/java/voltra/widget/server/WidgetScope.kt new file mode 100644 index 00000000..e830d049 --- /dev/null +++ b/packages/android-client/android/src/main/java/voltra/widget/server/WidgetScope.kt @@ -0,0 +1,32 @@ +package voltra.widget.server + +/** + * The unit everything server-driven is keyed by: settings, fetched props, the stored ETag, fetch + * coalescing, and the settings revision. + * + * Today the only case is a whole widget id. Per-instance server updates (ADR 0002, "Instance-ready") + * add an `Instance` case above it without changing a single caller, which is the reason this is a + * type rather than a bare `String`. + */ +sealed class WidgetScope { + /** Widget id this scope belongs to. An instance scope will report the id it is an instance of. */ + abstract val widgetId: String + + /** + * Stable, filesystem- and preference-safe key for per-scope storage. An instance scope will + * append its placement key, so widget-scoped records written today keep their keys. + */ + abstract val storageKey: String + + data class Widget( + override val widgetId: String, + ) : WidgetScope() { + override val storageKey: String + get() = widgetId + } + + companion object { + /** Convenience for the common case, so callers do not spell out the case name. */ + fun of(widgetId: String): WidgetScope = Widget(widgetId) + } +} diff --git a/packages/android-client/android/src/main/java/voltra/widget/server/WidgetServerDefaultsStore.kt b/packages/android-client/android/src/main/java/voltra/widget/server/WidgetServerDefaultsStore.kt new file mode 100644 index 00000000..166cec0e --- /dev/null +++ b/packages/android-client/android/src/main/java/voltra/widget/server/WidgetServerDefaultsStore.kt @@ -0,0 +1,116 @@ +package voltra.widget.server + +import android.content.Context +import android.util.Log +import org.json.JSONObject + +/** + * Build-time server-update defaults, read from the generated asset + * `voltra/widget_server_defaults.json`. + * + * These used to be inlined into each generated receiver as a URL and an interval literal. Moving + * them into an asset is what lets the runtime settings store override them: a receiver cannot be + * asked what its interval is now that the app can change it. + * + * Shape, keyed by widget id: + * ```json + * { "portfolio": { "url": "https://api.example.com/portfolio", "intervalMinutes": 30, "refresh": true } } + * ``` + * + * A widget id present in this map is server-driven. `url` is absent when app.json declared + * `serverUpdate` without one, meaning the app supplies it at runtime. + */ +class WidgetServerDefaultsStore( + private val readAsset: () -> String?, +) { + constructor(context: Context) : this({ + try { + context.assets + .open(ASSET_PATH) + .bufferedReader() + .use { it.readText() } + } catch (_: Exception) { + // No server-driven widgets in this app: the generator writes no asset at all. + null + } + }) + + @Volatile + private var cached: Map? = null + + data class Defaults( + val url: String?, + val intervalMinutes: Long, + val refresh: Boolean, + ) + + fun defaults(widgetId: String): Defaults? = all()[widgetId] + + fun isServerDriven(widgetId: String): Boolean = all().containsKey(widgetId) + + fun serverDrivenWidgetIds(): Set = all().keys + + private fun all(): Map { + cached?.let { return it } + + synchronized(this) { + cached?.let { return it } + + val parsed = read() + cached = parsed + return parsed + } + } + + private fun read(): Map { + val raw = readAsset() ?: return emptyMap() + + return try { + val json = JSONObject(raw) + val defaults = mutableMapOf() + + json.keys().forEach { widgetId -> + val entry = json.optJSONObject(widgetId) ?: return@forEach + val url = if (entry.has("url") && !entry.isNull("url")) entry.getString("url") else null + + defaults[widgetId] = + Defaults( + url = url?.takeIf { it.isNotBlank() }, + intervalMinutes = + WidgetServerUpdateDefaults.clampIntervalMinutes( + entry.optLong("intervalMinutes", WidgetServerUpdateDefaults.DEFAULT_INTERVAL_MINUTES), + ), + refresh = entry.optBoolean("refresh", false), + ) + } + + defaults + } catch (e: Exception) { + Log.e(TAG, "Failed to parse $ASSET_PATH: ${e.message}", e) + emptyMap() + } + } + + companion object { + const val ASSET_PATH = "voltra/widget_server_defaults.json" + private const val TAG = "VoltraWidgetServerDefaults" + } +} + +/** Lowest layer: what app.json asked for, read-only. */ +class ConfigWidgetServerSettingsLayer( + private val defaults: WidgetServerDefaultsStore, +) : WidgetServerSettingsLayer { + override val name: String = "config" + + override suspend fun settings(scope: WidgetScope): WidgetServerUpdateSettings? { + val entry = defaults.defaults(scope.widgetId) ?: return null + + return WidgetServerUpdateSettings( + url = entry.url, + intervalMinutes = entry.intervalMinutes, + ) + } + + override suspend fun isServerDriven(scope: WidgetScope): Boolean = defaults.isServerDriven(scope.widgetId) +} diff --git a/packages/android-client/android/src/main/java/voltra/widget/server/WidgetServerEtagStore.kt b/packages/android-client/android/src/main/java/voltra/widget/server/WidgetServerEtagStore.kt new file mode 100644 index 00000000..fb2ac5ef --- /dev/null +++ b/packages/android-client/android/src/main/java/voltra/widget/server/WidgetServerEtagStore.kt @@ -0,0 +1,61 @@ +package voltra.widget.server + +import android.content.Context + +/** + * The ETag from the last `200`, stored with the URL it came from. + * + * Keeping the URL alongside it is what makes `If-None-Match` safe once the app can change the URL + * at runtime: an ETag minted by one endpoint says nothing about another, and sending it could + * produce a `304` that leaves the widget showing the previous endpoint's data forever. + */ +class WidgetServerEtagStore( + context: Context, +) { + private val preferences = context.applicationContext.getSharedPreferences(PREFERENCES_NAME, Context.MODE_PRIVATE) + + /** The stored ETag, but only if it was minted by [url]. */ + fun etag( + scope: WidgetScope, + url: String?, + ): String? { + if (url == null) return null + if (preferences.getString(urlKey(scope), null) != url) return null + + return preferences.getString(etagKey(scope), null) + } + + fun put( + scope: WidgetScope, + url: String, + etag: String?, + ) { + preferences + .edit() + .apply { + if (etag == null) { + remove(etagKey(scope)) + remove(urlKey(scope)) + } else { + putString(etagKey(scope), etag) + putString(urlKey(scope), url) + } + }.apply() + } + + fun clear(scope: WidgetScope) { + preferences + .edit() + .remove(etagKey(scope)) + .remove(urlKey(scope)) + .apply() + } + + private fun etagKey(scope: WidgetScope) = "etag.${scope.storageKey}" + + private fun urlKey(scope: WidgetScope) = "etag_url.${scope.storageKey}" + + companion object { + private const val PREFERENCES_NAME = "voltra_widget_server_etags" + } +} diff --git a/packages/android-client/android/src/main/java/voltra/widget/server/WidgetServerFetcher.kt b/packages/android-client/android/src/main/java/voltra/widget/server/WidgetServerFetcher.kt new file mode 100644 index 00000000..cdd1b67b --- /dev/null +++ b/packages/android-client/android/src/main/java/voltra/widget/server/WidgetServerFetcher.kt @@ -0,0 +1,249 @@ +package voltra.widget.server + +import android.util.Log +import java.io.ByteArrayOutputStream +import java.io.IOException +import java.net.HttpURLConnection +import java.net.URL +import java.text.SimpleDateFormat +import java.util.Locale +import java.util.TimeZone + +/** + * What came back from the server, before either engine decides what it means. + * + * The request side is identical for a payload widget and a Dynamic Widget, so it lives here once. + * Only the interpretation of [body] differs: one parses a Voltra payload, the other props. + */ +sealed class WidgetServerFetchResult { + /** `200` with a body. [etag] is present when the response carried one. */ + data class Success( + val body: String, + val etag: String?, + val httpStatus: Int, + val nextIntervalMinutes: Long?, + ) : WidgetServerFetchResult() + + /** `304`: what is already committed is still current. */ + data class NotModified( + val nextIntervalMinutes: Long?, + ) : WidgetServerFetchResult() + + /** The request never completed: no connectivity, DNS, TLS, or a timeout. */ + data class NetworkFailure( + val message: String, + ) : WidgetServerFetchResult() + + /** + * A `2xx` whose body is over [WidgetServerFetcher.MAX_BODY_BYTES]. Kept apart from + * [HttpFailure] because the server did answer: this is a body the device refuses, so it is + * reported as a parse failure and asking again is pointless. + */ + data class TooLarge( + val httpStatus: Int, + ) : WidgetServerFetchResult() + + /** + * The server answered with a status we cannot use. [retryAfterMinutes] carries `Retry-After` + * when the server sent one on a `429` or `503`. + */ + data class HttpFailure( + val httpStatus: Int, + val retryAfterMinutes: Long?, + ) : WidgetServerFetchResult() { + val isUnauthorized: Boolean + get() = httpStatus == 401 || httpStatus == 403 + + /** True when waiting and asking again could plausibly succeed. */ + val isTransient: Boolean + get() = httpStatus >= 500 || httpStatus == 429 + } +} + +/** + * Executes a [WidgetServerRequest] and reports what happened, without deciding what to do about it. + */ +object WidgetServerFetcher { + private const val TAG = "VoltraWidgetServerFetch" + + /** + * Bodies larger than this are refused. The iOS widget extension has a 30 MB ceiling for the + * whole render, and a widget that needs more than a quarter of a megabyte of props is not + * going to fit on a home screen either. + */ + const val MAX_BODY_BYTES = 256 * 1024 + + /** Redirects are followed only within the configured host, and only this many times. */ + private const val MAX_REDIRECTS = 3 + + fun fetch(request: WidgetServerRequest): WidgetServerFetchResult { + var current = request + var redirects = 0 + + while (true) { + val connection = + try { + WidgetServerRequestBuilder.open(current) + } catch (e: IOException) { + return WidgetServerFetchResult.NetworkFailure(e.message ?: "Failed to open connection") + } + + try { + val status = + try { + connection.responseCode + } catch (e: IOException) { + return WidgetServerFetchResult.NetworkFailure(e.message ?: "Request failed") + } + + if (status in REDIRECT_STATUSES) { + val next = + resolveRedirect(current, connection) ?: return WidgetServerFetchResult.HttpFailure(status, null) + + if (redirects >= MAX_REDIRECTS) { + Log.w(TAG, "Too many redirects for ${request.url}") + return WidgetServerFetchResult.HttpFailure(status, null) + } + + redirects += 1 + current = + if (status == 303) { + // 303 means "fetch the result of your request from here", which is a GET. + current.copy(url = next, method = "GET", body = null) + } else { + current.copy(url = next) + } + continue + } + + val nextIntervalMinutes = maxAgeMinutes(connection.getHeaderField("Cache-Control")) + + if (status == HttpURLConnection.HTTP_NOT_MODIFIED) { + return WidgetServerFetchResult.NotModified(nextIntervalMinutes) + } + + if (status !in 200..299) { + return WidgetServerFetchResult.HttpFailure( + httpStatus = status, + retryAfterMinutes = retryAfterMinutes(connection.getHeaderField("Retry-After")), + ) + } + + val body = + try { + readBody(connection) + } catch (e: IOException) { + return WidgetServerFetchResult.NetworkFailure(e.message ?: "Failed to read response") + } + + if (body == null) { + // Over the size cap. Retrying returns the same oversized body, so this is a + // failure the app has to fix rather than one to back off from. + Log.e(TAG, "Response from ${current.url} is larger than $MAX_BODY_BYTES bytes") + return WidgetServerFetchResult.TooLarge(status) + } + + return WidgetServerFetchResult.Success( + body = body, + etag = connection.getHeaderField("ETag"), + httpStatus = status, + nextIntervalMinutes = nextIntervalMinutes, + ) + } finally { + connection.disconnect() + } + } + } + + private val REDIRECT_STATUSES = setOf(301, 302, 303, 307, 308) + + /** + * Same-host redirects only. Following one to another host would send the app's Authorization + * header somewhere it never agreed to send it. + */ + private fun resolveRedirect( + request: WidgetServerRequest, + connection: HttpURLConnection, + ): URL? { + val location = connection.getHeaderField("Location") ?: return null + + val target = + try { + URL(request.url, location) + } catch (_: Exception) { + return null + } + + if (!target.host.equals(request.url.host, ignoreCase = true) || target.protocol != request.url.protocol) { + Log.w(TAG, "Refusing cross-host redirect from ${request.url.host} to ${target.host}") + return null + } + + return target + } + + /** Returns null when the body is over [MAX_BODY_BYTES]. */ + private fun readBody(connection: HttpURLConnection): String? { + if (connection.contentLength > MAX_BODY_BYTES) { + return null + } + + connection.inputStream.use { stream -> + val buffer = ByteArray(8 * 1024) + // Bytes are collected whole and decoded once: decoding chunk by chunk would corrupt a + // multi-byte character that straddles a read boundary. + val out = ByteArrayOutputStream() + + while (true) { + val read = stream.read(buffer) + if (read == -1) break + + if (out.size() + read > MAX_BODY_BYTES) return null + + out.write(buffer, 0, read) + } + + return out.toString(Charsets.UTF_8.name()) + } + } + + /** `Cache-Control: max-age=N`, in minutes, rounded down. */ + internal fun maxAgeMinutes(header: String?): Long? { + val value = header ?: return null + val match = Regex("max-age\\s*=\\s*(\\d+)", RegexOption.IGNORE_CASE).find(value) ?: return null + val seconds = match.groupValues[1].toLongOrNull() ?: return null + + return seconds / 60 + } + + /** + * `Retry-After`, in minutes, rounded up so we never retry early. + * + * The header is delta-seconds or an HTTP date; both are in the wild, so both are read. + */ + internal fun retryAfterMinutes( + header: String?, + now: Long = System.currentTimeMillis(), + ): Long? { + val value = header?.trim()?.takeIf { it.isNotEmpty() } ?: return null + + value.toLongOrNull()?.let { seconds -> + return if (seconds <= 0) null else (seconds + 59) / 60 + } + + val date = + try { + SimpleDateFormat(HTTP_DATE_FORMAT, Locale.US) + .apply { timeZone = TimeZone.getTimeZone("GMT") } + .parse(value) + } catch (_: Exception) { + null + } ?: return null + + val seconds = (date.time - now) / 1000 + + return if (seconds <= 0) null else (seconds + 59) / 60 + } + + private const val HTTP_DATE_FORMAT = "EEE, dd MMM yyyy HH:mm:ss zzz" +} diff --git a/packages/android-client/android/src/main/java/voltra/widget/server/WidgetServerRequestBuilder.kt b/packages/android-client/android/src/main/java/voltra/widget/server/WidgetServerRequestBuilder.kt new file mode 100644 index 00000000..7ef757bb --- /dev/null +++ b/packages/android-client/android/src/main/java/voltra/widget/server/WidgetServerRequestBuilder.kt @@ -0,0 +1,159 @@ +package voltra.widget.server + +import android.content.Context +import android.content.res.Configuration +import android.net.Uri +import android.os.Build +import android.util.Log +import java.net.HttpURLConnection +import java.net.URL + +/** + * A request, fully decided, before anything opens a socket. Keeping this separate from the + * connection is what lets the request contract be unit-tested: what a backend sees is a value, not + * a side effect. + */ +data class WidgetServerRequest( + val url: URL, + val method: String, + val headers: Map, + val body: ByteArray?, +) { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is WidgetServerRequest) return false + + return url == other.url && + method == other.method && + headers == other.headers && + body.contentEqualsOrBothNull(other.body) + } + + override fun hashCode(): Int { + var result = url.hashCode() + result = 31 * result + method.hashCode() + result = 31 * result + headers.hashCode() + result = 31 * result + (body?.contentHashCode() ?: 0) + return result + } +} + +private fun ByteArray?.contentEqualsOrBothNull(other: ByteArray?): Boolean = + if (this == null || other == null) this == null && other == null else contentEquals(other) + +/** + * Turns resolved settings plus Voltra's own request parameters into the request the device sends. + * + * Both engines build their requests here, so a payload widget and a Dynamic Widget send the same + * shape and the app's runtime overrides apply to both. Only the response and what the device does + * with it differ. + */ +object WidgetServerRequestBuilder { + private const val TAG = "VoltraWidgetServerReq" + + private const val CONNECT_TIMEOUT_MS = 15_000 + private const val READ_TIMEOUT_MS = 15_000 + + /** + * @param etag from the last `200`, sent as `If-None-Match`. Callers pass null when the stored + * ETag belongs to a different URL than the one being fetched now. + * @return null when there is nothing to fetch — no URL, or fetching is off. + */ + fun build( + context: Context, + scope: WidgetScope, + settings: ResolvedWidgetServerSettings, + etag: String? = null, + ): WidgetServerRequest? { + if (!settings.shouldFetch) { + return null + } + + val method = settings.method.uppercase() + + val builder = + Uri + .parse(settings.url) + .buildUpon() + .appendQueryParameter("widgetId", scope.widgetId) + .appendQueryParameter("platform", "android") + .appendQueryParameter("theme", currentTheme(context)) + .appendQueryParameter("locale", currentLocale(context)) + + // Voltra's own keys are appended first and the app's keys are rejected at call time if they + // collide, so nothing here can shadow what the server relies on. + settings.query.forEach { (key, value) -> builder.appendQueryParameter(key, value) } + + val headers = mutableMapOf() + headers["Accept"] = "application/json" + headers["User-Agent"] = userAgent() + headers.putAll(settings.headers) + + if (etag != null) { + headers["If-None-Match"] = etag + } + + var body = settings.body?.toByteArray(Charsets.UTF_8) + + if (body != null && method in WidgetServerUpdateDefaults.BODYLESS_METHODS) { + // HttpURLConnection silently turns a GET with an output stream into a POST, which would + // hit a different endpoint than the app asked for. Dropping the body is the lesser + // surprise, and it is documented. + Log.w(TAG, "Dropping request body for widget '${scope.widgetId}': $method cannot carry one") + body = null + } + + if (body != null) { + headers["Content-Type"] = "application/json" + } + + return WidgetServerRequest( + url = URL(builder.build().toString()), + method = method, + headers = headers, + body = body, + ) + } + + /** Opens and configures a connection for [request]. The caller connects, reads, and disconnects. */ + fun open(request: WidgetServerRequest): HttpURLConnection { + val connection = request.url.openConnection() as HttpURLConnection + + connection.requestMethod = request.method + connection.connectTimeout = CONNECT_TIMEOUT_MS + connection.readTimeout = READ_TIMEOUT_MS + // Redirects are followed by WidgetServerFetcher instead, which refuses to leave the host + // the app configured so an Authorization header cannot be replayed somewhere else. + connection.instanceFollowRedirects = false + + request.headers.forEach { (key, value) -> connection.setRequestProperty(key, value) } + + request.body?.let { body -> + connection.doOutput = true + connection.outputStream.use { it.write(body) } + } + + return connection + } + + fun currentTheme(context: Context): String { + val nightModeFlags = context.resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK + return if (nightModeFlags == Configuration.UI_MODE_NIGHT_YES) "dark" else "light" + } + + fun currentLocale(context: Context): String { + val configuration = context.resources.configuration + val locale = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + configuration.locales[0] + } else { + @Suppress("DEPRECATION") + configuration.locale + } + + return locale?.toLanguageTag() ?: "en" + } + + private fun userAgent(): String = + "VoltraWidget/${voltra.BuildConfig.VOLTRA_VERSION} (Android/${Build.VERSION.RELEASE})" +} diff --git a/packages/android-client/android/src/main/java/voltra/widget/server/WidgetServerSettingsCodec.kt b/packages/android-client/android/src/main/java/voltra/widget/server/WidgetServerSettingsCodec.kt new file mode 100644 index 00000000..50653145 --- /dev/null +++ b/packages/android-client/android/src/main/java/voltra/widget/server/WidgetServerSettingsCodec.kt @@ -0,0 +1,76 @@ +package voltra.widget.server + +import org.json.JSONObject + +/** + * Serializes one settings layer for storage. Written as a small versioned envelope so a future + * shape change can be recognised rather than guessed at, the same way Dynamic Widget props are + * stored. + */ +object WidgetServerSettingsCodec { + private const val VERSION_KEY = "widgetServerSettingsVersion" + private const val SETTINGS_KEY = "widgetServerSettings" + private const val VERSION = 1 + + fun encode(settings: WidgetServerUpdateSettings): String { + val payload = JSONObject() + + settings.url?.let { payload.put("url", it) } + settings.intervalMinutes?.let { payload.put("intervalMinutes", it) } + settings.enabled?.let { payload.put("enabled", it) } + settings.method?.let { payload.put("method", it) } + settings.query?.let { payload.put("query", JSONObject(it as Map<*, *>)) } + settings.headers?.let { payload.put("headers", JSONObject(it as Map<*, *>)) } + settings.body?.let { payload.put("body", it) } + + return JSONObject() + .put(VERSION_KEY, VERSION) + .put(SETTINGS_KEY, payload) + .toString() + } + + /** Returns null for anything this version cannot read, so a bad record reads as "no opinion". */ + fun decode(serialized: String?): WidgetServerUpdateSettings? { + if (serialized.isNullOrBlank()) return null + + return try { + val envelope = JSONObject(serialized) + + if (envelope.optInt(VERSION_KEY, -1) != VERSION) return null + + val payload = envelope.optJSONObject(SETTINGS_KEY) ?: return null + + WidgetServerUpdateSettings( + url = payload.optStringOrNull("url"), + intervalMinutes = if (payload.has("intervalMinutes")) payload.optLong("intervalMinutes") else null, + enabled = if (payload.has("enabled")) payload.optBoolean("enabled") else null, + method = payload.optStringOrNull("method"), + query = payload.optStringMap("query"), + headers = payload.optStringMap("headers"), + body = payload.optStringOrNull("body"), + ) + } catch (_: Exception) { + null + } + } + + private fun JSONObject.optStringOrNull(key: String): String? = + if (has(key) && + !isNull(key) + ) { + getString(key) + } else { + null + } + + private fun JSONObject.optStringMap(key: String): Map? { + val nested = optJSONObject(key) ?: return null + val entries = mutableMapOf() + + nested.keys().forEach { nestedKey -> + entries[nestedKey] = nested.optString(nestedKey) + } + + return entries + } +} diff --git a/packages/android-client/android/src/main/java/voltra/widget/server/WidgetServerSettingsLayer.kt b/packages/android-client/android/src/main/java/voltra/widget/server/WidgetServerSettingsLayer.kt new file mode 100644 index 00000000..18e07033 --- /dev/null +++ b/packages/android-client/android/src/main/java/voltra/widget/server/WidgetServerSettingsLayer.kt @@ -0,0 +1,23 @@ +package voltra.widget.server + +/** + * One source of server-update settings. Implementations return a partial + * [WidgetServerUpdateSettings] or null when they have nothing to say about the scope. + * + * Layers are stacked in a fixed order by [WidgetServerSettingsResolver] and never consulted + * directly: nothing outside this package reads the generated asset, the DataStore, or anything + * else for server-update purposes. + */ +interface WidgetServerSettingsLayer { + /** A short name used in logs, so a surprising resolved value can be traced to its source. */ + val name: String + + suspend fun settings(scope: WidgetScope): WidgetServerUpdateSettings? + + /** + * Whether this layer knows the scope to be server-driven at all. Only the config layer can + * answer this — a runtime layer setting a URL does not turn a locally-rendered widget into a + * server-driven one, because the engine is chosen at generate time. + */ + suspend fun isServerDriven(scope: WidgetScope): Boolean = false +} diff --git a/packages/android-client/android/src/main/java/voltra/widget/server/WidgetServerSettingsResolver.kt b/packages/android-client/android/src/main/java/voltra/widget/server/WidgetServerSettingsResolver.kt new file mode 100644 index 00000000..7f303898 --- /dev/null +++ b/packages/android-client/android/src/main/java/voltra/widget/server/WidgetServerSettingsResolver.kt @@ -0,0 +1,108 @@ +package voltra.widget.server + +/** + * The only way to read server-update settings. + * + * Layers are walked lowest to highest and merged by the rule stated once here: `headers` and + * `query` merge per key, everything else takes the value from the highest layer that sets it. + * Adding a layer later — an instance layer above `widget`, say — is a new + * [WidgetServerSettingsLayer] plus one entry in [layers]; this API and every caller stay as they + * are. + * + * @param layers lowest priority first: config, credentials, global, widget. + */ +class WidgetServerSettingsResolver( + private val layers: List, + private val revisionSource: suspend () -> Long, +) { + /** + * Flattens every layer for [scope]. Safe to call for any widget: a widget that is not + * server-driven resolves to disabled with no URL, so a caller that fetches on [ + * ResolvedWidgetServerSettings.shouldFetch] does nothing rather than guessing. + */ + suspend fun resolve(scope: WidgetScope): ResolvedWidgetServerSettings { + var merged = WidgetServerUpdateSettings.EMPTY + var intervalFromConfig = false + + for ((index, layer) in layers.withIndex()) { + val settings = layer.settings(scope) ?: continue + + if (settings.intervalMinutes != null) { + // Index 0 is the config layer. An interval that came from app.json was already + // validated against this platform's rules when the native project was generated, + // so clamping it again here would silently change an existing widget's schedule. + intervalFromConfig = index == 0 + } + + merged = merge(merged, settings) + } + + val serverDriven = isServerDriven(scope) + val intervalMinutes = merged.intervalMinutes ?: WidgetServerUpdateDefaults.DEFAULT_INTERVAL_MINUTES + + return ResolvedWidgetServerSettings( + url = if (serverDriven) merged.url?.takeIf { it.isNotBlank() } else null, + intervalMinutes = + if (intervalFromConfig) { + intervalMinutes + } else { + WidgetServerUpdateDefaults.clampIntervalMinutes(intervalMinutes) + }, + enabled = serverDriven && (merged.enabled ?: true), + method = merged.method ?: WidgetServerUpdateDefaults.DEFAULT_METHOD, + query = merged.query ?: emptyMap(), + headers = merged.headers ?: emptyMap(), + body = merged.body, + ) + } + + /** + * True when app.json marked this widget server-driven. The engine is chosen at generate time, + * so a runtime URL cannot make a widget server-driven and `setWidgetServerUpdate` rejects + * settings for one that is not. + */ + suspend fun isServerDriven(scope: WidgetScope): Boolean = layers.any { it.isServerDriven(scope) } + + /** + * Raw contents of the global layer: no defaulting, no merge — there is nothing to resolve + * against without a widget scope. Null when nothing has been set globally. + */ + suspend fun globalSettings(): WidgetServerUpdateSettings? = + layers.filterIsInstance().firstOrNull()?.raw() + + /** + * Monotonic counter of settings changes. A fetcher records it before fetching and commits only + * if it is still current, so settings changed mid-flight cannot commit a response built from + * the old ones. + * + * It is one counter for the whole store rather than one per scope: a change to another widget + * can make an in-flight fetch drop its result, and the reload that every `set` queues fetches + * again, so the cost is one wasted request in a rare race. + */ + suspend fun revision( + @Suppress("UNUSED_PARAMETER") scope: WidgetScope, + ): Long = revisionSource() + + private fun merge( + lower: WidgetServerUpdateSettings, + higher: WidgetServerUpdateSettings, + ): WidgetServerUpdateSettings = + WidgetServerUpdateSettings( + url = higher.url ?: lower.url, + intervalMinutes = higher.intervalMinutes ?: lower.intervalMinutes, + enabled = higher.enabled ?: lower.enabled, + method = higher.method ?: lower.method, + query = mergePerKey(lower.query, higher.query), + headers = mergePerKey(lower.headers, higher.headers), + body = higher.body ?: lower.body, + ) + + private fun mergePerKey( + lower: Map?, + higher: Map?, + ): Map? { + if (lower == null) return higher + if (higher == null) return lower + return lower + higher + } +} diff --git a/packages/android-client/android/src/main/java/voltra/widget/server/WidgetServerSettingsStore.kt b/packages/android-client/android/src/main/java/voltra/widget/server/WidgetServerSettingsStore.kt new file mode 100644 index 00000000..09a477ae --- /dev/null +++ b/packages/android-client/android/src/main/java/voltra/widget/server/WidgetServerSettingsStore.kt @@ -0,0 +1,141 @@ +package voltra.widget.server + +import android.content.Context +import android.util.Log +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.longPreferencesKey +import androidx.datastore.preferences.core.stringPreferencesKey +import androidx.datastore.preferences.core.stringSetPreferencesKey +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.firstOrNull +import kotlinx.coroutines.flow.map + +/** + * The only way to write server-update settings, and the storage behind the three runtime layers. + * + * Records live in the same Tink-encrypted DataStore the widget credentials have always used, so + * nothing migrates: the deprecated `setWidgetServerCredentials` keeps writing the accounts it + * always did, and this store adds its own keys alongside them. + * + * Callers do not read through this class. They read through [WidgetServerSettingsResolver], which + * is what keeps the layer order and the merge rule in one place. + */ +class WidgetServerSettingsStore( + private val context: Context, +) { + /** Replaces the global layer, or one widget's layer when [scope] is given. */ + suspend fun set( + settings: WidgetServerUpdateSettings, + scope: WidgetScope?, + ) { + val encoded = WidgetServerSettingsCodec.encode(settings) + val encrypted = + VoltraCryptoManager.encrypt(context, encoded) + ?: throw IllegalStateException("Failed to encrypt widget server settings") + + context.voltraCredentialsDataStore.edit { prefs -> + if (scope == null) { + prefs[KEY_GLOBAL] = encrypted + } else { + prefs[widgetKey(scope)] = encrypted + prefs[KEY_WIDGET_SCOPES] = (prefs[KEY_WIDGET_SCOPES] ?: emptySet()) + scope.storageKey + } + + prefs[KEY_REVISION] = (prefs[KEY_REVISION] ?: 0L) + 1L + } + } + + /** Empties the global layer, or one widget's layer when [scope] is given. */ + suspend fun clear(scope: WidgetScope?) { + context.voltraCredentialsDataStore.edit { prefs -> + if (scope == null) { + prefs.remove(KEY_GLOBAL) + } else { + prefs.remove(widgetKey(scope)) + prefs[KEY_WIDGET_SCOPES] = (prefs[KEY_WIDGET_SCOPES] ?: emptySet()) - scope.storageKey + } + + prefs[KEY_REVISION] = (prefs[KEY_REVISION] ?: 0L) + 1L + } + } + + /** + * Bumps the revision without changing a layer. The credentials layer writes through the + * deprecated credential API, which does not go through [set], so it calls this to make sure an + * in-flight fetch built with the old token does not commit. + */ + suspend fun bumpRevision() { + context.voltraCredentialsDataStore.edit { prefs -> + prefs[KEY_REVISION] = (prefs[KEY_REVISION] ?: 0L) + 1L + } + } + + suspend fun revision(): Long = + try { + context.voltraCredentialsDataStore.data + .map { prefs -> prefs[KEY_REVISION] ?: 0L } + .first() + } catch (e: Exception) { + Log.e(TAG, "Failed to read settings revision: ${e.message}", e) + 0L + } + + /** Widget ids that currently have a widget-scoped layer, so `clear` can find them. */ + suspend fun scopedStorageKeys(): Set = + try { + context.voltraCredentialsDataStore.data + .map { prefs -> prefs[KEY_WIDGET_SCOPES] ?: emptySet() } + .firstOrNull() ?: emptySet() + } catch (e: Exception) { + Log.e(TAG, "Failed to read scoped settings keys: ${e.message}", e) + emptySet() + } + + internal suspend fun read( + key: androidx.datastore.preferences.core.Preferences.Key, + ): WidgetServerUpdateSettings? = + try { + val encrypted = + context.voltraCredentialsDataStore.data + .map { prefs -> prefs[key] } + .firstOrNull() ?: return null + + WidgetServerSettingsCodec.decode(VoltraCryptoManager.decrypt(context, encrypted)) + } catch (e: Exception) { + Log.e(TAG, "Failed to read widget server settings: ${e.message}", e) + null + } + + internal fun widgetKey(scope: WidgetScope) = stringPreferencesKey("$KEY_WIDGET_PREFIX${scope.storageKey}") + + companion object { + private const val TAG = "VoltraWidgetServerStore" + private const val KEY_WIDGET_PREFIX = "server_update_widget_" + + internal val KEY_GLOBAL = stringPreferencesKey("server_update_global") + internal val KEY_WIDGET_SCOPES = stringSetPreferencesKey("server_update_widget_keys") + internal val KEY_REVISION = longPreferencesKey("server_update_revision") + } +} + +/** Settings the app set for every server-driven widget. */ +class GlobalWidgetServerSettingsLayer( + private val store: WidgetServerSettingsStore, +) : WidgetServerSettingsLayer { + override val name: String = "global" + + override suspend fun settings(scope: WidgetScope): WidgetServerUpdateSettings? = + store.read(WidgetServerSettingsStore.KEY_GLOBAL) + + /** Raw contents of this layer, for reading back what was set — no scope to resolve against. */ + suspend fun raw(): WidgetServerUpdateSettings? = store.read(WidgetServerSettingsStore.KEY_GLOBAL) +} + +/** Settings the app set for one widget. Highest layer until instance scopes arrive. */ +class WidgetWidgetServerSettingsLayer( + private val store: WidgetServerSettingsStore, +) : WidgetServerSettingsLayer { + override val name: String = "widget" + + override suspend fun settings(scope: WidgetScope): WidgetServerUpdateSettings? = store.read(store.widgetKey(scope)) +} diff --git a/packages/android-client/android/src/main/java/voltra/widget/server/WidgetServerSettingsValidator.kt b/packages/android-client/android/src/main/java/voltra/widget/server/WidgetServerSettingsValidator.kt new file mode 100644 index 00000000..5e234af6 --- /dev/null +++ b/packages/android-client/android/src/main/java/voltra/widget/server/WidgetServerSettingsValidator.kt @@ -0,0 +1,86 @@ +package voltra.widget.server + +import android.net.Uri + +/** + * Call-time rules for `setWidgetServerUpdate`. Rejecting here rather than at fetch time means the + * app learns about a bad setting from the promise it just awaited, instead of from a widget that + * quietly stops updating hours later. + */ +object WidgetServerSettingsValidator { + /** Hosts reachable over plain http, so a debug build can talk to a dev server. */ + private val LOCAL_HTTP_HOSTS = setOf("localhost", "127.0.0.1", "::1", "10.0.2.2", "10.0.3.2") + + /** + * @param isDebugBuild whether plain http to a local dev host is allowed. Release builds have + * cleartext traffic blocked anyway, so allowing it there would only defer the failure. + * @return an error message, or null when the settings are usable. + */ + fun validate( + settings: WidgetServerUpdateSettings, + isDebugBuild: Boolean, + ): String? { + settings.url?.let { url -> + validateUrl(url, isDebugBuild)?.let { return it } + } + + settings.intervalMinutes?.let { interval -> + if (interval <= 0) { + return "intervalMinutes must be a positive number of minutes" + } + } + + settings.method?.let { method -> + if (method.uppercase() !in WidgetServerUpdateDefaults.SUPPORTED_METHODS) { + return "method '$method' is not supported. Use one of " + + WidgetServerUpdateDefaults.SUPPORTED_METHODS.joinToString(", ") + } + } + + settings.query?.keys?.forEach { key -> + if (key in WidgetServerUpdateDefaults.RESERVED_QUERY_KEYS) { + return "query key '$key' is reserved by Voltra and is sent on every request" + } + } + + val encoded = WidgetServerSettingsCodec.encode(settings) + + if (encoded.toByteArray(Charsets.UTF_8).size > WidgetServerUpdateDefaults.MAX_LAYER_BYTES) { + return "settings are larger than ${WidgetServerUpdateDefaults.MAX_LAYER_BYTES} bytes once serialized" + } + + return null + } + + private fun validateUrl( + url: String, + isDebugBuild: Boolean, + ): String? { + if (url.isBlank()) { + return "url must not be empty" + } + + val parsed = Uri.parse(url) + val scheme = parsed.scheme?.lowercase() + val host = parsed.host + + if (scheme == null || host.isNullOrBlank()) { + return "url '$url' must be an absolute http(s) URL" + } + + if (scheme == "https") { + return null + } + + if (scheme != "http") { + return "url '$url' must be an absolute http(s) URL" + } + + if (isDebugBuild && host in LOCAL_HTTP_HOSTS) { + return null + } + + return "url '$url' must use https. Plain http is allowed only in a debug build, and only " + + "for ${LOCAL_HTTP_HOSTS.joinToString(", ")}." + } +} diff --git a/packages/android-client/android/src/main/java/voltra/widget/server/WidgetServerUpdateSettings.kt b/packages/android-client/android/src/main/java/voltra/widget/server/WidgetServerUpdateSettings.kt new file mode 100644 index 00000000..08c76c70 --- /dev/null +++ b/packages/android-client/android/src/main/java/voltra/widget/server/WidgetServerUpdateSettings.kt @@ -0,0 +1,90 @@ +package voltra.widget.server + +/** + * Server-update settings as one layer holds them: every field is optional, and an unset field + * means "this layer has no opinion, ask the layer below". + * + * [body] is kept as the raw JSON text the app supplied rather than a parsed tree, because Voltra + * never inspects it — it only forwards it as the request body. + */ +data class WidgetServerUpdateSettings( + val url: String? = null, + val intervalMinutes: Long? = null, + val enabled: Boolean? = null, + val method: String? = null, + val query: Map? = null, + val headers: Map? = null, + val body: String? = null, +) { + val isEmpty: Boolean + get() = + url == null && + intervalMinutes == null && + enabled == null && + method == null && + query == null && + headers == null && + body == null + + companion object { + val EMPTY = WidgetServerUpdateSettings() + } +} + +/** + * The flattened settings a fetch actually runs on. Every field is decided: [intervalMinutes] has + * the floor and ceiling applied, [enabled] and [method] have their defaults filled in, and [query] + * and [headers] are the per-key merge of every layer. + * + * [url] is the one field that can still be absent, and it means the widget is server-driven but has + * nowhere to fetch from yet — the app is expected to supply one with `setWidgetServerUpdate`. + */ +data class ResolvedWidgetServerSettings( + val url: String?, + val intervalMinutes: Long, + val enabled: Boolean, + val method: String, + val query: Map, + val headers: Map, + val body: String?, +) { + /** True when this widget has both a URL to fetch and permission to do it. */ + val shouldFetch: Boolean + get() = enabled && !url.isNullOrBlank() +} + +object WidgetServerUpdateDefaults { + /** + * WorkManager will not run periodic work more often than every 15 minutes, so asking for less + * would only misreport what the widget actually does. + */ + const val MIN_INTERVAL_MINUTES = 15L + + /** + * A day. Past this the widget is effectively not server-driven, and `Cache-Control: max-age` + * from a misconfigured server should not be able to park a widget for a week. + */ + const val MAX_INTERVAL_MINUTES = 24L * 60L + + const val DEFAULT_INTERVAL_MINUTES = MIN_INTERVAL_MINUTES + + const val DEFAULT_METHOD = "GET" + + /** Methods either platform's HTTP stack can send. */ + val SUPPORTED_METHODS = setOf("GET", "POST", "PUT", "PATCH", "DELETE") + + /** Methods that cannot carry a body. A body set alongside one of these is dropped. */ + val BODYLESS_METHODS = setOf("GET", "HEAD") + + /** + * Query keys Voltra puts on every request. An app that set one of these would silently shadow + * what the server relies on, so `setWidgetServerUpdate` rejects them. + */ + val RESERVED_QUERY_KEYS = setOf("widgetId", "platform", "family", "theme", "locale", "instance") + + /** Serialized size cap for one layer, so a runaway `body` cannot fill the settings store. */ + const val MAX_LAYER_BYTES = 16 * 1024 + + fun clampIntervalMinutes(intervalMinutes: Long): Long = + intervalMinutes.coerceIn(MIN_INTERVAL_MINUTES, MAX_INTERVAL_MINUTES) +} diff --git a/packages/android-client/android/src/main/java/voltra/widget/server/WidgetServerUpdateSettingsJson.kt b/packages/android-client/android/src/main/java/voltra/widget/server/WidgetServerUpdateSettingsJson.kt new file mode 100644 index 00000000..24cfd8f5 --- /dev/null +++ b/packages/android-client/android/src/main/java/voltra/widget/server/WidgetServerUpdateSettingsJson.kt @@ -0,0 +1,110 @@ +package voltra.widget.server + +import org.json.JSONArray +import org.json.JSONObject +import org.json.JSONTokener + +/** + * Reads the settings object an app passes to `setWidgetServerUpdate`. + * + * Separate from [WidgetServerSettingsCodec], which is the versioned storage format: what the app + * sends and what Voltra persists are allowed to diverge, and conflating them would make either one + * hard to change. The one real difference today is `body`, which arrives as arbitrary JSON and is + * kept as text because Voltra only forwards it. + */ +object WidgetServerUpdateSettingsJson { + sealed class Result { + data class Parsed( + val settings: WidgetServerUpdateSettings, + ) : Result() + + data class Invalid( + val reason: String, + ) : Result() + } + + fun parse(json: String): Result { + val root = + try { + JSONObject(json) + } catch (_: Exception) { + return Result.Invalid("settings must be a JSON object") + } + + val query = stringMap(root, "query") ?: return Result.Invalid("query must be an object of strings") + val headers = stringMap(root, "headers") ?: return Result.Invalid("headers must be an object of strings") + + return Result.Parsed( + WidgetServerUpdateSettings( + url = root.optStringOrNull("url"), + intervalMinutes = if (root.has("intervalMinutes")) root.optLong("intervalMinutes") else null, + enabled = if (root.has("enabled")) root.optBoolean("enabled") else null, + method = root.optStringOrNull("method")?.uppercase(), + query = query.takeIf { root.has("query") }, + headers = headers.takeIf { root.has("headers") }, + body = if (root.has("body") && !root.isNull("body")) jsonText(root.get("body")) else null, + ), + ) + } + + /** + * The other direction of [parse]: serializes settings back to the same JSON shape, for + * `getWidgetServerUpdate` to hand across the bridge. No envelope — that is + * [WidgetServerSettingsCodec]'s job for storage, not this one's for a single read. + */ + fun stringify(settings: WidgetServerUpdateSettings): String { + val root = JSONObject() + + settings.url?.let { root.put("url", it) } + settings.intervalMinutes?.let { root.put("intervalMinutes", it) } + settings.enabled?.let { root.put("enabled", it) } + settings.method?.let { root.put("method", it) } + settings.query?.let { root.put("query", JSONObject(it as Map<*, *>)) } + settings.headers?.let { root.put("headers", JSONObject(it as Map<*, *>)) } + settings.body?.let { root.put("body", JSONTokener(it).nextValue()) } + + return root.toString() + } + + /** + * Re-serializes a parsed value back to JSON text. `toString()` alone is wrong for a string + * body: it would drop the quotes and send something that is not JSON at all. + */ + private fun jsonText(value: Any): String = + when (value) { + is JSONObject, is JSONArray -> value.toString() + is String -> JSONObject.quote(value) + is Boolean, is Number -> value.toString() + else -> JSONObject.quote(value.toString()) + } + + private fun JSONObject.optStringOrNull(key: String): String? = + if (has(key) && + !isNull(key) + ) { + getString(key) + } else { + null + } + + /** Returns an empty map when the key is absent, and null when it is present but not usable. */ + private fun stringMap( + root: JSONObject, + key: String, + ): Map? { + if (!root.has(key) || root.isNull(key)) return emptyMap() + + val nested = root.optJSONObject(key) ?: return null + val entries = mutableMapOf() + + nested.keys().forEach { nestedKey -> + val value = nested.opt(nestedKey) + + if (value !is String) return null + + entries[nestedKey] = value + } + + return entries + } +} diff --git a/packages/android-client/android/src/test/java/voltra/WidgetOrchestratorTest.kt b/packages/android-client/android/src/test/java/voltra/WidgetOrchestratorTest.kt index 650e2e66..bb088b7c 100644 --- a/packages/android-client/android/src/test/java/voltra/WidgetOrchestratorTest.kt +++ b/packages/android-client/android/src/test/java/voltra/WidgetOrchestratorTest.kt @@ -39,6 +39,47 @@ class WidgetOrchestratorTest { Dispatchers.resetMain() } + @Test + fun reloadWidgetsRefetchesAServerDrivenDynamicWidgetBeforeRerendering() = + runTest { + val fetched = mutableListOf() + val rendered = mutableListOf() + val orchestrator = + WidgetOrchestrator( + context = RuntimeEnvironment.getApplication(), + widgetKindClassifier = { VoltraWidgetKind.Dynamic }, + dynamicWidgetGlanceUpdateTrigger = { widgetId -> rendered += widgetId }, + dynamicWidgetServerFetchTrigger = { widgetId -> + fetched += widgetId + true + }, + ) + + orchestrator.reloadWidgets(listOf("server-driven-dynamic")) + + // The fetch is asked for first, but the render does not wait for it: the widget shows + // what it already has and updates again when the fetch lands. + assertEquals(listOf("server-driven-dynamic"), fetched) + assertEquals(listOf("server-driven-dynamic"), rendered) + } + + @Test + fun reloadWidgetsStillRerendersADynamicWidgetThatIsNotServerDriven() = + runTest { + val rendered = mutableListOf() + val orchestrator = + WidgetOrchestrator( + context = RuntimeEnvironment.getApplication(), + widgetKindClassifier = { VoltraWidgetKind.Dynamic }, + dynamicWidgetGlanceUpdateTrigger = { widgetId -> rendered += widgetId }, + dynamicWidgetServerFetchTrigger = { false }, + ) + + orchestrator.reloadWidgets(listOf("local-dynamic")) + + assertEquals(listOf("local-dynamic"), rendered) + } + @Test fun reloadClientWidgetsOnlyTriggersWidgetsTheResolverClassifiesAsDynamic() = runTest { diff --git a/packages/android-client/android/src/test/java/voltra/dynamicwidget/serverupdate/DynamicWidgetServerPropsTest.kt b/packages/android-client/android/src/test/java/voltra/dynamicwidget/serverupdate/DynamicWidgetServerPropsTest.kt new file mode 100644 index 00000000..020f91f5 --- /dev/null +++ b/packages/android-client/android/src/test/java/voltra/dynamicwidget/serverupdate/DynamicWidgetServerPropsTest.kt @@ -0,0 +1,69 @@ +package voltra.dynamicwidget.serverupdate + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class DynamicWidgetServerPropsTest { + private fun parse(body: String) = DynamicWidgetServerProps.parse(body) + + private fun invalidReason(body: String): String { + val result = parse(body) + + assertTrue("expected $body to be rejected", result is DynamicWidgetPropsParseResult.Invalid) + + return (result as DynamicWidgetPropsParseResult.Invalid).reason + } + + @Test + fun `accepts a JSON object and hands it through verbatim`() { + val result = parse("""{"total":42,"holdings":[{"symbol":"AAPL"}]}""") + + assertTrue(result is DynamicWidgetPropsParseResult.Props) + assertEquals( + """{"total":42,"holdings":[{"symbol":"AAPL"}]}""", + (result as DynamicWidgetPropsParseResult.Props).json, + ) + } + + @Test + fun `accepts an empty object, which is what a widget already gets before its first props`() { + assertTrue(parse("{}") is DynamicWidgetPropsParseResult.Props) + } + + @Test + fun `rejects a top-level array, primitive or null`() { + assertTrue(invalidReason("[1,2,3]").contains("array")) + assertTrue(invalidReason("42").contains("JSON object")) + assertTrue(invalidReason("\"hello\"").contains("JSON object")) + assertTrue(invalidReason("null").contains("JSON object")) + } + + @Test + fun `rejects a body that is not JSON at all`() { + assertTrue(invalidReason("nope").contains("JSON object")) + assertTrue(invalidReason(" ").contains("empty")) + } + + @Test + fun `rejects a Voltra payload by name, because that is the mistake sharing the config key invites`() { + val reason = invalidReason("""{"v":1,"variants":{"180x110":{"t":1}}}""") + + assertTrue(reason.contains("Voltra payload")) + assertTrue(reason.contains("entry")) + } + + @Test + fun `rejects a payload that carries shared elements instead of variants`() { + assertTrue(invalidReason("""{"v":1,"e":[{"t":1}]}""").contains("Voltra payload")) + } + + @Test + fun `does not mistake props that happen to have a v key for a payload`() { + assertTrue(parse("""{"v":1,"label":"hi"}""") is DynamicWidgetPropsParseResult.Props) + assertTrue(parse("""{"v":"1.2.3","variants":{"a":1}}""") is DynamicWidgetPropsParseResult.Props) + } +} diff --git a/packages/android-client/android/src/test/java/voltra/dynamicwidget/serverupdate/DynamicWidgetServerUpdateRunnerTest.kt b/packages/android-client/android/src/test/java/voltra/dynamicwidget/serverupdate/DynamicWidgetServerUpdateRunnerTest.kt new file mode 100644 index 00000000..23437cf7 --- /dev/null +++ b/packages/android-client/android/src/test/java/voltra/dynamicwidget/serverupdate/DynamicWidgetServerUpdateRunnerTest.kt @@ -0,0 +1,360 @@ +package voltra.dynamicwidget.serverupdate + +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import voltra.dynamicwidget.DynamicWidgetPropsPersistence +import voltra.widget.VoltraWidgetKind +import voltra.widget.VoltraWidgetKindResolution +import voltra.widget.server.ResolvedWidgetServerSettings +import voltra.widget.server.WidgetScope +import voltra.widget.server.WidgetServerFetchResult +import voltra.widget.server.WidgetServerUpdateDefaults + +/** + * The ADR 0002 failure table, one row at a time. Every collaborator is a fake, so what is under + * test is the decision — commit, keep, retry, give up — and nothing else. + */ +@RunWith(RobolectricTestRunner::class) +class DynamicWidgetServerUpdateRunnerTest { + private val scope = WidgetScope.of("portfolio") + + private class RecordingProps : DynamicWidgetPropsPersistence { + val committed = mutableListOf() + + override fun persistDynamicWidgetProps( + dynamicWidgetId: String, + dynamicWidgetPropsJson: String, + ) { + committed += dynamicWidgetPropsJson + } + } + + private class RecordingStatuses : DynamicWidgetServerStatusSink { + var successes = 0 + var failures = mutableListOf>() + var disabledFor: WidgetScope? = null + + override fun recordSuccess( + scope: WidgetScope, + fetchedAt: Long, + httpStatus: Int, + ) { + successes += 1 + } + + override fun recordFailure( + scope: WidgetScope, + error: String, + httpStatus: Int?, + ) { + failures += error to httpStatus + } + + override fun markDisabledIfNeeded( + scope: WidgetScope, + enabled: Boolean, + ) { + if (!enabled) disabledFor = scope + } + } + + private fun settings( + url: String? = "https://api.example.com/portfolio", + enabled: Boolean = true, + ) = ResolvedWidgetServerSettings( + url = url, + intervalMinutes = 15, + enabled = enabled, + method = "GET", + query = emptyMap(), + headers = emptyMap(), + body = null, + ) + + private class Harness( + val props: RecordingProps = RecordingProps(), + val statuses: RecordingStatuses = RecordingStatuses(), + ) { + var notified = 0 + var etags = mutableListOf>() + } + + private fun runner( + harness: Harness, + kind: VoltraWidgetKindResolution = VoltraWidgetKindResolution.Resolved(VoltraWidgetKind.Dynamic), + settings: ResolvedWidgetServerSettings = settings(), + result: WidgetServerFetchResult = WidgetServerFetchResult.Success("{}", null, 200, null), + trialRenders: Boolean = true, + revisions: List = listOf(1L, 1L), + storedEtag: String? = null, + onRequestEtag: (String?) -> Unit = {}, + ): DynamicWidgetServerUpdateRunner { + val revisionQueue = ArrayDeque(revisions) + + return DynamicWidgetServerUpdateRunner( + resolveKind = { kind }, + resolveSettings = { settings }, + currentRevision = { revisionQueue.removeFirstOrNull() ?: revisions.last() }, + readEtag = { _, _ -> storedEtag }, + fetch = { _, _, etag -> + onRequestEtag(etag) + result + }, + writeEtag = { widgetScope, url, etag -> harness.etags += Triple(widgetScope.widgetId, url, etag) }, + trialRender = { _, _ -> trialRenders }, + commitProps = harness.props, + statusStore = harness.statuses, + notifyWidget = { harness.notified += 1 }, + now = { 1_000L }, + ) + } + + @Test + fun `commits props that fetch, parse and render`() = + runTest { + val harness = Harness() + val outcome = + runner( + harness, + result = WidgetServerFetchResult.Success("""{"total":42}""", "\"abc\"", 200, null), + ).run(scope).outcome + + assertEquals(DynamicWidgetServerUpdateOutcome.Committed, outcome) + assertEquals(listOf("""{"total":42}"""), harness.props.committed) + assertEquals(1, harness.statuses.successes) + assertEquals(1, harness.notified) + } + + @Test + fun `stores the etag against the url it came from`() = + runTest { + val harness = Harness() + + runner(harness, result = WidgetServerFetchResult.Success("{}", "\"abc\"", 200, null)).run(scope).outcome + + assertEquals( + listOf(Triple("portfolio", "https://api.example.com/portfolio", "\"abc\"")), + harness.etags, + ) + } + + @Test + fun `sends the stored etag so an unchanged response costs nothing`() = + runTest { + var sent: String? = null + + runner(Harness(), storedEtag = "\"abc\"", onRequestEtag = { sent = it }).run(scope).outcome + + assertEquals("\"abc\"", sent) + } + + @Test + fun `treats 304 as fresh without touching the props`() = + runTest { + val harness = Harness() + val outcome = runner(harness, result = WidgetServerFetchResult.NotModified(null)).run(scope).outcome + + assertEquals(DynamicWidgetServerUpdateOutcome.Committed, outcome) + assertTrue(harness.props.committed.isEmpty()) + assertEquals(1, harness.statuses.successes) + } + + @Test + fun `does not commit props the widget cannot render`() = + runTest { + val harness = Harness() + val outcome = + runner( + harness, + result = WidgetServerFetchResult.Success("""{"total":42}""", null, 200, null), + trialRenders = false, + ).run(scope).outcome + + assertEquals(DynamicWidgetServerUpdateOutcome.Failed, outcome) + assertTrue(harness.props.committed.isEmpty()) + assertEquals( + DynamicWidgetServerStatus.ERROR_RENDER, + harness.statuses.failures + .single() + .first, + ) + } + + @Test + fun `does not commit a body that is not props, and does not ask again for it`() = + runTest { + val harness = Harness() + val outcome = + runner( + harness, + result = WidgetServerFetchResult.Success("""{"v":1,"variants":{}}""", null, 200, null), + ).run(scope).outcome + + assertEquals(DynamicWidgetServerUpdateOutcome.Failed, outcome) + assertTrue(harness.props.committed.isEmpty()) + assertEquals( + DynamicWidgetServerStatus.ERROR_PARSE, + harness.statuses.failures + .single() + .first, + ) + } + + @Test + fun `retries a network failure and keeps the previous props`() = + runTest { + val harness = Harness() + val outcome = runner(harness, result = WidgetServerFetchResult.NetworkFailure("timeout")).run(scope).outcome + + assertEquals(DynamicWidgetServerUpdateOutcome.Retry, outcome) + assertTrue(harness.props.committed.isEmpty()) + assertEquals( + DynamicWidgetServerStatus.ERROR_NETWORK, + harness.statuses.failures + .single() + .first, + ) + } + + @Test + fun `retries a 5xx and a 429`() = + runTest { + assertEquals( + DynamicWidgetServerUpdateOutcome.Retry, + runner(Harness(), result = WidgetServerFetchResult.HttpFailure(503, 2)).run(scope).outcome, + ) + assertEquals( + DynamicWidgetServerUpdateOutcome.Retry, + runner(Harness(), result = WidgetServerFetchResult.HttpFailure(429, null)).run(scope).outcome, + ) + } + + @Test + fun `passes Retry-After on, clamped to what WorkManager can honour`() = + runTest { + val soon = runner(Harness(), result = WidgetServerFetchResult.HttpFailure(503, 2)).run(scope) + val far = runner(Harness(), result = WidgetServerFetchResult.HttpFailure(503, 60 * 24 * 30)).run(scope) + val none = runner(Harness(), result = WidgetServerFetchResult.HttpFailure(503, null)).run(scope) + + assertEquals(WidgetServerUpdateDefaults.MIN_INTERVAL_MINUTES, soon.nextIntervalMinutes) + assertEquals(WidgetServerUpdateDefaults.MAX_INTERVAL_MINUTES, far.nextIntervalMinutes) + assertNull(none.nextIntervalMinutes) + } + + @Test + fun `passes Cache-Control max-age on, so the server can move its own next fetch`() = + runTest { + val committed = + runner( + Harness(), + result = WidgetServerFetchResult.Success("{\"total\":42}", null, 200, 360), + ).run(scope) + + assertEquals(360L, committed.nextIntervalMinutes) + } + + @Test + fun `does not ask again for a body that is too large to hold`() = + runTest { + val harness = Harness() + val outcome = runner(harness, result = WidgetServerFetchResult.TooLarge(200)).run(scope).outcome + + assertEquals(DynamicWidgetServerUpdateOutcome.Failed, outcome) + assertTrue(harness.props.committed.isEmpty()) + assertEquals( + DynamicWidgetServerStatus.ERROR_PARSE, + harness.statuses.failures + .single() + .first, + ) + } + + @Test + fun `does not retry a 401, which stays a 401 until the app sets a new token`() = + runTest { + val harness = Harness() + val outcome = runner(harness, result = WidgetServerFetchResult.HttpFailure(401, null)).run(scope).outcome + + assertEquals(DynamicWidgetServerUpdateOutcome.Failed, outcome) + assertEquals( + DynamicWidgetServerStatus.ERROR_UNAUTHORIZED to 401, + harness.statuses.failures.single(), + ) + } + + @Test + fun `does not retry another 4xx, which is a misconfiguration`() = + runTest { + val harness = Harness() + val outcome = runner(harness, result = WidgetServerFetchResult.HttpFailure(404, null)).run(scope).outcome + + assertEquals(DynamicWidgetServerUpdateOutcome.Failed, outcome) + assertEquals(DynamicWidgetServerStatus.ERROR_HTTP to 404, harness.statuses.failures.single()) + } + + @Test + fun `drops a result built from settings that have since changed`() = + runTest { + val harness = Harness() + val outcome = + runner( + harness, + result = WidgetServerFetchResult.Success("""{"total":42}""", null, 200, null), + revisions = listOf(1L, 2L), + ).run(scope).outcome + + assertEquals(DynamicWidgetServerUpdateOutcome.Dropped, outcome) + assertTrue(harness.props.committed.isEmpty()) + assertEquals(0, harness.statuses.successes) + assertEquals(0, harness.notified) + } + + @Test + fun `does not fetch for a widget with no url yet`() = + runTest { + val harness = Harness() + val outcome = runner(harness, settings = settings(url = null)).run(scope).outcome + + assertEquals(DynamicWidgetServerUpdateOutcome.Skipped, outcome) + assertNull(harness.statuses.disabledFor) + } + + @Test + fun `reports disabled when the app has taken the widget over`() = + runTest { + val harness = Harness() + val outcome = runner(harness, settings = settings(enabled = false)).run(scope).outcome + + assertEquals(DynamicWidgetServerUpdateOutcome.Skipped, outcome) + assertEquals(scope, harness.statuses.disabledFor) + } + + @Test + fun `refuses to touch a widget that is no longer a Dynamic Widget`() = + runTest { + val harness = Harness() + val outcome = + runner( + harness, + kind = VoltraWidgetKindResolution.Resolved(VoltraWidgetKind.Payload), + ).run(scope).outcome + + assertEquals(DynamicWidgetServerUpdateOutcome.Skipped, outcome) + assertTrue(harness.props.committed.isEmpty()) + } + + @Test + fun `refuses to touch a widget whose kind cannot be resolved`() = + runTest { + val harness = Harness() + val outcome = runner(harness, kind = VoltraWidgetKindResolution.Unresolved("gone")).run(scope).outcome + + assertEquals(DynamicWidgetServerUpdateOutcome.Skipped, outcome) + assertTrue(harness.props.committed.isEmpty()) + } +} diff --git a/packages/android-client/android/src/test/java/voltra/widget/server/WidgetServerDefaultsStoreTest.kt b/packages/android-client/android/src/test/java/voltra/widget/server/WidgetServerDefaultsStoreTest.kt new file mode 100644 index 00000000..d9d16625 --- /dev/null +++ b/packages/android-client/android/src/test/java/voltra/widget/server/WidgetServerDefaultsStoreTest.kt @@ -0,0 +1,86 @@ +package voltra.widget.server + +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +/** + * The generated asset is the build-time half of the settings stack, and the only thing that knows + * which widgets are server-driven at all. + */ +@RunWith(RobolectricTestRunner::class) +class WidgetServerDefaultsStoreTest { + private fun store(asset: String?) = WidgetServerDefaultsStore { asset } + + @Test + fun `reads url, interval and refresh for a widget`() { + val defaults = + store("""{"portfolio":{"url":"https://api.example.com/p","intervalMinutes":30,"refresh":true}}""") + .defaults("portfolio") + + assertEquals("https://api.example.com/p", defaults?.url) + assertEquals(30L, defaults?.intervalMinutes) + assertEquals(true, defaults?.refresh) + } + + @Test + fun `treats a widget with no url as server-driven, waiting for one at runtime`() { + val store = store("""{"portfolio":{"intervalMinutes":15,"refresh":false}}""") + + assertTrue(store.isServerDriven("portfolio")) + assertNull(store.defaults("portfolio")?.url) + } + + @Test + fun `a widget missing from the asset is not server-driven`() { + val store = store("""{"portfolio":{"intervalMinutes":15,"refresh":false}}""") + + assertFalse(store.isServerDriven("local")) + assertNull(store.defaults("local")) + } + + @Test + fun `an app with no server-driven widgets ships no asset and nothing is server-driven`() { + val store = store(null) + + assertFalse(store.isServerDriven("portfolio")) + assertEquals(emptySet(), store.serverDrivenWidgetIds()) + } + + @Test + fun `a broken asset is read as no server-driven widgets rather than crashing every fetch`() { + assertEquals(emptySet(), store("not json").serverDrivenWidgetIds()) + } + + @Test + fun `clamps an interval the asset could not have caught, such as a hand-edited file`() { + assertEquals( + WidgetServerUpdateDefaults.MIN_INTERVAL_MINUTES, + store("""{"portfolio":{"intervalMinutes":1}}""").defaults("portfolio")?.intervalMinutes, + ) + } + + @Test + fun `lists every server-driven widget, whichever engine renders it`() { + val store = store("""{"portfolio":{"intervalMinutes":15},"prices":{"intervalMinutes":60}}""") + + assertEquals(setOf("portfolio", "prices"), store.serverDrivenWidgetIds()) + } + + @Test + fun `feeds the config layer, which is what stops a runtime url reaching a local widget`() = + runTest { + val layer = + ConfigWidgetServerSettingsLayer(store("""{"portfolio":{"url":"https://a","intervalMinutes":30}}""")) + + assertEquals("https://a", layer.settings(WidgetScope.of("portfolio"))?.url) + assertNull(layer.settings(WidgetScope.of("local"))) + assertTrue(layer.isServerDriven(WidgetScope.of("portfolio"))) + assertFalse(layer.isServerDriven(WidgetScope.of("local"))) + } +} diff --git a/packages/android-client/android/src/test/java/voltra/widget/server/WidgetServerFetcherHeaderTest.kt b/packages/android-client/android/src/test/java/voltra/widget/server/WidgetServerFetcherHeaderTest.kt new file mode 100644 index 00000000..3aa8590f --- /dev/null +++ b/packages/android-client/android/src/test/java/voltra/widget/server/WidgetServerFetcherHeaderTest.kt @@ -0,0 +1,59 @@ +package voltra.widget.server + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +/** + * The two response headers that move the next fetch. Parsing only: the clamp and the rescheduling + * they feed are pinned down by DynamicWidgetServerUpdateRunnerTest. + */ +class WidgetServerFetcherHeaderTest { + @Test + fun `reads max-age out of a Cache-Control header, whatever else it carries`() { + assertEquals(30L, WidgetServerFetcher.maxAgeMinutes("max-age=1800")) + assertEquals(30L, WidgetServerFetcher.maxAgeMinutes("public, max-age=1800, must-revalidate")) + assertEquals(30L, WidgetServerFetcher.maxAgeMinutes("Max-Age = 1800")) + } + + @Test + fun `rounds max-age down, so we never claim data is fresher than the server said`() { + assertEquals(1L, WidgetServerFetcher.maxAgeMinutes("max-age=119")) + assertEquals(0L, WidgetServerFetcher.maxAgeMinutes("max-age=30")) + } + + @Test + fun `ignores a Cache-Control header with no max-age`() { + assertNull(WidgetServerFetcher.maxAgeMinutes(null)) + assertNull(WidgetServerFetcher.maxAgeMinutes("no-store")) + assertNull(WidgetServerFetcher.maxAgeMinutes("max-age=soon")) + } + + @Test + fun `rounds Retry-After up, so we never retry before the server asked us to`() { + assertEquals(1L, WidgetServerFetcher.retryAfterMinutes("1")) + assertEquals(1L, WidgetServerFetcher.retryAfterMinutes("60")) + assertEquals(2L, WidgetServerFetcher.retryAfterMinutes("61")) + } + + @Test + fun `reads Retry-After as an HTTP date, which servers send as often as seconds`() { + // 2015-10-21T07:28:00Z, asked for 90 seconds before that. + val now = 1_445_412_480_000L - 90_000L + + assertEquals(2L, WidgetServerFetcher.retryAfterMinutes("Wed, 21 Oct 2015 07:28:00 GMT", now = now)) + } + + @Test + fun `ignores a Retry-After already in the past, or one we cannot read at all`() { + assertNull(WidgetServerFetcher.retryAfterMinutes(null)) + assertNull(WidgetServerFetcher.retryAfterMinutes("soon")) + assertNull(WidgetServerFetcher.retryAfterMinutes("0")) + assertNull( + WidgetServerFetcher.retryAfterMinutes( + "Wed, 21 Oct 2015 07:28:00 GMT", + now = 1_445_412_480_000L + 60_000L, + ), + ) + } +} diff --git a/packages/android-client/android/src/test/java/voltra/widget/server/WidgetServerRequestBuilderTest.kt b/packages/android-client/android/src/test/java/voltra/widget/server/WidgetServerRequestBuilderTest.kt new file mode 100644 index 00000000..7a8e71e8 --- /dev/null +++ b/packages/android-client/android/src/test/java/voltra/widget/server/WidgetServerRequestBuilderTest.kt @@ -0,0 +1,128 @@ +package voltra.widget.server + +import android.content.Context +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment + +/** The request contract every backend sees, pinned down without a network. */ +@RunWith(RobolectricTestRunner::class) +class WidgetServerRequestBuilderTest { + private val context: Context = RuntimeEnvironment.getApplication() + private val scope = WidgetScope.of("portfolio") + + private fun settings( + url: String? = "https://api.example.com/widgets/portfolio", + enabled: Boolean = true, + method: String = "GET", + query: Map = emptyMap(), + headers: Map = emptyMap(), + body: String? = null, + ) = ResolvedWidgetServerSettings( + url = url, + intervalMinutes = 15, + enabled = enabled, + method = method, + query = query, + headers = headers, + body = body, + ) + + @Test + fun `sends the Voltra query parameters every backend can rely on`() { + val request = WidgetServerRequestBuilder.build(context, scope, settings())!! + val query = request.url.query + + assertTrue(query.contains("widgetId=portfolio")) + assertTrue(query.contains("platform=android")) + assertTrue(query.contains("theme=")) + assertTrue(query.contains("locale=")) + } + + @Test + fun `does not send family, because one fetch serves every size of a Dynamic Widget`() { + val request = WidgetServerRequestBuilder.build(context, scope, settings())!! + + assertFalse(request.url.query.contains("family=")) + } + + @Test + fun `keeps the path and any query the configured url already had`() { + val request = + WidgetServerRequestBuilder.build( + context, + scope, + settings(url = "https://api.example.com/widgets?tenant=acme"), + )!! + + assertEquals("/widgets", request.url.path) + assertTrue(request.url.query.contains("tenant=acme")) + } + + @Test + fun `appends the app's own query parameters`() { + val request = WidgetServerRequestBuilder.build(context, scope, settings(query = mapOf("account" to "42")))!! + + assertTrue(request.url.query.contains("account=42")) + } + + @Test + fun `sends Accept and a Voltra user agent, and lets the app add headers`() { + val request = + WidgetServerRequestBuilder.build( + context, + scope, + settings( + headers = + mapOf("Authorization" to "Bearer t"), + ), + )!! + + assertEquals("application/json", request.headers["Accept"]) + assertTrue(request.headers["User-Agent"]!!.startsWith("VoltraWidget/")) + assertEquals("Bearer t", request.headers["Authorization"]) + } + + @Test + fun `sends If-None-Match only when an etag was carried over`() { + val withEtag = WidgetServerRequestBuilder.build(context, scope, settings(), etag = "\"abc\"")!! + val without = WidgetServerRequestBuilder.build(context, scope, settings())!! + + assertEquals("\"abc\"", withEtag.headers["If-None-Match"]) + assertFalse(without.headers.containsKey("If-None-Match")) + } + + @Test + fun `sends a body with POST and declares its content type`() { + val request = WidgetServerRequestBuilder.build(context, scope, settings(method = "POST", body = "{\"a\":1}"))!! + + assertEquals("POST", request.method) + assertEquals("application/json", request.headers["Content-Type"]) + assertEquals("{\"a\":1}", String(request.body!!, Charsets.UTF_8)) + } + + @Test + fun `drops a body on GET, which HttpURLConnection would otherwise turn into a POST`() { + val request = WidgetServerRequestBuilder.build(context, scope, settings(method = "GET", body = "{\"a\":1}"))!! + + assertEquals("GET", request.method) + assertNull(request.body) + assertFalse(request.headers.containsKey("Content-Type")) + } + + @Test + fun `uppercases the method so a lowercase setting still reaches the right verb`() { + assertEquals("PATCH", WidgetServerRequestBuilder.build(context, scope, settings(method = "patch"))!!.method) + } + + @Test + fun `builds nothing when there is no url or fetching is off`() { + assertNull(WidgetServerRequestBuilder.build(context, scope, settings(url = null))) + assertNull(WidgetServerRequestBuilder.build(context, scope, settings(enabled = false))) + } +} diff --git a/packages/android-client/android/src/test/java/voltra/widget/server/WidgetServerSettingsCodecTest.kt b/packages/android-client/android/src/test/java/voltra/widget/server/WidgetServerSettingsCodecTest.kt new file mode 100644 index 00000000..a1a68b1b --- /dev/null +++ b/packages/android-client/android/src/test/java/voltra/widget/server/WidgetServerSettingsCodecTest.kt @@ -0,0 +1,70 @@ +package voltra.widget.server + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class WidgetServerSettingsCodecTest { + @Test + fun `round-trips every field`() { + val settings = + WidgetServerUpdateSettings( + url = "https://api.example.com/portfolio", + intervalMinutes = 30, + enabled = false, + method = "POST", + query = mapOf("account" to "1", "range" to "1d"), + headers = mapOf("Authorization" to "Bearer token"), + body = "{\"ids\":[1,2]}", + ) + + assertEquals(settings, WidgetServerSettingsCodec.decode(WidgetServerSettingsCodec.encode(settings))) + } + + @Test + fun `keeps unset fields unset, so a layer that says nothing stays silent`() { + val decoded = + WidgetServerSettingsCodec.decode( + WidgetServerSettingsCodec.encode(WidgetServerUpdateSettings(url = "https://a")), + ) + + assertEquals("https://a", decoded?.url) + assertNull(decoded?.intervalMinutes) + assertNull(decoded?.enabled) + assertNull(decoded?.method) + assertNull(decoded?.headers) + assertNull(decoded?.query) + assertNull(decoded?.body) + } + + @Test + fun `distinguishes enabled false from unset`() { + val decoded = + WidgetServerSettingsCodec.decode( + WidgetServerSettingsCodec.encode(WidgetServerUpdateSettings(enabled = false)), + ) + + assertEquals(false, decoded?.enabled) + } + + @Test + fun `an empty map is preserved, so clearing headers is not the same as never setting them`() { + val decoded = + WidgetServerSettingsCodec.decode( + WidgetServerSettingsCodec.encode(WidgetServerUpdateSettings(headers = emptyMap())), + ) + + assertEquals(emptyMap(), decoded?.headers) + } + + @Test + fun `reads an unknown version or a broken record as no opinion rather than crashing`() { + assertNull(WidgetServerSettingsCodec.decode(null)) + assertNull(WidgetServerSettingsCodec.decode("")) + assertNull(WidgetServerSettingsCodec.decode("not json")) + assertNull(WidgetServerSettingsCodec.decode("{\"widgetServerSettingsVersion\":99,\"widgetServerSettings\":{}}")) + } +} diff --git a/packages/android-client/android/src/test/java/voltra/widget/server/WidgetServerSettingsResolverTest.kt b/packages/android-client/android/src/test/java/voltra/widget/server/WidgetServerSettingsResolverTest.kt new file mode 100644 index 00000000..30437852 --- /dev/null +++ b/packages/android-client/android/src/test/java/voltra/widget/server/WidgetServerSettingsResolverTest.kt @@ -0,0 +1,214 @@ +package voltra.widget.server + +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment + +/** + * The merge rule from ADR 0002 lives in the resolver and nowhere else, so this is where it is + * pinned down. + */ +@RunWith(RobolectricTestRunner::class) +class WidgetServerSettingsResolverTest { + private val scope = WidgetScope.of("portfolio") + + private fun layer( + name: String, + settings: WidgetServerUpdateSettings?, + serverDriven: Boolean = false, + ) = object : WidgetServerSettingsLayer { + override val name: String = name + + override suspend fun settings(scope: WidgetScope): WidgetServerUpdateSettings? = settings + + override suspend fun isServerDriven(scope: WidgetScope): Boolean = serverDriven + } + + private fun resolver( + vararg layers: WidgetServerSettingsLayer, + revision: Long = 0L, + ) = WidgetServerSettingsResolver(layers.toList(), revisionSource = { revision }) + + @Test + fun `takes url, interval, method and body from the highest layer that sets them`() = + runTest { + val resolved = + resolver( + layer( + "config", + WidgetServerUpdateSettings(url = "https://config", intervalMinutes = 60), + serverDriven = true, + ), + layer("global", WidgetServerUpdateSettings(url = "https://global", method = "POST")), + layer("widget", WidgetServerUpdateSettings(url = "https://widget", body = "{\"a\":1}")), + ).resolve(scope) + + assertEquals("https://widget", resolved.url) + assertEquals("POST", resolved.method) + assertEquals("{\"a\":1}", resolved.body) + assertEquals(60L, resolved.intervalMinutes) + } + + @Test + fun `merges headers and query per key rather than replacing the whole map`() = + runTest { + val resolved = + resolver( + layer("config", WidgetServerUpdateSettings(), serverDriven = true), + layer( + "credentials", + WidgetServerUpdateSettings( + headers = mapOf("Authorization" to "Bearer legacy", "X-Env" to "prod"), + ), + ), + layer( + "global", + WidgetServerUpdateSettings( + headers = mapOf("Authorization" to "Bearer new"), + query = mapOf("account" to "1"), + ), + ), + layer("widget", WidgetServerUpdateSettings(query = mapOf("range" to "1d"))), + ).resolve(scope) + + assertEquals(mapOf("Authorization" to "Bearer new", "X-Env" to "prod"), resolved.headers) + assertEquals(mapOf("account" to "1", "range" to "1d"), resolved.query) + } + + @Test + fun `fills in the defaults a fetch needs`() = + runTest { + val resolved = + resolver( + layer("config", WidgetServerUpdateSettings(url = "https://a"), serverDriven = true), + ).resolve(scope) + + assertEquals(WidgetServerUpdateDefaults.DEFAULT_METHOD, resolved.method) + assertEquals(WidgetServerUpdateDefaults.DEFAULT_INTERVAL_MINUTES, resolved.intervalMinutes) + assertTrue(resolved.enabled) + assertTrue(resolved.query.isEmpty()) + } + + @Test + fun `clamps a runtime interval override to what the platform can honour`() = + runTest { + val tooShort = + resolver( + layer("config", WidgetServerUpdateSettings(intervalMinutes = 60), serverDriven = true), + layer("widget", WidgetServerUpdateSettings(intervalMinutes = 1)), + ).resolve(scope) + val tooLong = + resolver( + layer("config", WidgetServerUpdateSettings(intervalMinutes = 60), serverDriven = true), + layer("widget", WidgetServerUpdateSettings(intervalMinutes = 60 * 24 * 30)), + ).resolve(scope) + + assertEquals(WidgetServerUpdateDefaults.MIN_INTERVAL_MINUTES, tooShort.intervalMinutes) + assertEquals(WidgetServerUpdateDefaults.MAX_INTERVAL_MINUTES, tooLong.intervalMinutes) + } + + @Test + fun `leaves an interval from app_json alone, so an existing widget keeps its schedule`() = + runTest { + // The generators already validated this against the platform's own rules -- iOS allows + // a payload widget down to 1 minute -- so clamping it again here would silently change + // the schedule of a widget that has been shipping for months. + val resolved = + resolver( + layer("config", WidgetServerUpdateSettings(intervalMinutes = 5), serverDriven = true), + ).resolve(scope) + + assertEquals(5L, resolved.intervalMinutes) + } + + @Test + fun `a widget the config layer does not know is never fetched, whatever a runtime layer says`() = + runTest { + val resolved = + resolver( + layer("config", null, serverDriven = false), + layer("widget", WidgetServerUpdateSettings(url = "https://sneaky", enabled = true)), + ).resolve(scope) + + assertNull(resolved.url) + assertFalse(resolved.enabled) + assertFalse(resolved.shouldFetch) + } + + @Test + fun `enabled false stops fetching without dropping the url`() = + runTest { + val resolved = + resolver( + layer("config", WidgetServerUpdateSettings(url = "https://a"), serverDriven = true), + layer("widget", WidgetServerUpdateSettings(enabled = false)), + ).resolve(scope) + + assertEquals("https://a", resolved.url) + assertFalse(resolved.enabled) + assertFalse(resolved.shouldFetch) + } + + @Test + fun `a server-driven widget with no url yet does not fetch`() = + runTest { + val resolved = resolver(layer("config", WidgetServerUpdateSettings(), serverDriven = true)).resolve(scope) + + assertNull(resolved.url) + assertTrue(resolved.enabled) + assertFalse(resolved.shouldFetch) + } + + @Test + fun `a blank url is treated as no url`() = + runTest { + val resolved = + resolver( + layer("config", WidgetServerUpdateSettings(url = " "), serverDriven = true), + ).resolve(scope) + + assertNull(resolved.url) + assertFalse(resolved.shouldFetch) + } + + @Test + fun `revision comes from the store so a fetcher can tell whether settings moved under it`() = + runTest { + assertEquals(7L, resolver(layer("config", null), revision = 7L).revision(scope)) + } + + @Test + fun `globalSettings returns the global layer's raw contents, with no defaulting`() = + runTest { + val store = WidgetServerSettingsStore(RuntimeEnvironment.getApplication()) + store.set(WidgetServerUpdateSettings(url = "https://global"), scope = null) + + val resolved = + WidgetServerSettingsResolver( + layers = listOf(GlobalWidgetServerSettingsLayer(store)), + revisionSource = { store.revision() }, + ).globalSettings() + + assertEquals(WidgetServerUpdateSettings(url = "https://global"), resolved) + } + + @Test + fun `globalSettings is null when nothing has been set globally`() = + runTest { + val store = WidgetServerSettingsStore(RuntimeEnvironment.getApplication()) + + val resolved = + WidgetServerSettingsResolver( + layers = listOf(GlobalWidgetServerSettingsLayer(store)), + revisionSource = { store.revision() }, + ).globalSettings() + + assertNull(resolved) + } +} diff --git a/packages/android-client/android/src/test/java/voltra/widget/server/WidgetServerSettingsValidatorTest.kt b/packages/android-client/android/src/test/java/voltra/widget/server/WidgetServerSettingsValidatorTest.kt new file mode 100644 index 00000000..7ac41261 --- /dev/null +++ b/packages/android-client/android/src/test/java/voltra/widget/server/WidgetServerSettingsValidatorTest.kt @@ -0,0 +1,81 @@ +package voltra.widget.server + +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class WidgetServerSettingsValidatorTest { + private fun validate( + settings: WidgetServerUpdateSettings, + isDebugBuild: Boolean = false, + ) = WidgetServerSettingsValidator.validate(settings, isDebugBuild) + + @Test + fun `accepts https anywhere`() { + assertNull(validate(WidgetServerUpdateSettings(url = "https://api.example.com/portfolio"))) + } + + @Test + fun `rejects plain http in a release build even for a local host`() { + assertNotNull(validate(WidgetServerUpdateSettings(url = "http://localhost:3333"))) + } + + @Test + fun `accepts plain http to a dev host in a debug build`() { + assertNull(validate(WidgetServerUpdateSettings(url = "http://localhost:3333"), isDebugBuild = true)) + assertNull(validate(WidgetServerUpdateSettings(url = "http://10.0.2.2:3333/widgets"), isDebugBuild = true)) + } + + @Test + fun `rejects plain http to another host even in a debug build`() { + assertNotNull(validate(WidgetServerUpdateSettings(url = "http://api.example.com"), isDebugBuild = true)) + } + + @Test + fun `rejects a url with no scheme or no host`() { + assertNotNull(validate(WidgetServerUpdateSettings(url = "api.example.com/portfolio"))) + assertNotNull(validate(WidgetServerUpdateSettings(url = "https://"))) + assertNotNull(validate(WidgetServerUpdateSettings(url = " "))) + } + + @Test + fun `rejects a query key Voltra already sends`() { + val error = validate(WidgetServerUpdateSettings(query = mapOf("theme" to "dark"))) + + assertNotNull(error) + assertTrue(error!!.contains("reserved")) + } + + @Test + fun `rejects an instance key, which is reserved for per-placement fetches`() { + assertNotNull(validate(WidgetServerUpdateSettings(query = mapOf("instance" to "1")))) + } + + @Test + fun `rejects a method neither platform can send`() { + assertNotNull(validate(WidgetServerUpdateSettings(method = "TRACE"))) + assertNull(validate(WidgetServerUpdateSettings(method = "patch"))) + } + + @Test + fun `rejects a non-positive interval`() { + assertNotNull(validate(WidgetServerUpdateSettings(intervalMinutes = 0))) + assertNotNull(validate(WidgetServerUpdateSettings(intervalMinutes = -5))) + } + + @Test + fun `accepts a body with GET, which the request builder drops with a warning`() { + assertNull(validate(WidgetServerUpdateSettings(method = "GET", body = "{\"a\":1}"))) + } + + @Test + fun `rejects a layer larger than the storage cap`() { + val huge = "x".repeat(WidgetServerUpdateDefaults.MAX_LAYER_BYTES + 1) + + assertNotNull(validate(WidgetServerUpdateSettings(body = huge))) + } +} diff --git a/packages/android-client/android/src/test/java/voltra/widget/server/WidgetServerUpdateSettingsJsonTest.kt b/packages/android-client/android/src/test/java/voltra/widget/server/WidgetServerUpdateSettingsJsonTest.kt new file mode 100644 index 00000000..c236ef47 --- /dev/null +++ b/packages/android-client/android/src/test/java/voltra/widget/server/WidgetServerUpdateSettingsJsonTest.kt @@ -0,0 +1,98 @@ +package voltra.widget.server + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +/** What `setWidgetServerUpdate` sends over the bridge, and what it becomes on this side. */ +@RunWith(RobolectricTestRunner::class) +class WidgetServerUpdateSettingsJsonTest { + private fun parsed(json: String): WidgetServerUpdateSettings { + val result = WidgetServerUpdateSettingsJson.parse(json) + + assertTrue("expected $json to parse", result is WidgetServerUpdateSettingsJson.Result.Parsed) + + return (result as WidgetServerUpdateSettingsJson.Result.Parsed).settings + } + + @Test + fun `reads every field an app can set`() { + val settings = + parsed( + """ + { + "url": "https://api.example.com/p", + "intervalMinutes": 30, + "enabled": false, + "method": "POST", + "query": {"account": "1"}, + "headers": {"Authorization": "Bearer t"}, + "body": {"ids": [1, 2]} + } + """.trimIndent(), + ) + + assertEquals("https://api.example.com/p", settings.url) + assertEquals(30L, settings.intervalMinutes) + assertEquals(false, settings.enabled) + assertEquals("POST", settings.method) + assertEquals(mapOf("account" to "1"), settings.query) + assertEquals(mapOf("Authorization" to "Bearer t"), settings.headers) + assertEquals("""{"ids":[1,2]}""", settings.body) + } + + @Test + fun `leaves out what the app did not set, so those layers stay silent`() { + val settings = parsed("""{"url":"https://a"}""") + + assertNull(settings.intervalMinutes) + assertNull(settings.enabled) + assertNull(settings.method) + assertNull(settings.query) + assertNull(settings.headers) + assertNull(settings.body) + } + + @Test + fun `an empty object clears nothing and sets nothing`() { + assertTrue(parsed("{}").isEmpty) + } + + @Test + fun `distinguishes an explicitly empty header map from an absent one`() { + assertEquals(emptyMap(), parsed("""{"headers":{}}""").headers) + assertNull(parsed("{}").headers) + } + + @Test + fun `uppercases the method so a lowercase one still validates`() { + assertEquals("PATCH", parsed("""{"method":"patch"}""").method) + } + + @Test + fun `keeps a non-object body, which is legal JSON for a request`() { + assertEquals("""[1,2]""", parsed("""{"body":[1,2]}""").body) + assertEquals(""""hello"""", parsed("""{"body":"hello"}""").body) + } + + @Test + fun `rejects a settings value that is not a JSON object`() { + assertTrue(WidgetServerUpdateSettingsJson.parse("[]") is WidgetServerUpdateSettingsJson.Result.Invalid) + assertTrue(WidgetServerUpdateSettingsJson.parse("nope") is WidgetServerUpdateSettingsJson.Result.Invalid) + } + + @Test + fun `rejects headers or query whose values are not strings`() { + assertTrue( + WidgetServerUpdateSettingsJson.parse( + """{"headers":{"X":1}}""", + ) is WidgetServerUpdateSettingsJson.Result.Invalid, + ) + assertTrue( + WidgetServerUpdateSettingsJson.parse("""{"query":[]}""") is WidgetServerUpdateSettingsJson.Result.Invalid, + ) + } +} diff --git a/packages/android-client/expo-plugin/src/android/files/index.ts b/packages/android-client/expo-plugin/src/android/files/index.ts index 36cc0b27..a18729ba 100644 --- a/packages/android-client/expo-plugin/src/android/files/index.ts +++ b/packages/android-client/expo-plugin/src/android/files/index.ts @@ -4,6 +4,7 @@ import type { AndroidWidgetConfig } from '../../types' import { detectClientRenderedWidgets } from '../clientRendered' import { generateAndroidAssets } from './assets' import { generateAndroidConfigDefaults } from './configDefaults' +import { generateAndroidServerDefaults } from './serverDefaults' import { copyAndroidFonts } from './fonts' import { generateAndroidInitialStates } from './initialStates' import { generateWidgetReceivers } from './kotlin' @@ -104,6 +105,12 @@ export const generateAndroidWidgetFiles: ConfigPlugin { } }) - it('generates a serverUpdate receiver extending voltra.widget.payload.VoltraPayloadWidgetReceiver', async () => { + it('generates a server-driven Dynamic Widget receiver when a widget has both entry and serverUpdate', async () => { + const { platformProjectRoot, cleanup } = makeTempPlatformRoot() + + try { + const widget: DetectedAndroidWidget = { + ...baseWidget('portfolio'), + clientRendered: true, + clientSourcePath: '/tmp/does-not-matter.js', + entry: 'widgets/portfolio.tsx', + serverUpdate: { + url: 'https://example.com/portfolio', + intervalMinutes: 30, + refresh: true, + }, + } + + const content = await generateReceiverFile(platformProjectRoot, 'com.example.app', widget) + + expect(content).toBe( + [ + 'package com.example.app.widget', + '', + 'import voltra.dynamicwidget.serverupdate.VoltraServerDrivenClientWidgetReceiver', + '', + '/**', + ' * Auto-generated server-driven Dynamic Widget receiver for Widget portfolio', + ' * Widget ID: portfolio', + ' */', + 'class VoltraWidget_portfolioReceiver : VoltraServerDrivenClientWidgetReceiver() {', + ' override val widgetId: String = "portfolio"', + '}', + ].join('\n') + ) + } finally { + cleanup() + } + }) + + it('generates a payload serverUpdate receiver that schedules without inlining the url or interval', async () => { const { platformProjectRoot, cleanup } = makeTempPlatformRoot() try { @@ -102,14 +140,13 @@ describe('generateWidgetReceivers', () => { '', 'import android.appwidget.AppWidgetManager', 'import android.content.Context', + 'import kotlinx.coroutines.runBlocking', 'import voltra.widget.payload.VoltraPayloadWidgetReceiver', 'import voltra.widget.payload.VoltraWidgetUpdateScheduler', '', '/**', ' * Auto-generated widget receiver for Widget server', ' * Widget ID: server', - ' * Server Update: https://example.com/widget (every 30 minutes)', - ' * Refresh Button: true', ' */', 'class VoltraWidget_serverReceiver : VoltraPayloadWidgetReceiver() {', ' override val widgetId: String = "server"', @@ -117,14 +154,11 @@ describe('generateWidgetReceivers', () => { ' override fun onUpdate(context: Context, appWidgetManager: AppWidgetManager, appWidgetIds: IntArray) {', ' super.onUpdate(context, appWidgetManager, appWidgetIds)', '', - ' // Schedule periodic server updates via WorkManager', - ' VoltraWidgetUpdateScheduler.schedulePeriodicUpdate(', - ' context = context,', - ' widgetId = "server",', - ' serverUrl = "https://example.com/widget",', - ' intervalMinutes = 30L,', - ' refreshEnabled = true', - ' )', + ' // Blocking rather than launching: onReceive must not return before the work is', + ' // enqueued, or a widget added while the app is not running loses its schedule.', + ' runBlocking {', + ' VoltraWidgetUpdateScheduler.schedulePeriodicUpdate(context.applicationContext, "server")', + ' }', ' }', '', ' override fun onDeleted(context: Context, appWidgetIds: IntArray) {', diff --git a/packages/android-client/expo-plugin/src/android/files/kotlin.ts b/packages/android-client/expo-plugin/src/android/files/kotlin.ts index 40ad9c21..996a3308 100644 --- a/packages/android-client/expo-plugin/src/android/files/kotlin.ts +++ b/packages/android-client/expo-plugin/src/android/files/kotlin.ts @@ -47,14 +47,37 @@ export async function generateWidgetReceivers(props: GenerateKotlinFilesProps): /** * Generates Kotlin code for a single widget receiver class. - * If the widget has serverUpdate configured, includes WorkManager scheduling. + * + * `entry` picks the render engine and `serverUpdate` picks where the data comes from, so the two + * keys together select one of four base classes. Runtime code never asks "is this server-driven?" — + * the answer is baked in here, once, at generate time. */ function generateWidgetReceiverClass(widget: DetectedAndroidWidget, packageName: string): string { const className = `VoltraWidget_${widget.id}Receiver` const labelForComment = widgetLabelEnglish(widget.displayName) - // Dynamic Widgets host VoltraClientGlanceWidget (on-device JS render) and have no - // server payload, so they never schedule WorkManager server updates. + // A widget with an entry and a serverUpdate renders bundled JS from props fetched in the + // background. The scheduling lives in the base class, so the generated receiver stays a name + // and an id — the URL and the interval come from widget_server_defaults.json at runtime, where + // setWidgetServerUpdate can override them. + if (widget.clientRendered && widget.serverUpdate) { + return dedent` + package ${packageName}.widget + + import voltra.dynamicwidget.serverupdate.VoltraServerDrivenClientWidgetReceiver + + /** + * Auto-generated server-driven Dynamic Widget receiver for ${labelForComment} + * Widget ID: ${widget.id} + */ + class ${className} : VoltraServerDrivenClientWidgetReceiver() { + override val widgetId: String = "${widget.id}" + } + ` + } + + // Dynamic Widgets without a serverUpdate host VoltraClientGlanceWidget (on-device JS render) + // and are driven entirely by the app, so they never schedule background work. if (widget.clientRendered) { return dedent` package ${packageName}.widget @@ -72,21 +95,21 @@ function generateWidgetReceiverClass(widget: DetectedAndroidWidget, packageName: } if (widget.serverUpdate) { - const refreshEnabled = widget.serverUpdate.refresh === true - // Widget with server-driven updates: schedule WorkManager periodic task + // Payload widget with server-driven updates: schedule WorkManager periodic work. The URL and + // the interval are resolved from widget_server_defaults.json plus any runtime overrides, so + // they are deliberately not inlined here. return dedent` package ${packageName}.widget import android.appwidget.AppWidgetManager import android.content.Context + import kotlinx.coroutines.runBlocking import voltra.widget.payload.VoltraPayloadWidgetReceiver import voltra.widget.payload.VoltraWidgetUpdateScheduler /** * Auto-generated widget receiver for ${labelForComment} * Widget ID: ${widget.id} - * Server Update: ${widget.serverUpdate.url} (every ${widget.serverUpdate.intervalMinutes ?? 15} minutes) - * Refresh Button: ${refreshEnabled} */ class ${className} : VoltraPayloadWidgetReceiver() { override val widgetId: String = "${widget.id}" @@ -94,14 +117,11 @@ function generateWidgetReceiverClass(widget: DetectedAndroidWidget, packageName: override fun onUpdate(context: Context, appWidgetManager: AppWidgetManager, appWidgetIds: IntArray) { super.onUpdate(context, appWidgetManager, appWidgetIds) - // Schedule periodic server updates via WorkManager - VoltraWidgetUpdateScheduler.schedulePeriodicUpdate( - context = context, - widgetId = "${widget.id}", - serverUrl = "${widget.serverUpdate.url}", - intervalMinutes = ${widget.serverUpdate.intervalMinutes ?? 15}L, - refreshEnabled = ${refreshEnabled} - ) + // Blocking rather than launching: onReceive must not return before the work is + // enqueued, or a widget added while the app is not running loses its schedule. + runBlocking { + VoltraWidgetUpdateScheduler.schedulePeriodicUpdate(context.applicationContext, "${widget.id}") + } } override fun onDeleted(context: Context, appWidgetIds: IntArray) { diff --git a/packages/android-client/expo-plugin/src/android/files/serverDefaults.node.test.ts b/packages/android-client/expo-plugin/src/android/files/serverDefaults.node.test.ts new file mode 100644 index 00000000..dc129be1 --- /dev/null +++ b/packages/android-client/expo-plugin/src/android/files/serverDefaults.node.test.ts @@ -0,0 +1,55 @@ +import { createAndroidWidgetServerDefaults } from './serverDefaults' + +describe('createAndroidWidgetServerDefaults', () => { + it('emits nothing for a widget without serverUpdate', () => { + expect(createAndroidWidgetServerDefaults([{ id: 'plain' }])).toEqual({}) + }) + + it('marks a widget server-driven even when app.json set no url', () => { + expect(createAndroidWidgetServerDefaults([{ id: 'portfolio', serverUpdate: {} }])).toEqual({ + portfolio: { intervalMinutes: 60, refresh: false }, + }) + }) + + it('applies the payload default interval of 60 minutes', () => { + expect( + createAndroidWidgetServerDefaults([{ id: 'portfolio', serverUpdate: { url: 'https://a.example.com' } }]) + ).toEqual({ + portfolio: { url: 'https://a.example.com', intervalMinutes: 60, refresh: false }, + }) + }) + + it('applies the 15 minute default to a widget with an entry', () => { + expect( + createAndroidWidgetServerDefaults([ + { id: 'portfolio', entry: 'widgets/portfolio.tsx', serverUpdate: { url: 'https://a.example.com' } }, + ]) + ).toEqual({ + portfolio: { url: 'https://a.example.com', intervalMinutes: 15, refresh: false }, + }) + }) + + it('clamps a widget with an entry up to the interval both platforms can honour', () => { + expect( + createAndroidWidgetServerDefaults([ + { + id: 'portfolio', + entry: 'widgets/portfolio.tsx', + serverUpdate: { url: 'https://a.example.com', intervalMinutes: 5 }, + }, + ]) + ).toEqual({ + portfolio: { url: 'https://a.example.com', intervalMinutes: 15, refresh: false }, + }) + }) + + it('carries the refresh flag, which stays build-time because the button is generated UI', () => { + expect( + createAndroidWidgetServerDefaults([ + { id: 'portfolio', serverUpdate: { url: 'https://a.example.com', refresh: true } }, + ]) + ).toEqual({ + portfolio: { url: 'https://a.example.com', intervalMinutes: 60, refresh: true }, + }) + }) +}) diff --git a/packages/android-client/expo-plugin/src/android/files/serverDefaults.ts b/packages/android-client/expo-plugin/src/android/files/serverDefaults.ts new file mode 100644 index 00000000..6c270cb7 --- /dev/null +++ b/packages/android-client/expo-plugin/src/android/files/serverDefaults.ts @@ -0,0 +1,71 @@ +import fs from 'fs' +import path from 'path' + +import { logger } from '@use-voltra/expo-plugin' + +import type { AndroidWidgetConfig } from '../../types' +import { resolveAndroidWidgetServerUpdate } from '../serverUpdate' + +export interface GenerateServerDefaultsOptions { + widgets: AndroidWidgetConfig[] + platformProjectRoot: string +} + +/** `serverUpdate` defaults as the Kotlin side reads them, keyed by widget id. */ +export interface AndroidWidgetServerDefaults { + url?: string + intervalMinutes: number + refresh: boolean +} + +/** + * Emits `assets/voltra/widget_server_defaults.json`, the lowest layer of the settings stack read + * at runtime by `voltra.widget.server`. + * + * These values used to be inlined into each generated receiver as Kotlin literals. They live in an + * asset now because the app can override the URL and the interval at runtime with + * `setWidgetServerUpdate`, and a receiver compiled at build time cannot be asked what the interval + * is today. + * + * A widget id present in this file is server-driven. `url` is omitted when app.json declared + * `serverUpdate` without one, which means the app supplies it at runtime. + */ +export async function generateAndroidServerDefaults(options: GenerateServerDefaultsOptions): Promise { + const { widgets, platformProjectRoot } = options + const defaults = createAndroidWidgetServerDefaults(widgets) + const assetsDir = path.join(platformProjectRoot, 'app', 'src', 'main', 'assets', 'voltra') + const assetPath = path.join(assetsDir, 'widget_server_defaults.json') + + if (Object.keys(defaults).length === 0) { + // A project that removed its last server-driven widget must not keep an asset saying otherwise. + fs.rmSync(assetPath, { force: true }) + return + } + + fs.mkdirSync(assetsDir, { recursive: true }) + fs.writeFileSync(assetPath, `${JSON.stringify(defaults, null, 2)}\n`) + + logger.info(`Generated widget_server_defaults.json for ${Object.keys(defaults).length} widget(s)`) +} + +export function createAndroidWidgetServerDefaults( + widgets: Pick[] +): Record { + const defaults: Record = {} + + for (const widget of widgets) { + const serverUpdate = resolveAndroidWidgetServerUpdate(widget) + + if (!serverUpdate) { + continue + } + + defaults[widget.id] = { + ...(serverUpdate.url !== undefined ? { url: serverUpdate.url } : {}), + intervalMinutes: serverUpdate.intervalMinutes, + refresh: serverUpdate.refresh, + } + } + + return defaults +} diff --git a/packages/android-client/expo-plugin/src/android/serverUpdate.ts b/packages/android-client/expo-plugin/src/android/serverUpdate.ts new file mode 100644 index 00000000..ce96f738 --- /dev/null +++ b/packages/android-client/expo-plugin/src/android/serverUpdate.ts @@ -0,0 +1,29 @@ +import { resolveWidgetServerUpdate } from '@use-voltra/expo-plugin' + +import type { ResolvedWidgetServerUpdateConfig, WidgetServerUpdateRules } from '@use-voltra/expo-plugin' + +import type { AndroidWidgetConfig } from '../types' + +/** + * Android payload widgets have always defaulted to a 60 minute interval and been floored at the + * 15 minutes WorkManager can actually honour. A widget with an `entry` follows ADR 0002 instead: + * default 15, floor 15 on both platforms. + */ +export function androidServerUpdateRules(widget: Pick): WidgetServerUpdateRules { + return { + hasEntry: widget.entry !== undefined, + defaultIntervalMinutes: 60, + minimumIntervalMinutes: 15, + } +} + +/** `serverUpdate` with defaults applied, as the Kotlin and asset generators consume it. */ +export function resolveAndroidWidgetServerUpdate( + widget: Pick +): ResolvedWidgetServerUpdateConfig | undefined { + if (widget.serverUpdate === undefined) { + return undefined + } + + return resolveWidgetServerUpdate(widget.serverUpdate, androidServerUpdateRules(widget)) +} diff --git a/packages/android-client/expo-plugin/src/types.ts b/packages/android-client/expo-plugin/src/types.ts index 31cc28bd..98706fd3 100644 --- a/packages/android-client/expo-plugin/src/types.ts +++ b/packages/android-client/expo-plugin/src/types.ts @@ -70,8 +70,12 @@ export interface AndroidWidgetConfig extends DynamicWidgetEntryConfig { * Server-driven Android widget updates (WorkManager). */ export interface AndroidWidgetServerUpdateConfig { - url: string - /** @default 60 */ + /** + * Server endpoint that returns widget state updates. Omit it to mark the widget + * server-driven and supply the URL at runtime with `setWidgetServerUpdate`. + */ + url?: string + /** @default 60, or 15 when the widget has an `entry` */ intervalMinutes?: number /** @default false */ refresh?: boolean diff --git a/packages/android-client/expo-plugin/src/validation.node.test.ts b/packages/android-client/expo-plugin/src/validation.node.test.ts index 4dfdfa4e..45a7ca83 100644 --- a/packages/android-client/expo-plugin/src/validation.node.test.ts +++ b/packages/android-client/expo-plugin/src/validation.node.test.ts @@ -132,3 +132,47 @@ describe('validateAndroidConfigPluginProps', () => { } }) }) + +describe('validateAndroidConfigPluginProps serverUpdate', () => { + const widget = { + id: 'portfolio', + displayName: 'Portfolio', + description: 'Track holdings', + targetCellWidth: 2, + targetCellHeight: 2, + } + + function validate(overrides: Record): void { + validateAndroidConfigPluginProps({ widgets: [{ ...widget, ...overrides }] } as never) + } + + it('accepts a serverUpdate with no url, which the app supplies at runtime', () => { + expect(() => validate({ serverUpdate: {} })).not.toThrow() + }) + + it('rejects a url no HTTP stack can reach', () => { + expect(() => validate({ serverUpdate: { url: 'api.example.com' } })).toThrow(/absolute http\(s\) URL/) + }) + + it('rejects an interval below the payload floor', () => { + expect(() => validate({ serverUpdate: { url: 'https://a.example.com', intervalMinutes: 5 } })).toThrow( + /at least 15/ + ) + }) + + it('clamps rather than rejects a short interval on a widget with an entry', () => { + const warn = jest.spyOn(console, 'warn').mockImplementation(() => {}) + + try { + expect(() => + validate({ + entry: './widgets/portfolio.tsx', + serverUpdate: { url: 'https://a.example.com', intervalMinutes: 5 }, + }) + ).not.toThrow() + expect(warn).toHaveBeenCalledWith(expect.stringContaining('below the 15 minute floor')) + } finally { + warn.mockRestore() + } + }) +}) diff --git a/packages/android-client/expo-plugin/src/validation.ts b/packages/android-client/expo-plugin/src/validation.ts index 1309e023..45a727a6 100644 --- a/packages/android-client/expo-plugin/src/validation.ts +++ b/packages/android-client/expo-plugin/src/validation.ts @@ -6,8 +6,11 @@ import { validateInitialStatePath, validateWidgetEntry, validateWidgetLabel, + validateWidgetServerUpdate, } from '@use-voltra/expo-plugin' +import { androidServerUpdateRules } from './android/serverUpdate' + import type { AndroidConfigPluginProps, AndroidWidgetConfig } from './types' function validatePositiveIntegerField(widget: AndroidWidgetConfig, field: keyof AndroidWidgetConfig): void { @@ -27,6 +30,8 @@ export function validateAndroidWidgetConfig(widget: AndroidWidgetConfig, project validateWidgetEntry(widget.entry, widget.id, projectRoot) } + validateWidgetServerUpdate(widget.serverUpdate, widget.id, androidServerUpdateRules(widget)) + if (typeof widget.targetCellWidth !== 'number') { throw new Error(`Widget '${widget.id}': targetCellWidth is required and must be a number`) } diff --git a/packages/android-client/src/index.ts b/packages/android-client/src/index.ts index 191a92a2..5024be6a 100644 --- a/packages/android-client/src/index.ts +++ b/packages/android-client/src/index.ts @@ -49,6 +49,15 @@ export { type AndroidDynamicWidgetProps, type AndroidDynamicWidgetPropsValue, } from './dynamic-widget/api.js' +export { + clearWidgetServerUpdate, + getWidgetServerUpdate, + setWidgetServerUpdate, + type WidgetServerUpdateBody, + type WidgetServerUpdateOptions, + type WidgetServerUpdateSettings, + type WidgetServerUpdateSnapshot, +} from './widgets/server-update.js' export { clearAllAndroidWidgets, clearAndroidWidget, diff --git a/packages/android-client/src/native/NativeVoltraAndroid.ts b/packages/android-client/src/native/NativeVoltraAndroid.ts index 686ddbf9..49870f9f 100644 --- a/packages/android-client/src/native/NativeVoltraAndroid.ts +++ b/packages/android-client/src/native/NativeVoltraAndroid.ts @@ -100,6 +100,11 @@ export interface Spec extends TurboModule { requestPinGlanceAppWidget(widgetId: string, options?: RequestPinGlanceAppWidgetOptionsSpec): Promise preloadImages(images: PreloadImageOptions[]): Promise clearPreloadedImages(keys?: string[] | null): Promise + /** Settings are passed as JSON so an arbitrary `body` survives the bridge unchanged. */ + setWidgetServerUpdate(settingsJson: string, widgetId?: string | null): Promise + clearWidgetServerUpdate(widgetId?: string | null): Promise + /** Result is JSON so an arbitrary `body` survives the bridge unchanged, or null. */ + getWidgetServerUpdate(widgetId?: string | null): Promise setWidgetServerCredentials(credentials: WidgetServerCredentials): Promise clearWidgetServerCredentials(): Promise getActiveWidgets(): Promise> diff --git a/packages/android-client/src/types.ts b/packages/android-client/src/types.ts index 9b83ed51..7184dd9e 100644 --- a/packages/android-client/src/types.ts +++ b/packages/android-client/src/types.ts @@ -20,4 +20,8 @@ export type { VoltraPropValue, WidgetInfo, WidgetServerCredentials, + WidgetServerUpdateBody, + WidgetServerUpdateOptions, + WidgetServerUpdateSettings, + WidgetServerUpdateSnapshot, } from '@use-voltra/android' diff --git a/packages/android-client/src/widgets/server-credentials.ts b/packages/android-client/src/widgets/server-credentials.ts index aadbef00..f626b34f 100644 --- a/packages/android-client/src/widgets/server-credentials.ts +++ b/packages/android-client/src/widgets/server-credentials.ts @@ -3,6 +3,11 @@ import { getNativeVoltraAndroid } from '../native/NativeVoltraAndroid.js' export type { WidgetServerCredentials } from '../types.js' +/** + * @deprecated Use {@link setWidgetServerUpdate} with an `Authorization` header. This writes the + * same stored credentials and keeps the same replace-everything semantics, so migrating is a + * one-line change; it will be removed in a future major. + */ export async function setWidgetServerCredentials(credentials: WidgetServerCredentials): Promise { if (!credentials.token) { throw new Error('[Voltra] [Android] setWidgetServerCredentials: token is required') @@ -10,6 +15,9 @@ export async function setWidgetServerCredentials(credentials: WidgetServerCreden return getNativeVoltraAndroid().setWidgetServerCredentials(credentials) } +/** + * @deprecated Use {@link clearWidgetServerUpdate} instead. + */ export async function clearWidgetServerCredentials(): Promise { return getNativeVoltraAndroid().clearWidgetServerCredentials() } diff --git a/packages/android-client/src/widgets/server-update.ts b/packages/android-client/src/widgets/server-update.ts new file mode 100644 index 00000000..d9a2f5c5 --- /dev/null +++ b/packages/android-client/src/widgets/server-update.ts @@ -0,0 +1,84 @@ +import type { WidgetServerUpdateOptions, WidgetServerUpdateSettings, WidgetServerUpdateSnapshot } from '../types.js' +import { getNativeVoltraAndroid } from '../native/NativeVoltraAndroid.js' + +export type { + WidgetServerUpdateBody, + WidgetServerUpdateOptions, + WidgetServerUpdateSettings, + WidgetServerUpdateSnapshot, +} from '../types.js' + +/** + * Overrides a server-driven widget's `serverUpdate` settings at runtime. + * + * The `serverUpdate` entry in app.json supplies the defaults; this replaces any of them for one + * widget, or for every server-driven widget when no `widgetId` is given. A widget-scoped call wins + * over a global one, and `headers` and `query` merge per key across the two. + * + * Each call replaces the whole layer it writes, so pass every field you want to keep. Setting + * anything reschedules the widgets it affects and fetches once immediately. + * + * @example Point a widget at the tenant's own backend once the user has logged in. + * ```ts + * await setWidgetServerUpdate( + * { url: `https://${tenant}.example.com/widgets/portfolio`, headers: { Authorization: `Bearer ${token}` } }, + * { widgetId: 'portfolio' } + * ) + * ``` + * + * @example Take a widget over and drive it from the app until you hand it back. + * ```ts + * await setWidgetServerUpdate({ enabled: false }, { widgetId: 'portfolio' }) + * await updateAndroidDynamicWidget('portfolio', localProps) + * ``` + * + * @throws if the widget has no `serverUpdate` in app.json, if the URL is not https (plain http is + * allowed only in a debug build, and only for a local dev host), or if `query` names one of the + * parameters Voltra already sends. + */ +export async function setWidgetServerUpdate( + settings: WidgetServerUpdateSettings, + options?: WidgetServerUpdateOptions +): Promise { + return getNativeVoltraAndroid().setWidgetServerUpdate(JSON.stringify(settings ?? {}), options?.widgetId ?? null) +} + +/** + * Drops the runtime settings for one widget, or the global ones when no `widgetId` is given, so + * the widget falls back to what app.json configured. + * + * Clearing the global settings is the logout gesture: along with the settings it drops what the + * server last sent for every server-driven widget, so a Dynamic Widget goes back to `{}` with + * `env.serverUpdate.status` of `never` rather than showing the previous account's data. Credentials + * set with the deprecated `setWidgetServerCredentials` are stored separately — clear those with + * `clearWidgetServerCredentials`. + */ +export async function clearWidgetServerUpdate(options?: WidgetServerUpdateOptions): Promise { + return getNativeVoltraAndroid().clearWidgetServerUpdate(options?.widgetId ?? null) +} + +/** + * Reads a widget's `serverUpdate` settings back, without reasoning about what was set where. + * + * With a `widgetId`, this is the fully resolved settings that widget would fetch with right now: + * every layer flattened and app.json's defaults applied — `null` if the widget is not + * server-driven. Without one, this is the raw contents of the global layer only — what the last + * `setWidgetServerUpdate(settings)` call (with no `widgetId`) wrote, with no defaults applied and + * every field optional — `null` if nothing has been set globally. + * + * @example Check what a widget is about to fetch from. + * ```ts + * const snapshot = await getWidgetServerUpdate({ widgetId: 'portfolio' }) + * if (snapshot?.enabled) { + * console.log(`portfolio fetches ${snapshot.url} every ${snapshot.intervalMinutes}m`) + * } + * ``` + */ +export async function getWidgetServerUpdate(options: { widgetId: string }): Promise +export async function getWidgetServerUpdate(options?: undefined): Promise +export async function getWidgetServerUpdate( + options?: WidgetServerUpdateOptions +): Promise { + const json = await getNativeVoltraAndroid().getWidgetServerUpdate(options?.widgetId ?? null) + return json == null ? null : JSON.parse(json) +} diff --git a/packages/android/src/index.ts b/packages/android/src/index.ts index 9dbbdfbd..1b9cda2a 100644 --- a/packages/android/src/index.ts +++ b/packages/android/src/index.ts @@ -48,6 +48,10 @@ export type { VoltraNodeJson, VoltraPropValue, WidgetServerCredentials, + WidgetServerUpdateBody, + WidgetServerUpdateOptions, + WidgetServerUpdateSettings, + WidgetServerUpdateSnapshot, } from './types.js' export type { AndroidWidgetSize, diff --git a/packages/android/src/types.ts b/packages/android/src/types.ts index 2369cf20..d55c1914 100644 --- a/packages/android/src/types.ts +++ b/packages/android/src/types.ts @@ -32,7 +32,66 @@ export type PreloadImagesResult = { failed: PreloadImageFailure[] } +/** + * @deprecated Use `setWidgetServerUpdate` with an `Authorization` header instead. These are + * stored in the same place and keep the same replace-the-whole-set semantics. + */ export type WidgetServerCredentials = { token: string headers?: Record } + +/** + * Runtime overrides for a widget's `serverUpdate` settings — the twin of the `serverUpdate` key + * in app.json, which supplies the defaults. + * + * Every field is optional and replaces the app.json value when set. `headers` and `query` merge + * per key across layers; everything else takes the value from the most specific layer that sets + * it. Passing settings without a `widgetId` sets them for every server-driven widget. + */ +export type WidgetServerUpdateSettings = { + /** Endpoint to fetch from. Must be https, or http to a local dev host in a debug build. */ + url?: string + /** How often to fetch, in minutes. Clamped to at least 15 and at most 24 hours. */ + intervalMinutes?: number + /** Set false to stop fetching and drive the widget from the app instead. Defaults to true. */ + enabled?: boolean + /** HTTP method. Defaults to GET. A body is dropped on GET and HEAD, with a warning. */ + method?: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE' + /** Extra query parameters. Voltra's own keys are reserved and rejected. */ + query?: Record + /** Extra request headers, for example `Authorization`. */ + headers?: Record + /** Request body, sent as `application/json`. */ + body?: WidgetServerUpdateBody +} + +/** A JSON value, as accepted for a server-update request body. */ +export type WidgetServerUpdateBody = + | string + | number + | boolean + | null + | WidgetServerUpdateBody[] + | { [key: string]: WidgetServerUpdateBody } + +/** Options selecting which widget a settings call applies to. */ +export type WidgetServerUpdateOptions = { + /** Widget id to scope the settings to. Omit to set them for every server-driven widget. */ + widgetId?: string +} + +/** + * Fully resolved settings for one widget: every layer flattened and app.json's defaults applied, + * exactly what it would fetch with right now. + */ +export type WidgetServerUpdateSnapshot = { + /** Absent when the widget is server-driven but has no URL yet. */ + url?: string + intervalMinutes: number + enabled: boolean + method: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE' + query: Record + headers: Record + body?: WidgetServerUpdateBody +} diff --git a/packages/cli/src/apply/index.ts b/packages/cli/src/apply/index.ts index 55085a86..0db6b56d 100644 --- a/packages/cli/src/apply/index.ts +++ b/packages/cli/src/apply/index.ts @@ -108,7 +108,10 @@ export async function runApplyPipeline(options: ApplyOptions, dependencies: Appl const deletedChanges = await removeStaleGeneratedFiles(normalizedConfig.projectRoot, stateDiff.staleFiles) await saveVoltraState(normalizedConfig.projectRoot, { files: stateDiff.nextFiles }) - const summaryWarnings = platformResults.flatMap((result) => result.warnings ?? []).filter(isDefined) + const summaryWarnings = [ + ...(normalizedConfig.warnings ?? []), + ...platformResults.flatMap((result) => result.warnings ?? []), + ].filter(isDefined) const summaryChanges = [...platformResults.flatMap((result) => result.changes), ...deletedChanges] await resolvedDependencies.writeSummary({ changes: summaryChanges, warnings: summaryWarnings }) diff --git a/packages/cli/src/config/normalize.ts b/packages/cli/src/config/normalize.ts index b2d95fcd..0af645c9 100644 --- a/packages/cli/src/config/normalize.ts +++ b/packages/cli/src/config/normalize.ts @@ -3,6 +3,7 @@ import path from 'node:path' import { resolveFromRoot } from '../fs/path' import { CLI_DEFAULTS } from './defaults' import { isPerConfigurationMap } from './perConfiguration' +import { resolveServerUpdateInterval, resolveServerUpdateUrl, validateServerUpdateRefresh } from './serverUpdate' import type { PerConfiguration } from './perConfiguration' @@ -13,6 +14,7 @@ import type { IOSWidgetConfig, LoadedVoltraConfig, NormalizedAndroidWidgetConfig, + NormalizedWidgetServerUpdateConfig, NormalizedVoltraAndroidConfig, NormalizedVoltraConfig, NormalizedVoltraIOSConfig, @@ -344,42 +346,68 @@ function normalizeIOSAppIntent( return { parameters } } -function normalizeServerUpdate( - serverUpdate: { url: string; intervalMinutes?: number; refresh?: boolean }, - context: string, - defaultIntervalMinutes: number, - defaultRefresh: boolean, +interface NormalizeServerUpdateOptions { + context: string + /** True when the widget has an `entry`, so the response is props rather than a payload. */ + hasEntry: boolean + defaultIntervalMinutes: number + defaultRefresh: boolean minimumIntervalMinutes: number -): { url: string; intervalMinutes: number; refresh: boolean } { + warnings: string[] +} + +function normalizeServerUpdate( + serverUpdate: { url?: string; intervalMinutes?: number; refresh?: boolean }, + options: NormalizeServerUpdateOptions +): NormalizedWidgetServerUpdateConfig { + const { context, hasEntry, defaultIntervalMinutes, defaultRefresh, minimumIntervalMinutes, warnings } = options + assertObject(serverUpdate, context) - assertNonEmptyString(serverUpdate.url, `${context}.url`) - if (serverUpdate.intervalMinutes !== undefined) { - if (typeof serverUpdate.intervalMinutes !== 'number' || !Number.isFinite(serverUpdate.intervalMinutes)) { - throw new VoltraConfigNormalizationError(`${context}.intervalMinutes must be a number`) - } + const url = resolveServerUpdateUrl(serverUpdate.url, context) - if (!Number.isInteger(serverUpdate.intervalMinutes)) { - throw new VoltraConfigNormalizationError(`${context}.intervalMinutes must be an integer`) - } + if (url.kind === 'invalid') { + throw new VoltraConfigNormalizationError(url.error) + } - if (serverUpdate.intervalMinutes < minimumIntervalMinutes) { - throw new VoltraConfigNormalizationError(`${context}.intervalMinutes must be at least ${minimumIntervalMinutes}`) - } + if (url.kind === 'insecure') { + warnings.push(url.warning) + } + + const interval = resolveServerUpdateInterval({ + intervalMinutes: serverUpdate.intervalMinutes, + context, + hasEntry, + defaultIntervalMinutes, + minimumIntervalMinutes, + }) + + if (interval.kind === 'invalid') { + throw new VoltraConfigNormalizationError(interval.error) + } + + if (interval.kind === 'clamped') { + warnings.push(interval.warning) } - if (serverUpdate.refresh !== undefined && typeof serverUpdate.refresh !== 'boolean') { - throw new VoltraConfigNormalizationError(`${context}.refresh must be a boolean`) + const refreshError = validateServerUpdateRefresh(serverUpdate.refresh, context) + + if (refreshError) { + throw new VoltraConfigNormalizationError(refreshError) } return { url: serverUpdate.url, - intervalMinutes: serverUpdate.intervalMinutes ?? defaultIntervalMinutes, + intervalMinutes: interval.intervalMinutes, refresh: serverUpdate.refresh ?? defaultRefresh, } } -function normalizeAndroidWidget(projectRoot: string, widget: AndroidWidgetConfig): NormalizedAndroidWidgetConfig { +function normalizeAndroidWidget( + projectRoot: string, + widget: AndroidWidgetConfig, + warnings: string[] +): NormalizedAndroidWidgetConfig { assertObject(widget, 'android.widgets[]') assertNonEmptyString(widget.id, 'android.widgets[].id') assertValidWidgetId(widget.id, 'android.widgets[].id') @@ -408,18 +436,23 @@ function normalizeAndroidWidget(projectRoot: string, widget: AndroidWidgetConfig previewLayout: resolveOptionalPathFromProjectRoot(projectRoot, widget.previewLayout), appIntent: normalizeAndroidAppIntent(widget.appIntent, `android.widgets[${widget.id}].appIntent`), serverUpdate: widget.serverUpdate - ? normalizeServerUpdate( - widget.serverUpdate, - `android.widgets[${widget.id}].serverUpdate`, - CLI_DEFAULTS.android.serverUpdateIntervalMinutes, - CLI_DEFAULTS.android.serverUpdateRefresh, - 15 - ) + ? normalizeServerUpdate(widget.serverUpdate, { + context: `android.widgets[${widget.id}].serverUpdate`, + hasEntry: widget.entry !== undefined, + defaultIntervalMinutes: CLI_DEFAULTS.android.serverUpdateIntervalMinutes, + defaultRefresh: CLI_DEFAULTS.android.serverUpdateRefresh, + minimumIntervalMinutes: 15, + warnings, + }) : undefined, } } -function normalizeIOSWidget(projectRoot: string, widget: IOSWidgetConfig): NormalizedIOSWidgetConfig { +function normalizeIOSWidget( + projectRoot: string, + widget: IOSWidgetConfig, + warnings: string[] +): NormalizedIOSWidgetConfig { assertObject(widget, 'ios.widgets[]') assertNonEmptyString(widget.id, 'ios.widgets[].id') assertValidWidgetId(widget.id, 'ios.widgets[].id') @@ -451,13 +484,14 @@ function normalizeIOSWidget(projectRoot: string, widget: IOSWidgetConfig): Norma ), appIntent: normalizeIOSAppIntent(widget.appIntent, `ios.widgets[${widget.id}].appIntent`), serverUpdate: widget.serverUpdate - ? normalizeServerUpdate( - widget.serverUpdate, - `ios.widgets[${widget.id}].serverUpdate`, - CLI_DEFAULTS.ios.serverUpdateIntervalMinutes, - CLI_DEFAULTS.ios.serverUpdateRefresh, - 1 - ) + ? normalizeServerUpdate(widget.serverUpdate, { + context: `ios.widgets[${widget.id}].serverUpdate`, + hasEntry: widget.entry !== undefined, + defaultIntervalMinutes: CLI_DEFAULTS.ios.serverUpdateIntervalMinutes, + defaultRefresh: CLI_DEFAULTS.ios.serverUpdateRefresh, + minimumIntervalMinutes: 1, + warnings, + }) : undefined, } } @@ -491,6 +525,7 @@ function assertValidIOSTargetName(targetName: string, context: string): void { } function normalizeAndroidConfig( + warnings: string[], projectRoot: string, config: LoadedVoltraConfig['config']['android'] ): NormalizedVoltraAndroidConfig | undefined { @@ -515,7 +550,7 @@ function normalizeAndroidConfig( throw new VoltraConfigNormalizationError('android.widgets must be an array') } - const widgets = (config.widgets ?? []).map((widget) => normalizeAndroidWidget(projectRoot, widget)) + const widgets = (config.widgets ?? []).map((widget) => normalizeAndroidWidget(projectRoot, widget, warnings)) assertUniqueWidgetIds( widgets.map((widget) => widget.id), 'android' @@ -539,6 +574,7 @@ function normalizeAndroidConfig( } function normalizeIOSConfig( + warnings: string[], projectRoot: string, config: LoadedVoltraConfig['config']['ios'] ): NormalizedVoltraIOSConfig | undefined { @@ -573,7 +609,7 @@ function normalizeIOSConfig( assertValidIOSTargetName(config.targetName, 'ios.targetName') } - const widgets = (config.widgets ?? []).map((widget) => normalizeIOSWidget(projectRoot, widget)) + const widgets = (config.widgets ?? []).map((widget) => normalizeIOSWidget(projectRoot, widget, warnings)) assertUniqueWidgetIds( widgets.map((widget) => widget.id), 'ios' @@ -608,11 +644,38 @@ export function normalizeVoltraConfig(loadedConfig: LoadedVoltraConfig): Normali loadedConfig.config.projectRoot ?? loadedConfig.configDir ) + const warnings: string[] = [] + const android = normalizeAndroidConfig(warnings, projectRoot, loadedConfig.config.android) + const ios = normalizeIOSConfig(warnings, projectRoot, loadedConfig.config.ios) + + assertServerDrivenDynamicWidgetsAreSupported(ios) + return { configPath: loadedConfig.configPath, configDir: loadedConfig.configDir, projectRoot, - android: normalizeAndroidConfig(projectRoot, loadedConfig.config.android), - ios: normalizeIOSConfig(projectRoot, loadedConfig.config.ios), + android, + ios, + warnings, + } +} + +/** + * A Dynamic Widget commits fetched props to the App Group so the widget extension can read them, + * so a server-driven one without a `groupIdentifier` would fetch and have nowhere to put the + * result. + */ +function assertServerDrivenDynamicWidgetsAreSupported(ios: NormalizedVoltraIOSConfig | undefined): void { + if (ios === undefined || ios.groupIdentifier !== undefined) { + return + } + + for (const widget of ios.widgets) { + if (widget.entry !== undefined && widget.serverUpdate !== undefined) { + throw new VoltraConfigNormalizationError( + `ios.widgets[${widget.id}] has both entry and serverUpdate, which requires ios.groupIdentifier ` + + 'so fetched props can be shared with the widget extension.' + ) + } } } diff --git a/packages/cli/src/config/serverUpdate.ts b/packages/cli/src/config/serverUpdate.ts new file mode 100644 index 00000000..bf44a0e9 --- /dev/null +++ b/packages/cli/src/config/serverUpdate.ts @@ -0,0 +1,160 @@ +/** + * Config rules for `serverUpdate`, shared by the Expo plugins and the `voltra` CLI. + * + * `serverUpdate` marks a widget as server-driven for both render engines. On a widget with + * `entry` the device fetches a JSON object and hands it to the bundled JS as props; without + * `entry` the server returns a full Voltra payload. Widgets without `entry` keep exactly the + * rules they had before ADR 0002, so no existing config breaks. + * + * `url` is optional. `serverUpdate: {}` means "server-driven, URL supplied at runtime" through + * `setWidgetServerUpdate`, which covers per-tenant backends whose URL is only known after login. + * + * These helpers report problems instead of throwing so each caller can raise its own error type. + * + * The `@use-voltra/expo-plugin` package holds the other copy of this module, shared by the + * Expo config plugins; keep the two in sync. + */ + +/** + * Interval floor and default for a widget with `entry`. WorkManager cannot run periodic work + * more often than every 15 minutes, and WidgetKit stretches timelines requested closer together + * than five minutes, so a smaller number would only mislead. + */ +export const DYNAMIC_WIDGET_SERVER_UPDATE_INTERVAL_MINUTES = 15 + +/** Hosts allowed over plain `http`, for talking to a dev server from a simulator or emulator. */ +const LOCAL_HTTP_HOSTS = new Set(['localhost', '127.0.0.1', '::1', '10.0.2.2', '10.0.3.2']) + +/** Outcome of resolving `serverUpdate.intervalMinutes` against a platform's floor. */ +export type ServerUpdateIntervalResolution = + | { kind: 'ok'; intervalMinutes: number } + | { kind: 'clamped'; intervalMinutes: number; warning: string } + | { kind: 'invalid'; error: string } + +export interface ResolveServerUpdateIntervalOptions { + /** Raw `intervalMinutes` from app.json, if the widget set one. */ + intervalMinutes: unknown + /** Config path used in messages, e.g. `android.widgets[portfolio].serverUpdate`. */ + context: string + /** True when the widget has an `entry` and therefore renders on device. */ + hasEntry: boolean + /** Interval used when the widget does not set one. Ignored for widgets with `entry`. */ + defaultIntervalMinutes: number + /** Platform floor for payload widgets. Ignored for widgets with `entry`. */ + minimumIntervalMinutes: number +} + +/** + * Resolves the interval a widget should be scheduled on. + * + * A widget with `entry` is clamped up to {@link DYNAMIC_WIDGET_SERVER_UPDATE_INTERVAL_MINUTES} + * with a warning, because a shorter interval is not something either platform can honour. A + * payload widget keeps the platform's existing rule and is rejected below the floor. + */ +export function resolveServerUpdateInterval( + options: ResolveServerUpdateIntervalOptions +): ServerUpdateIntervalResolution { + const { intervalMinutes, context, hasEntry, defaultIntervalMinutes, minimumIntervalMinutes } = options + const fallback = hasEntry ? DYNAMIC_WIDGET_SERVER_UPDATE_INTERVAL_MINUTES : defaultIntervalMinutes + + if (intervalMinutes === undefined) { + return { kind: 'ok', intervalMinutes: fallback } + } + + if (typeof intervalMinutes !== 'number' || !Number.isFinite(intervalMinutes)) { + return { kind: 'invalid', error: `${context}.intervalMinutes must be a number` } + } + + if (!Number.isInteger(intervalMinutes)) { + return { kind: 'invalid', error: `${context}.intervalMinutes must be an integer` } + } + + if (hasEntry) { + if (intervalMinutes < DYNAMIC_WIDGET_SERVER_UPDATE_INTERVAL_MINUTES) { + return { + kind: 'clamped', + intervalMinutes: DYNAMIC_WIDGET_SERVER_UPDATE_INTERVAL_MINUTES, + warning: + `${context}.intervalMinutes is ${intervalMinutes}, below the ` + + `${DYNAMIC_WIDGET_SERVER_UPDATE_INTERVAL_MINUTES} minute floor for widgets with an entry. ` + + `Using ${DYNAMIC_WIDGET_SERVER_UPDATE_INTERVAL_MINUTES}.`, + } + } + + return { kind: 'ok', intervalMinutes } + } + + if (intervalMinutes < minimumIntervalMinutes) { + return { kind: 'invalid', error: `${context}.intervalMinutes must be at least ${minimumIntervalMinutes}` } + } + + return { kind: 'ok', intervalMinutes } +} + +/** Outcome of checking `serverUpdate.url`. */ +export type ServerUpdateUrlResolution = + | { kind: 'ok' } + | { kind: 'insecure'; warning: string } + | { kind: 'invalid'; error: string } + +/** + * Checks `serverUpdate.url`. An absent URL is fine — the app supplies it at runtime through + * `setWidgetServerUpdate`. + * + * A URL that is not an absolute `http`/`https` URL is rejected: no platform HTTP stack here + * accepts one, so such a config could only ever have failed to fetch. Plain `http` to a + * non-local host is reported as insecure rather than rejected — App Transport Security and + * Android's cleartext policy already block it in a release build, and rejecting it outright + * would break configs that point at a LAN dev server. + */ +export function resolveServerUpdateUrl(url: unknown, context: string): ServerUpdateUrlResolution { + if (url === undefined) { + return { kind: 'ok' } + } + + if (typeof url !== 'string' || !url.trim()) { + return { kind: 'invalid', error: `${context}.url must be a non-empty string` } + } + + let parsed: URL + + try { + parsed = new URL(url) + } catch { + return { kind: 'invalid', error: `${context}.url must be an absolute http(s) URL, received '${url}'` } + } + + if (parsed.protocol === 'https:') { + return { kind: 'ok' } + } + + if (parsed.protocol !== 'http:') { + return { kind: 'invalid', error: `${context}.url must be an absolute http(s) URL, received '${url}'` } + } + + if (isLocalHttpHost(parsed.hostname)) { + return { kind: 'ok' } + } + + return { + kind: 'insecure', + warning: + `${context}.url uses plain http ('${url}'). Release builds block cleartext traffic, so the ` + + 'widget will not fetch outside a development build. Use https, or a local dev host ' + + `(${[...LOCAL_HTTP_HOSTS].join(', ')}).`, + } +} + +/** True for the hosts Voltra allows over plain `http` — dev servers reachable from a simulator. */ +export function isLocalHttpHost(hostname: string): boolean { + return LOCAL_HTTP_HOSTS.has(hostname.replace(/^\[|\]$/g, '')) +} + +/** Validates `serverUpdate.refresh`. Returns an error message, or `undefined` when it is fine. */ +export function validateServerUpdateRefresh(refresh: unknown, context: string): string | undefined { + if (refresh !== undefined && typeof refresh !== 'boolean') { + return `${context}.refresh must be a boolean` + } + + return undefined +} diff --git a/packages/cli/src/config/types.ts b/packages/cli/src/config/types.ts index 52806c54..be270a2c 100644 --- a/packages/cli/src/config/types.ts +++ b/packages/cli/src/config/types.ts @@ -16,8 +16,11 @@ export type WidgetLabel = string | WidgetLocalizedValue export type WidgetInitialStatePath = string | WidgetLocalizedValue export interface AndroidWidgetServerUpdateConfig { - /** Server endpoint that returns widget state updates. */ - url: string + /** + * Server endpoint that returns widget state updates. Optional — omit it to mark the widget + * server-driven and supply the URL at runtime with `setWidgetServerUpdate`. + */ + url?: string /** Refresh interval, in minutes, for fetching server updates. */ intervalMinutes?: number /** Whether fetched updates should trigger an immediate widget refresh. */ @@ -103,8 +106,11 @@ export type IOSWidgetFamily = | 'accessoryInline' export interface IOSWidgetServerUpdateConfig { - /** Server endpoint that returns widget state updates. */ - url: string + /** + * Server endpoint that returns widget state updates. Optional — omit it to mark the widget + * server-driven and supply the URL at runtime with `setWidgetServerUpdate`. + */ + url?: string /** Refresh interval, in minutes, for fetching server updates. */ intervalMinutes?: number /** Whether fetched updates should trigger an immediate widget refresh. */ @@ -239,28 +245,27 @@ export interface LoadedVoltraConfig { configDir: string } -export interface NormalizedAndroidWidgetServerUpdateConfig { - /** Server endpoint that returns widget state updates. */ - url: string +/** + * Build-time server-update defaults after normalization. The device treats these as the lowest + * settings layer; `setWidgetServerUpdate` overrides `url` and `intervalMinutes` at runtime. + */ +export interface NormalizedWidgetServerUpdateConfig { + /** Server endpoint, when app.json set one. Absent means "URL supplied at runtime". */ + url?: string /** Refresh interval, in minutes, for fetching server updates. */ intervalMinutes: number - /** Whether fetched updates should trigger an immediate widget refresh. */ + /** Whether the widget draws a refresh button. Build-time only: it is generated UI structure. */ refresh: boolean } +export type NormalizedAndroidWidgetServerUpdateConfig = NormalizedWidgetServerUpdateConfig + export interface NormalizedAndroidWidgetConfig extends Omit { /** Server-driven update settings after defaults have been applied. */ serverUpdate?: NormalizedAndroidWidgetServerUpdateConfig } -export interface NormalizedIOSWidgetServerUpdateConfig { - /** Server endpoint that returns widget state updates. */ - url: string - /** Refresh interval, in minutes, for fetching server updates. */ - intervalMinutes: number - /** Whether fetched updates should trigger an immediate widget refresh. */ - refresh: boolean -} +export type NormalizedIOSWidgetServerUpdateConfig = NormalizedWidgetServerUpdateConfig export interface NormalizedIOSWidgetConfig extends Omit { /** Supported iOS widget families after defaults have been applied. */ @@ -366,6 +371,8 @@ export interface NormalizedVoltraConfig { android?: NormalizedVoltraAndroidConfig /** Normalized iOS-specific Voltra configuration. */ ios?: NormalizedVoltraIOSConfig + /** Non-fatal config problems, surfaced in the `voltra apply` summary. */ + warnings?: string[] } export type CliDefaults = typeof CLI_DEFAULTS diff --git a/packages/cli/src/platforms/android/generated.ts b/packages/cli/src/platforms/android/generated.ts index f11ec6e3..b73bac36 100644 --- a/packages/cli/src/platforms/android/generated.ts +++ b/packages/cli/src/platforms/android/generated.ts @@ -28,6 +28,7 @@ const LOCALIZED_INITIAL_STATE_KEY = '__voltraLocales' const DEFAULT_WIDGET_LOCALE_QUALIFIER = 'en' const ANDROID_DYNAMIC_WIDGET_MANIFEST_PATH = path.join('.voltra', 'manifest.android.json') const ANDROID_WIDGET_CONFIG_DEFAULTS_PATH = path.join('assets', 'voltra', 'widget_config_defaults.json') +const ANDROID_WIDGET_SERVER_DEFAULTS_PATH = path.join('assets', 'voltra', 'widget_server_defaults.json') export interface GenerateAndroidFilesOptions { projectRoot: string @@ -119,6 +120,11 @@ export async function generateAndroidFiles(options: GenerateAndroidFilesOptions) mergeSingleResult(configDefaultsResult, changes, generatedFiles) } + const serverDefaultsResult = await generateAndroidServerDefaults(projectRoot, resourceRoot, detectedWidgets) + if (serverDefaultsResult) { + mergeSingleResult(serverDefaultsResult, changes, generatedFiles) + } + return { changes, files: [...generatedFiles].sort(), @@ -680,6 +686,59 @@ async function generateAndroidConfigDefaults( ) } +/** + * Emits the build-time `serverUpdate` defaults the runtime settings resolver reads as its lowest + * layer. + * + * These used to be Kotlin literals inside each generated receiver. They live in an asset now + * because the app can override the URL and the interval with `setWidgetServerUpdate`, and a + * receiver compiled at build time cannot be asked what the interval is today. + * + * A widget id present in this file is server-driven. `url` is omitted when app.json declared + * `serverUpdate` without one, which means the app supplies it at runtime. + */ +async function generateAndroidServerDefaults( + projectRoot: string, + resourceRoot: string, + widgets: DetectedAndroidWidget[] +): Promise { + const defaults = createWidgetServerDefaults(widgets) + + if (Object.keys(defaults).length === 0) { + return undefined + } + + return writeGeneratedTextFile( + projectRoot, + path.join(resourceRoot, ANDROID_WIDGET_SERVER_DEFAULTS_PATH), + `${JSON.stringify(defaults, null, 2)}\n` + ) +} + +interface WidgetServerDefaults { + url?: string + intervalMinutes: number + refresh: boolean +} + +function createWidgetServerDefaults(widgets: DetectedAndroidWidget[]): Record { + const defaults: Record = {} + + for (const widget of widgets) { + if (!widget.serverUpdate) { + continue + } + + defaults[widget.id] = { + ...(widget.serverUpdate.url !== undefined ? { url: widget.serverUpdate.url } : {}), + intervalMinutes: widget.serverUpdate.intervalMinutes, + refresh: widget.serverUpdate.refresh, + } + } + + return defaults +} + function createWidgetConfigDefaults(widgets: DetectedAndroidWidget[]): WidgetConfigDefaults { const defaults: WidgetConfigDefaults = {} @@ -926,10 +985,36 @@ async function getLargeImageWarning(imagePath: string, fileName: string): Promis return `Image '${fileName}' is ${stat.size} bytes. Large Android widget images may not display correctly.` } +/** + * `entry` picks the render engine and `serverUpdate` picks where the data comes from, so the two + * keys together select one of four base classes. Runtime code never asks "is this server-driven?" — + * the answer is baked in here, once, at generate time. + */ function generateWidgetReceiverContent(widget: DetectedAndroidWidget, packageName: string): string { const className = `VoltraWidget_${widget.id}Receiver` const labelForComment = widgetLabelEnglish(widget.displayName) + // A widget with an entry and a serverUpdate renders bundled JS from props fetched in the + // background. The scheduling lives in the base class, so the generated receiver stays a name and + // an id — the URL and the interval come from widget_server_defaults.json at runtime, where + // setWidgetServerUpdate can override them. + if (widget.clientRendered && widget.serverUpdate) { + return [ + `package ${packageName}.widget`, + '', + 'import voltra.dynamicwidget.serverupdate.VoltraServerDrivenClientWidgetReceiver', + '', + '/**', + ` * Auto-generated server-driven Dynamic Widget receiver for ${labelForComment}`, + ` * Widget ID: ${widget.id}`, + ' */', + `class ${className} : VoltraServerDrivenClientWidgetReceiver() {`, + ` override val widgetId: String = "${widget.id}"`, + '}', + '', + ].join('\n') + } + if (widget.clientRendered) { return [ `package ${packageName}.widget`, @@ -948,21 +1033,18 @@ function generateWidgetReceiverContent(widget: DetectedAndroidWidget, packageNam } if (widget.serverUpdate) { - const refreshEnabled = widget.serverUpdate.refresh === true - return [ `package ${packageName}.widget`, '', 'import android.appwidget.AppWidgetManager', 'import android.content.Context', + 'import kotlinx.coroutines.runBlocking', 'import voltra.widget.payload.VoltraPayloadWidgetReceiver', 'import voltra.widget.payload.VoltraWidgetUpdateScheduler', '', '/**', ` * Auto-generated widget receiver for ${labelForComment}`, ` * Widget ID: ${widget.id}`, - ` * Server Update: ${widget.serverUpdate.url} (every ${widget.serverUpdate.intervalMinutes} minutes)`, - ` * Refresh Button: ${String(refreshEnabled)}`, ' */', `class ${className} : VoltraPayloadWidgetReceiver() {`, ` override val widgetId: String = "${widget.id}"`, @@ -970,13 +1052,11 @@ function generateWidgetReceiverContent(widget: DetectedAndroidWidget, packageNam ' override fun onUpdate(context: Context, appWidgetManager: AppWidgetManager, appWidgetIds: IntArray) {', ' super.onUpdate(context, appWidgetManager, appWidgetIds)', '', - ' VoltraWidgetUpdateScheduler.schedulePeriodicUpdate(', - ' context = context,', - ` widgetId = "${widget.id}",`, - ` serverUrl = "${widget.serverUpdate.url}",`, - ` intervalMinutes = ${widget.serverUpdate.intervalMinutes}L,`, - ` refreshEnabled = ${String(refreshEnabled)}`, - ' )', + ' // Blocking rather than launching: onReceive must not return before the work is', + ' // enqueued, or a widget added while the app is not running loses its schedule.', + ' runBlocking {', + ` VoltraWidgetUpdateScheduler.schedulePeriodicUpdate(context.applicationContext, "${widget.id}")`, + ' }', ' }', '', ' override fun onDeleted(context: Context, appWidgetIds: IntArray) {', diff --git a/packages/cli/src/platforms/ios/generated.ts b/packages/cli/src/platforms/ios/generated.ts index e5bd7f59..c090e850 100644 --- a/packages/cli/src/platforms/ios/generated.ts +++ b/packages/cli/src/platforms/ios/generated.ts @@ -234,7 +234,13 @@ async function generateInfoPlistFile( const fontNames = ios.fonts.map((fontPath) => path.basename(fontPath)).sort() const serverWidgets = widgets.filter((widget) => widget.serverUpdate) const hasClientRenderedWidget = widgets.some((widget) => widget.clientRendered) - const serverUrls = Object.fromEntries(serverWidgets.map((widget) => [widget.id, widget.serverUpdate?.url])) + // Keys of the intervals dictionary are the set of server-driven widget ids; a URL appears only + // when app.json set one, because it may instead arrive at runtime via setWidgetServerUpdate. + const serverUrls = Object.fromEntries( + serverWidgets + .filter((widget) => widget.serverUpdate?.url !== undefined) + .map((widget) => [widget.id, widget.serverUpdate?.url]) + ) const serverIntervals = Object.fromEntries( serverWidgets.map((widget) => [widget.id, widget.serverUpdate?.intervalMinutes]) ) @@ -628,9 +634,14 @@ function generateWidgetStruct(widget: DetectedIOSWidget): string { const familiesSwift = widget.supportedFamilies.map((family) => IOS_WIDGET_FAMILY_MAP[family]).join(', ') const displayNameExpr = createSwiftLabelExpression(widget.id, 'displayName', widget.displayName) const descriptionExpr = createSwiftLabelExpression(widget.id, 'description', widget.description) + // `entry` picks the render engine and `serverUpdate` picks where the data comes from, so the two + // keys together select one of four providers. Runtime code never asks "is this server-driven?" — + // the answer is baked in here, once, at generate time. const providerAndContent = widget.clientRendered ? [ - ' provider: VoltraClientWidgetProvider(', + ` provider: ${ + widget.serverUpdate ? 'VoltraDynamicWidgetServerUpdateProvider' : 'VoltraClientWidgetProvider' + }(`, ' widgetId: widgetId,', ' initialState: VoltraWidgetInitialStates.getInitialState(for: widgetId)', ' )', @@ -707,6 +718,22 @@ function generateClientAppIntentWidgetCode( const defaultDictionary = createSwiftDictionaryLiteral( widget.appIntent.parameters.map((parameter) => `${JSON.stringify(parameter.name)}: ${swiftDefaultValue(parameter)}`) ) + // A server-driven Dynamic Widget fetches on every timeline request and schedules the next one + // from its resolved interval; a plain one has nothing to ask again for, so its policy is .never. + const timelineBody = widget.serverUpdate + ? [ + ' return await VoltraDynamicWidgetServerUpdateProvider.timeline(', + ' widgetId: widgetId,', + ' family: context.family,', + ` configuration: ${configuredDictionary}`, + ' )', + ' }', + ] + : [ + ` let entry = await VoltraClientWidgetProvider.loadEntry(widgetId: widgetId, configuration: ${configuredDictionary})`, + ' return Timeline(entries: [entry], policy: .never)', + ' }', + ] return [ `@available(iOS 17.0, *)`, @@ -736,10 +763,8 @@ function generateClientAppIntentWidgetCode( ` await VoltraClientWidgetProvider.loadEntry(widgetId: widgetId, configuration: ${configuredDictionary})`, ' }', '', - ` func timeline(for configuration: ${intentName}, in _: Context) async -> Timeline {`, - ` let entry = await VoltraClientWidgetProvider.loadEntry(widgetId: widgetId, configuration: ${configuredDictionary})`, - ' return Timeline(entries: [entry], policy: .never)', - ' }', + ` func timeline(for configuration: ${intentName}, in context: Context) async -> Timeline {`, + ...timelineBody, '}', '', `@available(iOS 17.0, *)`, diff --git a/packages/cli/src/platforms/ios/plist.ts b/packages/cli/src/platforms/ios/plist.ts index 616ae647..1b939414 100644 --- a/packages/cli/src/platforms/ios/plist.ts +++ b/packages/cli/src/platforms/ios/plist.ts @@ -71,8 +71,15 @@ async function ensureSingleInfoPlist( const widgetIds = ios.widgets.map((widget) => widget.id) setOrDeleteVoltraKey(infoPlist, 'Voltra_WidgetIds', widgetIds.length > 0 ? widgetIds : undefined) + // Every server-driven widget gets an interval, so this dictionary's keys are the set of + // server-driven widget ids the runtime settings store validates against. A URL is written only + // when app.json set one; otherwise the app supplies it with setWidgetServerUpdate. const serverWidgets = ios.widgets.filter((widget) => widget.serverUpdate) - const serverUrls = Object.fromEntries(serverWidgets.map((widget) => [widget.id, widget.serverUpdate?.url])) + const serverUrls = Object.fromEntries( + serverWidgets + .filter((widget) => widget.serverUpdate?.url !== undefined) + .map((widget) => [widget.id, widget.serverUpdate?.url]) + ) const serverIntervals = Object.fromEntries( serverWidgets.map((widget) => [widget.id, widget.serverUpdate?.intervalMinutes]) ) diff --git a/packages/cli/test/cli.test.js b/packages/cli/test/cli.test.js index 8a0d0c31..2b5cd10d 100644 --- a/packages/cli/test/cli.test.js +++ b/packages/cli/test/cli.test.js @@ -410,6 +410,165 @@ test('android config normalization rejects missing widget dimensions', () => { ) }) +function normalizeWidgetConfig(config) { + const { normalizeVoltraConfig } = loadCliModule() + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'voltra-cli-test-')) + + return normalizeVoltraConfig({ + configDir: tempDir, + configPath: path.join(tempDir, 'voltra.config.json'), + config, + }) +} + +function androidWidgetConfig(widget) { + return { + android: { + widgets: [ + { + id: 'portfolio', + displayName: 'Portfolio', + description: 'Track holdings', + targetCellWidth: 2, + targetCellHeight: 2, + ...widget, + }, + ], + }, + } +} + +test('the CLI copy of the serverUpdate rules matches the one shared by the Expo plugins', () => { + const cliCopy = fs.readFileSync(path.join(packageRoot, 'src/config/serverUpdate.ts'), 'utf8') + const pluginCopy = fs.readFileSync(path.join(packageRoot, '..', 'expo-plugin/src/serverUpdate.ts'), 'utf8') + + // Only the pointer at the other copy differs; everything below the header must be identical. + const body = (source) => source.slice(source.indexOf('*/')) + + assert.equal(body(cliCopy), body(pluginCopy)) +}) + +test('serverUpdate without a url marks the widget server-driven and leaves the url unset', () => { + const normalized = normalizeWidgetConfig(androidWidgetConfig({ serverUpdate: {} })) + + assert.deepEqual(normalized.android.widgets[0].serverUpdate, { + url: undefined, + intervalMinutes: 60, + refresh: false, + }) +}) + +test('serverUpdate on a widget with an entry defaults to a 15 minute interval', () => { + const normalized = normalizeWidgetConfig( + androidWidgetConfig({ + entry: './widgets/portfolio.tsx', + serverUpdate: { url: 'https://api.example.com/portfolio' }, + }) + ) + + assert.equal(normalized.android.widgets[0].serverUpdate.intervalMinutes, 15) +}) + +test('serverUpdate on a widget with an entry clamps a short interval and warns', () => { + const normalized = normalizeWidgetConfig( + androidWidgetConfig({ + entry: './widgets/portfolio.tsx', + serverUpdate: { url: 'https://api.example.com/portfolio', intervalMinutes: 5 }, + }) + ) + + assert.equal(normalized.android.widgets[0].serverUpdate.intervalMinutes, 15) + assert.equal(normalized.warnings.length, 1) + assert.match(normalized.warnings[0], /below the 15 minute floor/) +}) + +test('serverUpdate on an iOS widget with an entry keeps the 15 minute floor, not the payload floor of 1', () => { + const normalized = normalizeWidgetConfig({ + ios: { + groupIdentifier: 'group.com.example.app', + widgets: [ + { + id: 'portfolio', + displayName: 'Portfolio', + description: 'Track holdings', + entry: './widgets/portfolio.tsx', + serverUpdate: { url: 'https://api.example.com/portfolio', intervalMinutes: 2 }, + }, + ], + }, + }) + + assert.equal(normalized.ios.widgets[0].serverUpdate.intervalMinutes, 15) +}) + +test('serverUpdate on a payload widget keeps the platform floor and still rejects short intervals', () => { + const { VoltraConfigNormalizationError } = loadCliModule() + + assert.throws( + () => + normalizeWidgetConfig( + androidWidgetConfig({ serverUpdate: { url: 'https://a.example.com', intervalMinutes: 5 } }) + ), + (error) => { + assert.ok(error instanceof VoltraConfigNormalizationError) + assert.match(error.message, /must be at least 15/) + return true + } + ) +}) + +test('serverUpdate rejects a url that no HTTP stack can reach', () => { + const { VoltraConfigNormalizationError } = loadCliModule() + + assert.throws( + () => normalizeWidgetConfig(androidWidgetConfig({ serverUpdate: { url: 'api.example.com/portfolio' } })), + (error) => { + assert.ok(error instanceof VoltraConfigNormalizationError) + assert.match(error.message, /absolute http\(s\) URL/) + return true + } + ) +}) + +test('serverUpdate warns rather than failing on plain http to a non-local host', () => { + const normalized = normalizeWidgetConfig(androidWidgetConfig({ serverUpdate: { url: 'http://192.168.1.5:3333' } })) + + assert.equal(normalized.android.widgets[0].serverUpdate.url, 'http://192.168.1.5:3333') + assert.match(normalized.warnings[0], /cleartext/) +}) + +test('serverUpdate accepts the emulator dev host over plain http without warning', () => { + const normalized = normalizeWidgetConfig(androidWidgetConfig({ serverUpdate: { url: 'http://10.0.2.2:3333' } })) + + assert.deepEqual(normalized.warnings, []) +}) + +test('an iOS widget with entry and serverUpdate requires a groupIdentifier for the props it commits', () => { + const { VoltraConfigNormalizationError } = loadCliModule() + + assert.throws( + () => + normalizeWidgetConfig({ + ios: { + widgets: [ + { + id: 'portfolio', + displayName: 'Portfolio', + description: 'Track holdings', + entry: './widgets/portfolio.tsx', + serverUpdate: { url: 'https://api.example.com/portfolio' }, + }, + ], + }, + }), + (error) => { + assert.ok(error instanceof VoltraConfigNormalizationError) + assert.match(error.message, /requires ios\.groupIdentifier/) + return true + } + ) +}) + test('config normalization keeps Dynamic Widget entry project-relative', () => { const { normalizeVoltraConfig } = loadCliModule() const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'voltra-cli-test-')) @@ -551,7 +710,7 @@ test('generateIOSFiles writes Dynamic Widget manifest and AppIntent Swift scaffo assert.ok(result.files.includes('.voltra/manifest.ios.json')) }) -test('generateAndroidFiles writes Dynamic Widget manifest, client receiver, and config defaults', async () => { +test('generateAndroidFiles writes Dynamic Widget manifest, receivers, and generated defaults', async () => { const { generateAndroidFiles } = loadCliModule() const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'voltra-cli-test-')) const appModuleRoot = path.join(tempDir, 'android', 'app') @@ -627,6 +786,19 @@ test('generateAndroidFiles writes Dynamic Widget manifest, client receiver, and parameters: [{ name: 'label', title: 'Label', default: 'Hello' }], }, }, + { + id: 'portfolio', + displayName: 'Portfolio', + description: 'Client rendered from fetched props', + targetCellWidth: 2, + targetCellHeight: 2, + entry: 'widgets/dynamic.js', + serverUpdate: { + url: 'https://example.com/portfolio', + intervalMinutes: 15, + refresh: false, + }, + }, ], }, discovery: { @@ -643,7 +815,10 @@ test('generateAndroidFiles writes Dynamic Widget manifest, client receiver, and assert.deepEqual(manifest, { version: 1, platform: 'android', - widgets: [{ id: 'dynamic', entry: 'widgets/dynamic.js' }], + widgets: [ + { id: 'dynamic', entry: 'widgets/dynamic.js' }, + { id: 'portfolio', entry: 'widgets/dynamic.js' }, + ], }) const dynamicReceiver = fs.readFileSync( @@ -667,6 +842,30 @@ test('generateAndroidFiles writes Dynamic Widget manifest, client receiver, and assert.match(serverReceiver, /^import voltra\.widget\.payload\.VoltraPayloadWidgetReceiver$/m) assert.match(serverReceiver, /^import voltra\.widget\.payload\.VoltraWidgetUpdateScheduler$/m) assert.match(serverReceiver, /^class VoltraWidget_serverReceiver : VoltraPayloadWidgetReceiver\(\) \{$/m) + // The url and the interval are resolved at runtime now, so they must not be baked into the class. + assert.doesNotMatch(serverReceiver, /https:\/\/example\.com\/widget/) + assert.doesNotMatch(serverReceiver, /intervalMinutes/) + + const serverDrivenDynamicReceiver = fs.readFileSync( + path.join(resourceRoot, 'java', 'com', 'example', 'app', 'widget', 'VoltraWidget_portfolioReceiver.kt'), + 'utf8' + ) + assert.match( + serverDrivenDynamicReceiver, + /^import voltra\.dynamicwidget\.serverupdate\.VoltraServerDrivenClientWidgetReceiver$/m + ) + assert.match( + serverDrivenDynamicReceiver, + /^class VoltraWidget_portfolioReceiver : VoltraServerDrivenClientWidgetReceiver\(\) \{$/m + ) + + const serverDefaults = JSON.parse( + fs.readFileSync(path.join(resourceRoot, 'assets', 'voltra', 'widget_server_defaults.json'), 'utf8') + ) + assert.deepEqual(serverDefaults, { + server: { url: 'https://example.com/widget', intervalMinutes: 30, refresh: true }, + portfolio: { url: 'https://example.com/portfolio', intervalMinutes: 15, refresh: false }, + }) const defaults = JSON.parse( fs.readFileSync(path.join(resourceRoot, 'assets', 'voltra', 'widget_config_defaults.json'), 'utf8') diff --git a/packages/core/src/widget-environment.ts b/packages/core/src/widget-environment.ts index 65197cc7..d48dedde 100644 --- a/packages/core/src/widget-environment.ts +++ b/packages/core/src/widget-environment.ts @@ -65,6 +65,53 @@ export type WidgetEnvironment | undefine /** Build / process-level metadata, populated by the runtime once per process. Static for * the JS runtime's lifetime; does not change between renders. */ build: WidgetBuildEnvironment + + // --------------------------------------------------------------------------- + // Server-driven updates + // --------------------------------------------------------------------------- + + /** Outcome of the last server fetch, on widgets configured with `serverUpdate`. `undefined` + * on every other widget. See {@link WidgetServerUpdateEnvironment}. */ + serverUpdate?: WidgetServerUpdateEnvironment +} + +/** + * What the device knows about the last attempt to fetch this widget's props from the server. + * + * A server-driven Dynamic Widget renders whatever props were last committed, so a fetch that + * fails leaves the previous props on screen rather than blanking it. This is how the widget tells + * the difference: show an "updated 3 min ago" line from `fetchedAt`, dim the UI when `status` is + * `stale`, or hide the freshness line entirely while the app has taken the widget over + * (`disabled`). + */ +export type WidgetServerUpdateEnvironment = { + /** + * - `fresh` — the last fetch succeeded (`200` or `304`). + * - `stale` — a fetch has succeeded before, but the most recent one failed. `error` says how. + * - `never` — no fetch has succeeded yet, so props are `{}` or whatever the app last wrote. + * - `disabled` — fetching is off for this widget, because the app called + * `setWidgetServerUpdate({ enabled: false })` or no URL has been configured. + */ + status: 'fresh' | 'stale' | 'never' | 'disabled' + + /** Epoch ms of the last `200` or `304`. Absent until a fetch has succeeded. */ + fetchedAt?: number + + /** + * How the most recent fetch failed. Absent when `status` is `fresh`. + * + * - `network` — the request never completed (no connectivity, DNS, TLS, timeout). + * - `http` — the server answered with a status the device cannot use. + * - `unauthorized` — `401` or `403`; the app most likely needs to set a fresh token. + * - `parse` — the body was not a JSON object, was too large, or looked like a Voltra payload + * rather than props. + * - `render` — the props arrived but the widget threw while rendering them, so they were + * discarded rather than committed. + */ + error?: 'network' | 'http' | 'unauthorized' | 'parse' | 'render' + + /** HTTP status of the last response, when there was one. */ + httpStatus?: number } /** diff --git a/packages/expo-plugin/src/index.ts b/packages/expo-plugin/src/index.ts index ca8ed8ce..b76b1ebf 100644 --- a/packages/expo-plugin/src/index.ts +++ b/packages/expo-plugin/src/index.ts @@ -12,6 +12,20 @@ export type { WidgetLabel, WidgetLocalizedCopy, } from './types' +export type { ServerUpdateIntervalResolution, ServerUpdateUrlResolution } from './serverUpdate' +export { + DYNAMIC_WIDGET_SERVER_UPDATE_INTERVAL_MINUTES, + isLocalHttpHost, + resolveServerUpdateInterval, + resolveServerUpdateUrl, + validateServerUpdateRefresh, +} from './serverUpdate' +export type { + ResolvedWidgetServerUpdateConfig, + WidgetServerUpdateConfig, + WidgetServerUpdateRules, +} from './widgetServerUpdate' +export { resolveWidgetServerUpdate, validateWidgetServerUpdate } from './widgetServerUpdate' export { assertValidLocaleKey, normalizeWidgetEntryPath, diff --git a/packages/expo-plugin/src/serverUpdate.node.test.ts b/packages/expo-plugin/src/serverUpdate.node.test.ts new file mode 100644 index 00000000..faffd7d1 --- /dev/null +++ b/packages/expo-plugin/src/serverUpdate.node.test.ts @@ -0,0 +1,126 @@ +import { + DYNAMIC_WIDGET_SERVER_UPDATE_INTERVAL_MINUTES, + resolveServerUpdateInterval, + resolveServerUpdateUrl, + validateServerUpdateRefresh, +} from './serverUpdate' + +const PAYLOAD_WIDGET = { + context: 'android.widgets[portfolio].serverUpdate', + hasEntry: false, + defaultIntervalMinutes: 60, + minimumIntervalMinutes: 15, +} + +const DYNAMIC_WIDGET = { ...PAYLOAD_WIDGET, hasEntry: true } + +describe('resolveServerUpdateInterval', () => { + it('falls back to the platform default for a payload widget that sets no interval', () => { + expect(resolveServerUpdateInterval({ ...PAYLOAD_WIDGET, intervalMinutes: undefined })).toEqual({ + kind: 'ok', + intervalMinutes: 60, + }) + }) + + it('falls back to 15 for a widget with an entry, ignoring the platform default', () => { + expect(resolveServerUpdateInterval({ ...DYNAMIC_WIDGET, intervalMinutes: undefined })).toEqual({ + kind: 'ok', + intervalMinutes: DYNAMIC_WIDGET_SERVER_UPDATE_INTERVAL_MINUTES, + }) + }) + + it('keeps the payload widget rule and rejects an interval below the platform floor', () => { + const resolution = resolveServerUpdateInterval({ ...PAYLOAD_WIDGET, intervalMinutes: 5 }) + + expect(resolution.kind).toBe('invalid') + expect(resolution).toHaveProperty('error', expect.stringContaining('at least 15')) + }) + + it('accepts an interval below 15 on iOS payload widgets, where the floor is 1', () => { + expect( + resolveServerUpdateInterval({ + ...PAYLOAD_WIDGET, + context: 'ios.widgets[portfolio].serverUpdate', + defaultIntervalMinutes: 15, + minimumIntervalMinutes: 1, + intervalMinutes: 5, + }) + ).toEqual({ kind: 'ok', intervalMinutes: 5 }) + }) + + it('clamps a widget with an entry up to 15 and warns instead of failing the build', () => { + const resolution = resolveServerUpdateInterval({ + ...DYNAMIC_WIDGET, + minimumIntervalMinutes: 1, + intervalMinutes: 5, + }) + + expect(resolution).toEqual({ + kind: 'clamped', + intervalMinutes: 15, + warning: expect.stringContaining('below the 15 minute floor'), + }) + }) + + it('accepts an interval at or above 15 for a widget with an entry', () => { + expect(resolveServerUpdateInterval({ ...DYNAMIC_WIDGET, intervalMinutes: 30 })).toEqual({ + kind: 'ok', + intervalMinutes: 30, + }) + }) + + it('rejects a non-integer or non-finite interval on either engine', () => { + expect(resolveServerUpdateInterval({ ...DYNAMIC_WIDGET, intervalMinutes: 15.5 }).kind).toBe('invalid') + expect(resolveServerUpdateInterval({ ...PAYLOAD_WIDGET, intervalMinutes: Number.NaN }).kind).toBe('invalid') + expect(resolveServerUpdateInterval({ ...PAYLOAD_WIDGET, intervalMinutes: '30' }).kind).toBe('invalid') + }) +}) + +describe('resolveServerUpdateUrl', () => { + const context = 'ios.widgets[portfolio].serverUpdate' + + it('accepts an absent url, so the app can supply one at runtime', () => { + expect(resolveServerUpdateUrl(undefined, context)).toEqual({ kind: 'ok' }) + }) + + it('accepts https', () => { + expect(resolveServerUpdateUrl('https://api.example.com/widgets/portfolio', context)).toEqual({ kind: 'ok' }) + }) + + it('accepts plain http for the dev hosts a simulator and an emulator reach', () => { + expect(resolveServerUpdateUrl('http://localhost:3333', context)).toEqual({ kind: 'ok' }) + expect(resolveServerUpdateUrl('http://10.0.2.2:3333/widgets', context)).toEqual({ kind: 'ok' }) + expect(resolveServerUpdateUrl('http://127.0.0.1:3333', context)).toEqual({ kind: 'ok' }) + }) + + it('warns rather than failing on plain http to another host, which release builds block', () => { + const resolution = resolveServerUpdateUrl('http://192.168.1.5:3333', context) + + expect(resolution.kind).toBe('insecure') + expect(resolution).toHaveProperty('warning', expect.stringContaining('cleartext')) + }) + + it('rejects a url with no scheme, which no platform HTTP stack accepts', () => { + expect(resolveServerUpdateUrl('api.example.com/widgets', context).kind).toBe('invalid') + }) + + it('rejects a non-http scheme', () => { + expect(resolveServerUpdateUrl('ftp://api.example.com', context).kind).toBe('invalid') + }) + + it('rejects an empty url', () => { + expect(resolveServerUpdateUrl(' ', context).kind).toBe('invalid') + expect(resolveServerUpdateUrl(42, context).kind).toBe('invalid') + }) +}) + +describe('validateServerUpdateRefresh', () => { + it('accepts a boolean or nothing', () => { + expect(validateServerUpdateRefresh(undefined, 'ctx')).toBeUndefined() + expect(validateServerUpdateRefresh(true, 'ctx')).toBeUndefined() + }) + + it('rejects a non-boolean', () => { + expect(validateServerUpdateRefresh('yes', 'ctx')).toContain('must be a boolean') + }) +}) diff --git a/packages/expo-plugin/src/serverUpdate.ts b/packages/expo-plugin/src/serverUpdate.ts new file mode 100644 index 00000000..b14f4898 --- /dev/null +++ b/packages/expo-plugin/src/serverUpdate.ts @@ -0,0 +1,160 @@ +/** + * Config rules for `serverUpdate`, shared by the Expo plugins and the `voltra` CLI. + * + * `serverUpdate` marks a widget as server-driven for both render engines. On a widget with + * `entry` the device fetches a JSON object and hands it to the bundled JS as props; without + * `entry` the server returns a full Voltra payload. Widgets without `entry` keep exactly the + * rules they had before ADR 0002, so no existing config breaks. + * + * `url` is optional. `serverUpdate: {}` means "server-driven, URL supplied at runtime" through + * `setWidgetServerUpdate`, which covers per-tenant backends whose URL is only known after login. + * + * These helpers report problems instead of throwing so each caller can raise its own error type. + * + * The `voltra` CLI validates `serverUpdate` from its own copy of this module; keep the two in + * sync. + */ + +/** + * Interval floor and default for a widget with `entry`. WorkManager cannot run periodic work + * more often than every 15 minutes, and WidgetKit stretches timelines requested closer together + * than five minutes, so a smaller number would only mislead. + */ +export const DYNAMIC_WIDGET_SERVER_UPDATE_INTERVAL_MINUTES = 15 + +/** Hosts allowed over plain `http`, for talking to a dev server from a simulator or emulator. */ +const LOCAL_HTTP_HOSTS = new Set(['localhost', '127.0.0.1', '::1', '10.0.2.2', '10.0.3.2']) + +/** Outcome of resolving `serverUpdate.intervalMinutes` against a platform's floor. */ +export type ServerUpdateIntervalResolution = + | { kind: 'ok'; intervalMinutes: number } + | { kind: 'clamped'; intervalMinutes: number; warning: string } + | { kind: 'invalid'; error: string } + +export interface ResolveServerUpdateIntervalOptions { + /** Raw `intervalMinutes` from app.json, if the widget set one. */ + intervalMinutes: unknown + /** Config path used in messages, e.g. `android.widgets[portfolio].serverUpdate`. */ + context: string + /** True when the widget has an `entry` and therefore renders on device. */ + hasEntry: boolean + /** Interval used when the widget does not set one. Ignored for widgets with `entry`. */ + defaultIntervalMinutes: number + /** Platform floor for payload widgets. Ignored for widgets with `entry`. */ + minimumIntervalMinutes: number +} + +/** + * Resolves the interval a widget should be scheduled on. + * + * A widget with `entry` is clamped up to {@link DYNAMIC_WIDGET_SERVER_UPDATE_INTERVAL_MINUTES} + * with a warning, because a shorter interval is not something either platform can honour. A + * payload widget keeps the platform's existing rule and is rejected below the floor. + */ +export function resolveServerUpdateInterval( + options: ResolveServerUpdateIntervalOptions +): ServerUpdateIntervalResolution { + const { intervalMinutes, context, hasEntry, defaultIntervalMinutes, minimumIntervalMinutes } = options + const fallback = hasEntry ? DYNAMIC_WIDGET_SERVER_UPDATE_INTERVAL_MINUTES : defaultIntervalMinutes + + if (intervalMinutes === undefined) { + return { kind: 'ok', intervalMinutes: fallback } + } + + if (typeof intervalMinutes !== 'number' || !Number.isFinite(intervalMinutes)) { + return { kind: 'invalid', error: `${context}.intervalMinutes must be a number` } + } + + if (!Number.isInteger(intervalMinutes)) { + return { kind: 'invalid', error: `${context}.intervalMinutes must be an integer` } + } + + if (hasEntry) { + if (intervalMinutes < DYNAMIC_WIDGET_SERVER_UPDATE_INTERVAL_MINUTES) { + return { + kind: 'clamped', + intervalMinutes: DYNAMIC_WIDGET_SERVER_UPDATE_INTERVAL_MINUTES, + warning: + `${context}.intervalMinutes is ${intervalMinutes}, below the ` + + `${DYNAMIC_WIDGET_SERVER_UPDATE_INTERVAL_MINUTES} minute floor for widgets with an entry. ` + + `Using ${DYNAMIC_WIDGET_SERVER_UPDATE_INTERVAL_MINUTES}.`, + } + } + + return { kind: 'ok', intervalMinutes } + } + + if (intervalMinutes < minimumIntervalMinutes) { + return { kind: 'invalid', error: `${context}.intervalMinutes must be at least ${minimumIntervalMinutes}` } + } + + return { kind: 'ok', intervalMinutes } +} + +/** Outcome of checking `serverUpdate.url`. */ +export type ServerUpdateUrlResolution = + | { kind: 'ok' } + | { kind: 'insecure'; warning: string } + | { kind: 'invalid'; error: string } + +/** + * Checks `serverUpdate.url`. An absent URL is fine — the app supplies it at runtime through + * `setWidgetServerUpdate`. + * + * A URL that is not an absolute `http`/`https` URL is rejected: no platform HTTP stack here + * accepts one, so such a config could only ever have failed to fetch. Plain `http` to a + * non-local host is reported as insecure rather than rejected — App Transport Security and + * Android's cleartext policy already block it in a release build, and rejecting it outright + * would break configs that point at a LAN dev server. + */ +export function resolveServerUpdateUrl(url: unknown, context: string): ServerUpdateUrlResolution { + if (url === undefined) { + return { kind: 'ok' } + } + + if (typeof url !== 'string' || !url.trim()) { + return { kind: 'invalid', error: `${context}.url must be a non-empty string` } + } + + let parsed: URL + + try { + parsed = new URL(url) + } catch { + return { kind: 'invalid', error: `${context}.url must be an absolute http(s) URL, received '${url}'` } + } + + if (parsed.protocol === 'https:') { + return { kind: 'ok' } + } + + if (parsed.protocol !== 'http:') { + return { kind: 'invalid', error: `${context}.url must be an absolute http(s) URL, received '${url}'` } + } + + if (isLocalHttpHost(parsed.hostname)) { + return { kind: 'ok' } + } + + return { + kind: 'insecure', + warning: + `${context}.url uses plain http ('${url}'). Release builds block cleartext traffic, so the ` + + 'widget will not fetch outside a development build. Use https, or a local dev host ' + + `(${[...LOCAL_HTTP_HOSTS].join(', ')}).`, + } +} + +/** True for the hosts Voltra allows over plain `http` — dev servers reachable from a simulator. */ +export function isLocalHttpHost(hostname: string): boolean { + return LOCAL_HTTP_HOSTS.has(hostname.replace(/^\[|\]$/g, '')) +} + +/** Validates `serverUpdate.refresh`. Returns an error message, or `undefined` when it is fine. */ +export function validateServerUpdateRefresh(refresh: unknown, context: string): string | undefined { + if (refresh !== undefined && typeof refresh !== 'boolean') { + return `${context}.refresh must be a boolean` + } + + return undefined +} diff --git a/packages/expo-plugin/src/widgetServerUpdate.ts b/packages/expo-plugin/src/widgetServerUpdate.ts new file mode 100644 index 00000000..765d16f3 --- /dev/null +++ b/packages/expo-plugin/src/widgetServerUpdate.ts @@ -0,0 +1,109 @@ +import { resolveServerUpdateInterval, resolveServerUpdateUrl, validateServerUpdateRefresh } from './serverUpdate' +import { logger } from './utils/logger' + +/** + * Expo-plugin side of the `serverUpdate` config rules. The rules themselves live in + * `./serverUpdate`, which the `voltra` CLI mirrors; this module only turns them into the plugin's + * thrown errors and console warnings. + */ + +/** `serverUpdate` as it appears in app.json, before defaults are applied. */ +export interface WidgetServerUpdateConfig { + url?: string + intervalMinutes?: number + refresh?: boolean +} + +/** `serverUpdate` after defaults, as the generators consume it. */ +export interface ResolvedWidgetServerUpdateConfig { + /** Absent when app.json set no URL — the app supplies one at runtime. */ + url?: string + intervalMinutes: number + refresh: boolean +} + +export interface WidgetServerUpdateRules { + /** True when the widget has an `entry` and so renders bundled JS from fetched props. */ + hasEntry: boolean + /** Interval used when the widget sets none. Ignored for widgets with `entry`. */ + defaultIntervalMinutes: number + /** Platform floor for payload widgets. Ignored for widgets with `entry`. */ + minimumIntervalMinutes: number +} + +export function validateWidgetServerUpdate( + serverUpdate: unknown, + widgetId: string, + rules: WidgetServerUpdateRules +): void { + if (serverUpdate === undefined) { + return + } + + const context = `Widget '${widgetId}': serverUpdate` + + if (typeof serverUpdate !== 'object' || serverUpdate === null || Array.isArray(serverUpdate)) { + throw new Error(`${context} must be an object`) + } + + const { url, intervalMinutes, refresh } = serverUpdate as WidgetServerUpdateConfig + + const resolvedUrl = resolveServerUpdateUrl(url, context) + + if (resolvedUrl.kind === 'invalid') { + throw new Error(resolvedUrl.error) + } + + if (resolvedUrl.kind === 'insecure') { + logger.warn(resolvedUrl.warning) + } + + const interval = resolveServerUpdateInterval({ + intervalMinutes, + context, + hasEntry: rules.hasEntry, + defaultIntervalMinutes: rules.defaultIntervalMinutes, + minimumIntervalMinutes: rules.minimumIntervalMinutes, + }) + + if (interval.kind === 'invalid') { + throw new Error(interval.error) + } + + if (interval.kind === 'clamped') { + logger.warn(interval.warning) + } + + const refreshError = validateServerUpdateRefresh(refresh, context) + + if (refreshError) { + throw new Error(refreshError) + } +} + +/** + * Applies defaults to a validated `serverUpdate`. Generators call this instead of reading + * `intervalMinutes` directly, so the value written into a plist or a generated asset is the one + * the widget will actually be scheduled on. + */ +export function resolveWidgetServerUpdate( + serverUpdate: WidgetServerUpdateConfig, + rules: WidgetServerUpdateRules +): ResolvedWidgetServerUpdateConfig { + const interval = resolveServerUpdateInterval({ + intervalMinutes: serverUpdate.intervalMinutes, + context: 'serverUpdate', + hasEntry: rules.hasEntry, + defaultIntervalMinutes: rules.defaultIntervalMinutes, + minimumIntervalMinutes: rules.minimumIntervalMinutes, + }) + + return { + url: serverUpdate.url, + // An invalid interval cannot reach here — validateWidgetServerUpdate throws on it first — but + // if the two are ever called out of order, falling back to the platform's own default is less + // surprising than silently switching a payload widget to the Dynamic one. + intervalMinutes: interval.kind === 'invalid' ? rules.defaultIntervalMinutes : interval.intervalMinutes, + refresh: serverUpdate.refresh === true, + } +} diff --git a/packages/ios-client/expo-plugin/src/ios-widget/files/swift.node.test.ts b/packages/ios-client/expo-plugin/src/ios-widget/files/swift.node.test.ts index 63439d50..33b2d0f9 100644 --- a/packages/ios-client/expo-plugin/src/ios-widget/files/swift.node.test.ts +++ b/packages/ios-client/expo-plugin/src/ios-widget/files/swift.node.test.ts @@ -158,6 +158,40 @@ describe('generateWidgetBundleSwift — AppIntent configuration', () => { expect(swift).toContain('["label": configuration.label]') }) + it('emits VoltraDynamicWidgetServerUpdateProvider for a Dynamic Widget with a serverUpdate', () => { + const swift = __test__.generateWidgetBundleSwift([ + { + ...plainClientWidget, + serverUpdate: { url: 'https://example.com/weather', intervalMinutes: 15, refresh: false }, + }, + ]) + + expect(swift).toContain('VoltraDynamicWidgetServerUpdateProvider(') + expect(swift).toContain('VoltraClientWidgetContentView(') + // The provider wraps the plain one; the widget must not also be given it directly. + expect(swift).not.toContain('provider: VoltraClientWidgetProvider(') + }) + + it('fetches on every timeline request for a configurable server-driven Dynamic Widget', () => { + const swift = __test__.generateWidgetBundleSwift([ + { + ...configurableWidget, + serverUpdate: { url: 'https://example.com/weather', intervalMinutes: 15, refresh: false }, + }, + ]) + + expect(swift).toContain('VoltraDynamicWidgetServerUpdateProvider.timeline(') + expect(swift).toContain('family: context.family') + expect(swift).not.toContain('policy: .never') + }) + + it('keeps a Dynamic Widget without a serverUpdate on the never policy', () => { + const swift = __test__.generateWidgetBundleSwift([configurableWidget]) + + expect(swift).toContain('policy: .never') + expect(swift).not.toContain('VoltraDynamicWidgetServerUpdateProvider') + }) + it('does NOT emit AppIntent code for a client widget without appIntent', () => { const swift = __test__.generateWidgetBundleSwift([plainClientWidget]) expect(swift).toContain('VoltraClientWidgetProvider(') diff --git a/packages/ios-client/expo-plugin/src/ios-widget/files/swift.ts b/packages/ios-client/expo-plugin/src/ios-widget/files/swift.ts index 3f1f57f0..ce9e47f2 100644 --- a/packages/ios-client/expo-plugin/src/ios-widget/files/swift.ts +++ b/packages/ios-client/expo-plugin/src/ios-widget/files/swift.ts @@ -301,6 +301,12 @@ function iosWidgetGalleryLabelSwiftExpr( * - Dynamic Widget → `VoltraClientWidgetProvider` + `VoltraClientWidgetContentView` * (the content view internally renders via VoltraHomeWidgetView so the UI layer is * identical to the fallback state path — see VoltraClientWidgetRuntime.swift) + * - Dynamic Widget with a `serverUpdate` → `VoltraDynamicWidgetServerUpdateProvider`, which wraps + * the one above and adds the fetch, the commit and the schedule + * + * `entry` picks the render engine and `serverUpdate` picks where the data comes from, so the two + * keys together select one of four providers. Runtime code never asks "is this server-driven?" — + * the answer is baked in here, once, at generate time. */ function widgetUsesAppIntent(widget: DetectedIOSWidget): boolean { return widget.clientRendered && !!widget.appIntent && widget.appIntent.parameters.length > 0 @@ -322,9 +328,13 @@ function generateWidgetStruct(widget: DetectedIOSWidget): string { const displayNameExpr = iosWidgetGalleryLabelSwiftExpr(widget.id, 'displayName', widget.displayName) const descriptionExpr = iosWidgetGalleryLabelSwiftExpr(widget.id, 'description', widget.description) + const clientProviderName = widget.serverUpdate + ? 'VoltraDynamicWidgetServerUpdateProvider' + : 'VoltraClientWidgetProvider' + const providerAndContent = widget.clientRendered ? dedent` - provider: VoltraClientWidgetProvider( + provider: ${clientProviderName}( widgetId: widgetId, initialState: VoltraWidgetInitialStates.getInitialState(for: widgetId) ) @@ -393,6 +403,18 @@ function generateClientAppIntentWidgetCode(widget: DetectedIOSWidget): string { const initBody = params.map((p) => ` self.${p.name} = ${p.name}`).join('\n') const configuredDict = dictLiteral(params.map((p) => `"${p.name}": configuration.${p.name}`)) const defaultDict = dictLiteral(params.map((p) => `"${p.name}": ${swiftDefault(p)}`)) + // A server-driven Dynamic Widget fetches on every timeline request and schedules the next one + // from its resolved interval; a plain one has nothing to ask again for, so its policy is .never. + const appIntentTimelineBody = widget.serverUpdate + ? dedent` + return await VoltraDynamicWidgetServerUpdateProvider.timeline( + widgetId: widgetId, + family: context.family, + configuration: ${configuredDict} + )` + : dedent` + let entry = await VoltraClientWidgetProvider.loadEntry(widgetId: widgetId, configuration: ${configuredDict}) + return Timeline(entries: [entry], policy: .never)` return dedent` // MARK: - Client-rendered AppIntent widget: ${widget.id} @@ -424,9 +446,8 @@ function generateClientAppIntentWidgetCode(widget: DetectedIOSWidget): string { await VoltraClientWidgetProvider.loadEntry(widgetId: widgetId, configuration: ${configuredDict}) } - func timeline(for configuration: ${intentName}, in _: Context) async -> Timeline { - let entry = await VoltraClientWidgetProvider.loadEntry(widgetId: widgetId, configuration: ${configuredDict}) - return Timeline(entries: [entry], policy: .never) + func timeline(for configuration: ${intentName}, in context: Context) async -> Timeline { + ${appIntentTimelineBody} } } diff --git a/packages/ios-client/expo-plugin/src/ios-widget/widgetPlist.ts b/packages/ios-client/expo-plugin/src/ios-widget/widgetPlist.ts index 46611875..70fe14e6 100644 --- a/packages/ios-client/expo-plugin/src/ios-widget/widgetPlist.ts +++ b/packages/ios-client/expo-plugin/src/ios-widget/widgetPlist.ts @@ -6,6 +6,7 @@ import { join as joinPath } from 'path' import type { IOSWidgetConfig } from '../types' import { detectClientRenderedWidgets } from './clientRendered' import { logger } from '@use-voltra/expo-plugin' +import { resolveIOSWidgetServerUpdate } from '../ios/serverUpdate' export interface ConfigureMainAppPlistProps { targetName: string @@ -105,18 +106,31 @@ export const configureWidgetExtensionPlist: ConfigPlugin = {} for (const widget of widgets) { - if (widget.serverUpdate) { - serverUrls[widget.id] = widget.serverUpdate.url - serverIntervals[widget.id] = widget.serverUpdate.intervalMinutes ?? 15 - if (widget.serverUpdate.refresh) { - serverRefresh[widget.id] = true - } + const serverUpdate = resolveIOSWidgetServerUpdate(widget) + + if (!serverUpdate) { + continue + } + + // Every server-driven widget gets an interval, so this dictionary's keys are the set + // of server-driven widget ids. A URL is written only when app.json set one. + serverIntervals[widget.id] = serverUpdate.intervalMinutes + + if (serverUpdate.url !== undefined) { + serverUrls[widget.id] = serverUpdate.url + } + + if (serverUpdate.refresh) { + serverRefresh[widget.id] = true } } + if (Object.keys(serverIntervals).length > 0) { + ;(content as any)['Voltra_WidgetServerIntervals'] = serverIntervals + } + if (Object.keys(serverUrls).length > 0) { ;(content as any)['Voltra_WidgetServerUrls'] = serverUrls - ;(content as any)['Voltra_WidgetServerIntervals'] = serverIntervals } if (Object.keys(serverRefresh).length > 0) { diff --git a/packages/ios-client/expo-plugin/src/ios/infoPlist.ts b/packages/ios-client/expo-plugin/src/ios/infoPlist.ts index 8559cda7..d6d39a9c 100644 --- a/packages/ios-client/expo-plugin/src/ios/infoPlist.ts +++ b/packages/ios-client/expo-plugin/src/ios/infoPlist.ts @@ -1,6 +1,7 @@ import { ConfigPlugin, withInfoPlist } from '@expo/config-plugins' import type { IOSWidgetConfig } from '../types' +import { resolveIOSWidgetServerUpdate } from './serverUpdate' export interface ConfigureInfoPlistProps { groupIdentifier?: string @@ -43,15 +44,28 @@ export const configureInfoPlist: ConfigPlugin = (config const serverIntervals: Record = {} for (const widget of props.widgets) { - if (widget.serverUpdate) { - serverUrls[widget.id] = widget.serverUpdate.url - serverIntervals[widget.id] = widget.serverUpdate.intervalMinutes ?? 15 + const serverUpdate = resolveIOSWidgetServerUpdate(widget) + + if (!serverUpdate) { + continue + } + + // Every server-driven widget gets an interval, so this dictionary's keys are the set of + // server-driven widget ids the runtime settings store validates against. A URL is written + // only when app.json set one; otherwise the app supplies it with setWidgetServerUpdate. + serverIntervals[widget.id] = serverUpdate.intervalMinutes + + if (serverUpdate.url !== undefined) { + serverUrls[widget.id] = serverUpdate.url } } + if (Object.keys(serverIntervals).length > 0) { + mod.modResults.Voltra_WidgetServerIntervals = serverIntervals + } + if (Object.keys(serverUrls).length > 0) { mod.modResults.Voltra_WidgetServerUrls = serverUrls - mod.modResults.Voltra_WidgetServerIntervals = serverIntervals } } diff --git a/packages/ios-client/expo-plugin/src/ios/serverUpdate.ts b/packages/ios-client/expo-plugin/src/ios/serverUpdate.ts new file mode 100644 index 00000000..4177f330 --- /dev/null +++ b/packages/ios-client/expo-plugin/src/ios/serverUpdate.ts @@ -0,0 +1,29 @@ +import { resolveWidgetServerUpdate } from '@use-voltra/expo-plugin' + +import type { ResolvedWidgetServerUpdateConfig, WidgetServerUpdateRules } from '@use-voltra/expo-plugin' + +import type { IOSWidgetConfig } from '../types' + +/** + * iOS payload widgets have always defaulted to 15 minutes and accepted anything down to 1, since + * WidgetKit stretches a timeline it cannot honour rather than refusing it. A widget with an + * `entry` follows ADR 0002 instead: default 15, floor 15 on both platforms. + */ +export function iosServerUpdateRules(widget: Pick): WidgetServerUpdateRules { + return { + hasEntry: widget.entry !== undefined, + defaultIntervalMinutes: 15, + minimumIntervalMinutes: 1, + } +} + +/** `serverUpdate` with defaults applied, as the plist and Swift generators consume it. */ +export function resolveIOSWidgetServerUpdate( + widget: Pick +): ResolvedWidgetServerUpdateConfig | undefined { + if (widget.serverUpdate === undefined) { + return undefined + } + + return resolveWidgetServerUpdate(widget.serverUpdate, iosServerUpdateRules(widget)) +} diff --git a/packages/ios-client/expo-plugin/src/types.ts b/packages/ios-client/expo-plugin/src/types.ts index e72a70c1..a4e5d068 100644 --- a/packages/ios-client/expo-plugin/src/types.ts +++ b/packages/ios-client/expo-plugin/src/types.ts @@ -75,8 +75,12 @@ export interface IOSDynamicLiveActivityConfig extends DynamicLiveActivityEntryCo * Server-driven iOS widget updates (WidgetKit background refresh). */ export interface IOSWidgetServerUpdateConfig { - url: string - /** @default 15 */ + /** + * Server endpoint that returns widget state updates. Omit it to mark the widget + * server-driven and supply the URL at runtime with `setWidgetServerUpdate`. + */ + url?: string + /** @default 15, or 15 when the widget has an `entry` */ intervalMinutes?: number /** @default false */ refresh?: boolean diff --git a/packages/ios-client/expo-plugin/src/validation.ts b/packages/ios-client/expo-plugin/src/validation.ts index fbd56d75..7e346d7e 100644 --- a/packages/ios-client/expo-plugin/src/validation.ts +++ b/packages/ios-client/expo-plugin/src/validation.ts @@ -3,7 +3,10 @@ import { validateInitialStatePath, validateWidgetEntry, validateWidgetLabel, + validateWidgetServerUpdate, } from '@use-voltra/expo-plugin' + +import { iosServerUpdateRules } from './ios/serverUpdate' import { getDynamicLiveActivityAttributesType } from '@use-voltra/expo-plugin' import type { IOSConfigPluginProps, IOSDynamicLiveActivityConfig, IOSWidgetConfig, IOSWidgetFamily } from './types' @@ -47,6 +50,8 @@ export function validateIOSWidgetConfig(widget: IOSWidgetConfig, projectRoot?: s validateWidgetEntry(widget.entry, widget.id, projectRoot) } + validateWidgetServerUpdate(widget.serverUpdate, widget.id, iosServerUpdateRules(widget)) + if (widget.supportedFamilies) { if (!Array.isArray(widget.supportedFamilies)) { throw new Error(`Widget '${widget.id}': supportedFamilies must be an array`) @@ -83,6 +88,15 @@ export function validateIOSConfigPluginProps(props: IOSConfigPluginProps, projec for (const widget of props.widgets) { validateIOSWidgetConfig(widget, projectRoot) + // A server-driven Dynamic Widget commits fetched props to the App Group so the widget + // extension can read them. Without one it would fetch and have nowhere to put the result. + if (widget.entry !== undefined && widget.serverUpdate !== undefined && !props.groupIdentifier) { + throw new Error( + `Widget '${widget.id}' has both entry and serverUpdate, which requires groupIdentifier ` + + 'so fetched props can be shared with the widget extension.' + ) + } + if (seenIds.has(widget.id)) { throw new Error(`Duplicate widget ID: '${widget.id}'`) } diff --git a/packages/ios-client/ios/Package.swift b/packages/ios-client/ios/Package.swift index 07c42fba..28cd55d9 100644 --- a/packages/ios-client/ios/Package.swift +++ b/packages/ios-client/ios/Package.swift @@ -44,6 +44,25 @@ let package = Package( "DynamicWidgetUpdater.swift", "ServerWidgetContentResolver.swift", "ServerWidgetResponseStore.swift", + // The settings stack's pure half. The Keychain-, Bundle- and URLSession-backed files in + // this folder are compiled only by the podspec, which ships the whole tree. + "WidgetServer/WidgetScope.swift", + "WidgetServer/WidgetServerUpdateSettings.swift", + "WidgetServer/WidgetServerSettingsResolver.swift", + "WidgetServer/WidgetServerSettingsCodec.swift", + "WidgetServer/WidgetServerSettingsValidator.swift", + "WidgetServer/WidgetServerRequestBuilder.swift", + "WidgetServer/WidgetServerFetcher.swift", + "WidgetServer/WidgetServerSettingsStore.swift", + "WidgetServer/WidgetServerEtagStore.swift", + "WidgetServer/VoltraKeychainHelper.swift", + "WidgetServer/VoltraWidgetServer.swift", + "WidgetServer/WidgetServerUpdateSettingsJson.swift", + "DynamicWidgetServerUpdate/DynamicWidgetServerProps.swift", + "DynamicWidgetServerUpdate/DynamicWidgetServerPropsStore.swift", + "DynamicWidgetServerUpdate/DynamicWidgetServerFetchResolver.swift", + "DynamicWidgetServerUpdate/DynamicWidgetServerUpdateRunner.swift", + "VoltraLogger.swift", "dynamic-live-activity/VoltraDynamicLiveActivityTypes.swift", "dynamic-live-activity/VoltraDynamicLiveActivityPayloadValidator.swift", "dynamic-live-activity/VoltraDynamicLiveActivityRenderFailureQueue.swift", diff --git a/packages/ios-client/ios/Tests/VoltraSharedTests/DynamicWidgetServerPropsTests.swift b/packages/ios-client/ios/Tests/VoltraSharedTests/DynamicWidgetServerPropsTests.swift new file mode 100644 index 00000000..66d42407 --- /dev/null +++ b/packages/ios-client/ios/Tests/VoltraSharedTests/DynamicWidgetServerPropsTests.swift @@ -0,0 +1,64 @@ +@testable import VoltraSharedCore +import XCTest + +final class DynamicWidgetServerPropsTests: XCTestCase { + private func parse(_ body: String) -> DynamicWidgetPropsParseResult { + DynamicWidgetServerProps.parse(Data(body.utf8)) + } + + private func invalidReason(_ body: String) -> String { + guard case let .invalid(reason) = parse(body) else { + XCTFail("expected \(body) to be rejected") + return "" + } + + return reason + } + + func testAcceptsAJsonObjectAndHandsItThrough() { + guard case let .props(json) = parse(#"{"total":42}"#) else { + return XCTFail("expected props") + } + + XCTAssertEqual(json, #"{"total":42}"#) + } + + func testAcceptsAnEmptyObjectWhichIsWhatAWidgetAlreadyGetsBeforeItsFirstProps() { + guard case .props = parse("{}") else { + return XCTFail("expected props") + } + } + + func testRejectsATopLevelArrayPrimitiveOrNull() { + XCTAssertTrue(invalidReason("[1,2,3]").contains("JSON object")) + XCTAssertTrue(invalidReason("42").contains("JSON object")) + XCTAssertTrue(invalidReason("\"hello\"").contains("JSON object")) + XCTAssertTrue(invalidReason("null").contains("JSON object")) + } + + func testRejectsABodyThatIsNotJsonAtAll() { + XCTAssertTrue(invalidReason("nope").contains("not JSON")) + XCTAssertTrue(invalidReason("").contains("empty")) + } + + func testRejectsAVoltraPayloadByNameBecauseThatIsTheMistakeSharingTheConfigKeyInvites() { + let reason = invalidReason(#"{"v":1,"variants":{"systemSmall":{"t":1}}}"#) + + XCTAssertTrue(reason.contains("Voltra payload")) + XCTAssertTrue(reason.contains("entry")) + } + + func testRejectsAPayloadThatCarriesSharedElementsInsteadOfVariants() { + XCTAssertTrue(invalidReason(#"{"v":1,"e":[{"t":1}]}"#).contains("Voltra payload")) + } + + func testDoesNotMistakePropsThatHappenToHaveAVKeyForAPayload() { + guard case .props = parse(#"{"v":1,"label":"hi"}"#) else { + return XCTFail("expected props") + } + + guard case .props = parse(#"{"v":"1.2.3","variants":{"a":1}}"#) else { + return XCTFail("expected props") + } + } +} diff --git a/packages/ios-client/ios/Tests/VoltraSharedTests/DynamicWidgetServerUpdateRunnerTests.swift b/packages/ios-client/ios/Tests/VoltraSharedTests/DynamicWidgetServerUpdateRunnerTests.swift new file mode 100644 index 00000000..8211c6ac --- /dev/null +++ b/packages/ios-client/ios/Tests/VoltraSharedTests/DynamicWidgetServerUpdateRunnerTests.swift @@ -0,0 +1,248 @@ +@testable import VoltraSharedCore +import XCTest + +/// The ADR 0002 failure table, one row at a time. Every collaborator is a fake, so what is under +/// test is the decision — commit, keep, retry, give up — and nothing else. +final class DynamicWidgetServerUpdateRunnerTests: XCTestCase { + private let scope = WidgetScope.of("portfolio") + + private final class Recorder { + var committed: [String] = [] + var successes = 0 + var failures: [(String, Int?)] = [] + var disabledFor: WidgetScope? + var etags: [(String, String, String?)] = [] + var sentEtag: String? + var commitThrows = false + } + + private struct CommitFailure: Error {} + + private func settings(url: String? = "https://api.example.com/portfolio", enabled: Bool = true) + -> ResolvedWidgetServerSettings + { + ResolvedWidgetServerSettings( + url: url, + intervalMinutes: 15, + enabled: enabled, + method: "GET", + query: [:], + headers: [:], + body: nil + ) + } + + private func runner( + _ recorder: Recorder, + settings: ResolvedWidgetServerSettings? = nil, + result: WidgetServerFetchResult = .success(body: Data("{}".utf8), etag: nil, httpStatus: 200, nextIntervalMinutes: nil), + trialRenders: Bool = true, + revisions: [Int] = [1, 1], + storedEtag: String? = nil + ) -> DynamicWidgetServerUpdateRunner { + var remaining = revisions + let resolved = settings ?? self.settings() + + return DynamicWidgetServerUpdateRunner( + resolveSettings: { _ in resolved }, + currentRevision: { _ in remaining.isEmpty ? revisions.last! : remaining.removeFirst() }, + readEtag: { _, _ in storedEtag }, + fetch: { _, _, etag in + recorder.sentEtag = etag + return result + }, + writeEtag: { recorder.etags.append(($0.widgetId, $1, $2)) }, + trialRender: { _, _ in trialRenders }, + commitProps: { _, props in + if recorder.commitThrows { + throw CommitFailure() + } + recorder.committed.append(props) + }, + recordSuccess: { _, _, _ in recorder.successes += 1 }, + recordFailure: { _, error, status in recorder.failures.append((error, status)) }, + markDisabled: { + scope, enabled in if !enabled { + recorder.disabledFor = scope + } + }, + now: { Date(timeIntervalSince1970: 1) } + ) + } + + func testCommitsPropsThatFetchParseAndRender() async { + let recorder = Recorder() + let outcome = await runner( + recorder, + result: .success(body: Data(#"{"total":42}"#.utf8), etag: "\"abc\"", httpStatus: 200, nextIntervalMinutes: nil) + ).run(scope).outcome + + XCTAssertEqual(outcome, .committed) + XCTAssertEqual(recorder.committed, [#"{"total":42}"#]) + XCTAssertEqual(recorder.successes, 1) + } + + func testStoresTheEtagAgainstTheUrlItCameFrom() async { + let recorder = Recorder() + _ = await runner( + recorder, + result: .success(body: Data("{}".utf8), etag: "\"abc\"", httpStatus: 200, nextIntervalMinutes: nil) + ).run(scope) + + XCTAssertEqual(recorder.etags.count, 1) + XCTAssertEqual(recorder.etags[0].1, "https://api.example.com/portfolio") + XCTAssertEqual(recorder.etags[0].2, "\"abc\"") + } + + func testSendsTheStoredEtagSoAnUnchangedResponseCostsNothing() async { + let recorder = Recorder() + _ = await runner(recorder, storedEtag: "\"abc\"").run(scope) + + XCTAssertEqual(recorder.sentEtag, "\"abc\"") + } + + func testTreats304AsFreshWithoutTouchingTheProps() async { + let recorder = Recorder() + let outcome = await runner(recorder, result: .notModified(nextIntervalMinutes: nil)).run(scope).outcome + + XCTAssertEqual(outcome, .committed) + XCTAssertTrue(recorder.committed.isEmpty) + XCTAssertEqual(recorder.successes, 1) + } + + func testDoesNotCommitPropsTheWidgetCannotRender() async { + let recorder = Recorder() + let outcome = await runner( + recorder, + result: .success(body: Data(#"{"total":42}"#.utf8), etag: nil, httpStatus: 200, nextIntervalMinutes: nil), + trialRenders: false + ).run(scope).outcome + + XCTAssertEqual(outcome, .failed) + XCTAssertTrue(recorder.committed.isEmpty) + XCTAssertEqual(recorder.failures.first?.0, DynamicWidgetServerStatus.errorRender) + } + + func testDoesNotCommitABodyThatIsNotProps() async { + let recorder = Recorder() + let outcome = await runner( + recorder, + result: .success(body: Data(#"{"v":1,"variants":{}}"#.utf8), etag: nil, httpStatus: 200, nextIntervalMinutes: nil) + ).run(scope).outcome + + XCTAssertEqual(outcome, .failed) + XCTAssertTrue(recorder.committed.isEmpty) + XCTAssertEqual(recorder.failures.first?.0, DynamicWidgetServerStatus.errorParse) + } + + func testKeepsThePreviousPropsWhenTheCommitItselfFails() async { + let recorder = Recorder() + recorder.commitThrows = true + + let outcome = await runner(recorder).run(scope).outcome + + XCTAssertEqual(outcome, .failed) + XCTAssertEqual(recorder.successes, 0) + XCTAssertTrue(recorder.etags.isEmpty) + } + + func testRetriesANetworkFailureAndKeepsThePreviousProps() async { + let recorder = Recorder() + let outcome = await runner(recorder, result: .networkFailure(message: "timeout")).run(scope).outcome + + XCTAssertEqual(outcome, .retry) + XCTAssertTrue(recorder.committed.isEmpty) + XCTAssertEqual(recorder.failures.first?.0, DynamicWidgetServerStatus.errorNetwork) + } + + func testRetriesA5xxAndA429() async { + let outcome503 = await runner(Recorder(), result: .httpFailure(httpStatus: 503, retryAfterMinutes: 2)).run(scope).outcome + let outcome429 = await runner(Recorder(), result: .httpFailure(httpStatus: 429, retryAfterMinutes: nil)).run(scope).outcome + + XCTAssertEqual(outcome503, .retry) + XCTAssertEqual(outcome429, .retry) + } + + func testPassesRetryAfterOnClampedToWhatWidgetKitCanHonour() async { + let soon = await runner(Recorder(), result: .httpFailure(httpStatus: 503, retryAfterMinutes: 2)).run(scope) + let far = await runner(Recorder(), result: .httpFailure(httpStatus: 503, retryAfterMinutes: 60 * 24 * 30)).run(scope) + let none = await runner(Recorder(), result: .httpFailure(httpStatus: 503, retryAfterMinutes: nil)).run(scope) + + XCTAssertEqual(soon.nextIntervalMinutes, WidgetServerUpdateDefaults.minIntervalMinutes) + XCTAssertEqual(far.nextIntervalMinutes, WidgetServerUpdateDefaults.maxIntervalMinutes) + XCTAssertNil(none.nextIntervalMinutes) + } + + func testPassesCacheControlMaxAgeOnSoTheServerCanMoveItsOwnNextFetch() async { + let committed = await runner( + Recorder(), + result: .success(body: Data(#"{"total":42}"#.utf8), etag: nil, httpStatus: 200, nextIntervalMinutes: 360) + ).run(scope) + + XCTAssertEqual(committed.nextIntervalMinutes, 360) + } + + func testDoesNotAskAgainForABodyThatIsTooLargeToHold() async { + let recorder = Recorder() + let outcome = await runner(recorder, result: .tooLarge(httpStatus: 200)).run(scope).outcome + + XCTAssertEqual(outcome, .failed) + XCTAssertTrue(recorder.committed.isEmpty) + XCTAssertEqual(recorder.failures.first?.0, DynamicWidgetServerStatus.errorParse) + } + + func testKeepsThePreviousPropsAndReportsRenderWhenTheCommitFails() async { + let recorder = Recorder() + recorder.commitThrows = true + + _ = await runner(recorder).run(scope) + + XCTAssertEqual(recorder.failures.first?.0, DynamicWidgetServerStatus.errorRender) + } + + func testDoesNotRetryA401WhichStaysA401UntilTheAppSetsANewToken() async { + let recorder = Recorder() + let outcome = await runner(recorder, result: .httpFailure(httpStatus: 401, retryAfterMinutes: nil)).run(scope).outcome + + XCTAssertEqual(outcome, .failed) + XCTAssertEqual(recorder.failures.first?.0, DynamicWidgetServerStatus.errorUnauthorized) + XCTAssertEqual(recorder.failures.first?.1, 401) + } + + func testDoesNotRetryAnother4xxWhichIsAMisconfiguration() async { + let recorder = Recorder() + let outcome = await runner(recorder, result: .httpFailure(httpStatus: 404, retryAfterMinutes: nil)).run(scope).outcome + + XCTAssertEqual(outcome, .failed) + XCTAssertEqual(recorder.failures.first?.0, DynamicWidgetServerStatus.errorHttp) + } + + func testDropsAResultBuiltFromSettingsThatHaveSinceChanged() async { + let recorder = Recorder() + let outcome = await runner( + recorder, + result: .success(body: Data(#"{"total":42}"#.utf8), etag: nil, httpStatus: 200, nextIntervalMinutes: nil), + revisions: [1, 2] + ).run(scope).outcome + + XCTAssertEqual(outcome, .dropped) + XCTAssertTrue(recorder.committed.isEmpty) + XCTAssertEqual(recorder.successes, 0) + } + + func testDoesNotFetchForAWidgetWithNoUrlYet() async { + let recorder = Recorder() + let outcome = await runner(recorder, settings: settings(url: nil)).run(scope).outcome + + XCTAssertEqual(outcome, .skipped) + XCTAssertNil(recorder.disabledFor) + } + + func testReportsDisabledWhenTheAppHasTakenTheWidgetOver() async { + let recorder = Recorder() + let outcome = await runner(recorder, settings: settings(enabled: false)).run(scope).outcome + + XCTAssertEqual(outcome, .skipped) + XCTAssertEqual(recorder.disabledFor, scope) + } +} diff --git a/packages/ios-client/ios/Tests/VoltraSharedTests/WidgetServerFetcherHeaderTests.swift b/packages/ios-client/ios/Tests/VoltraSharedTests/WidgetServerFetcherHeaderTests.swift new file mode 100644 index 00000000..b61fbe2b --- /dev/null +++ b/packages/ios-client/ios/Tests/VoltraSharedTests/WidgetServerFetcherHeaderTests.swift @@ -0,0 +1,53 @@ +@testable import VoltraSharedCore +import XCTest + +/// The two response headers that move the next fetch. Parsing only: the clamp and the rescheduling +/// they feed are pinned down by `DynamicWidgetServerUpdateRunnerTests`. +final class WidgetServerFetcherHeaderTests: XCTestCase { + func testReadsMaxAgeOutOfACacheControlHeaderWhateverElseItCarries() { + XCTAssertEqual(WidgetServerFetcher.maxAgeMinutes("max-age=1800"), 30) + XCTAssertEqual(WidgetServerFetcher.maxAgeMinutes("public, max-age=1800, must-revalidate"), 30) + XCTAssertEqual(WidgetServerFetcher.maxAgeMinutes("Max-Age = 1800"), 30) + } + + func testRoundsMaxAgeDownSoWeNeverClaimDataIsFresherThanTheServerSaid() { + XCTAssertEqual(WidgetServerFetcher.maxAgeMinutes("max-age=119"), 1) + XCTAssertEqual(WidgetServerFetcher.maxAgeMinutes("max-age=30"), 0) + } + + func testIgnoresACacheControlHeaderWithNoMaxAge() { + XCTAssertNil(WidgetServerFetcher.maxAgeMinutes(nil)) + XCTAssertNil(WidgetServerFetcher.maxAgeMinutes("no-store")) + XCTAssertNil(WidgetServerFetcher.maxAgeMinutes("max-age=soon")) + } + + func testRoundsRetryAfterUpSoWeNeverRetryBeforeTheServerAskedUsTo() { + XCTAssertEqual(WidgetServerFetcher.retryAfterMinutes("1"), 1) + XCTAssertEqual(WidgetServerFetcher.retryAfterMinutes("60"), 1) + XCTAssertEqual(WidgetServerFetcher.retryAfterMinutes("61"), 2) + } + + func testReadsRetryAfterAsAnHttpDateWhichServersSendAsOftenAsSeconds() { + let asked = Date(timeIntervalSince1970: 1_445_412_480) // 2015-10-21T07:28:00Z + let now = asked.addingTimeInterval(-90) + + XCTAssertEqual(WidgetServerFetcher.retryAfterMinutes("Wed, 21 Oct 2015 07:28:00 GMT", now: now), 2) + } + + func testIgnoresARetryAfterAlreadyInThePastOrOneWeCannotReadAtAll() { + XCTAssertNil(WidgetServerFetcher.retryAfterMinutes(nil)) + XCTAssertNil(WidgetServerFetcher.retryAfterMinutes("soon")) + XCTAssertNil(WidgetServerFetcher.retryAfterMinutes("0")) + XCTAssertNil( + WidgetServerFetcher.retryAfterMinutes( + "Wed, 21 Oct 2015 07:28:00 GMT", + now: Date(timeIntervalSince1970: 1_445_412_540) + ) + ) + } + + func testReportsTheDeviceLocaleAsABcp47TagSoBothPlatformsAgree() { + XCTAssertEqual(VoltraWidgetServer.bcp47Locale(Locale(identifier: "en_US")), "en-US") + XCTAssertEqual(VoltraWidgetServer.bcp47Locale(Locale(identifier: "pt_BR")), "pt-BR") + } +} diff --git a/packages/ios-client/ios/Tests/VoltraSharedTests/WidgetServerRequestBuilderTests.swift b/packages/ios-client/ios/Tests/VoltraSharedTests/WidgetServerRequestBuilderTests.swift new file mode 100644 index 00000000..2a55127e --- /dev/null +++ b/packages/ios-client/ios/Tests/VoltraSharedTests/WidgetServerRequestBuilderTests.swift @@ -0,0 +1,138 @@ +@testable import VoltraSharedCore +import XCTest + +/// The request contract every backend sees, pinned down without a network. +final class WidgetServerRequestBuilderTests: XCTestCase { + private let scope = WidgetScope.of("portfolio") + + private func context(family: String? = nil) -> WidgetServerRequestContext { + WidgetServerRequestContext(theme: "dark", locale: "en-US", userAgent: "VoltraWidget/2.2.0 (iOS/18.0)", family: family) + } + + private func settings( + url: String? = "https://api.example.com/widgets/portfolio", + enabled: Bool = true, + method: String = "GET", + query: [String: String] = [:], + headers: [String: String] = [:], + body: String? = nil + ) -> ResolvedWidgetServerSettings { + ResolvedWidgetServerSettings( + url: url, + intervalMinutes: 15, + enabled: enabled, + method: method, + query: query, + headers: headers, + body: body + ) + } + + private func queryItems(_ request: URLRequest) -> [String: String] { + let components = URLComponents(url: request.url!, resolvingAgainstBaseURL: false)! + + return Dictionary(uniqueKeysWithValues: (components.queryItems ?? []).map { ($0.name, $0.value ?? "") }) + } + + func testSendsTheVoltraQueryParametersEveryBackendCanRelyOn() throws { + let request = try XCTUnwrap(WidgetServerRequestBuilder.build(scope: scope, settings: settings(), context: context())) + let items = queryItems(request) + + XCTAssertEqual(items["widgetId"], "portfolio") + XCTAssertEqual(items["platform"], "ios") + XCTAssertEqual(items["theme"], "dark") + XCTAssertEqual(items["locale"], "en-US") + } + + func testDoesNotSendFamilyForADynamicWidgetWhoseOneFetchServesEverySize() throws { + let request = try XCTUnwrap(WidgetServerRequestBuilder.build(scope: scope, settings: settings(), context: context())) + + XCTAssertNil(queryItems(request)["family"]) + } + + func testStillSendsFamilyForAPayloadWidget() throws { + let request = try XCTUnwrap(WidgetServerRequestBuilder.build( + scope: scope, + settings: settings(), + context: context(family: "systemSmall") + )) + + XCTAssertEqual(queryItems(request)["family"], "systemSmall") + } + + func testKeepsThePathAndAnyQueryTheConfiguredUrlAlreadyHad() throws { + let request = try XCTUnwrap(WidgetServerRequestBuilder.build( + scope: scope, + settings: settings(url: "https://api.example.com/widgets?tenant=acme"), + context: context() + )) + + XCTAssertEqual(request.url?.path, "/widgets") + XCTAssertEqual(queryItems(request)["tenant"], "acme") + } + + func testAppendsTheAppsOwnQueryParameters() throws { + let request = try XCTUnwrap(WidgetServerRequestBuilder.build( + scope: scope, + settings: settings(query: ["account": "42"]), + context: context() + )) + + XCTAssertEqual(queryItems(request)["account"], "42") + } + + func testSendsAcceptAndAVoltraUserAgentAndLetsTheAppAddHeaders() throws { + let request = try XCTUnwrap(WidgetServerRequestBuilder.build( + scope: scope, + settings: settings(headers: ["Authorization": "Bearer t"]), + context: context() + )) + + XCTAssertEqual(request.value(forHTTPHeaderField: "Accept"), "application/json") + XCTAssertEqual(request.value(forHTTPHeaderField: "User-Agent"), "VoltraWidget/2.2.0 (iOS/18.0)") + XCTAssertEqual(request.value(forHTTPHeaderField: "Authorization"), "Bearer t") + } + + func testSendsIfNoneMatchOnlyWhenAnEtagWasCarriedOver() throws { + let withEtag = try XCTUnwrap(WidgetServerRequestBuilder.build(scope: scope, settings: settings(), context: context(), etag: "\"abc\"")) + let without = try XCTUnwrap(WidgetServerRequestBuilder.build(scope: scope, settings: settings(), context: context())) + + XCTAssertEqual(withEtag.value(forHTTPHeaderField: "If-None-Match"), "\"abc\"") + XCTAssertNil(without.value(forHTTPHeaderField: "If-None-Match")) + } + + func testSendsABodyWithPostAndDeclaresItsContentType() throws { + let request = try XCTUnwrap(WidgetServerRequestBuilder.build( + scope: scope, + settings: settings(method: "POST", body: #"{"a":1}"#), + context: context() + )) + + XCTAssertEqual(request.httpMethod, "POST") + XCTAssertEqual(request.value(forHTTPHeaderField: "Content-Type"), "application/json") + XCTAssertEqual(request.httpBody, Data(#"{"a":1}"#.utf8)) + } + + func testDropsABodyOnGetWhichURLSessionWouldNotSendAnyway() throws { + let request = try XCTUnwrap(WidgetServerRequestBuilder.build( + scope: scope, + settings: settings(method: "GET", body: #"{"a":1}"#), + context: context() + )) + + XCTAssertEqual(request.httpMethod, "GET") + XCTAssertNil(request.httpBody) + XCTAssertNil(request.value(forHTTPHeaderField: "Content-Type")) + } + + func testUppercasesTheMethodSoALowercaseSettingStillReachesTheRightVerb() throws { + let request = try XCTUnwrap(WidgetServerRequestBuilder.build(scope: scope, settings: settings(method: "patch"), context: context())) + + XCTAssertEqual(request.httpMethod, "PATCH") + } + + func testBuildsNothingWhenThereIsNoUrlOrFetchingIsOff() { + XCTAssertNil(WidgetServerRequestBuilder.build(scope: scope, settings: settings(url: nil), context: context())) + XCTAssertNil(WidgetServerRequestBuilder.build(scope: scope, settings: settings(enabled: false), context: context())) + } +} diff --git a/packages/ios-client/ios/Tests/VoltraSharedTests/WidgetServerSettingsCodecTests.swift b/packages/ios-client/ios/Tests/VoltraSharedTests/WidgetServerSettingsCodecTests.swift new file mode 100644 index 00000000..b7cced97 --- /dev/null +++ b/packages/ios-client/ios/Tests/VoltraSharedTests/WidgetServerSettingsCodecTests.swift @@ -0,0 +1,50 @@ +@testable import VoltraSharedCore +import XCTest + +final class WidgetServerSettingsCodecTests: XCTestCase { + func testRoundTripsEveryField() { + let settings = WidgetServerUpdateSettings( + url: "https://api.example.com/portfolio", + intervalMinutes: 30, + enabled: false, + method: "POST", + query: ["account": "1", "range": "1d"], + headers: ["Authorization": "Bearer token"], + body: #"{"ids":[1,2]}"# + ) + + XCTAssertEqual(WidgetServerSettingsCodec.decode(WidgetServerSettingsCodec.encode(settings)), settings) + } + + func testKeepsUnsetFieldsUnsetSoALayerThatSaysNothingStaysSilent() { + let decoded = WidgetServerSettingsCodec.decode( + WidgetServerSettingsCodec.encode(WidgetServerUpdateSettings(url: "https://a")) + ) + + XCTAssertEqual(decoded?.url, "https://a") + XCTAssertNil(decoded?.intervalMinutes) + XCTAssertNil(decoded?.enabled) + XCTAssertNil(decoded?.method) + XCTAssertNil(decoded?.headers) + XCTAssertNil(decoded?.query) + XCTAssertNil(decoded?.body) + } + + func testDistinguishesEnabledFalseFromUnset() { + let decoded = WidgetServerSettingsCodec.decode( + WidgetServerSettingsCodec.encode(WidgetServerUpdateSettings(enabled: false)) + ) + + XCTAssertEqual(decoded?.enabled, false) + } + + func testReadsAnUnknownVersionOrABrokenRecordAsNoOpinion() { + XCTAssertNil(WidgetServerSettingsCodec.decode(nil)) + XCTAssertNil(WidgetServerSettingsCodec.decode(Data("not json".utf8))) + XCTAssertNil( + WidgetServerSettingsCodec.decode( + Data(#"{"widgetServerSettingsVersion":99,"widgetServerSettings":{}}"#.utf8) + ) + ) + } +} diff --git a/packages/ios-client/ios/Tests/VoltraSharedTests/WidgetServerSettingsResolverTests.swift b/packages/ios-client/ios/Tests/VoltraSharedTests/WidgetServerSettingsResolverTests.swift new file mode 100644 index 00000000..ba36b381 --- /dev/null +++ b/packages/ios-client/ios/Tests/VoltraSharedTests/WidgetServerSettingsResolverTests.swift @@ -0,0 +1,139 @@ +@testable import VoltraSharedCore +import XCTest + +private struct StubLayer: WidgetServerSettingsLayer { + let name: String + let stubbed: WidgetServerUpdateSettings? + let serverDriven: Bool + + init(_ name: String, _ stubbed: WidgetServerUpdateSettings?, serverDriven: Bool = false) { + self.name = name + self.stubbed = stubbed + self.serverDriven = serverDriven + } + + func settings(for _: WidgetScope) -> WidgetServerUpdateSettings? { + stubbed + } + + func isServerDriven(_: WidgetScope) -> Bool { + serverDriven + } +} + +/// The merge rule from ADR 0002 lives in the resolver and nowhere else, so this is where it is +/// pinned down. +final class WidgetServerSettingsResolverTests: XCTestCase { + private let scope = WidgetScope.of("portfolio") + + private func resolver(_ layers: [any WidgetServerSettingsLayer], revision: Int = 0) -> WidgetServerSettingsResolver { + WidgetServerSettingsResolver(layers: layers, revisionSource: { revision }) + } + + func testTakesScalarsFromTheHighestLayerThatSetsThem() { + let resolved = resolver([ + StubLayer("config", .init(url: "https://config", intervalMinutes: 60), serverDriven: true), + StubLayer("global", .init(url: "https://global", method: "POST")), + StubLayer("widget", .init(url: "https://widget", body: #"{"a":1}"#)), + ]).resolve(scope) + + XCTAssertEqual(resolved.url, "https://widget") + XCTAssertEqual(resolved.method, "POST") + XCTAssertEqual(resolved.body, #"{"a":1}"#) + XCTAssertEqual(resolved.intervalMinutes, 60) + } + + func testMergesHeadersAndQueryPerKeyRatherThanReplacingTheWholeMap() { + let resolved = resolver([ + StubLayer("config", .init(), serverDriven: true), + StubLayer("credentials", .init(headers: ["Authorization": "Bearer legacy", "X-Env": "prod"])), + StubLayer("global", .init(query: ["account": "1"], headers: ["Authorization": "Bearer new"])), + StubLayer("widget", .init(query: ["range": "1d"])), + ]).resolve(scope) + + XCTAssertEqual(resolved.headers, ["Authorization": "Bearer new", "X-Env": "prod"]) + XCTAssertEqual(resolved.query, ["account": "1", "range": "1d"]) + } + + func testFillsInTheDefaultsAFetchNeeds() { + let resolved = resolver([StubLayer("config", .init(url: "https://a"), serverDriven: true)]).resolve(scope) + + XCTAssertEqual(resolved.method, WidgetServerUpdateDefaults.defaultMethod) + XCTAssertEqual(resolved.intervalMinutes, WidgetServerUpdateDefaults.defaultIntervalMinutes) + XCTAssertTrue(resolved.enabled) + XCTAssertTrue(resolved.query.isEmpty) + } + + func testClampsARuntimeIntervalOverrideToWhatWidgetKitCanHonour() { + let tooShort = resolver([ + StubLayer("config", .init(intervalMinutes: 60), serverDriven: true), + StubLayer("widget", .init(intervalMinutes: 1)), + ]).resolve(scope) + let tooLong = resolver([ + StubLayer("config", .init(intervalMinutes: 60), serverDriven: true), + StubLayer("widget", .init(intervalMinutes: 60 * 24 * 30)), + ]).resolve(scope) + + XCTAssertEqual(tooShort.intervalMinutes, WidgetServerUpdateDefaults.minIntervalMinutes) + XCTAssertEqual(tooLong.intervalMinutes, WidgetServerUpdateDefaults.maxIntervalMinutes) + } + + func testLeavesAnIntervalFromAppJsonAloneSoAnExistingWidgetKeepsItsSchedule() { + // The generators already validated this against the platform's own rules — iOS allows a + // payload widget down to 1 minute — so clamping it again here would silently change the + // schedule of a widget that has been shipping for months. + let resolved = resolver([StubLayer("config", .init(intervalMinutes: 5), serverDriven: true)]).resolve(scope) + + XCTAssertEqual(resolved.intervalMinutes, 5) + } + + func testAWidgetTheConfigLayerDoesNotKnowIsNeverFetched() { + let resolved = resolver([ + StubLayer("config", nil), + StubLayer("widget", .init(url: "https://sneaky", enabled: true)), + ]).resolve(scope) + + XCTAssertNil(resolved.url) + XCTAssertFalse(resolved.enabled) + XCTAssertFalse(resolved.shouldFetch) + } + + func testEnabledFalseStopsFetchingWithoutDroppingTheUrl() { + let resolved = resolver([ + StubLayer("config", .init(url: "https://a"), serverDriven: true), + StubLayer("widget", .init(enabled: false)), + ]).resolve(scope) + + XCTAssertEqual(resolved.url, "https://a") + XCTAssertFalse(resolved.shouldFetch) + } + + func testAServerDrivenWidgetWithNoUrlYetDoesNotFetch() { + let resolved = resolver([StubLayer("config", .init(), serverDriven: true)]).resolve(scope) + + XCTAssertNil(resolved.url) + XCTAssertTrue(resolved.enabled) + XCTAssertFalse(resolved.shouldFetch) + } + + func testRevisionComesFromTheStoreSoAFetcherCanTellWhetherSettingsMoved() { + XCTAssertEqual(resolver([StubLayer("config", nil)], revision: 7).revision(scope), 7) + } + + func testGlobalSettingsReturnsTheGlobalLayersRawContentsWithNoDefaulting() { + WidgetServerSettingsStore.set(WidgetServerUpdateSettings(url: "https://global"), scope: nil) + defer { WidgetServerSettingsStore.clear(scope: nil) } + + let resolved = resolver([GlobalWidgetServerSettingsLayer()]).globalSettings() + + XCTAssertEqual(resolved, WidgetServerUpdateSettings(url: "https://global")) + } + + func testGlobalSettingsIsNilWhenNothingHasBeenSetGlobally() { + WidgetServerSettingsStore.clear(scope: nil) + + let resolved = resolver([GlobalWidgetServerSettingsLayer()]).globalSettings() + + XCTAssertNil(resolved) + } +} diff --git a/packages/ios-client/ios/Tests/VoltraSharedTests/WidgetServerSettingsValidatorTests.swift b/packages/ios-client/ios/Tests/VoltraSharedTests/WidgetServerSettingsValidatorTests.swift new file mode 100644 index 00000000..8c6228c5 --- /dev/null +++ b/packages/ios-client/ios/Tests/VoltraSharedTests/WidgetServerSettingsValidatorTests.swift @@ -0,0 +1,58 @@ +@testable import VoltraSharedCore +import XCTest + +final class WidgetServerSettingsValidatorTests: XCTestCase { + private func validate(_ settings: WidgetServerUpdateSettings, isDebugBuild: Bool = false) -> String? { + WidgetServerSettingsValidator.validate(settings, isDebugBuild: isDebugBuild) + } + + func testAcceptsHttpsAnywhere() { + XCTAssertNil(validate(.init(url: "https://api.example.com/portfolio"))) + } + + func testRejectsPlainHttpInAReleaseBuildEvenForALocalHost() { + XCTAssertNotNil(validate(.init(url: "http://localhost:3333"))) + } + + func testAcceptsPlainHttpToADevHostInADebugBuild() { + XCTAssertNil(validate(.init(url: "http://localhost:3333"), isDebugBuild: true)) + XCTAssertNil(validate(.init(url: "http://127.0.0.1:3333/widgets"), isDebugBuild: true)) + } + + func testRejectsPlainHttpToAnotherHostEvenInADebugBuild() { + XCTAssertNotNil(validate(.init(url: "http://api.example.com"), isDebugBuild: true)) + } + + func testRejectsAUrlWithNoSchemeOrNoHost() { + XCTAssertNotNil(validate(.init(url: "api.example.com/portfolio"))) + XCTAssertNotNil(validate(.init(url: " "))) + } + + func testRejectsAQueryKeyVoltraAlreadySends() { + XCTAssertTrue(validate(.init(query: ["theme": "dark"]))?.contains("reserved") == true) + } + + func testRejectsAnInstanceKeyReservedForPerPlacementFetches() { + XCTAssertNotNil(validate(.init(query: ["instance": "1"]))) + } + + func testRejectsAMethodNeitherPlatformCanSend() { + XCTAssertNotNil(validate(.init(method: "TRACE"))) + XCTAssertNil(validate(.init(method: "patch"))) + } + + func testRejectsANonPositiveInterval() { + XCTAssertNotNil(validate(.init(intervalMinutes: 0))) + XCTAssertNotNil(validate(.init(intervalMinutes: -5))) + } + + func testAcceptsABodyWithGetWhichTheRequestBuilderDropsWithAWarning() { + XCTAssertNil(validate(.init(method: "GET", body: #"{"a":1}"#))) + } + + func testRejectsALayerLargerThanTheStorageCap() { + let huge = String(repeating: "x", count: WidgetServerUpdateDefaults.maxLayerBytes + 1) + + XCTAssertNotNil(validate(.init(body: huge))) + } +} diff --git a/packages/ios-client/ios/Tests/VoltraSharedTests/WidgetServerUpdateSettingsJsonTests.swift b/packages/ios-client/ios/Tests/VoltraSharedTests/WidgetServerUpdateSettingsJsonTests.swift new file mode 100644 index 00000000..59675327 --- /dev/null +++ b/packages/ios-client/ios/Tests/VoltraSharedTests/WidgetServerUpdateSettingsJsonTests.swift @@ -0,0 +1,81 @@ +@testable import VoltraSharedCore +import XCTest + +/// What `setWidgetServerUpdate` sends over the bridge, and what it becomes on this side. +final class WidgetServerUpdateSettingsJsonTests: XCTestCase { + private func parsed(_ json: String) -> WidgetServerUpdateSettings { + guard case let .parsed(settings) = WidgetServerUpdateSettingsJson.parse(json) else { + XCTFail("expected \(json) to parse") + return .empty + } + + return settings + } + + func testReadsEveryFieldAnAppCanSet() { + let settings = parsed(""" + { + "url": "https://api.example.com/p", + "intervalMinutes": 30, + "enabled": false, + "method": "POST", + "query": {"account": "1"}, + "headers": {"Authorization": "Bearer t"}, + "body": {"ids": [1, 2]} + } + """) + + XCTAssertEqual(settings.url, "https://api.example.com/p") + XCTAssertEqual(settings.intervalMinutes, 30) + XCTAssertEqual(settings.enabled, false) + XCTAssertEqual(settings.method, "POST") + XCTAssertEqual(settings.query, ["account": "1"]) + XCTAssertEqual(settings.headers, ["Authorization": "Bearer t"]) + XCTAssertEqual(settings.body, #"{"ids":[1,2]}"#) + } + + func testLeavesOutWhatTheAppDidNotSetSoThoseLayersStaySilent() { + let settings = parsed(#"{"url":"https://a"}"#) + + XCTAssertNil(settings.intervalMinutes) + XCTAssertNil(settings.enabled) + XCTAssertNil(settings.method) + XCTAssertNil(settings.query) + XCTAssertNil(settings.headers) + XCTAssertNil(settings.body) + } + + func testAnEmptyObjectClearsNothingAndSetsNothing() { + XCTAssertTrue(parsed("{}").isEmpty) + } + + func testDistinguishesAnExplicitlyEmptyHeaderMapFromAnAbsentOne() { + XCTAssertEqual(parsed(#"{"headers":{}}"#).headers, [:]) + XCTAssertNil(parsed("{}").headers) + } + + func testUppercasesTheMethodSoALowercaseOneStillValidates() { + XCTAssertEqual(parsed(#"{"method":"patch"}"#).method, "PATCH") + } + + func testKeepsANonObjectBodyWhichIsLegalJsonForARequest() { + XCTAssertEqual(parsed(#"{"body":[1,2]}"#).body, "[1,2]") + XCTAssertEqual(parsed(#"{"body":"hello"}"#).body, "\"hello\"") + XCTAssertEqual(parsed(#"{"body":42}"#).body, "42") + } + + func testRejectsASettingsValueThatIsNotAJsonObject() { + XCTAssertEqual(WidgetServerUpdateSettingsJson.parse("[]"), .invalid(reason: "settings must be a JSON object")) + XCTAssertEqual(WidgetServerUpdateSettingsJson.parse("nope"), .invalid(reason: "settings must be a JSON object")) + } + + func testRejectsHeadersOrQueryWhoseValuesAreNotStrings() { + guard case .invalid = WidgetServerUpdateSettingsJson.parse(#"{"headers":{"X":1}}"#) else { + return XCTFail("expected headers with a non-string value to be rejected") + } + + guard case .invalid = WidgetServerUpdateSettingsJson.parse(#"{"query":[]}"#) else { + return XCTFail("expected a non-object query to be rejected") + } + } +} diff --git a/packages/ios-client/ios/app/NativeVoltra.mm b/packages/ios-client/ios/app/NativeVoltra.mm index 42c7864c..8a548d4f 100644 --- a/packages/ios-client/ios/app/NativeVoltra.mm +++ b/packages/ios-client/ios/app/NativeVoltra.mm @@ -407,6 +407,39 @@ - (void)getActiveWidgets:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejec }]; } +- (void)setWidgetServerUpdate:(NSString *)settingsJson + widgetId:(NSString *)widgetId + resolve:(RCTPromiseResolveBlock)resolve + reject:(RCTPromiseRejectBlock)reject +{ + NSString *error = [self.module setWidgetServerUpdate:settingsJson widgetId:widgetId]; + if (error) { + reject(@"VOLTRA_INVALID_SERVER_UPDATE_SETTINGS", error, nil); + } else { + resolve(nil); + } +} + +- (void)clearWidgetServerUpdate:(NSString *)widgetId + resolve:(RCTPromiseResolveBlock)resolve + reject:(RCTPromiseRejectBlock)reject +{ + NSString *error = [self.module clearWidgetServerUpdate:widgetId]; + if (error) { + reject(@"VOLTRA_INVALID_SERVER_UPDATE_SETTINGS", error, nil); + } else { + resolve(nil); + } +} + +- (void)getWidgetServerUpdate:(NSString *)widgetId + resolve:(RCTPromiseResolveBlock)resolve + reject:(RCTPromiseRejectBlock)reject +{ + NSString *json = [self.module getWidgetServerUpdate:widgetId]; + resolve(json); +} + - (void)setWidgetServerCredentials:(JS::NativeVoltra::WidgetServerCredentials &)credentials resolve:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject diff --git a/packages/ios-client/ios/app/VoltraModule.swift b/packages/ios-client/ios/app/VoltraModule.swift index c758f95f..17dd9388 100644 --- a/packages/ios-client/ios/app/VoltraModule.swift +++ b/packages/ios-client/ios/app/VoltraModule.swift @@ -307,6 +307,22 @@ public enum VoltraErrors: Error, CustomNSError { } } + // MARK: - Widget Server Update Settings + + /// - Returns: an error message when the settings were rejected, or nil when they were applied. + @objc public func setWidgetServerUpdate(_ settingsJson: String, widgetId: String?) -> NSString? { + impl.setWidgetServerUpdate(settingsJson: settingsJson, widgetId: widgetId) as NSString? + } + + @objc public func clearWidgetServerUpdate(_ widgetId: String?) -> NSString? { + impl.clearWidgetServerUpdate(widgetId: widgetId) as NSString? + } + + /// - Returns: settings as JSON (resolved for a `widgetId`, raw global otherwise), or nil. + @objc public func getWidgetServerUpdate(_ widgetId: String?) -> NSString? { + impl.getWidgetServerUpdate(widgetId: widgetId) as NSString? + } + // MARK: - Widget Server Credentials @objc public func setWidgetServerCredentials(_ token: String, headers: NSDictionary?) { diff --git a/packages/ios-client/ios/app/VoltraModuleImpl.swift b/packages/ios-client/ios/app/VoltraModuleImpl.swift index eb3b512d..38f95bcb 100644 --- a/packages/ios-client/ios/app/VoltraModuleImpl.swift +++ b/packages/ios-client/ios/app/VoltraModuleImpl.swift @@ -326,6 +326,13 @@ public class VoltraModuleImpl { let dynamicWidgetUpdater = DynamicWidgetUpdater( dynamicWidgetPropsPersistence: DynamicWidgetPropsStore(), dynamicWidgetTimelineReload: { dynamicWidgetId in + // On a server-driven widget the reload below runs getTimeline, which would otherwise + // fetch and overwrite what was just written. ADR 0002 says the *next scheduled* fetch + // overwrites app-written props, not the reload the write itself caused. + if VoltraWidgetServer.isServerDriven(dynamicWidgetId) { + DynamicWidgetServerPropsStore().noteAppWrite(for: .of(dynamicWidgetId)) + } + VoltraWidgetService.reloadTimeline(for: dynamicWidgetId) } ) @@ -369,11 +376,17 @@ public class VoltraModuleImpl { func clearWidget(widgetId: String) async { VoltraWidgetService.removeAllData(for: widgetId) + VoltraWidgetService.clearWidgetServerState(for: widgetId) VoltraWidgetService.reloadTimeline(for: widgetId) } func clearAllWidgets() async { VoltraWidgetService.removeAllWidgets() + + for widgetId in VoltraWidgetServer.serverDrivenWidgetIds { + VoltraWidgetService.clearWidgetServerState(for: widgetId) + } + VoltraWidgetService.reloadAllTimelines() } @@ -381,6 +394,19 @@ public class VoltraModuleImpl { try await VoltraWidgetService.getActiveWidgets() } + /// - Returns: an error message when the settings were rejected, or nil when they were applied. + func setWidgetServerUpdate(settingsJson: String, widgetId: String?) -> String? { + VoltraWidgetService.setWidgetServerUpdate(settingsJson: settingsJson, widgetId: widgetId) + } + + func clearWidgetServerUpdate(widgetId: String?) -> String? { + VoltraWidgetService.clearWidgetServerUpdate(widgetId: widgetId) + } + + func getWidgetServerUpdate(widgetId: String?) -> String? { + VoltraWidgetService.getWidgetServerUpdate(widgetId: widgetId) + } + func setWidgetServerCredentials(token: String, headers: [String: String]?) { VoltraWidgetService.setWidgetServerCredentials(token: token, headers: headers) } diff --git a/packages/ios-client/ios/app/VoltraWidgetService.swift b/packages/ios-client/ios/app/VoltraWidgetService.swift index 41406508..3c44ce53 100644 --- a/packages/ios-client/ios/app/VoltraWidgetService.swift +++ b/packages/ios-client/ios/app/VoltraWidgetService.swift @@ -49,10 +49,145 @@ enum VoltraWidgetService { VoltraLogger.widget.info("Reloaded all timelines") } - // MARK: - Server credentials + // MARK: - Server update settings + + /// Applies runtime server-update settings and reloads the widgets they affect. + /// + /// Storing is only half of what an app expects from `setWidgetServerUpdate`: a new URL should be + /// fetched from now on, and `enabled: false` should take effect without waiting out the current + /// interval. A reload makes the affected timelines re-resolve their settings straight away. + /// + /// - Parameter widgetId: the widget to scope the settings to, or nil for every server-driven one. + /// - Returns: an error message, or nil when the settings were applied. + static func setWidgetServerUpdate(settingsJson: String, widgetId: String?) -> String? { + let settings: WidgetServerUpdateSettings + + switch WidgetServerUpdateSettingsJson.parse(settingsJson) { + case let .invalid(reason): + return reason + case let .parsed(parsed): + settings = parsed + } + + if let widgetId, let error = rejectIfNotServerDriven(widgetId) { + return error + } + + if let error = WidgetServerSettingsValidator.validate(settings, isDebugBuild: VoltraWidgetServer.isDebugBuild) { + return error + } + + WidgetServerSettingsStore.set(settings, scope: widgetId.map { .of($0) }) + reloadServerDrivenWidgets(widgetId: widgetId) + + return nil + } + + /// Drops the runtime settings for one widget, or the global ones, so the widget falls back to + /// what app.json configured. Clearing the global settings is the logout gesture. + static func clearWidgetServerUpdate(widgetId: String?) -> String? { + if let widgetId, let error = rejectIfNotServerDriven(widgetId) { + return error + } + + WidgetServerSettingsStore.clear(scope: widgetId.map { .of($0) }) + + // Clearing the global settings is logout: what the previous account's server sent has to go + // with it, or the widget keeps showing their data. A widget-scoped clear only drops that + // widget's overrides, so its props are left alone. + if widgetId == nil { + clearFetchedState() + } + + reloadServerDrivenWidgets(widgetId: widgetId) + + return nil + } + + /// Drops what the server last sent for every server-driven widget, so a Dynamic Widget falls + /// back to `{}` with `env.serverUpdate.status` of `never` — the state it is in before its first + /// fetch. + private static func clearFetchedState() { + let statuses = DynamicWidgetServerPropsStore() + let propsStore = DynamicWidgetPropsStore() + + for widgetId in VoltraWidgetServer.serverDrivenWidgetIds { + let scope = WidgetScope.of(widgetId) + + try? propsStore.clearDynamicWidgetProps(for: widgetId) + statuses.clear(scope) + WidgetServerEtagStore.clear(scope) + } + } + + /// The engine is chosen at generate time, so a runtime URL cannot turn a locally-driven widget + /// into a server-driven one. Saying so at call time is much easier to act on than a widget that + /// quietly never fetches. + private static func rejectIfNotServerDriven(_ widgetId: String) -> String? { + guard !VoltraWidgetServer.isServerDriven(widgetId) else { return nil } + + return "Widget '\(widgetId)' is not server-driven. Add a serverUpdate entry for it in app.json " + + "and rebuild; a runtime url does not change how a widget is rendered." + } + + private static func reloadServerDrivenWidgets(widgetId: String?) { + guard let widgetId else { + for id in VoltraWidgetServer.serverDrivenWidgetIds { + reloadTimeline(for: id) + } + return + } + + reloadTimeline(for: widgetId) + } + + /// Reads settings back rather than reasoning about what was set: with a `widgetId`, the fully + /// resolved settings that widget would fetch with right now (nil if it is not server-driven); + /// with none, the raw global layer only, no defaults applied. + static func getWidgetServerUpdate(widgetId: String?) -> String? { + if let widgetId { + let scope = WidgetScope.of(widgetId) + + guard VoltraWidgetServer.resolver.isServerDriven(scope) else { return nil } + + let resolved = VoltraWidgetServer.resolver.resolve(scope) + let settings = WidgetServerUpdateSettings( + url: resolved.url, + intervalMinutes: resolved.intervalMinutes, + enabled: resolved.enabled, + method: resolved.method, + query: resolved.query, + headers: resolved.headers, + body: resolved.body + ) + + return WidgetServerUpdateSettingsJson.stringify(settings) + } + + guard let global = VoltraWidgetServer.resolver.globalSettings() else { return nil } + + return WidgetServerUpdateSettingsJson.stringify(global) + } + + /// Drops one widget's runtime settings and its fetch history, for `clearWidget`. + static func clearWidgetServerState(for widgetId: String) { + guard VoltraWidgetServer.isServerDriven(widgetId) else { return } + + let scope = WidgetScope.of(widgetId) + + WidgetServerSettingsStore.clear(scope: scope) + WidgetServerEtagStore.clear(scope) + DynamicWidgetServerPropsStore().clear(scope) + } + + // MARK: - Server credentials (deprecated) /// Saves widget server credentials to the Keychain and reloads all widget timelines /// so extensions can use them on the next fetch. + /// + /// Deprecated in favour of `setWidgetServerUpdate` with an `Authorization` header. Kept as a + /// wrapper over the same Keychain accounts, so an app that has not migrated keeps working and + /// nothing has to be moved on device. static func setWidgetServerCredentials(token: String, headers: [String: String]?) { VoltraKeychainHelper.saveToken(token) if let headers = headers { @@ -60,13 +195,20 @@ enum VoltraWidgetService { } else { VoltraKeychainHelper.deleteHeaders() } + // The credentials layer does not go through the settings store, but a new token is exactly the + // thing a widget stuck on a 401 is waiting for, so an in-flight fetch built with the old one + // must not commit. + WidgetServerSettingsStore.bumpRevision() VoltraLogger.widget.info("Server credentials saved") reloadAllTimelines() } /// Clears widget server credentials from the Keychain and reloads all widget timelines. + /// + /// Deprecated alongside `setWidgetServerCredentials`. static func clearWidgetServerCredentials() { VoltraKeychainHelper.clearAll() + WidgetServerSettingsStore.bumpRevision() VoltraLogger.widget.info("Server credentials cleared") reloadAllTimelines() } diff --git a/packages/ios-client/ios/shared/DynamicWidgetServerUpdate/DynamicWidgetServerFetchResolver.swift b/packages/ios-client/ios/shared/DynamicWidgetServerUpdate/DynamicWidgetServerFetchResolver.swift new file mode 100644 index 00000000..bf1018d4 --- /dev/null +++ b/packages/ios-client/ios/shared/DynamicWidgetServerUpdate/DynamicWidgetServerFetchResolver.swift @@ -0,0 +1,34 @@ +import Foundation + +/// Collapses a burst of timeline requests for one scope into a single fetch. +/// +/// WidgetKit asks for a timeline once per widget instance per reload, and tapping the refresh +/// button produces two reloads for one tap (the intent's own reload plus the automatic +/// post-intent one). Without this, a widget placed twice in two sizes would fetch four times for +/// what the user experienced as one refresh — and the reload budget is shared across every widget +/// in the app. +/// +/// The payload engine already coalesces this way; this is the same window, keyed by scope so it +/// keeps working when instances arrive. +public actor DynamicWidgetServerFetchCoordinator { + public static let defaultCoalesceInterval: TimeInterval = 3 + + public static let shared = DynamicWidgetServerFetchCoordinator() + + private var lastRun: [WidgetScope: Date] = [:] + private let coalesceInterval: TimeInterval + + public init(coalesceInterval: TimeInterval = DynamicWidgetServerFetchCoordinator.defaultCoalesceInterval) { + self.coalesceInterval = coalesceInterval + } + + /// Whether this request should fetch, or ride on one that just happened. + public func shouldFetch(_ scope: WidgetScope, now: Date = Date()) -> Bool { + if let last = lastRun[scope], now.timeIntervalSince(last) < coalesceInterval { + return false + } + + lastRun[scope] = now + return true + } +} diff --git a/packages/ios-client/ios/shared/DynamicWidgetServerUpdate/DynamicWidgetServerProps.swift b/packages/ios-client/ios/shared/DynamicWidgetServerUpdate/DynamicWidgetServerProps.swift new file mode 100644 index 00000000..708e5080 --- /dev/null +++ b/packages/ios-client/ios/shared/DynamicWidgetServerUpdate/DynamicWidgetServerProps.swift @@ -0,0 +1,55 @@ +import Foundation + +/// What a `200` body turned out to be. +public enum DynamicWidgetPropsParseResult: Equatable { + case props(String) + case invalid(reason: String) +} + +/// Reads a server response as Dynamic Widget props. +/// +/// The whole point of ADR 0002 is that the server returns data, not UI, so the only thing accepted +/// here is a JSON object. The one shape called out specially is a Voltra payload: `serverUpdate` +/// is the same config key for both engines, so pointing a widget with an `entry` at a payload +/// endpoint is the easy mistake to make, and it has to fail loudly rather than look like unusable +/// props. +public enum DynamicWidgetServerProps { + public static func parse(_ body: Data) -> DynamicWidgetPropsParseResult { + guard !body.isEmpty else { + return .invalid(reason: "response body was empty") + } + + guard let parsed = try? JSONSerialization.jsonObject(with: body) else { + return .invalid(reason: "response body is not JSON; a Dynamic Widget's props must be a JSON object") + } + + guard let object = parsed as? [String: Any] else { + return .invalid(reason: "response body is not a JSON object; a Dynamic Widget's props must be a JSON object") + } + + if looksLikeVoltraPayload(object) { + return .invalid(reason: """ + response body looks like a Voltra payload (top-level 'v' with 'variants' or 'e'). This widget \ + has an entry, so it renders on the device: return the props it should render, not a rendered \ + payload. + """) + } + + guard let normalized = try? JSONSerialization.data(withJSONObject: object), + let json = String(data: normalized, encoding: .utf8) + else { + return .invalid(reason: "response body could not be re-encoded as props") + } + + return .props(json) + } + + /// A Voltra payload always carries a version under `v` alongside either the size variants a + /// widget renders or the shared element table. Props that happen to have a `v` key are not + /// mistaken for one. + private static func looksLikeVoltraPayload(_ body: [String: Any]) -> Bool { + guard body["v"] is Int else { return false } + + return body["variants"] is [String: Any] || body["e"] is [Any] + } +} diff --git a/packages/ios-client/ios/shared/DynamicWidgetServerUpdate/DynamicWidgetServerPropsStore.swift b/packages/ios-client/ios/shared/DynamicWidgetServerUpdate/DynamicWidgetServerPropsStore.swift new file mode 100644 index 00000000..5b276fa9 --- /dev/null +++ b/packages/ios-client/ios/shared/DynamicWidgetServerUpdate/DynamicWidgetServerPropsStore.swift @@ -0,0 +1,146 @@ +import Foundation + +/// What the widget is told about the server side of its props, as `env.serverUpdate`. +/// +/// Deliberately not the props themselves: fetched props are committed to the Dynamic Widget's +/// existing props slot, so the render path cannot tell whether they came from a fetch or from +/// `updateDynamicWidget`. This record is only the story around them. +public struct DynamicWidgetServerStatus: Equatable, Codable { + public static let fresh = "fresh" + public static let stale = "stale" + public static let never = "never" + public static let disabled = "disabled" + + public static let errorNetwork = "network" + public static let errorHttp = "http" + public static let errorUnauthorized = "unauthorized" + public static let errorParse = "parse" + public static let errorRender = "render" + + public let status: String + public let fetchedAt: Int? + public let error: String? + public let httpStatus: Int? + + public init(status: String, fetchedAt: Int? = nil, error: String? = nil, httpStatus: Int? = nil) { + self.status = status + self.fetchedAt = fetchedAt + self.error = error + self.httpStatus = httpStatus + } + + /// What a widget sees before any fetch has succeeded. + public static let neverFetched = DynamicWidgetServerStatus(status: never) + + public func toJSON() -> String { + var fields = ["\"status\": \(jsonString(status))"] + + fetchedAt.map { fields.append("\"fetchedAt\": \($0)") } + error.map { fields.append("\"error\": \(jsonString($0))") } + httpStatus.map { fields.append("\"httpStatus\": \($0)") } + + return "{ \(fields.joined(separator: ", ")) }" + } + + private func jsonString(_ value: String) -> String { + let escaped = value + .replacingOccurrences(of: "\\", with: "\\\\") + .replacingOccurrences(of: "\"", with: "\\\"") + + return "\"\(escaped)\"" + } +} + +/// Per-scope record of how the last fetch went, in the App Group so the app and the widget +/// extension agree on it. +/// +/// Kept apart from the props themselves so that clearing a widget's props — logout, +/// `clearWidget` — and clearing its fetch history stay separate decisions. +public struct DynamicWidgetServerPropsStore { + private let defaults: UserDefaults? + + public init(defaults: UserDefaults? = VoltraConfig.groupIdentifier().flatMap { UserDefaults(suiteName: $0) }) { + self.defaults = defaults + } + + public func status(for scope: WidgetScope) -> DynamicWidgetServerStatus { + guard let data = defaults?.data(forKey: key(scope)), + let status = try? JSONDecoder().decode(DynamicWidgetServerStatus.self, from: data) + else { + return .neverFetched + } + + return status + } + + public func put(_ status: DynamicWidgetServerStatus, for scope: WidgetScope) { + guard let data = try? JSONEncoder().encode(status) else { return } + + defaults?.set(data, forKey: key(scope)) + } + + /// Records a failure without losing the fact that a fetch once worked. `stale` is only + /// meaningful next to the `fetchedAt` of the last success, so that is carried forward. + public func recordFailure(_ error: String, httpStatus: Int? = nil, for scope: WidgetScope) { + let previous = status(for: scope) + + put( + DynamicWidgetServerStatus( + status: previous.fetchedAt == nil ? DynamicWidgetServerStatus.never : DynamicWidgetServerStatus.stale, + fetchedAt: previous.fetchedAt, + error: error, + httpStatus: httpStatus + ), + for: scope + ) + } + + public func recordSuccess(fetchedAt: Int, httpStatus: Int, for scope: WidgetScope) { + put( + DynamicWidgetServerStatus(status: DynamicWidgetServerStatus.fresh, fetchedAt: fetchedAt, httpStatus: httpStatus), + for: scope + ) + } + + /// Reports `disabled` once, keeping the last `fetchedAt` so a widget that comes back under app + /// control can still say when the server last spoke. + public func markDisabledIfNeeded(enabled: Bool, for scope: WidgetScope) { + guard !enabled else { return } + + let previous = status(for: scope) + + guard previous.status != DynamicWidgetServerStatus.disabled else { return } + + put( + DynamicWidgetServerStatus(status: DynamicWidgetServerStatus.disabled, fetchedAt: previous.fetchedAt), + for: scope + ) + } + + /// Records that the app just wrote props itself, so the reload that write triggers renders them + /// instead of racing a fetch that would immediately overwrite them. + public func noteAppWrite(for scope: WidgetScope, at date: Date = Date()) { + defaults?.set(date.timeIntervalSince1970, forKey: appWriteKey(scope)) + } + + public func appWriteAt(for scope: WidgetScope) -> Date? { + guard let seconds = defaults?.object(forKey: appWriteKey(scope)) as? TimeInterval else { + return nil + } + + return Date(timeIntervalSince1970: seconds) + } + + public func clear(_ scope: WidgetScope) { + defaults?.removeObject(forKey: key(scope)) + defaults?.removeObject(forKey: appWriteKey(scope)) + } + + private func key(_ scope: WidgetScope) -> String { + "Voltra_DynamicWidgetServer_v1_\(scope.storageKey)" + } + + private func appWriteKey(_ scope: WidgetScope) -> String { + "Voltra_DynamicWidgetServer_AppWrite_v1_\(scope.storageKey)" + } +} diff --git a/packages/ios-client/ios/shared/DynamicWidgetServerUpdate/DynamicWidgetServerUpdateRunner.swift b/packages/ios-client/ios/shared/DynamicWidgetServerUpdate/DynamicWidgetServerUpdateRunner.swift new file mode 100644 index 00000000..5c9397b1 --- /dev/null +++ b/packages/ios-client/ios/shared/DynamicWidgetServerUpdate/DynamicWidgetServerUpdateRunner.swift @@ -0,0 +1,193 @@ +import Foundation + +/// What one server-update run decided, before it becomes a timeline. +public enum DynamicWidgetServerUpdateOutcome: Equatable { + /// Props were committed, or the server said `304` and what we have is still current. + case committed + /// Nothing to do: the widget has no URL, the app turned fetching off, or another request in the + /// same burst just fetched. + case skipped + /// The fetch failed in a way that waiting could fix. Previous props stay on screen. + case retry + /// The fetch or the response failed in a way waiting will not fix. Previous props stay. + case failed + /// The settings the response was built from are no longer current, so it was dropped. + case dropped +} + +/// The outcome plus what the server asked about the next fetch. +public struct DynamicWidgetServerUpdateResult: Equatable { + public let outcome: DynamicWidgetServerUpdateOutcome + /// `Cache-Control: max-age` on a success, `Retry-After` on a `429` or `503`, in minutes and + /// already clamped to what WidgetKit can honour. Nil when the server said nothing and the + /// widget's own interval stands. + public let nextIntervalMinutes: Int? + + public init(_ outcome: DynamicWidgetServerUpdateOutcome, nextIntervalMinutes: Int? = nil) { + self.outcome = outcome + self.nextIntervalMinutes = nextIntervalMinutes + } +} + +/// Fetch, parse, trial-render, commit — the four steps ADR 0002 requires of every server-driven +/// widget, with every collaborator injected so the failure table can be tested without a network, +/// a JS runtime, or WidgetKit. +/// +/// The rule the ordering exists for: props that do not render are never committed. A server that +/// starts returning a shape the widget throws on leaves the last good props on screen instead of +/// replacing them with a blank tile. +public struct DynamicWidgetServerUpdateRunner { + public typealias Fetch = (WidgetScope, ResolvedWidgetServerSettings, String?) async -> WidgetServerFetchResult + + private let resolveSettings: (WidgetScope) -> ResolvedWidgetServerSettings + private let currentRevision: (WidgetScope) -> Int + private let readEtag: (WidgetScope, String?) -> String? + private let fetch: Fetch + private let writeEtag: (WidgetScope, String, String?) -> Void + private let trialRender: (WidgetScope, String) -> Bool + private let commitProps: (WidgetScope, String) throws -> Void + private let recordSuccess: (WidgetScope, Int, Int) -> Void + private let recordFailure: (WidgetScope, String, Int?) -> Void + private let markDisabled: (WidgetScope, Bool) -> Void + private let now: () -> Date + + public init( + resolveSettings: @escaping (WidgetScope) -> ResolvedWidgetServerSettings, + currentRevision: @escaping (WidgetScope) -> Int, + readEtag: @escaping (WidgetScope, String?) -> String?, + fetch: @escaping Fetch, + writeEtag: @escaping (WidgetScope, String, String?) -> Void, + trialRender: @escaping (WidgetScope, String) -> Bool, + commitProps: @escaping (WidgetScope, String) throws -> Void, + recordSuccess: @escaping (WidgetScope, Int, Int) -> Void, + recordFailure: @escaping (WidgetScope, String, Int?) -> Void, + markDisabled: @escaping (WidgetScope, Bool) -> Void, + now: @escaping () -> Date = Date.init + ) { + self.resolveSettings = resolveSettings + self.currentRevision = currentRevision + self.readEtag = readEtag + self.fetch = fetch + self.writeEtag = writeEtag + self.trialRender = trialRender + self.commitProps = commitProps + self.recordSuccess = recordSuccess + self.recordFailure = recordFailure + self.markDisabled = markDisabled + self.now = now + } + + public func run(_ scope: WidgetScope) async -> DynamicWidgetServerUpdateResult { + let settings = resolveSettings(scope) + + guard settings.shouldFetch, let url = settings.url else { + markDisabled(scope, settings.enabled) + return DynamicWidgetServerUpdateResult(.skipped) + } + + let revision = currentRevision(scope) + let result = await fetch(scope, settings, readEtag(scope, url)) + + // Settings that moved while we were on the network make this response answer a question + // nobody is asking any more. + guard currentRevision(scope) == revision else { + VoltraLogger.widget.debug("Dropping server update for '\(scope.widgetId, privacy: .public)': settings changed mid-fetch") + return DynamicWidgetServerUpdateResult(.dropped) + } + + switch result { + case let .notModified(nextIntervalMinutes): + recordSuccess(scope, epochMs(), 304) + return DynamicWidgetServerUpdateResult(.committed, nextIntervalMinutes: clamped(nextIntervalMinutes)) + + case let .networkFailure(message): + VoltraLogger.widget.error("Server update for '\(scope.widgetId, privacy: .public)' failed: \(message, privacy: .public)") + recordFailure(scope, DynamicWidgetServerStatus.errorNetwork, nil) + return DynamicWidgetServerUpdateResult(.retry) + + case let .tooLarge(httpStatus): + // The server answered, with a body the extension will not hold. Asking again returns the + // same one, so this is a parse failure rather than something to back off from. + VoltraLogger.widget.error("Server update for '\(scope.widgetId, privacy: .public)' returned a body that is too large") + recordFailure(scope, DynamicWidgetServerStatus.errorParse, httpStatus) + return DynamicWidgetServerUpdateResult(.failed) + + case let .httpFailure(httpStatus, retryAfterMinutes): + let error = result.isUnauthorized ? DynamicWidgetServerStatus.errorUnauthorized : DynamicWidgetServerStatus.errorHttp + + VoltraLogger.widget.error("Server update for '\(scope.widgetId, privacy: .public)' got HTTP \(httpStatus, privacy: .public)") + recordFailure(scope, error, httpStatus) + + // A 401 will keep being a 401 until the app sets a fresh token, and setting one reloads the + // widget. Retrying sooner would only spend the reload budget. + guard result.isTransient else { + return DynamicWidgetServerUpdateResult(.failed) + } + + return DynamicWidgetServerUpdateResult(.retry, nextIntervalMinutes: clamped(retryAfterMinutes)) + + case let .success(body, etag, httpStatus, nextIntervalMinutes): + return commit( + scope: scope, + url: url, + body: body, + etag: etag, + httpStatus: httpStatus, + nextIntervalMinutes: clamped(nextIntervalMinutes) + ) + } + } + + /// What the server asked for, held to what WidgetKit can honour: never sooner than 15 minutes, + /// never further out than a day. + private func clamped(_ minutes: Int?) -> Int? { + minutes.map { WidgetServerUpdateDefaults.clampIntervalMinutes($0) } + } + + private func commit( + scope: WidgetScope, + url: String, + body: Data, + etag: String?, + httpStatus: Int, + nextIntervalMinutes: Int? + ) -> DynamicWidgetServerUpdateResult { + switch DynamicWidgetServerProps.parse(body) { + case let .invalid(reason): + VoltraLogger.widget.error("Server update for '\(scope.widgetId, privacy: .public)' rejected: \(reason, privacy: .public)") + recordFailure(scope, DynamicWidgetServerStatus.errorParse, httpStatus) + // Asking again returns the same body, so this is not something to retry. + return DynamicWidgetServerUpdateResult(.failed) + + case let .props(json): + guard trialRender(scope, json) else { + VoltraLogger.widget.error( + "Server update for '\(scope.widgetId, privacy: .public)' did not render; keeping the previous props" + ) + recordFailure(scope, DynamicWidgetServerStatus.errorRender, httpStatus) + return DynamicWidgetServerUpdateResult(.failed) + } + + do { + try commitProps(scope, json) + } catch { + // Storage is not something the server can fix, and there is no error kind for it, so it is + // reported as a render failure: the props arrived and could not be put on screen. + VoltraLogger.widget.error( + "Could not store fetched props for '\(scope.widgetId, privacy: .public)': \(error.localizedDescription, privacy: .public)" + ) + recordFailure(scope, DynamicWidgetServerStatus.errorRender, httpStatus) + return DynamicWidgetServerUpdateResult(.failed) + } + + writeEtag(scope, url, etag) + recordSuccess(scope, epochMs(), httpStatus) + + return DynamicWidgetServerUpdateResult(.committed, nextIntervalMinutes: nextIntervalMinutes) + } + } + + private func epochMs() -> Int { + Int(now().timeIntervalSince1970 * 1000) + } +} diff --git a/packages/ios-client/ios/shared/VoltraWidgetServerFetcher.swift b/packages/ios-client/ios/shared/VoltraWidgetServerFetcher.swift index ebe006dd..d64c95f4 100644 --- a/packages/ios-client/ios/shared/VoltraWidgetServerFetcher.swift +++ b/packages/ios-client/ios/shared/VoltraWidgetServerFetcher.swift @@ -1,8 +1,12 @@ import Foundation -import UIKit -/// Handles fetching widget content from a remote Voltra SSR server. -/// Used by the TimelineProvider to pull server-driven widget updates. +/// Fetches a Voltra payload from the server for a payload-driven widget. +/// +/// Since ADR 0002 the request itself is built by `shared/WidgetServer`, the same code the Dynamic +/// engine uses. With no runtime settings set, that produces the request this fetcher always sent, +/// plus `locale` and a conditional `If-None-Match`; with settings set, this widget gains the +/// runtime URL, method, headers, query and body too. What the timeline does with the response is +/// unchanged. public enum VoltraWidgetServerFetcher { /// Errors that can occur during server fetch public enum FetchError: Error, LocalizedError { @@ -12,6 +16,8 @@ public enum VoltraWidgetServerFetcher { case httpError(statusCode: Int) case invalidResponse case emptyResponse + /// `304`: what is already stored is still current, so there is nothing new to render. + case notModified public var errorDescription: String? { switch self { @@ -27,142 +33,81 @@ public enum VoltraWidgetServerFetcher { return "Invalid response from server" case .emptyResponse: return "Empty response from server" + case .notModified: + return "Server content is unchanged" } } } - /// Read the server update URL for a widget from Info.plist / UserDefaults config. + /// The URL this widget will fetch from, after runtime overrides. Callers use it to decide + /// whether a widget is server-driven at all before starting a timeline. public static func serverUrl(for widgetId: String) -> String? { - // Check Info.plist first (set at build time by config plugin) - if let urls = Bundle.main.object(forInfoDictionaryKey: VoltraStorageKeys.widgetServerUrls) as? [String: String], - let url = urls[widgetId] - { - return url - } - - // Fallback to UserDefaults (can be set at runtime) - if let group = VoltraConfig.groupIdentifier(), - let defaults = UserDefaults(suiteName: group) - { - return defaults.string(forKey: VoltraStorageKeys.widgetServerUrl(widgetId)) - } - - return nil + VoltraWidgetServer.resolver.resolve(.of(widgetId)).url } - /// Read the update interval (in minutes) for a widget. + /// The resolved interval, in minutes. Runtime settings win over app.json. public static func updateInterval(for widgetId: String) -> Int { - if let intervals = Bundle.main.object(forInfoDictionaryKey: VoltraStorageKeys.widgetServerIntervals) as? [String: Int], - let interval = intervals[widgetId] - { - return interval - } - return 60 // default: 1 hour + VoltraWidgetServer.resolver.resolve(.of(widgetId)).intervalMinutes } + /// Whether the widget draws a refresh button. Build-time only: the button is generated UI + /// structure, so unlike the URL and the interval it cannot be changed at runtime. public static func isRefreshEnabled(for widgetId: String) -> Bool { - if let refreshDict = Bundle.main.object(forInfoDictionaryKey: VoltraStorageKeys.widgetServerRefresh) as? [String: Bool], - let enabled = refreshDict[widgetId] - { - return enabled - } - return false - } - - private static func currentColorScheme() -> String { - if #available(iOSApplicationExtension 13.0, *) { - switch UITraitCollection.current.userInterfaceStyle { - case .dark: - return "dark" - case .light: - return "light" - default: - return "light" - } - } - return "light" + VoltraWidgetServer.isRefreshEnabled(for: widgetId) } /// Fetch widget content from the remote Voltra SSR server. /// - /// The request includes: - /// - `widgetId` query parameter - /// - `family` query parameter (e.g., "systemSmall") - /// - `platform` query parameter (`ios`) - /// - `theme` query parameter (`light` or `dark`) - /// - `Authorization: Bearer ` header (if credentials stored in Keychain) - /// - Any custom headers stored in Keychain + /// The request carries `widgetId`, `family`, `platform`, `theme` and `locale` as query + /// parameters, `Accept: application/json`, a Voltra user agent, whatever headers the app has set + /// — including `Authorization` from the deprecated credential API — and `If-None-Match` when a + /// stored ETag belongs to the URL being fetched. /// /// Returns the raw JSON data from the server, ready to be parsed by VoltraNode. public static func fetchWidgetContent( widgetId: String, family: String ) async throws -> Data { - guard let baseUrl = serverUrl(for: widgetId) else { - throw FetchError.noServerUrl - } - - // Build URL with query parameters - guard var components = URLComponents(string: baseUrl) else { - throw FetchError.invalidUrl(baseUrl) - } - - let theme = currentColorScheme() + let scope = WidgetScope.of(widgetId) + let settings = VoltraWidgetServer.resolver.resolve(scope) - var queryItems = components.queryItems ?? [] - queryItems.append(URLQueryItem(name: "widgetId", value: widgetId)) - queryItems.append(URLQueryItem(name: "family", value: family)) - queryItems.append(URLQueryItem(name: "platform", value: "ios")) - queryItems.append(URLQueryItem(name: "theme", value: theme)) - components.queryItems = queryItems - - guard let url = components.url else { - throw FetchError.invalidUrl(baseUrl) + guard let url = settings.url else { + throw FetchError.noServerUrl } - var request = URLRequest(url: url) - request.httpMethod = "GET" - request.timeoutInterval = 15 // Widgets have limited execution time - - // Add auth token from Keychain if available - if let token = VoltraKeychainHelper.readToken() { - request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") + // No If-None-Match: a payload widget sends the request it always sent, plus locale. The last + // successful response is kept only for this extension process, so a 304 could arrive with + // nothing to fall back on and leave the widget on its initial state. + guard let request = WidgetServerRequestBuilder.build( + scope: scope, + settings: settings, + context: VoltraWidgetAppearance.requestContext(family: family) + ) else { + throw FetchError.invalidUrl(url) } - // Add custom headers from Keychain if available - if let headers = VoltraKeychainHelper.readHeaders() { - for (key, value) in headers { - request.setValue(value, forHTTPHeaderField: key) + switch await WidgetServerFetcher.fetch(request) { + case let .success(body, _, _, _): + guard !body.isEmpty else { + throw FetchError.emptyResponse } - } - // Add Voltra-specific headers - let systemVersion = await MainActor.run { - UIDevice.current.systemVersion - } - request.setValue("application/json", forHTTPHeaderField: "Accept") - request.setValue("VoltraWidget/1.0 (iOS/\(systemVersion))", forHTTPHeaderField: "User-Agent") - - do { - let (data, response) = try await URLSession.shared.data(for: request) + return body - guard let httpResponse = response as? HTTPURLResponse else { - throw FetchError.invalidResponse - } + case .notModified: + // Only reachable if the server answers 304 unprompted; nothing was requested conditionally. + throw FetchError.notModified - guard (200 ... 299).contains(httpResponse.statusCode) else { - throw FetchError.httpError(statusCode: httpResponse.statusCode) - } + case let .tooLarge(statusCode): + throw FetchError.httpError(statusCode: statusCode) - guard !data.isEmpty else { - throw FetchError.emptyResponse - } + case let .httpFailure(statusCode, _): + throw FetchError.httpError(statusCode: statusCode) - return data - } catch let error as FetchError { - throw error - } catch { - throw FetchError.networkError(error) + case let .networkFailure(message): + throw FetchError.networkError(NSError(domain: "VoltraWidgetServerFetcher", code: -1, userInfo: [ + NSLocalizedDescriptionKey: message, + ])) } } } diff --git a/packages/ios-client/ios/shared/VoltraKeychainHelper.swift b/packages/ios-client/ios/shared/WidgetServer/VoltraKeychainHelper.swift similarity index 100% rename from packages/ios-client/ios/shared/VoltraKeychainHelper.swift rename to packages/ios-client/ios/shared/WidgetServer/VoltraKeychainHelper.swift diff --git a/packages/ios-client/ios/shared/WidgetServer/VoltraWidgetAppearance.swift b/packages/ios-client/ios/shared/WidgetServer/VoltraWidgetAppearance.swift new file mode 100644 index 00000000..94297666 --- /dev/null +++ b/packages/ios-client/ios/shared/WidgetServer/VoltraWidgetAppearance.swift @@ -0,0 +1,17 @@ +import UIKit + +/// The appearance a widget is being drawn in, as the request contract reports it. +/// +/// Split out from the rest of `WidgetServer` because it is the one piece that needs UIKit, and +/// keeping it here lets everything else — the resolver, the store, the request builder, the +/// fetcher — be compiled and tested on its own. +public enum VoltraWidgetAppearance { + public static func currentTheme() -> String { + UITraitCollection.current.userInterfaceStyle == .dark ? "dark" : "light" + } + + /// The device state a request carries, for callers that are already on a UIKit-capable target. + public static func requestContext(family: String? = nil) -> WidgetServerRequestContext { + VoltraWidgetServer.requestContext(theme: currentTheme(), family: family) + } +} diff --git a/packages/ios-client/ios/shared/WidgetServer/VoltraWidgetServer.swift b/packages/ios-client/ios/shared/WidgetServer/VoltraWidgetServer.swift new file mode 100644 index 00000000..8eebd138 --- /dev/null +++ b/packages/ios-client/ios/shared/WidgetServer/VoltraWidgetServer.swift @@ -0,0 +1,138 @@ +import Foundation + +/// Build-time server-update defaults, read from the Info.plist keys the config plugin and the CLI +/// write. +/// +/// `Voltra_WidgetServerIntervals` carries an entry for every server-driven widget, so its keys are +/// the set of widget ids this app may fetch for. `Voltra_WidgetServerUrls` carries only the ones +/// app.json gave a URL; the rest expect one at runtime from `setWidgetServerUpdate`. +struct ConfigWidgetServerSettingsLayer: WidgetServerSettingsLayer { + let name = "config" + + private let urls: [String: String] + private let intervals: [String: Int] + + init(bundle: Bundle = .main) { + urls = bundle.object(forInfoDictionaryKey: VoltraStorageKeys.widgetServerUrls) as? [String: String] ?? [:] + intervals = bundle.object(forInfoDictionaryKey: VoltraStorageKeys.widgetServerIntervals) as? [String: Int] ?? [:] + } + + init(urls: [String: String], intervals: [String: Int]) { + self.urls = urls + self.intervals = intervals + } + + func settings(for scope: WidgetScope) -> WidgetServerUpdateSettings? { + guard let interval = intervals[scope.widgetId] else { return nil } + + return WidgetServerUpdateSettings(url: urls[scope.widgetId], intervalMinutes: interval) + } + + func isServerDriven(_ scope: WidgetScope) -> Bool { + intervals[scope.widgetId] != nil + } + + var serverDrivenWidgetIds: Set { + Set(intervals.keys) + } +} + +/// Assembles the settings stack for the process. +/// +/// Everything that needs server-update settings goes through here: the payload timeline, the +/// Dynamic Widget timeline, and the bridge methods. Neither engine reads Info.plist, the Keychain, +/// or the credential records on its own, which is what keeps the layer order and the merge rule in +/// one place. +public enum VoltraWidgetServer { + private static let configLayer = ConfigWidgetServerSettingsLayer() + + /// Fixed order, lowest priority first. An instance layer will slot in above `widget`. + public static let resolver = WidgetServerSettingsResolver( + layers: [ + configLayer, + CredentialsWidgetServerSettingsLayer(), + GlobalWidgetServerSettingsLayer(), + WidgetWidgetServerSettingsLayer(), + ], + revisionSource: { WidgetServerSettingsStore.revision() } + ) + + /// Every widget app.json marked server-driven, whichever engine renders it. + public static var serverDrivenWidgetIds: Set { + configLayer.serverDrivenWidgetIds + } + + public static func isServerDriven(_ widgetId: String) -> Bool { + configLayer.isServerDriven(.of(widgetId)) + } + + /// Whether the widget draws a refresh button. Build-time only: the button is generated UI + /// structure, so unlike the URL and the interval it cannot be changed at runtime. + public static func isRefreshEnabled(for widgetId: String) -> Bool { + guard let refresh = Bundle.main.object(forInfoDictionaryKey: VoltraStorageKeys.widgetServerRefresh) as? [String: Bool] else { + return false + } + + return refresh[widgetId] ?? false + } + + /// Whether plain http to a local dev host is allowed. Release builds have App Transport Security + /// blocking cleartext anyway, so accepting such a URL there would only move the failure to fetch + /// time. + public static var isDebugBuild: Bool { + #if DEBUG + return true + #else + return false + #endif + } + + /// The device state a request carries. The theme is passed in rather than read here so this + /// whole module stays free of UIKit and the request contract can be tested without a simulator; + /// `VoltraWidgetAppearance` is the UIKit-side helper that supplies it. + public static func requestContext(theme: String, family: String? = nil) -> WidgetServerRequestContext { + WidgetServerRequestContext( + theme: theme, + locale: bcp47Locale(), + userAgent: userAgent(), + family: family + ) + } + + /// The device locale as a BCP-47 tag, so a backend sees `en-US` from both platforms. + /// `Locale.identifier` is the ICU form (`en_US`), which is not what the contract promises. + static func bcp47Locale(_ locale: Locale = .current) -> String { + if #available(iOS 16.0, macOS 13.0, *) { + return locale.identifier(.bcp47) + } + + return locale.identifier.replacingOccurrences(of: "_", with: "-") + } + + static func userAgent() -> String { + "VoltraWidget/\(VoltraConfig.voltraVersion()) (iOS/\(systemVersion))" + } + + /// `UIDevice.current.systemVersion` is main-actor isolated and a timeline provider is not, so + /// the OS version comes from `ProcessInfo`, which is not. + private static var systemVersion: String { + let version = ProcessInfo.processInfo.operatingSystemVersion + + return "\(version.majorVersion).\(version.minorVersion).\(version.patchVersion)" + } + + /// Resolves settings and builds the request for one scope, or returns nil when there is nothing + /// to fetch. + public static func request( + for scope: WidgetScope, + context: WidgetServerRequestContext, + etag: String? = nil + ) -> URLRequest? { + WidgetServerRequestBuilder.build( + scope: scope, + settings: resolver.resolve(scope), + context: context, + etag: etag + ) + } +} diff --git a/packages/ios-client/ios/shared/WidgetServer/WidgetScope.swift b/packages/ios-client/ios/shared/WidgetServer/WidgetScope.swift new file mode 100644 index 00000000..5c496c3f --- /dev/null +++ b/packages/ios-client/ios/shared/WidgetServer/WidgetScope.swift @@ -0,0 +1,33 @@ +import Foundation + +/// The unit everything server-driven is keyed by: settings, fetched props, the stored ETag, fetch +/// coalescing, and the settings revision. +/// +/// Today the only case is a whole widget id. Per-instance server updates (ADR 0002, +/// "Instance-ready") add an `instance` case above it without changing a single caller, which is +/// the reason this is a type rather than a bare `String`. +public enum WidgetScope: Hashable, Sendable { + case widget(id: String) + + /// Widget id this scope belongs to. An instance scope will report the id it is an instance of. + public var widgetId: String { + switch self { + case let .widget(id): + return id + } + } + + /// Stable key for per-scope storage. An instance scope will append its placement key, so + /// widget-scoped records written today keep their keys. + public var storageKey: String { + switch self { + case let .widget(id): + return id + } + } + + /// Convenience for the common case, so callers do not spell out the case name. + public static func of(_ widgetId: String) -> WidgetScope { + .widget(id: widgetId) + } +} diff --git a/packages/ios-client/ios/shared/WidgetServer/WidgetServerEtagStore.swift b/packages/ios-client/ios/shared/WidgetServer/WidgetServerEtagStore.swift new file mode 100644 index 00000000..660b638a --- /dev/null +++ b/packages/ios-client/ios/shared/WidgetServer/WidgetServerEtagStore.swift @@ -0,0 +1,55 @@ +import Foundation + +/// The ETag from the last `200`, stored with the URL it came from. +/// +/// Keeping the URL alongside it is what makes `If-None-Match` safe once the app can change the URL +/// at runtime: an ETag minted by one endpoint says nothing about another, and sending it could +/// produce a `304` that leaves the widget showing the previous endpoint's data forever. +/// +/// Lives in the App Group so the widget extension and the app agree on it. Without an App Group +/// there is nowhere shared to put it, and the widget simply revalidates nothing. +public enum WidgetServerEtagStore { + /// The stored ETag, but only if it was minted by `url`. + public static func etag(for scope: WidgetScope, url: String?) -> String? { + guard let url, let defaults = defaults(), + defaults.string(forKey: urlKey(scope)) == url + else { + return nil + } + + return defaults.string(forKey: etagKey(scope)) + } + + public static func put(_ etag: String?, for scope: WidgetScope, url: String) { + guard let defaults = defaults() else { return } + + guard let etag else { + clear(scope) + return + } + + defaults.set(etag, forKey: etagKey(scope)) + defaults.set(url, forKey: urlKey(scope)) + } + + public static func clear(_ scope: WidgetScope) { + guard let defaults = defaults() else { return } + + defaults.removeObject(forKey: etagKey(scope)) + defaults.removeObject(forKey: urlKey(scope)) + } + + private static func defaults() -> UserDefaults? { + guard let group = VoltraConfig.groupIdentifier() else { return nil } + + return UserDefaults(suiteName: group) + } + + private static func etagKey(_ scope: WidgetScope) -> String { + "Voltra_WidgetServerEtag_\(scope.storageKey)" + } + + private static func urlKey(_ scope: WidgetScope) -> String { + "Voltra_WidgetServerEtagUrl_\(scope.storageKey)" + } +} diff --git a/packages/ios-client/ios/shared/WidgetServer/WidgetServerFetcher.swift b/packages/ios-client/ios/shared/WidgetServer/WidgetServerFetcher.swift new file mode 100644 index 00000000..09022a94 --- /dev/null +++ b/packages/ios-client/ios/shared/WidgetServer/WidgetServerFetcher.swift @@ -0,0 +1,179 @@ +import Foundation + +/// What came back from the server, before either engine decides what it means. +/// +/// The request side is identical for a payload widget and a Dynamic Widget, so it lives here once. +/// Only the interpretation of `body` differs: one parses a Voltra payload, the other props. +public enum WidgetServerFetchResult: Sendable { + /// `200` with a body. `etag` is present when the response carried one. + case success(body: Data, etag: String?, httpStatus: Int, nextIntervalMinutes: Int?) + /// `304`: what is already committed is still current. + case notModified(nextIntervalMinutes: Int?) + /// The request never completed: no connectivity, DNS, TLS, or a timeout. + case networkFailure(message: String) + /// The server answered with a status we cannot use. + case httpFailure(httpStatus: Int, retryAfterMinutes: Int?) + /// A `2xx` whose body is over `WidgetServerUpdateDefaults.maxBodyBytes`. Kept apart from + /// `httpFailure` because the server did answer: this is a body the device refuses, so it is + /// reported as a parse failure and asking again is pointless. + case tooLarge(httpStatus: Int) + + public var isUnauthorized: Bool { + if case let .httpFailure(status, _) = self { + return status == 401 || status == 403 + } + return false + } + + /// True when waiting and asking again could plausibly succeed. + public var isTransient: Bool { + switch self { + case .networkFailure: + return true + case let .httpFailure(status, _): + return status >= 500 || status == 429 + default: + return false + } + } +} + +/// Executes a request built by `WidgetServerRequestBuilder` and reports what happened, without +/// deciding what to do about it. +public enum WidgetServerFetcher { + /// Refuses to leave the host the app configured, so an `Authorization` header or a request body + /// cannot be replayed somewhere the app never agreed to send it. + private final class SameHostRedirectDelegate: NSObject, URLSessionTaskDelegate { + func urlSession( + _: URLSession, + task: URLSessionTask, + willPerformHTTPRedirection _: HTTPURLResponse, + newRequest request: URLRequest, + completionHandler: @escaping (URLRequest?) -> Void + ) { + guard let originalHost = task.originalRequest?.url?.host, + let originalScheme = task.originalRequest?.url?.scheme, + let nextHost = request.url?.host, + nextHost.caseInsensitiveCompare(originalHost) == .orderedSame, + request.url?.scheme == originalScheme + else { + VoltraLogger.widget.warning("Refusing cross-host redirect for a widget server request") + completionHandler(nil) + return + } + + completionHandler(request) + } + } + + private static let redirectDelegate = SameHostRedirectDelegate() + + public static func fetch( + _ request: URLRequest, + session: URLSession = .shared + ) async -> WidgetServerFetchResult { + do { + // bytes(for:) rather than data(for:) so an oversized body is abandoned mid-stream. A widget + // extension has a 30 MB ceiling for the whole render, and buffering first would spend it + // before we ever got to check. + let (stream, response) = try await session.bytes(for: request, delegate: redirectDelegate) + + guard let http = response as? HTTPURLResponse else { + return .networkFailure(message: "Response was not an HTTP response") + } + + let nextIntervalMinutes = maxAgeMinutes(http.value(forHTTPHeaderField: "Cache-Control")) + + if http.statusCode == 304 { + return .notModified(nextIntervalMinutes: nextIntervalMinutes) + } + + guard (200 ... 299).contains(http.statusCode) else { + return .httpFailure( + httpStatus: http.statusCode, + retryAfterMinutes: retryAfterMinutes(http.value(forHTTPHeaderField: "Retry-After")) + ) + } + + guard let body = try await readBody(stream) else { + // Asking again returns the same oversized body, so this is a failure the app has to fix + // rather than one to back off from. + VoltraLogger.widget.error( + "Response is larger than \(WidgetServerUpdateDefaults.maxBodyBytes, privacy: .public) bytes" + ) + return .tooLarge(httpStatus: http.statusCode) + } + + return .success( + body: body, + etag: http.value(forHTTPHeaderField: "ETag"), + httpStatus: http.statusCode, + nextIntervalMinutes: nextIntervalMinutes + ) + } catch { + return .networkFailure(message: error.localizedDescription) + } + } + + /// Returns nil as soon as the body passes the cap, without holding the rest of it. + private static func readBody(_ stream: URLSession.AsyncBytes) async throws -> Data? { + var body = Data() + body.reserveCapacity(16 * 1024) + + for try await byte in stream { + if body.count >= WidgetServerUpdateDefaults.maxBodyBytes { + return nil + } + + body.append(byte) + } + + return body + } + + /// `Cache-Control: max-age=N`, in minutes, rounded down. + static func maxAgeMinutes(_ header: String?) -> Int? { + guard let header else { return nil } + + let pattern = try? NSRegularExpression(pattern: "max-age\\s*=\\s*(\\d+)", options: .caseInsensitive) + let range = NSRange(header.startIndex ..< header.endIndex, in: header) + + guard let match = pattern?.firstMatch(in: header, range: range), + let secondsRange = Range(match.range(at: 1), in: header), + let seconds = Int(header[secondsRange]) + else { + return nil + } + + return seconds / 60 + } + + /// `Retry-After`, in minutes, rounded up so we never retry early. + /// + /// The header is delta-seconds or an HTTP date; both are in the wild, so both are read. + static func retryAfterMinutes(_ header: String?, now: Date = Date()) -> Int? { + guard let value = header?.trimmingCharacters(in: .whitespaces), !value.isEmpty else { + return nil + } + + if let seconds = Int(value) { + return seconds > 0 ? (seconds + 59) / 60 : nil + } + + guard let date = httpDateFormatter.date(from: value) else { + return nil + } + + let seconds = Int(date.timeIntervalSince(now)) + + return seconds > 0 ? (seconds + 59) / 60 : nil + } + + private static let httpDateFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.timeZone = TimeZone(identifier: "GMT") + formatter.dateFormat = "EEE, dd MMM yyyy HH:mm:ss zzz" + return formatter + }() +} diff --git a/packages/ios-client/ios/shared/WidgetServer/WidgetServerRequestBuilder.swift b/packages/ios-client/ios/shared/WidgetServer/WidgetServerRequestBuilder.swift new file mode 100644 index 00000000..05eeb20e --- /dev/null +++ b/packages/ios-client/ios/shared/WidgetServer/WidgetServerRequestBuilder.swift @@ -0,0 +1,101 @@ +import Foundation + +/// The device state a request carries that is not part of the settings: the theme and locale the +/// widget is being drawn in, and who is asking. +/// +/// Passed in rather than read here so the request contract can be tested without UIKit, and so a +/// timeline request built for one appearance cannot accidentally report another. +public struct WidgetServerRequestContext: Equatable, Sendable { + public let theme: String + public let locale: String + public let userAgent: String + /// Only payload widgets send `family`: one Dynamic Widget fetch serves every size, so its props + /// must be size-agnostic and the entry picks its layout from `env.widgetFamily`. + public let family: String? + + public init(theme: String, locale: String, userAgent: String, family: String? = nil) { + self.theme = theme + self.locale = locale + self.userAgent = userAgent + self.family = family + } +} + +/// Turns resolved settings plus Voltra's own request parameters into the request the device sends. +/// +/// Both engines build their requests here, so a payload widget and a Dynamic Widget send the same +/// shape and the app's runtime overrides apply to both. Only the response and what the device does +/// with it differ. +public enum WidgetServerRequestBuilder { + /// Widgets have limited execution time, so a request that has not answered in this long is not + /// going to be useful even if it eventually does. + public static let timeoutSeconds: TimeInterval = 15 + + /// - Parameter etag: from the last `200`, sent as `If-None-Match`. Callers pass nil when the + /// stored ETag belongs to a different URL than the one being fetched now. + /// - Returns: nil when there is nothing to fetch — no URL, fetching is off, or the URL will not + /// parse. + public static func build( + scope: WidgetScope, + settings: ResolvedWidgetServerSettings, + context: WidgetServerRequestContext, + etag: String? = nil + ) -> URLRequest? { + guard settings.shouldFetch, let url = settings.url, var components = URLComponents(string: url) else { + return nil + } + + var queryItems = components.queryItems ?? [] + queryItems.append(URLQueryItem(name: "widgetId", value: scope.widgetId)) + queryItems.append(URLQueryItem(name: "platform", value: "ios")) + + if let family = context.family { + queryItems.append(URLQueryItem(name: "family", value: family)) + } + + queryItems.append(URLQueryItem(name: "theme", value: context.theme)) + queryItems.append(URLQueryItem(name: "locale", value: context.locale)) + + // Voltra's own keys are appended first and the app's keys are rejected at call time if they + // collide, so nothing here can shadow what the server relies on. + for key in settings.query.keys.sorted() { + queryItems.append(URLQueryItem(name: key, value: settings.query[key])) + } + + components.queryItems = queryItems + + guard let resolvedUrl = components.url else { return nil } + + var request = URLRequest(url: resolvedUrl) + let method = settings.method.uppercased() + + request.httpMethod = method + request.timeoutInterval = timeoutSeconds + request.setValue("application/json", forHTTPHeaderField: "Accept") + request.setValue(context.userAgent, forHTTPHeaderField: "User-Agent") + + for (key, value) in settings.headers { + request.setValue(value, forHTTPHeaderField: key) + } + + if let etag { + request.setValue(etag, forHTTPHeaderField: "If-None-Match") + } + + if let body = settings.body { + if WidgetServerUpdateDefaults.bodylessMethods.contains(method) { + // URLSession sends a Content-Length and drops the body on GET, so the server would see a + // request the app did not mean to send. Dropping it here is the lesser surprise, and it is + // documented. + VoltraLogger.widget.warning( + "Dropping request body for widget '\(scope.widgetId, privacy: .public)': \(method, privacy: .public) cannot carry one" + ) + } else { + request.httpBody = Data(body.utf8) + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + } + } + + return request + } +} diff --git a/packages/ios-client/ios/shared/WidgetServer/WidgetServerSettingsCodec.swift b/packages/ios-client/ios/shared/WidgetServer/WidgetServerSettingsCodec.swift new file mode 100644 index 00000000..dc044a0d --- /dev/null +++ b/packages/ios-client/ios/shared/WidgetServer/WidgetServerSettingsCodec.swift @@ -0,0 +1,47 @@ +import Foundation + +/// Serializes one settings layer for storage. Written as a small versioned envelope so a future +/// shape change can be recognised rather than guessed at, the same way Dynamic Widget props are +/// stored. +public enum WidgetServerSettingsCodec { + private static let versionKey = "widgetServerSettingsVersion" + private static let settingsKey = "widgetServerSettings" + private static let version = 1 + + public static func encode(_ settings: WidgetServerUpdateSettings) -> Data? { + var payload: [String: Any] = [:] + + settings.url.map { payload["url"] = $0 } + settings.intervalMinutes.map { payload["intervalMinutes"] = $0 } + settings.enabled.map { payload["enabled"] = $0 } + settings.method.map { payload["method"] = $0 } + settings.query.map { payload["query"] = $0 } + settings.headers.map { payload["headers"] = $0 } + settings.body.map { payload["body"] = $0 } + + let envelope: [String: Any] = [versionKey: version, settingsKey: payload] + + return try? JSONSerialization.data(withJSONObject: envelope) + } + + /// Returns nil for anything this version cannot read, so a bad record reads as "no opinion". + public static func decode(_ data: Data?) -> WidgetServerUpdateSettings? { + guard let data, + let envelope = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + envelope[versionKey] as? Int == version, + let payload = envelope[settingsKey] as? [String: Any] + else { + return nil + } + + return WidgetServerUpdateSettings( + url: payload["url"] as? String, + intervalMinutes: payload["intervalMinutes"] as? Int, + enabled: payload["enabled"] as? Bool, + method: payload["method"] as? String, + query: payload["query"] as? [String: String], + headers: payload["headers"] as? [String: String], + body: payload["body"] as? String + ) + } +} diff --git a/packages/ios-client/ios/shared/WidgetServer/WidgetServerSettingsResolver.swift b/packages/ios-client/ios/shared/WidgetServer/WidgetServerSettingsResolver.swift new file mode 100644 index 00000000..d12ebbad --- /dev/null +++ b/packages/ios-client/ios/shared/WidgetServer/WidgetServerSettingsResolver.swift @@ -0,0 +1,129 @@ +import Foundation + +/// One source of server-update settings. Implementations return a partial +/// `WidgetServerUpdateSettings`, or nil when they have nothing to say about the scope. +/// +/// Layers are stacked in a fixed order by `WidgetServerSettingsResolver` and never consulted +/// directly: nothing outside this folder reads Info.plist, the Keychain, or anything else for +/// server-update purposes. +public protocol WidgetServerSettingsLayer: Sendable { + /// A short name used in logs, so a surprising resolved value can be traced to its source. + var name: String { get } + + func settings(for scope: WidgetScope) -> WidgetServerUpdateSettings? + + /// Whether this layer knows the scope to be server-driven at all. Only the config layer can + /// answer this — a runtime layer setting a URL does not turn a locally-rendered widget into a + /// server-driven one, because the engine is chosen at generate time. + func isServerDriven(_ scope: WidgetScope) -> Bool +} + +public extension WidgetServerSettingsLayer { + func isServerDriven(_: WidgetScope) -> Bool { + false + } +} + +/// The only way to read server-update settings. +/// +/// Layers are walked lowest to highest and merged by the rule stated once here: `headers` and +/// `query` merge per key, everything else takes the value from the highest layer that sets it. +/// Adding a layer later — an instance layer above `widget`, say — is a new +/// `WidgetServerSettingsLayer` plus one entry in `layers`; this API and every caller stay as they +/// are. +public struct WidgetServerSettingsResolver: Sendable { + private let layers: [any WidgetServerSettingsLayer] + private let revisionSource: @Sendable () -> Int + + /// - Parameter layers: lowest priority first: config, credentials, global, widget. + public init(layers: [any WidgetServerSettingsLayer], revisionSource: @escaping @Sendable () -> Int) { + self.layers = layers + self.revisionSource = revisionSource + } + + /// Flattens every layer for `scope`. Safe to call for any widget: a widget that is not + /// server-driven resolves to disabled with no URL, so a caller that fetches on `shouldFetch` + /// does nothing rather than guessing. + public func resolve(_ scope: WidgetScope) -> ResolvedWidgetServerSettings { + var merged = WidgetServerUpdateSettings.empty + var intervalFromConfig = false + + for (index, layer) in layers.enumerated() { + guard let settings = layer.settings(for: scope) else { continue } + + if settings.intervalMinutes != nil { + // Index 0 is the config layer. An interval that came from app.json was already validated + // against this platform's rules when the native project was generated, so clamping it + // again here would silently change an existing widget's schedule. + intervalFromConfig = index == 0 + } + + merged = Self.merge(lower: merged, higher: settings) + } + + let serverDriven = isServerDriven(scope) + let url = serverDriven ? merged.url.flatMap { $0.isEmpty ? nil : $0 } : nil + let intervalMinutes = merged.intervalMinutes ?? WidgetServerUpdateDefaults.defaultIntervalMinutes + + return ResolvedWidgetServerSettings( + url: url, + intervalMinutes: intervalFromConfig + ? intervalMinutes + : WidgetServerUpdateDefaults.clampIntervalMinutes(intervalMinutes), + enabled: serverDriven && (merged.enabled ?? true), + method: merged.method ?? WidgetServerUpdateDefaults.defaultMethod, + query: merged.query ?? [:], + headers: merged.headers ?? [:], + body: merged.body + ) + } + + /// True when app.json marked this widget server-driven. The engine is chosen at generate time, + /// so a runtime URL cannot make a widget server-driven and `setWidgetServerUpdate` rejects + /// settings for one that is not. + public func isServerDriven(_ scope: WidgetScope) -> Bool { + layers.contains { $0.isServerDriven(scope) } + } + + /// Raw contents of the global layer: no defaulting, no merge — there is nothing to resolve + /// against without a widget scope. Nil when nothing has been set globally. + public func globalSettings() -> WidgetServerUpdateSettings? { + layers.lazy.compactMap { $0 as? GlobalWidgetServerSettingsLayer }.first?.raw() + } + + /// Monotonic counter of settings changes. A fetcher records it before fetching and commits only + /// if it is still current, so settings changed mid-flight cannot commit a response built from + /// the old ones. + /// + /// It is one counter for the whole store rather than one per scope: a change to another widget + /// can make an in-flight fetch drop its result, and the reload that every `set` queues fetches + /// again, so the cost is one wasted request in a rare race. + public func revision(_: WidgetScope) -> Int { + revisionSource() + } + + static func merge( + lower: WidgetServerUpdateSettings, + higher: WidgetServerUpdateSettings + ) -> WidgetServerUpdateSettings { + WidgetServerUpdateSettings( + url: higher.url ?? lower.url, + intervalMinutes: higher.intervalMinutes ?? lower.intervalMinutes, + enabled: higher.enabled ?? lower.enabled, + method: higher.method ?? lower.method, + query: mergePerKey(lower.query, higher.query), + headers: mergePerKey(lower.headers, higher.headers), + body: higher.body ?? lower.body + ) + } + + private static func mergePerKey( + _ lower: [String: String]?, + _ higher: [String: String]? + ) -> [String: String]? { + guard let lower else { return higher } + guard let higher else { return lower } + + return lower.merging(higher) { _, fromHigher in fromHigher } + } +} diff --git a/packages/ios-client/ios/shared/WidgetServer/WidgetServerSettingsStore.swift b/packages/ios-client/ios/shared/WidgetServer/WidgetServerSettingsStore.swift new file mode 100644 index 00000000..86f10af7 --- /dev/null +++ b/packages/ios-client/ios/shared/WidgetServer/WidgetServerSettingsStore.swift @@ -0,0 +1,170 @@ +import Foundation +import Security + +/// The only way to write server-update settings, and the storage behind the three runtime layers. +/// +/// Records live in the same shared Keychain the widget credentials have always used, so nothing +/// migrates: the deprecated `setWidgetServerCredentials` keeps writing the accounts it always did, +/// and this store adds its own accounts alongside them. The Keychain is also the only store both +/// the app and the widget extension can reach without depending on an App Group being configured. +/// +/// Callers do not read through this class. They read through `WidgetServerSettingsResolver`, which +/// is what keeps the layer order and the merge rule in one place. +public enum WidgetServerSettingsStore { + private static let service = "voltra-widget-server-credentials" + private static let globalAccount = "server_update_global" + private static let widgetAccountPrefix = "server_update_widget_" + private static let revisionAccount = "server_update_revision" + + // MARK: - Writes + + /// Replaces the global layer, or one widget's layer when `scope` is given. + @discardableResult + public static func set(_ settings: WidgetServerUpdateSettings, scope: WidgetScope?) -> Bool { + guard let data = WidgetServerSettingsCodec.encode(settings) else { return false } + + let saved = write(data, account: account(for: scope)) + bumpRevision() + + return saved + } + + /// Empties the global layer, or one widget's layer when `scope` is given. + public static func clear(scope: WidgetScope?) { + delete(account: account(for: scope)) + bumpRevision() + } + + /// Bumps the revision without changing a layer. The credentials layer writes through the + /// deprecated credential API, which does not go through `set`, so it calls this to make sure an + /// in-flight fetch built with the old token does not commit. + public static func bumpRevision() { + let next = revision() &+ 1 + write(Data("\(next)".utf8), account: revisionAccount) + } + + public static func revision() -> Int { + guard let data = read(account: revisionAccount), + let text = String(data: data, encoding: .utf8), + let value = Int(text) + else { + return 0 + } + + return value + } + + // MARK: - Reads + + static func settings(scope: WidgetScope?) -> WidgetServerUpdateSettings? { + WidgetServerSettingsCodec.decode(read(account: account(for: scope))) + } + + private static func account(for scope: WidgetScope?) -> String { + guard let scope else { return globalAccount } + + return "\(widgetAccountPrefix)\(scope.storageKey)" + } + + // MARK: - Keychain + + private static func baseQuery(account: String) -> [String: Any] { + var query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: account, + ] + + if let group = VoltraConfig.keychainGroup() { + query[kSecAttrAccessGroup as String] = group + } + + return query + } + + @discardableResult + private static func write(_ data: Data, account: String) -> Bool { + delete(account: account) + + var query = baseQuery(account: account) + query[kSecValueData as String] = data + query[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlock + + let status = SecItemAdd(query as CFDictionary, nil) + + if status != errSecSuccess { + VoltraLogger.keychain.error("Failed to save widget server settings: \(status, privacy: .public)") + } + + return status == errSecSuccess + } + + private static func read(account: String) -> Data? { + var query = baseQuery(account: account) + query[kSecReturnData as String] = true + query[kSecMatchLimit as String] = kSecMatchLimitOne + + var item: CFTypeRef? + let status = SecItemCopyMatching(query as CFDictionary, &item) + + guard status == errSecSuccess, let data = item as? Data else { + if status != errSecItemNotFound { + VoltraLogger.keychain.error("Failed to read widget server settings: \(status, privacy: .public)") + } + return nil + } + + return data + } + + private static func delete(account: String) { + SecItemDelete(baseQuery(account: account) as CFDictionary) + } +} + +/// Settings the app set for every server-driven widget. +struct GlobalWidgetServerSettingsLayer: WidgetServerSettingsLayer { + let name = "global" + + func settings(for _: WidgetScope) -> WidgetServerUpdateSettings? { + WidgetServerSettingsStore.settings(scope: nil) + } + + /// Raw contents of this layer, for reading back what was set — no scope to resolve against. + func raw() -> WidgetServerUpdateSettings? { + WidgetServerSettingsStore.settings(scope: nil) + } +} + +/// Settings the app set for one widget. Highest layer until instance scopes arrive. +struct WidgetWidgetServerSettingsLayer: WidgetServerSettingsLayer { + let name = "widget" + + func settings(for scope: WidgetScope) -> WidgetServerUpdateSettings? { + WidgetServerSettingsStore.settings(scope: scope) + } +} + +/// The deprecated `setWidgetServerCredentials` API, expressed as a settings layer. +/// +/// It reads the same token and header records it always has, which is why nothing migrates. It +/// sits below the global layer so an app that has moved to +/// `setWidgetServerUpdate({ headers: { Authorization: ... } })` overrides whatever an older call +/// left behind, rather than the other way round. +struct CredentialsWidgetServerSettingsLayer: WidgetServerSettingsLayer { + let name = "credentials" + + func settings(for _: WidgetScope) -> WidgetServerUpdateSettings? { + var headers: [String: String] = [:] + + if let token = VoltraKeychainHelper.readToken(), !token.isEmpty { + headers["Authorization"] = "Bearer \(token)" + } + + if let custom = VoltraKeychainHelper.readHeaders() { + headers.merge(custom) { _, fromCustom in fromCustom } + } + + return headers.isEmpty ? nil : WidgetServerUpdateSettings(headers: headers) + } +} diff --git a/packages/ios-client/ios/shared/WidgetServer/WidgetServerSettingsValidator.swift b/packages/ios-client/ios/shared/WidgetServer/WidgetServerSettingsValidator.swift new file mode 100644 index 00000000..f9248f16 --- /dev/null +++ b/packages/ios-client/ios/shared/WidgetServer/WidgetServerSettingsValidator.swift @@ -0,0 +1,73 @@ +import Foundation + +/// Call-time rules for `setWidgetServerUpdate`. Rejecting here rather than at fetch time means the +/// app learns about a bad setting from the promise it just awaited, instead of from a widget that +/// quietly stops updating hours later. +public enum WidgetServerSettingsValidator { + /// Hosts reachable over plain http, so a debug build can talk to a dev server. + static let localHttpHosts: Set = ["localhost", "127.0.0.1", "::1", "10.0.2.2", "10.0.3.2"] + + /// - Parameter isDebugBuild: whether plain http to a local dev host is allowed. Release builds + /// have App Transport Security blocking it anyway, so allowing it there would only defer the + /// failure. + /// - Returns: an error message, or nil when the settings are usable. + public static func validate(_ settings: WidgetServerUpdateSettings, isDebugBuild: Bool) -> String? { + if let url = settings.url, let error = validateUrl(url, isDebugBuild: isDebugBuild) { + return error + } + + if let interval = settings.intervalMinutes, interval <= 0 { + return "intervalMinutes must be a positive number of minutes" + } + + if let method = settings.method, + !WidgetServerUpdateDefaults.supportedMethods.contains(method.uppercased()) + { + let supported = WidgetServerUpdateDefaults.supportedMethods.sorted().joined(separator: ", ") + return "method '\(method)' is not supported. Use one of \(supported)" + } + + for key in settings.query?.keys ?? [String: String]().keys + where WidgetServerUpdateDefaults.reservedQueryKeys.contains(key) + { + return "query key '\(key)' is reserved by Voltra and is sent on every request" + } + + if let encoded = WidgetServerSettingsCodec.encode(settings), + encoded.count > WidgetServerUpdateDefaults.maxLayerBytes + { + return "settings are larger than \(WidgetServerUpdateDefaults.maxLayerBytes) bytes once serialized" + } + + return nil + } + + private static func validateUrl(_ url: String, isDebugBuild: Bool) -> String? { + if url.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + return "url must not be empty" + } + + guard let components = URLComponents(string: url), + let scheme = components.scheme?.lowercased(), + let host = components.host, + !host.isEmpty + else { + return "url '\(url)' must be an absolute http(s) URL" + } + + if scheme == "https" { + return nil + } + + if scheme != "http" { + return "url '\(url)' must be an absolute http(s) URL" + } + + if isDebugBuild, localHttpHosts.contains(host) { + return nil + } + + let hosts = localHttpHosts.sorted().joined(separator: ", ") + return "url '\(url)' must use https. Plain http is allowed only in a debug build, and only for \(hosts)." + } +} diff --git a/packages/ios-client/ios/shared/WidgetServer/WidgetServerUpdateSettings.swift b/packages/ios-client/ios/shared/WidgetServer/WidgetServerUpdateSettings.swift new file mode 100644 index 00000000..43a7dbdb --- /dev/null +++ b/packages/ios-client/ios/shared/WidgetServer/WidgetServerUpdateSettings.swift @@ -0,0 +1,121 @@ +import Foundation + +/// Server-update settings as one layer holds them: every field is optional, and an unset field +/// means "this layer has no opinion, ask the layer below". +/// +/// `body` is kept as the raw JSON text the app supplied rather than a parsed tree, because Voltra +/// never inspects it — it only forwards it as the request body. +public struct WidgetServerUpdateSettings: Equatable, Sendable { + public var url: String? + public var intervalMinutes: Int? + public var enabled: Bool? + public var method: String? + public var query: [String: String]? + public var headers: [String: String]? + public var body: String? + + public init( + url: String? = nil, + intervalMinutes: Int? = nil, + enabled: Bool? = nil, + method: String? = nil, + query: [String: String]? = nil, + headers: [String: String]? = nil, + body: String? = nil + ) { + self.url = url + self.intervalMinutes = intervalMinutes + self.enabled = enabled + self.method = method + self.query = query + self.headers = headers + self.body = body + } + + public var isEmpty: Bool { + url == nil && intervalMinutes == nil && enabled == nil && method == nil + && query == nil && headers == nil && body == nil + } + + public static let empty = WidgetServerUpdateSettings() +} + +/// The flattened settings a fetch actually runs on. Every field is decided: `intervalMinutes` has +/// the floor and ceiling applied, `enabled` and `method` have their defaults filled in, and `query` +/// and `headers` are the per-key merge of every layer. +/// +/// `url` is the one field that can still be absent, and it means the widget is server-driven but +/// has nowhere to fetch from yet — the app is expected to supply one with `setWidgetServerUpdate`. +public struct ResolvedWidgetServerSettings: Equatable, Sendable { + public let url: String? + public let intervalMinutes: Int + public let enabled: Bool + public let method: String + public let query: [String: String] + public let headers: [String: String] + public let body: String? + + public init( + url: String?, + intervalMinutes: Int, + enabled: Bool, + method: String, + query: [String: String], + headers: [String: String], + body: String? + ) { + self.url = url + self.intervalMinutes = intervalMinutes + self.enabled = enabled + self.method = method + self.query = query + self.headers = headers + self.body = body + } + + /// True when this widget has both a URL to fetch and permission to do it. + public var shouldFetch: Bool { + guard enabled, let url, !url.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { + return false + } + + return true + } +} + +public enum WidgetServerUpdateDefaults { + /// WidgetKit stretches a timeline asking for entries much closer together than five minutes, and + /// the reload budget is shared across every widget in the app, so a smaller number would only + /// misreport what the widget actually does. Matches Android's WorkManager floor. + public static let minIntervalMinutes = 15 + + /// A day. Past this the widget is effectively not server-driven, and `Cache-Control: max-age` + /// from a misconfigured server should not be able to park a widget for a week. + public static let maxIntervalMinutes = 24 * 60 + + public static let defaultIntervalMinutes = minIntervalMinutes + + public static let defaultMethod = "GET" + + /// Methods either platform's HTTP stack can send. + public static let supportedMethods: Set = ["GET", "POST", "PUT", "PATCH", "DELETE"] + + /// Methods that cannot carry a body. A body set alongside one of these is dropped. + public static let bodylessMethods: Set = ["GET", "HEAD"] + + /// Query keys Voltra puts on every request. An app that set one of these would silently shadow + /// what the server relies on, so `setWidgetServerUpdate` rejects them. + public static let reservedQueryKeys: Set = ["widgetId", "platform", "family", "theme", "locale", "instance"] + + /// Serialized size cap for one layer, so a runaway `body` cannot fill the settings store. + public static let maxLayerBytes = 16 * 1024 + + /// Response bodies larger than this are refused. The widget extension has a 30 MB ceiling for + /// the whole render, and a widget needing a quarter of a megabyte of props will not fit on a + /// home screen either. + public static let maxBodyBytes = 256 * 1024 + + public static func clampIntervalMinutes(_ intervalMinutes: Int) -> Int { + min(max(intervalMinutes, minIntervalMinutes), maxIntervalMinutes) + } +} diff --git a/packages/ios-client/ios/shared/WidgetServer/WidgetServerUpdateSettingsJson.swift b/packages/ios-client/ios/shared/WidgetServer/WidgetServerUpdateSettingsJson.swift new file mode 100644 index 00000000..fe6d0ca1 --- /dev/null +++ b/packages/ios-client/ios/shared/WidgetServer/WidgetServerUpdateSettingsJson.swift @@ -0,0 +1,115 @@ +import Foundation + +/// Reads the settings object an app passes to `setWidgetServerUpdate`. +/// +/// Separate from `WidgetServerSettingsCodec`, which is the versioned storage format: what the app +/// sends and what Voltra persists are allowed to diverge, and conflating them would make either one +/// hard to change. The one real difference today is `body`, which arrives as arbitrary JSON and is +/// kept as text because Voltra only forwards it. +public enum WidgetServerUpdateSettingsJson { + public enum Result: Equatable { + case parsed(WidgetServerUpdateSettings) + case invalid(reason: String) + } + + public static func parse(_ json: String) -> Result { + guard let object = try? JSONSerialization.jsonObject(with: Data(json.utf8)) as? [String: Any] else { + return .invalid(reason: "settings must be a JSON object") + } + + guard let query = stringMap(object, "query") else { + return .invalid(reason: "query must be an object of strings") + } + + guard let headers = stringMap(object, "headers") else { + return .invalid(reason: "headers must be an object of strings") + } + + var body: String? + + if let rawBody = object["body"], !(rawBody is NSNull) { + guard let text = jsonText(rawBody) else { + return .invalid(reason: "body must be a JSON value") + } + + body = text + } + + return .parsed( + WidgetServerUpdateSettings( + url: object["url"] as? String, + intervalMinutes: object["intervalMinutes"] as? Int, + enabled: object["enabled"] as? Bool, + method: (object["method"] as? String)?.uppercased(), + query: object["query"] == nil ? nil : query, + headers: object["headers"] == nil ? nil : headers, + body: body + ) + ) + } + + /// The other direction of `parse`: serializes settings back to the same JSON shape, for + /// `getWidgetServerUpdate` to hand across the bridge. No envelope — that is + /// `WidgetServerSettingsCodec`'s job for storage, not this one's for a single read. + public static func stringify(_ settings: WidgetServerUpdateSettings) -> String? { + var object: [String: Any] = [:] + + if let url = settings.url { + object["url"] = url + } + if let intervalMinutes = settings.intervalMinutes { + object["intervalMinutes"] = intervalMinutes + } + if let enabled = settings.enabled { + object["enabled"] = enabled + } + if let method = settings.method { + object["method"] = method + } + if let query = settings.query { + object["query"] = query + } + if let headers = settings.headers { + object["headers"] = headers + } + + if let body = settings.body { + guard let bodyData = body.data(using: .utf8), + let bodyValue = try? JSONSerialization.jsonObject(with: bodyData, options: [.fragmentsAllowed]) + else { + return nil + } + + object["body"] = bodyValue + } + + guard let data = try? JSONSerialization.data(withJSONObject: object) else { return nil } + + return String(data: data, encoding: .utf8) + } + + /// Re-serializes a parsed value back to JSON text. A string body has to keep its quotes: without + /// them the request would carry something that is not JSON at all. + private static func jsonText(_ value: Any) -> String? { + if JSONSerialization.isValidJSONObject(value) { + return (try? JSONSerialization.data(withJSONObject: value)).flatMap { String(data: $0, encoding: .utf8) } + } + + // A top-level scalar is not a valid JSON *object*, so it is wrapped, encoded and unwrapped. + guard let wrapped = try? JSONSerialization.data(withJSONObject: ["v": value]), + let text = String(data: wrapped, encoding: .utf8), + let start = text.firstIndex(of: ":") + else { + return nil + } + + return String(text[text.index(after: start) ..< text.index(before: text.endIndex)]) + } + + /// Returns an empty map when the key is absent, and nil when it is present but not usable. + private static func stringMap(_ object: [String: Any], _ key: String) -> [String: String]? { + guard let raw = object[key], !(raw is NSNull) else { return [:] } + + return raw as? [String: String] + } +} diff --git a/packages/ios-client/ios/target/VoltraClientWidgetRuntime.swift b/packages/ios-client/ios/target/VoltraClientWidgetRuntime.swift index 8154f535..367fb03b 100644 --- a/packages/ios-client/ios/target/VoltraClientWidgetRuntime.swift +++ b/packages/ios-client/ios/target/VoltraClientWidgetRuntime.swift @@ -41,6 +41,12 @@ public struct VoltraClientWidgetEntry: TimelineEntry { /// process, where the provider's process-static JSContext is empty. The View re-evaluates from /// this source so `render()` always has the widget's function available in its own process. public let bundleSource: String? + /// How the last server fetch went, as `env.serverUpdate`, for a widget that has a `serverUpdate` + /// in app.json. `nil` for every other Dynamic Widget, and the reason `env.serverUpdate` is + /// `undefined` there. Carried on the entry rather than read at render time because the provider + /// is the thing that knows the fetch outcome, and WidgetKit re-renders archived entries in a + /// fresh process. + public let serverUpdateJSON: String? public init( date: Date, @@ -48,7 +54,8 @@ public struct VoltraClientWidgetEntry: TimelineEntry { bundleReady: Bool, errorMessage: String? = nil, configuration: [String: String] = [:], - bundleSource: String? = nil + bundleSource: String? = nil, + serverUpdateJSON: String? = nil ) { self.date = date self.widgetId = widgetId @@ -56,6 +63,20 @@ public struct VoltraClientWidgetEntry: TimelineEntry { self.errorMessage = errorMessage self.configuration = configuration self.bundleSource = bundleSource + self.serverUpdateJSON = serverUpdateJSON + } + + /// The same entry, told how the server side is doing. + public func withServerUpdate(_ serverUpdateJSON: String?) -> VoltraClientWidgetEntry { + VoltraClientWidgetEntry( + date: date, + widgetId: widgetId, + bundleReady: bundleReady, + errorMessage: errorMessage, + configuration: configuration, + bundleSource: bundleSource, + serverUpdateJSON: serverUpdateJSON + ) } } @@ -234,7 +255,8 @@ public enum VoltraClientWidgetEnvBuilder { widgetRenderingMode: WidgetRenderingMode, showsWidgetContainerBackground: Bool, locale: Locale, - configuration: [String: String] + configuration: [String: String], + serverUpdateJSON: String? = nil ) -> String { let timestampMs = Int(date.timeIntervalSince1970 * 1000) let appVersion = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "unknown" @@ -268,6 +290,10 @@ public enum VoltraClientWidgetEnvBuilder { configurationJSON = "{ \(entries) }" } + // Only a server-driven widget gets env.serverUpdate; leaving the key out entirely is what + // makes it `undefined` for every other Dynamic Widget. + let serverUpdateEntry = serverUpdateJSON.map { ",\n \"serverUpdate\": \($0)" } ?? "" + return """ { "date": \(timestampMs), @@ -277,7 +303,7 @@ public enum VoltraClientWidgetEnvBuilder { "widgetRenderingMode": \(jsonString(renderingModeString(widgetRenderingMode))), "showsWidgetContainerBackground": \(showsWidgetContainerBackground), "configuration": \(configurationJSON), - "build": \(buildJSON) + "build": \(buildJSON)\(serverUpdateEntry) } """ } @@ -350,7 +376,8 @@ public struct VoltraClientWidgetContentView: View { widgetRenderingMode: widgetRenderingMode, showsWidgetContainerBackground: showsWidgetContainerBackground, locale: locale, - configuration: entry.configuration + configuration: entry.configuration, + serverUpdateJSON: entry.serverUpdateJSON ) let dynamicWidgetPropsStore = DynamicWidgetPropsStore() let dynamicWidgetRenderCoordinator = DynamicWidgetRenderCoordinator( diff --git a/packages/ios-client/ios/target/VoltraDynamicWidgetServerUpdateProvider.swift b/packages/ios-client/ios/target/VoltraDynamicWidgetServerUpdateProvider.swift new file mode 100644 index 00000000..157bbc64 --- /dev/null +++ b/packages/ios-client/ios/target/VoltraDynamicWidgetServerUpdateProvider.swift @@ -0,0 +1,191 @@ +import Foundation +import SwiftUI +import WidgetKit + +/// Timeline provider for a Dynamic Widget that has a `serverUpdate`. +/// +/// It wraps `VoltraClientWidgetProvider` rather than replacing it: the bundle load, the render and +/// the fallback to the prerendered initial state are exactly what they are for any Dynamic Widget. +/// What this adds is a fetch on `getTimeline`, a commit of the fetched props into the widget's +/// existing props slot, and a schedule — a plain Dynamic Widget's timeline is `.never`, so without +/// one nothing would ever ask again. +/// +/// `placeholder` and `getSnapshot` stay local. Apple's guidance is to keep the gallery preview off +/// the network, and a snapshot that waited for a fetch would show a spinner in the widget picker. +public struct VoltraDynamicWidgetServerUpdateProvider: TimelineProvider { + public let widgetId: String + public let initialState: Data? + + public init(widgetId: String, initialState: Data? = nil) { + self.widgetId = widgetId + self.initialState = initialState + } + + private var scope: WidgetScope { + .of(widgetId) + } + + public func placeholder(in _: Context) -> VoltraClientWidgetEntry { + VoltraClientWidgetEntry(date: Date(), widgetId: widgetId, bundleReady: false) + } + + public func getSnapshot(in _: Context, completion: @escaping (VoltraClientWidgetEntry) -> Void) { + Task { completion(await localEntry(configuration: [:])) } + } + + public func getTimeline(in context: Context, completion: @escaping (Timeline) -> Void) { + Task { + completion(await timeline(family: context.family, configuration: [:])) + } + } + + /// Shared with the generated `AppIntentTimelineProvider`s, which pass the user-configured + /// parameters as `configuration`. + public static func timeline( + widgetId: String, + family: WidgetFamily, + configuration: [String: String] + ) async -> Timeline { + await VoltraDynamicWidgetServerUpdateProvider(widgetId: widgetId).timeline( + family: family, + configuration: configuration + ) + } + + func timeline(family: WidgetFamily, configuration: [String: String]) async -> Timeline { + let settings = VoltraWidgetServer.resolver.resolve(scope) + + guard settings.shouldFetch else { + // No URL yet, or the app has taken the widget over. Either way there is nothing to schedule: + // the app reloads the widget itself, and setting a URL later queues a reload of its own. + DynamicWidgetServerPropsStore().markDisabledIfNeeded(enabled: settings.enabled, for: scope) + + return Timeline(entries: [await localEntry(configuration: configuration)], policy: .never) + } + + var nextIntervalMinutes = settings.intervalMinutes + + if await shouldFetch() { + let result = await runner(family: family, configuration: configuration).run(scope) + + // What the server asked for wins over the configured interval: `Cache-Control: max-age` on a + // success, `Retry-After` on a 429 or 503. + if let asked = result.nextIntervalMinutes { + nextIntervalMinutes = asked + } else if result.outcome == .retry || result.outcome == .failed { + // A failure the server did not put a number on. Coming back sooner than the widget's own + // interval would spend the reload budget on an endpoint that is already unhappy, so the + // floor is used rather than the configured value only when that is shorter. + nextIntervalMinutes = max(settings.intervalMinutes, WidgetServerUpdateDefaults.minIntervalMinutes) + } + } + + let entry = await localEntry(configuration: configuration) + let nextUpdate = Date().addingTimeInterval(TimeInterval(nextIntervalMinutes * 60)) + + return Timeline(entries: [entry], policy: .after(nextUpdate)) + } + + /// Whether this timeline request should fetch, or ride on one that just happened. + /// + /// Skipped for a moment after `updateDynamicWidget` writes props, so an optimistic update is not + /// wiped out by the very reload it triggered. ADR 0002 says the *next scheduled* fetch overwrites + /// app-written props, not the one the write itself caused. + private func shouldFetch() async -> Bool { + if let writtenAt = DynamicWidgetServerPropsStore().appWriteAt(for: scope), + Date().timeIntervalSince(writtenAt) < DynamicWidgetServerFetchCoordinator.defaultCoalesceInterval + { + return false + } + + return await DynamicWidgetServerFetchCoordinator.shared.shouldFetch(scope) + } + + /// The entry a widget renders from: the bundle, the configuration, and how the last fetch went. + /// The props themselves are read by the view from the same slot `updateDynamicWidget` writes, so + /// the render path does not know where they came from. + private func localEntry(configuration: [String: String]) async -> VoltraClientWidgetEntry { + let entry = await VoltraClientWidgetProvider.loadEntry(widgetId: widgetId, configuration: configuration) + + return entry.withServerUpdate(DynamicWidgetServerPropsStore().status(for: scope).toJSON()) + } + + private func runner(family: WidgetFamily, configuration: [String: String]) -> DynamicWidgetServerUpdateRunner { + let statuses = DynamicWidgetServerPropsStore() + + return DynamicWidgetServerUpdateRunner( + resolveSettings: { VoltraWidgetServer.resolver.resolve($0) }, + currentRevision: { VoltraWidgetServer.resolver.revision($0) }, + readEtag: { WidgetServerEtagStore.etag(for: $0, url: $1) }, + fetch: { scope, settings, etag in + guard let request = WidgetServerRequestBuilder.build( + scope: scope, + settings: settings, + // No `family`: one fetch serves every size and instance of a Dynamic Widget, so its + // props must be size-agnostic and the entry picks its layout from env.widgetFamily. + context: VoltraWidgetAppearance.requestContext(), + etag: etag + ) else { + return .networkFailure(message: "Could not build a request") + } + + return await WidgetServerFetcher.fetch(request) + }, + writeEtag: { WidgetServerEtagStore.put($2, for: $0, url: $1) }, + trialRender: { scope, props in + VoltraDynamicWidgetTrialRender.canRender( + widgetId: scope.widgetId, + propsJSON: props, + family: family, + configuration: configuration + ) + }, + commitProps: { scope, props in + try DynamicWidgetPropsStore().persistDynamicWidgetProps(props, for: scope.widgetId) + }, + recordSuccess: { statuses.recordSuccess(fetchedAt: $1, httpStatus: $2, for: $0) }, + recordFailure: { statuses.recordFailure($1, httpStatus: $2, for: $0) }, + markDisabled: { statuses.markDisabledIfNeeded(enabled: $1, for: $0) } + ) + } +} + +/// Renders fetched props once, off screen, before they are allowed anywhere near the widget. +/// +/// The trial uses one environment — the family WidgetKit asked the timeline for, in the current +/// appearance. A widget that only throws for another family slips through, and ADR 0002 accepts +/// that: rendering every supported family on every fetch would cost more than the failure it +/// prevents, inside a 30 MB extension. +enum VoltraDynamicWidgetTrialRender { + static func canRender( + widgetId: String, + propsJSON: String, + family: WidgetFamily, + configuration: [String: String] + ) -> Bool { + // env.serverUpdate is deliberately absent here. The trial asks whether the props render, and a + // widget that only fails when told the fetch went badly is a different problem from props that + // cannot be drawn. + let envJSON = VoltraClientWidgetEnvBuilder.build( + date: Date(), + widgetFamily: family, + colorScheme: nil, + widgetRenderingMode: .fullColor, + showsWidgetContainerBackground: true, + locale: Locale.current, + configuration: configuration + ) + + guard let resolved = VoltraJSRenderer.render(widgetId: widgetId, propsJSON: propsJSON, envJSON: envJSON), + let json = try? JSONValue.parse(from: resolved) + else { + return false + } + + if case .empty = VoltraNode.parse(from: json) { + return false + } + + return true + } +} diff --git a/packages/ios-client/ios/target/VoltraHomeWidget.swift b/packages/ios-client/ios/target/VoltraHomeWidget.swift index 390f9c22..40b021e5 100644 --- a/packages/ios-client/ios/target/VoltraHomeWidget.swift +++ b/packages/ios-client/ios/target/VoltraHomeWidget.swift @@ -167,6 +167,14 @@ public struct VoltraHomeWidgetProvider: TimelineProvider { let nextUpdate = Calendar.current.date(byAdding: .minute, value: intervalMinutes, to: Date()) ?? retryDate return serverTimeline(from: data, in: context, policy: .after(nextUpdate)) case let .lastKnown(data, error): + // A 304 is not a failure: the server confirmed what we already have is current, so the next + // fetch belongs at the normal interval rather than the shorter retry one. + if case VoltraWidgetServerFetcher.FetchError.notModified = error { + let intervalMinutes = VoltraWidgetServerFetcher.updateInterval(for: widgetId) + let nextUpdate = Calendar.current.date(byAdding: .minute, value: intervalMinutes, to: Date()) ?? retryDate + return serverTimeline(from: data, in: context, policy: .after(nextUpdate)) + } + VoltraLogger.widget.error("Server-driven update failed for '\(widgetId)', keeping last server content: \(error.localizedDescription)") return serverTimeline(from: data, in: context, policy: .after(retryDate)) case let .unavailable(error): diff --git a/packages/ios-client/src/index.ts b/packages/ios-client/src/index.ts index 460324b4..660bb917 100644 --- a/packages/ios-client/src/index.ts +++ b/packages/ios-client/src/index.ts @@ -49,6 +49,15 @@ export { enableDynamicLiveActivityHotReload } from './utils/enableDynamicLiveAct export { useUpdateOnHMR } from './utils/useUpdateOnHMR.js' export * from './utils/helpers.js' export type { VoltraElementJson, VoltraNodeJson } from './types.js' +export { + clearWidgetServerUpdate, + getWidgetServerUpdate, + setWidgetServerUpdate, + type WidgetServerUpdateBody, + type WidgetServerUpdateOptions, + type WidgetServerUpdateSettings, + type WidgetServerUpdateSnapshot, +} from './widgets/server-update.js' export { clearWidgetServerCredentials, setWidgetServerCredentials, diff --git a/packages/ios-client/src/native/NativeVoltra.ts b/packages/ios-client/src/native/NativeVoltra.ts index ea692a42..ed2b1948 100644 --- a/packages/ios-client/src/native/NativeVoltra.ts +++ b/packages/ios-client/src/native/NativeVoltra.ts @@ -123,6 +123,11 @@ export interface Spec extends TurboModule { clearWidget(widgetId: string): Promise clearAllWidgets(): Promise getActiveWidgets(): Promise + /** Settings are passed as JSON so an arbitrary `body` survives the bridge unchanged. */ + setWidgetServerUpdate(settingsJson: string, widgetId?: string | null): Promise + clearWidgetServerUpdate(widgetId?: string | null): Promise + /** Result is JSON so an arbitrary `body` survives the bridge unchanged, or null. */ + getWidgetServerUpdate(widgetId?: string | null): Promise setWidgetServerCredentials(credentials: WidgetServerCredentials): Promise clearWidgetServerCredentials(): Promise } diff --git a/packages/ios-client/src/types.ts b/packages/ios-client/src/types.ts index d979c59b..059cf62c 100644 --- a/packages/ios-client/src/types.ts +++ b/packages/ios-client/src/types.ts @@ -9,4 +9,8 @@ export type { VoltraNodeJson, VoltraPropValue, WidgetServerCredentials, + WidgetServerUpdateBody, + WidgetServerUpdateOptions, + WidgetServerUpdateSettings, + WidgetServerUpdateSnapshot, } from '@use-voltra/ios' diff --git a/packages/ios-client/src/widgets/server-credentials.ts b/packages/ios-client/src/widgets/server-credentials.ts index b7c9670c..aec96fe0 100644 --- a/packages/ios-client/src/widgets/server-credentials.ts +++ b/packages/ios-client/src/widgets/server-credentials.ts @@ -3,6 +3,11 @@ import { getNativeVoltra } from '../VoltraModule.js' export type { WidgetServerCredentials } from '../types.js' +/** + * @deprecated Use {@link setWidgetServerUpdate} with an `Authorization` header. This writes the + * same stored credentials and keeps the same replace-everything semantics, so migrating is a + * one-line change; it will be removed in a future major. + */ export async function setWidgetServerCredentials(credentials: WidgetServerCredentials): Promise { if (!credentials.token) { throw new Error('[Voltra][iOS] setWidgetServerCredentials: token is required') @@ -11,6 +16,9 @@ export async function setWidgetServerCredentials(credentials: WidgetServerCreden return getNativeVoltra().setWidgetServerCredentials(credentials) } +/** + * @deprecated Use {@link clearWidgetServerUpdate} instead. + */ export async function clearWidgetServerCredentials(): Promise { return getNativeVoltra().clearWidgetServerCredentials() } diff --git a/packages/ios-client/src/widgets/server-update.ts b/packages/ios-client/src/widgets/server-update.ts new file mode 100644 index 00000000..83dd90ea --- /dev/null +++ b/packages/ios-client/src/widgets/server-update.ts @@ -0,0 +1,84 @@ +import type { WidgetServerUpdateOptions, WidgetServerUpdateSettings, WidgetServerUpdateSnapshot } from '../types.js' +import { getNativeVoltra } from '../VoltraModule.js' + +export type { + WidgetServerUpdateBody, + WidgetServerUpdateOptions, + WidgetServerUpdateSettings, + WidgetServerUpdateSnapshot, +} from '../types.js' + +/** + * Overrides a server-driven widget's `serverUpdate` settings at runtime. + * + * The `serverUpdate` entry in app.json supplies the defaults; this replaces any of them for one + * widget, or for every server-driven widget when no `widgetId` is given. A widget-scoped call wins + * over a global one, and `headers` and `query` merge per key across the two. + * + * Each call replaces the whole layer it writes, so pass every field you want to keep. Setting + * anything reschedules the widgets it affects and fetches once immediately. + * + * @example Point a widget at the tenant's own backend once the user has logged in. + * ```ts + * await setWidgetServerUpdate( + * { url: `https://${tenant}.example.com/widgets/portfolio`, headers: { Authorization: `Bearer ${token}` } }, + * { widgetId: 'portfolio' } + * ) + * ``` + * + * @example Take a widget over and drive it from the app until you hand it back. + * ```ts + * await setWidgetServerUpdate({ enabled: false }, { widgetId: 'portfolio' }) + * await updateDynamicWidget('portfolio', localProps) + * ``` + * + * @throws if the widget has no `serverUpdate` in app.json, if the URL is not https (plain http is + * allowed only in a debug build, and only for a local dev host), or if `query` names one of the + * parameters Voltra already sends. + */ +export async function setWidgetServerUpdate( + settings: WidgetServerUpdateSettings, + options?: WidgetServerUpdateOptions +): Promise { + return getNativeVoltra().setWidgetServerUpdate(JSON.stringify(settings ?? {}), options?.widgetId ?? null) +} + +/** + * Drops the runtime settings for one widget, or the global ones when no `widgetId` is given, so + * the widget falls back to what app.json configured. + * + * Clearing the global settings is the logout gesture: along with the settings it drops what the + * server last sent for every server-driven widget, so a Dynamic Widget goes back to `{}` with + * `env.serverUpdate.status` of `never` rather than showing the previous account's data. Credentials + * set with the deprecated `setWidgetServerCredentials` are stored separately — clear those with + * `clearWidgetServerCredentials`. + */ +export async function clearWidgetServerUpdate(options?: WidgetServerUpdateOptions): Promise { + return getNativeVoltra().clearWidgetServerUpdate(options?.widgetId ?? null) +} + +/** + * Reads a widget's `serverUpdate` settings back, without reasoning about what was set where. + * + * With a `widgetId`, this is the fully resolved settings that widget would fetch with right now: + * every layer flattened and app.json's defaults applied — `null` if the widget is not + * server-driven. Without one, this is the raw contents of the global layer only — what the last + * `setWidgetServerUpdate(settings)` call (with no `widgetId`) wrote, with no defaults applied and + * every field optional — `null` if nothing has been set globally. + * + * @example Check what a widget is about to fetch from. + * ```ts + * const snapshot = await getWidgetServerUpdate({ widgetId: 'portfolio' }) + * if (snapshot?.enabled) { + * console.log(`portfolio fetches ${snapshot.url} every ${snapshot.intervalMinutes}m`) + * } + * ``` + */ +export async function getWidgetServerUpdate(options: { widgetId: string }): Promise +export async function getWidgetServerUpdate(options?: undefined): Promise +export async function getWidgetServerUpdate( + options?: WidgetServerUpdateOptions +): Promise { + const json = await getNativeVoltra().getWidgetServerUpdate(options?.widgetId ?? null) + return json == null ? null : JSON.parse(json) +} diff --git a/packages/ios/src/index.ts b/packages/ios/src/index.ts index f2f42153..f9468004 100644 --- a/packages/ios/src/index.ts +++ b/packages/ios/src/index.ts @@ -36,6 +36,10 @@ export type { VoltraNodeJson, VoltraPropValue, WidgetServerCredentials, + WidgetServerUpdateBody, + WidgetServerUpdateOptions, + WidgetServerUpdateSettings, + WidgetServerUpdateSnapshot, } from './types.js' export { renderWidgetToJson, renderWidgetToString } from './widgets/renderer.js' export type { ScheduledWidgetEntry, WidgetFamily, WidgetInfo, WidgetVariants } from './widgets/types.js' diff --git a/packages/ios/src/types.ts b/packages/ios/src/types.ts index 6359e6b5..bc5ccda3 100644 --- a/packages/ios/src/types.ts +++ b/packages/ios/src/types.ts @@ -36,7 +36,66 @@ export type UpdateWidgetOptions = { deepLinkUrl?: string } +/** + * @deprecated Use `setWidgetServerUpdate` with an `Authorization` header instead. These are + * stored in the same place and keep the same replace-the-whole-set semantics. + */ export type WidgetServerCredentials = { token: string headers?: Record } + +/** + * Runtime overrides for a widget's `serverUpdate` settings — the twin of the `serverUpdate` key + * in app.json, which supplies the defaults. + * + * Every field is optional and replaces the app.json value when set. `headers` and `query` merge + * per key across layers; everything else takes the value from the most specific layer that sets + * it. Passing settings without a `widgetId` sets them for every server-driven widget. + */ +export type WidgetServerUpdateSettings = { + /** Endpoint to fetch from. Must be https, or http to a local dev host in a debug build. */ + url?: string + /** How often to fetch, in minutes. Clamped to at least 15 and at most 24 hours. */ + intervalMinutes?: number + /** Set false to stop fetching and drive the widget from the app instead. Defaults to true. */ + enabled?: boolean + /** HTTP method. Defaults to GET. A body is dropped on GET and HEAD, with a warning. */ + method?: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE' + /** Extra query parameters. Voltra's own keys are reserved and rejected. */ + query?: Record + /** Extra request headers, for example `Authorization`. */ + headers?: Record + /** Request body, sent as `application/json`. */ + body?: WidgetServerUpdateBody +} + +/** A JSON value, as accepted for a server-update request body. */ +export type WidgetServerUpdateBody = + | string + | number + | boolean + | null + | WidgetServerUpdateBody[] + | { [key: string]: WidgetServerUpdateBody } + +/** Options selecting which widget a settings call applies to. */ +export type WidgetServerUpdateOptions = { + /** Widget id to scope the settings to. Omit to set them for every server-driven widget. */ + widgetId?: string +} + +/** + * Fully resolved settings for one widget: every layer flattened and app.json's defaults applied, + * exactly what it would fetch with right now. + */ +export type WidgetServerUpdateSnapshot = { + /** Absent when the widget is server-driven but has no URL yet. */ + url?: string + intervalMinutes: number + enabled: boolean + method: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE' + query: Record + headers: Record + body?: WidgetServerUpdateBody +} diff --git a/skills/voltra/references/plugin-schema.md b/skills/voltra/references/plugin-schema.md index 56916000..7f68e1e4 100644 --- a/skills/voltra/references/plugin-schema.md +++ b/skills/voltra/references/plugin-schema.md @@ -22,9 +22,10 @@ Use top-level `widgets` for iOS widget gallery registration. - `description` (string or per-locale map) - `supportedFamilies`: array of iOS families such as `systemSmall`, `systemMedium`, `systemLarge` - `initialStatePath` (string or per-locale map of paths for localized pre-render) -- `serverUpdate.url`: widget endpoint, Voltra appends `widgetId`, `platform=ios`, `family`, and `theme` -- `serverUpdate.intervalMinutes`: polling interval, default `15`, subject to WidgetKit throttling +- `serverUpdate.url`: widget endpoint, Voltra appends `widgetId`, `platform=ios`, `family`, `theme`, and `locale`. Optional; omit it to supply the URL at runtime with `setWidgetServerUpdate` +- `serverUpdate.intervalMinutes`: polling interval, default `15`, subject to WidgetKit throttling. With `entry`, floor and default are both `15` - `serverUpdate.refresh`: native refresh button, default `false` +- `entry` plus `serverUpdate`: server returns plain JSON props rather than a rendered payload; requires `groupIdentifier`. `family` is not sent Other important Apple-side keys: @@ -60,9 +61,10 @@ Use `@use-voltra/android-client` for Android config. - `resizeMode` - `widgetCategory` - `initialStatePath` (string or per-locale map of paths) -- `serverUpdate.url`: widget endpoint, Voltra appends `widgetId`, `platform=android`, and `theme` -- `serverUpdate.intervalMinutes`: polling interval; use at least 15 minutes +- `serverUpdate.url`: widget endpoint, Voltra appends `widgetId`, `platform=android`, `theme`, and `locale`. Optional; omit it to supply the URL at runtime with `setWidgetServerUpdate` +- `serverUpdate.intervalMinutes`: polling interval, default `60`; use at least 15 minutes. With `entry`, floor and default are both `15` - `serverUpdate.refresh`: native refresh button, default `false` +- `entry` plus `serverUpdate`: server returns plain JSON props rather than a rendered payload. `family` is not sent - `previewImage` - `previewLayout` diff --git a/website/docs/v2/android/api/plugin-configuration.md b/website/docs/v2/android/api/plugin-configuration.md index 0c93200c..81bdbbd4 100644 --- a/website/docs/v2/android/api/plugin-configuration.md +++ b/website/docs/v2/android/api/plugin-configuration.md @@ -68,9 +68,9 @@ Array of widget configurations for Home Screen widgets. Each widget will be avai - `initialStatePath`: (optional) Path to a file that exports initial widget state, or a locale map of paths for localized build-time pre-rendering (see [Widget Pre-rendering](../development/widget-pre-rendering)) - `previewImage`: (optional) Path to preview image for widget picker (PNG/JPG/WebP) - `previewLayout`: (optional) Path to custom XML layout for widget picker preview (Android 12+) -- `serverUpdate`: (optional) Enable server-driven updates. See [Server-driven widgets](../development/server-driven-widgets) for full details. +- `serverUpdate`: (optional) Fetch the widget's content on a schedule. Without `entry` the server returns a rendered payload; with `entry` it returns plain JSON that the widget renders on the device. `url` is optional — leave it out to supply one at runtime. See [Server-driven widgets](../development/server-driven-widgets) for full details. - `url`: The Voltra SSR endpoint URL - - `intervalMinutes`: Update interval in minutes (default: `15`, minimum 15 per WorkManager) + - `intervalMinutes`: Update interval in minutes (default: `60`, or `15` for a widget with `entry`; minimum 15) - `refresh`: Show a native refresh button (default: `false`) ### Localizing `displayName` and `description` diff --git a/website/docs/v2/android/development/dynamic-widgets.md b/website/docs/v2/android/development/dynamic-widgets.md index cd6ae30d..81b9428d 100644 --- a/website/docs/v2/android/development/dynamic-widgets.md +++ b/website/docs/v2/android/development/dynamic-widgets.md @@ -149,6 +149,25 @@ Before the first call to `updateAndroidDynamicWidget`, the entry component recei `updateAndroidDynamicWidget` updates an entry-based Dynamic Widget by passing runtime props to its entry component. The legacy `updateAndroidWidget` API sends pre-rendered variant payloads to a payload-driven widget and cannot update an entry-based Dynamic Widget. Calling it on a Dynamic Widget now rejects with `VOLTRA_WIDGET_KIND_MISMATCH`, and `updateAndroidDynamicWidget` rejects the same way when called on a payload-driven widget. ::: +## Fetching props from a server + +Props do not have to come from the app. Add a `serverUpdate` alongside `entry` and the widget fetches a JSON object on a schedule and renders it, without the app running: + +```json +{ + "id": "portfolio", + "entry": "./widgets/android/portfolio.tsx", + "serverUpdate": { + "url": "https://api.example.com/widgets/portfolio", + "intervalMinutes": 30 + } +} +``` + +The response object becomes the same first argument `updateAndroidDynamicWidget` passes, so the entry component does not change. Because the server returns data rather than a rendered payload, the backend can be written in any language. + +`updateAndroidDynamicWidget` keeps working on a server-driven widget — the next fetch simply overwrites what you wrote. See [Server-driven widgets](./server-driven-widgets) for the response contract, the `env.serverUpdate` fields, and how to take a widget over. + ## Runtime props and configuration are separate Dynamic Widget props are app-owned state passed as the entry component's first argument. Configuration values are declared through `appIntent.parameters`, updated in-app with `setWidgetConfiguration(widgetId, key, value)`, and read from `env.configuration`. Updating one does not replace the other. diff --git a/website/docs/v2/android/development/server-driven-widgets.md b/website/docs/v2/android/development/server-driven-widgets.md index 4f63a3a9..268bc392 100644 --- a/website/docs/v2/android/development/server-driven-widgets.md +++ b/website/docs/v2/android/development/server-driven-widgets.md @@ -15,6 +15,8 @@ Android semantic color tokens from [`AndroidDynamicColors`](./dynamic-colors) wo Your app doesn't need to be running. WorkManager handles everything in the background. +A widget that also has an `entry` works the other way round: your server returns plain JSON data and the widget renders it on the device, so the backend can be written in any language. See [Returning data instead of UI](#returning-data-instead-of-ui). + ## Plugin configuration Add the `serverUpdate` option to your Android widget in `app.json` or `app.config.js`: @@ -48,8 +50,8 @@ Add the `serverUpdate` option to your Android widget in `app.json` or `app.confi **`serverUpdate` options:** -- `url`: The Voltra SSR endpoint that returns widget JSON. Voltra appends `widgetId`, `platform`, and `theme` query parameters automatically (e.g. `?widgetId=dynamic_weather&platform=android&theme=dark`). -- `intervalMinutes`: How often the widget fetches updates. Defaults to `15`. The minimum effective interval is 15 minutes (WorkManager requirement). +- `url`: The endpoint the widget fetches from. Voltra appends `widgetId`, `platform`, `theme`, and `locale` query parameters automatically (e.g. `?widgetId=dynamic_weather&platform=android&theme=dark&locale=en-US`). Optional — leave it out to mark the widget server-driven and supply the URL after login with [`setWidgetServerUpdate`](#changing-settings-at-runtime). +- `intervalMinutes`: How often the widget fetches updates. Defaults to `60`, or `15` for a widget that has an `entry`. The minimum is 15 minutes. - `refresh`: Whether to show a native refresh button in the top-right corner of the widget. When tapped, triggers an immediate server fetch. Defaults to `false`. After updating plugin configuration, run `npx expo prebuild` if you're using Continuous Native Generation, then rebuild the app so the generated native widget code picks up the new server update settings. @@ -121,13 +123,117 @@ The handler responds to GET requests with these query parameters: | `platform` | The requesting platform. Must be `android` (required). | | `family` | Not used on Android | | `theme` | The system color scheme (`light` or `dark`) | +| `locale` | The device locale as a BCP-47 tag, e.g. `en-US` | The `User-Agent` header is set to `VoltraWidget/ (Android/)`. +## Returning data instead of UI + +Everything above assumes your server renders Voltra components and returns a UI payload, which means it has to run Node. If you give the widget an `entry`, it renders on the device instead and your server returns plain JSON — so it can be written in any language. + +Add both keys to the same widget: + +```json +{ + "id": "portfolio", + "displayName": "Portfolio", + "description": "Your holdings", + "targetCellWidth": 2, + "targetCellHeight": 2, + "entry": "./widgets/android/portfolio.tsx", + "initialStatePath": "./widgets/android/portfolio.tsx", + "serverUpdate": { + "url": "https://api.example.com/widgets/portfolio", + "intervalMinutes": 30 + } +} +``` + +The response body becomes the widget's props, verbatim: + +```php + 12480.55, + 'change' => 1.8, + 'holdings' => [ + ['symbol' => 'AAPL', 'value' => 8200.00], + ['symbol' => 'MSFT', 'value' => 4280.55], + ], +]); +``` + +Your widget entry receives that object as its first argument, the same shape you would pass to `updateAndroidDynamicWidget`: + +```tsx +export default function PortfolioWidget(props, env) { + return ( + + ${props.total.toFixed(2)} + {props.change}% today + + ) +} +``` + +Check the endpoint with curl before wiring up the widget: + +```bash +curl "https://api.example.com/widgets/portfolio?widgetId=portfolio&platform=android&theme=dark&locale=en-US" \ + -H "Accept: application/json" +``` + +### What the response has to be + +- Status `200` with `Content-Type: application/json` and a JSON **object**. An array, a string, a number or `null` at the top level is rejected. +- At most 256 KB. +- Return `ETag` and Voltra sends it back as `If-None-Match` on the next fetch. A `304` means the widget keeps what it has and counts as fresh. This applies to widgets with an `entry`; a payload widget's request is unconditional. +- `Cache-Control: max-age=N` moves the next fetch, clamped between 15 minutes and 24 hours. `Retry-After` on a `429` or `503` is honoured the same way. + +Returning a rendered Voltra payload from an endpoint whose widget has an `entry` is rejected with a log line naming the mismatch. The widget has an entry, so it wants the data, not the picture. + +### Telling fresh data from stale + +The widget is never blanked by a failed fetch: it keeps rendering the last props that arrived, whether from the server or from `updateAndroidDynamicWidget`. `env.serverUpdate` says which: + +```tsx +export default function PortfolioWidget(props, env) { + const stale = env.serverUpdate?.status === 'stale' + + return ( + + ${props.total?.toFixed(2) ?? '—'} + {env.serverUpdate?.fetchedAt && ( + + Updated {new Date(env.serverUpdate.fetchedAt).toLocaleTimeString()} + + )} + + ) +} +``` + +| Field | Meaning | +|-------|---------| +| `status` | `fresh` after a `200` or `304`; `stale` when a fetch has succeeded before but the last one failed; `never` before the first success; `disabled` while your app has taken the widget over | +| `fetchedAt` | Epoch milliseconds of the last `200` or `304`. Absent until a fetch succeeds. | +| `error` | `network`, `http`, `unauthorized`, `parse`, or `render`. Absent when `status` is `fresh`. | +| `httpStatus` | Status code of the last response, when there was one | + +`env.serverUpdate` is `undefined` on widgets without a `serverUpdate`. + +Props that throw during rendering are never committed — Voltra renders them once off screen first, and keeps the previous props when that fails. `env.serverUpdate.error` is `render` while that lasts. + ## Authentication Widgets on Android are part of the main app binary, so the WorkManager background worker can access credential storage directly. Voltra credentials are encrypted at rest on-device. +:::note +`setWidgetServerCredentials` is deprecated in favour of [`setWidgetServerUpdate`](#changing-settings-at-runtime) with an `Authorization` header, which can also set the URL, the interval, the method, query parameters and a body. Both write the same encrypted records, so switching is a one-line change and nothing has to be migrated on device. +::: + ### Setting credentials Call `setWidgetServerCredentials` after the user logs in: @@ -157,6 +263,90 @@ await clearWidgetServerCredentials() All widgets are automatically reloaded after credentials are cleared, so they revert to their default/unauthenticated state immediately. +## Changing settings at runtime + +`serverUpdate` in `app.json` is the default. Once the app runs it can change any of it, per widget or for all of them, without a rebuild: + +```typescript +import { setWidgetServerUpdate } from '@use-voltra/android-client' + +await setWidgetServerUpdate( + { + url: `https://${tenant}.example.com/widgets/portfolio`, + intervalMinutes: 30, + headers: { Authorization: `Bearer ${accessToken}` }, + }, + { widgetId: 'portfolio' } +) +``` + +| Setting | | +|---------|--| +| `url` | Must be `https`, or `http` to a local dev host (`localhost`, `127.0.0.1`, `::1`, `10.0.2.2`, `10.0.3.2`) in a debug build | +| `intervalMinutes` | Clamped between 15 minutes and 24 hours | +| `enabled` | `false` stops fetching until you set it back | +| `method` | `GET` (default), `POST`, `PUT`, `PATCH` or `DELETE` | +| `query` | Extra query parameters | +| `headers` | Extra request headers | +| `body` | Sent as `application/json` | + +Leave out `widgetId` to set the same values for every server-driven widget. A widget-scoped call wins over a global one; `headers` and `query` merge per key across the two, everything else takes the more specific value. + +Each call replaces everything it set last time, so pass every field you want to keep. Setting anything reschedules the widgets it affects and fetches once straight away. + +Use this instead of `setWidgetServerCredentials`, which does the same thing for the `Authorization` header alone: + +```typescript +// Before +await setWidgetServerCredentials({ token: accessToken, headers: { 'X-App-Version': '1.0.0' } }) + +// After +await setWidgetServerUpdate({ + headers: { Authorization: `Bearer ${accessToken}`, 'X-App-Version': '1.0.0' }, +}) +``` + +To go back to what `app.json` configured, clear the settings: + +```typescript +import { clearWidgetServerUpdate } from '@use-voltra/android-client' + +await clearWidgetServerUpdate({ widgetId: 'portfolio' }) +await clearWidgetServerUpdate() +``` + +Clearing the global settings is the logout gesture. Along with the settings it drops what the server last sent — the props and the "updated at" of every server-driven widget — so a Dynamic Widget goes back to rendering `{}` with `env.serverUpdate.status` of `never` rather than showing the previous account's data. A widget-scoped clear only drops that widget's overrides and leaves its props alone. + +Read the settings back with `getWidgetServerUpdate`: + +```typescript +import { getWidgetServerUpdate } from '@use-voltra/android-client' + +const snapshot = await getWidgetServerUpdate({ widgetId: 'portfolio' }) +if (snapshot?.enabled) { + console.log(`portfolio fetches ${snapshot.url} every ${snapshot.intervalMinutes}m`) +} +``` + +With a `widgetId`, this is the fully resolved settings that widget would fetch with right now — every layer flattened and `app.json`'s defaults applied, or `null` if the widget isn't server-driven. Without one, it's the raw global layer only: what the last widget-less `setWidgetServerUpdate` call wrote, with no defaults applied, or `null` if nothing has been set globally. + +Credentials set with the deprecated `setWidgetServerCredentials` are stored separately and are not affected; clear those with `clearWidgetServerCredentials`. + +Calling either function for a widget that has no `serverUpdate` in `app.json` throws. Whether a widget is server-driven is decided when the native project is generated, so a runtime URL cannot turn a local widget into one — add `serverUpdate` to `app.json` and rebuild. + +A `body` set alongside `GET` or `HEAD` is dropped with a warning, because neither platform's HTTP stack can send one. + +### Driving a widget from the app + +`updateAndroidWidget` and `updateAndroidDynamicWidget` work on server-driven widgets, but the next scheduled fetch overwrites what you wrote. To keep it, turn fetching off first: + +```typescript +await setWidgetServerUpdate({ enabled: false }, { widgetId: 'portfolio' }) +await updateAndroidDynamicWidget('portfolio', localProps) +``` + +While fetching is off, `env.serverUpdate.status` is `disabled`, so the widget can hide its "updated N minutes ago" line. Set `enabled: true`, or clear the settings, to hand it back to the server. + ## Refresh button Server-driven widgets can display a native refresh button that lets users trigger an immediate update on demand. Enable it in your widget config: @@ -171,7 +361,9 @@ Server-driven widgets can display a native refresh button that lets users trigge } ``` -When enabled, a small circular button (↻) appears in the top-right corner of the widget. Tapping it performs an inline HTTP fetch and pushes the update directly to the widget—all without waiting for the next WorkManager cycle. +When enabled, a small circular button (↻) appears in the top-right corner of the widget. + +On a payload widget, tapping it performs an inline HTTP fetch and pushes the update directly—without waiting for the next scheduled fetch. On a widget with an `entry`, a tap with no signal is queued and retries until it succeeds, rather than failing silently. ## Resize handling @@ -255,3 +447,6 @@ WorkManager automatically handles failures with exponential backoff. After 5 con - **Server errors (non-2xx):** The worker retries with exponential backoff, up to 3 attempts. - **Empty response:** The worker retries with exponential backoff, up to 3 attempts. - **Parse errors:** If the JSON is stored but parsing fails, the data is still saved so Glance can attempt to use it later. This counts as a success since the data is persisted. +- **`401` or `403`:** The worker stops rather than backing off — the status will not change until you set a new token, and doing so reloads the widget anyway. + +For widgets that return props rather than UI, the widget keeps rendering the last props it has through every one of these, and `env.serverUpdate` says what went wrong. diff --git a/website/docs/v2/ios/api/plugin-configuration.md b/website/docs/v2/ios/api/plugin-configuration.md index de8a648f..fd983ca2 100644 --- a/website/docs/v2/ios/api/plugin-configuration.md +++ b/website/docs/v2/ios/api/plugin-configuration.md @@ -103,7 +103,7 @@ Array of widget configurations for Home Screen widgets. Each widget will be avai - `description`: Description shown in the widget gallery (same localization rules as `displayName`) - `supportedFamilies`: Array of supported widget sizes (`systemSmall`, `systemMedium`, `systemLarge`) - `initialStatePath`: (optional) Project-relative path to a file that exports initial widget state, **or** a locale map of paths for localized build-time pre-rendering (see [Widget Pre-rendering](../development/widget-pre-rendering)) -- `serverUpdate`: (optional) Enable server-driven updates. See [Server-driven widgets](../development/server-driven-widgets) for full details. +- `serverUpdate`: (optional) Fetch the widget's content on a schedule. Without `entry` the server returns a rendered payload; with `entry` it returns plain JSON that the widget renders on the device. `url` is optional — leave it out to supply one at runtime. See [Server-driven widgets](../development/server-driven-widgets) for full details. - `url`: The Voltra SSR endpoint URL - `intervalMinutes`: Update interval in minutes (default: `15`) - `refresh`: Show a native refresh button (default: `false`, requires iOS 17+) diff --git a/website/docs/v2/ios/development/dynamic-widgets.md b/website/docs/v2/ios/development/dynamic-widgets.md index 019bda91..0654b62a 100644 --- a/website/docs/v2/ios/development/dynamic-widgets.md +++ b/website/docs/v2/ios/development/dynamic-widgets.md @@ -136,6 +136,25 @@ Before the first call to `updateDynamicWidget`, the entry component receives `{} `updateDynamicWidget` updates an entry-based Dynamic Widget by passing runtime props to its entry component. The legacy `updateWidget` API sends pre-rendered variant payloads to a payload-driven widget and cannot update an entry-based Dynamic Widget. ::: +## Fetching props from a server + +Props do not have to come from the app. Add a `serverUpdate` alongside `entry` and the widget fetches a JSON object on a schedule and renders it, without the app running: + +```json +{ + "id": "portfolio", + "entry": "./widgets/ios/portfolio.tsx", + "serverUpdate": { + "url": "https://api.example.com/widgets/portfolio", + "intervalMinutes": 30 + } +} +``` + +The response object becomes the same first argument `updateDynamicWidget` passes, so the entry component does not change. Because the server returns data rather than a rendered payload, the backend can be written in any language. + +`updateDynamicWidget` keeps working on a server-driven widget — the next fetch simply overwrites what you wrote. See [Server-driven widgets](./server-driven-widgets) for the response contract, the `env.serverUpdate` fields, and how to take a widget over. + ## Runtime props and configuration are separate Dynamic Widget props are app-owned state passed as the entry component's first argument. Configuration values are declared through `appIntent.parameters`, edited by the user in the native iOS Edit Widget sheet, and read from `env.configuration`. Updating runtime props does not replace configuration. diff --git a/website/docs/v2/ios/development/server-driven-widgets.md b/website/docs/v2/ios/development/server-driven-widgets.md index 4eaa289d..3aa0e140 100644 --- a/website/docs/v2/ios/development/server-driven-widgets.md +++ b/website/docs/v2/ios/development/server-driven-widgets.md @@ -13,6 +13,8 @@ Before you start, make sure the widget is registered in the Voltra plugin config The entire lifecycle is managed by the OS timeline system. Your app doesn't need to be running. +A widget that also has an `entry` works the other way round: your server returns plain JSON data and the widget renders it on the device, so the backend can be written in any language. See [Returning data instead of UI](#returning-data-instead-of-ui). + ## Plugin configuration Add the `serverUpdate` option to your widget in `app.json` or `app.config.js`: @@ -45,8 +47,8 @@ Add the `serverUpdate` option to your widget in `app.json` or `app.config.js`: **`serverUpdate` options:** -- `url`: The Voltra SSR endpoint that returns widget JSON. Voltra appends `widgetId`, `platform`, `family`, and `theme` query parameters automatically (e.g. `?widgetId=dynamic_weather&platform=ios&family=systemSmall&theme=dark`). -- `intervalMinutes`: How often the widget fetches updates. Defaults to `15`. iOS WidgetKit may throttle requests; the minimum effective interval is ~15 minutes. +- `url`: The endpoint the widget fetches from. Voltra appends `widgetId`, `platform`, `family`, `theme`, and `locale` query parameters automatically (e.g. `?widgetId=dynamic_weather&platform=ios&family=systemSmall&theme=dark&locale=en-US`). Optional — leave it out to mark the widget server-driven and supply the URL after login with [`setWidgetServerUpdate`](#changing-settings-at-runtime). +- `intervalMinutes`: How often the widget fetches updates. Defaults to `15`. WidgetKit may stretch it: the reload budget is shared across every widget in your app, and frequently viewed widgets get roughly 40 to 70 reloads a day between them. - `refresh`: Whether to show a native refresh button in the top-right corner of the widget. When tapped, triggers an immediate server fetch. Defaults to `false`. Requires iOS 17+. After updating plugin configuration, run `npx expo prebuild` if you're using Continuous Native Generation, then rebuild the app so the generated native files and widget extension pick up the new server update settings. @@ -110,6 +112,7 @@ The handler responds to GET requests with these query parameters: | `platform` | The requesting platform. Must be `ios` for iOS widgets (required). | | `family` | The widget family/size (iOS only) | | `theme` | The system color scheme (`light` or `dark`) | +| `locale` | The device locale as a BCP-47 tag, e.g. `en-US` | The `Authorization: Bearer ` header is automatically extracted and passed to `validateToken` and `render`. The `User-Agent` header is set to `VoltraWidget/1.0 (iOS/)`. @@ -125,10 +128,114 @@ export const GET = createIOSWidgetUpdateHandler({ }) ``` +## Returning data instead of UI + +Everything above assumes your server renders Voltra components and returns a UI payload, which means it has to run Node. If you give the widget an `entry`, it renders on the device instead and your server returns plain JSON — so it can be written in any language. + +Add both keys to the same widget. A widget with an `entry` and a `serverUpdate` also needs a `groupIdentifier`, because the fetched props are shared with the widget extension through the App Group: + +```json +{ + "id": "portfolio", + "displayName": "Portfolio", + "description": "Your holdings", + "supportedFamilies": ["systemSmall", "systemMedium"], + "entry": "./widgets/ios/portfolio.tsx", + "initialStatePath": "./widgets/ios/portfolio.tsx", + "serverUpdate": { + "url": "https://api.example.com/widgets/portfolio", + "intervalMinutes": 30 + } +} +``` + +The response body becomes the widget's props, verbatim: + +```php + 12480.55, + 'change' => 1.8, + 'holdings' => [ + ['symbol' => 'AAPL', 'value' => 8200.00], + ['symbol' => 'MSFT', 'value' => 4280.55], + ], +]); +``` + +Your widget entry receives that object as its first argument, the same shape you would pass to `updateDynamicWidget`: + +```tsx +export default function PortfolioWidget(props, env) { + return ( + + ${props.total.toFixed(2)} + {props.change}% today + + ) +} +``` + +Check the endpoint with curl before wiring up the widget: + +```bash +curl "https://api.example.com/widgets/portfolio?widgetId=portfolio&platform=ios&theme=dark&locale=en-US" \ + -H "Accept: application/json" +``` + +One fetch serves every size and instance of the widget, so the request carries no `family` and your props have to be size-agnostic. The entry picks its layout from `env.widgetFamily`, the way it already does. + +### What the response has to be + +- Status `200` with `Content-Type: application/json` and a JSON **object**. An array, a string, a number or `null` at the top level is rejected. +- At most 256 KB. The widget extension has a 30 MB memory ceiling for the whole render. +- Return `ETag` and Voltra sends it back as `If-None-Match` on the next fetch. A `304` means the widget keeps what it has and counts as fresh. This applies to widgets with an `entry`; a payload widget's request is unconditional. +- `Cache-Control: max-age=N` moves the next fetch, clamped between 15 minutes and 24 hours. `Retry-After` on a `429` or `503` is honoured the same way. + +Returning a rendered Voltra payload from an endpoint whose widget has an `entry` is rejected with a log line naming the mismatch. The widget has an entry, so it wants the data, not the picture. + +### Telling fresh data from stale + +The widget is never blanked by a failed fetch: it keeps rendering the last props that arrived, whether from the server or from `updateDynamicWidget`. `env.serverUpdate` says which: + +```tsx +export default function PortfolioWidget(props, env) { + const stale = env.serverUpdate?.status === 'stale' + + return ( + + ${props.total?.toFixed(2) ?? '—'} + {env.serverUpdate?.fetchedAt && ( + + Updated {new Date(env.serverUpdate.fetchedAt).toLocaleTimeString()} + + )} + + ) +} +``` + +| Field | Meaning | +|-------|---------| +| `status` | `fresh` after a `200` or `304`; `stale` when a fetch has succeeded before but the last one failed; `never` before the first success; `disabled` while your app has taken the widget over | +| `fetchedAt` | Epoch milliseconds of the last `200` or `304`. Absent until a fetch succeeds. | +| `error` | `network`, `http`, `unauthorized`, `parse`, or `render`. Absent when `status` is `fresh`. | +| `httpStatus` | Status code of the last response, when there was one | + +`env.serverUpdate` is `undefined` on widgets without a `serverUpdate`. + +Props that throw during rendering are never committed — Voltra renders them once off screen first, and keeps the previous props when that fails. `env.serverUpdate.error` is `render` while that lasts. + ## Authentication Widgets run in a separate extension process and can't access your app's network layer or auth state. Voltra solves this by storing credentials in the **Shared Keychain**, which is accessible by both the main app and the widget extension. +:::note +`setWidgetServerCredentials` is deprecated in favour of [`setWidgetServerUpdate`](#changing-settings-at-runtime) with an `Authorization` header, which can also set the URL, the interval, the method, query parameters and a body. Both write the same Keychain records, so switching is a one-line change and nothing has to be migrated on device. +::: + ### Setting credentials Call `setWidgetServerCredentials` after the user logs in: @@ -180,6 +287,90 @@ For credentials to be shared between the main app and the widget extension, both If you don't specify `keychainGroup` but any widget has `serverUpdate` configured, Voltra automatically derives a default: `$(AppIdentifierPrefix)`. +## Changing settings at runtime + +`serverUpdate` in `app.json` is the default. Once the app runs it can change any of it, per widget or for all of them, without a rebuild: + +```typescript +import { setWidgetServerUpdate } from '@use-voltra/ios-client' + +await setWidgetServerUpdate( + { + url: `https://${tenant}.example.com/widgets/portfolio`, + intervalMinutes: 30, + headers: { Authorization: `Bearer ${accessToken}` }, + }, + { widgetId: 'portfolio' } +) +``` + +| Setting | | +|---------|--| +| `url` | Must be `https`, or `http` to a local dev host (`localhost`, `127.0.0.1`, `::1`, `10.0.2.2`, `10.0.3.2`) in a debug build | +| `intervalMinutes` | Clamped between 15 minutes and 24 hours | +| `enabled` | `false` stops fetching until you set it back | +| `method` | `GET` (default), `POST`, `PUT`, `PATCH` or `DELETE` | +| `query` | Extra query parameters | +| `headers` | Extra request headers | +| `body` | Sent as `application/json` | + +Leave out `widgetId` to set the same values for every server-driven widget. A widget-scoped call wins over a global one; `headers` and `query` merge per key across the two, everything else takes the more specific value. + +Each call replaces everything it set last time, so pass every field you want to keep. Setting anything reloads the widgets it affects, so the change takes effect without waiting out the current interval. + +Use this instead of `setWidgetServerCredentials`, which does the same thing for the `Authorization` header alone: + +```typescript +// Before +await setWidgetServerCredentials({ token: accessToken, headers: { 'X-App-Version': '1.0.0' } }) + +// After +await setWidgetServerUpdate({ + headers: { Authorization: `Bearer ${accessToken}`, 'X-App-Version': '1.0.0' }, +}) +``` + +To go back to what `app.json` configured, clear the settings: + +```typescript +import { clearWidgetServerUpdate } from '@use-voltra/ios-client' + +await clearWidgetServerUpdate({ widgetId: 'portfolio' }) +await clearWidgetServerUpdate() +``` + +Clearing the global settings is the logout gesture. Along with the settings it drops what the server last sent — the props and the "updated at" of every server-driven widget — so a Dynamic Widget goes back to rendering `{}` with `env.serverUpdate.status` of `never` rather than showing the previous account's data. A widget-scoped clear only drops that widget's overrides and leaves its props alone. + +Read the settings back with `getWidgetServerUpdate`: + +```typescript +import { getWidgetServerUpdate } from '@use-voltra/ios-client' + +const snapshot = await getWidgetServerUpdate({ widgetId: 'portfolio' }) +if (snapshot?.enabled) { + console.log(`portfolio fetches ${snapshot.url} every ${snapshot.intervalMinutes}m`) +} +``` + +With a `widgetId`, this is the fully resolved settings that widget would fetch with right now — every layer flattened and `app.json`'s defaults applied, or `null` if the widget isn't server-driven. Without one, it's the raw global layer only: what the last widget-less `setWidgetServerUpdate` call wrote, with no defaults applied, or `null` if nothing has been set globally. + +Credentials set with the deprecated `setWidgetServerCredentials` are stored separately and are not affected; clear those with `clearWidgetServerCredentials`. + +Calling either function for a widget that has no `serverUpdate` in `app.json` throws. Whether a widget is server-driven is decided when the native project is generated, so a runtime URL cannot turn a local widget into one — add `serverUpdate` to `app.json` and rebuild. + +A `body` set alongside `GET` or `HEAD` is dropped with a warning, because `URLSession` cannot send one. + +### Driving a widget from the app + +`updateWidget` and `updateDynamicWidget` work on server-driven widgets, but the next scheduled fetch overwrites what you wrote. To keep it, turn fetching off first: + +```typescript +await setWidgetServerUpdate({ enabled: false }, { widgetId: 'portfolio' }) +await updateDynamicWidget('portfolio', localProps) +``` + +While fetching is off, `env.serverUpdate.status` is `disabled`, so the widget can hide its "updated N minutes ago" line. Set `enabled: true`, or clear the settings, to hand it back to the server. + ## Refresh button Server-driven widgets can display a native refresh button that lets users trigger an immediate update on demand. Enable it in your widget config: @@ -250,6 +441,10 @@ When a server fetch fails, the widget extension falls back to the last successfu Parse errors are handled slightly differently: if the server returns a 2xx response but the JSON can't be parsed into a valid widget tree, the cached data from the previous successful fetch is preserved (not overwritten), so the widget keeps showing the last known good content. +A `401` or `403` does not shorten the retry: the status will not change until you set a new token, and doing so reloads the widget anyway. + +For widgets that return props rather than UI, the widget keeps rendering the last props it has through every one of these, and `env.serverUpdate` says what went wrong. + :::note WidgetKit may also throttle updates based on battery level and widget visibility. :::