Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions .changeset/server-driven-dynamic-widgets.md
Original file line number Diff line number Diff line change
@@ -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.
7 changes: 6 additions & 1 deletion docs/adr/0002-server-driven-dynamic-widgets.md
Original file line number Diff line number Diff line change
@@ -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).

Expand Down Expand Up @@ -197,6 +197,11 @@ type WidgetServerUpdateSettings = {
setWidgetServerUpdate(settings: WidgetServerUpdateSettings, options?: { widgetId?: string }): Promise<void>
clearWidgetServerUpdate(options?: { widgetId?: string }): Promise<void>

// widgetId given: fully resolved (defaults applied), or null if the widget is not server-driven.
getWidgetServerUpdate(options: { widgetId: string }): Promise<WidgetServerUpdateSnapshot | null>
// no widgetId: raw GLOBAL layer contents only (no defaults applied), or null if nothing set globally.
getWidgetServerUpdate(options?: undefined): Promise<WidgetServerUpdateSettings | null>

/** @deprecated use setWidgetServerUpdate with an Authorization header */
setWidgetServerCredentials({ token, headers? }): Promise<void>
/** @deprecated */
Expand Down
10 changes: 5 additions & 5 deletions docs/adr/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
116 changes: 112 additions & 4 deletions packages/android-client/android/src/main/java/voltra/VoltraModule.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -53,6 +57,10 @@ class VoltraModule(
VoltraWidgetManager(reactApplicationContext)
}

private val widgetServerUpdateCoordinator by lazy {
WidgetServerUpdateCoordinator(reactApplicationContext)
}

private val widgetOrchestrator by lazy {
WidgetOrchestrator(reactApplicationContext, widgetManager)
}
Expand Down Expand Up @@ -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 -> {
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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 ?: "<all>"}")

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 ?: "<all>"}")

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 ?: "<all>"}")

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,
Expand Down Expand Up @@ -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)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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) },
) {
Expand Down Expand Up @@ -96,7 +113,7 @@ internal class WidgetOrchestrator(
*/
private suspend fun reloadSingleWidget(widgetId: String) {
if (widgetKindClassifier.classify(widgetId) == VoltraWidgetKind.Dynamic) {
dynamicWidgetGlanceUpdateTrigger(widgetId)
reloadDynamicWidget(widgetId)
return
}

Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -195,14 +215,26 @@ 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}")
}
}
}
}

/**
* 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.
Expand Down
Loading
Loading