diff --git a/README.md b/README.md index 869fe7e5..3a41f18a 100644 --- a/README.md +++ b/README.md @@ -97,10 +97,15 @@ Native iOS work is also present in this repository as a pre-release Swift Packag adapter and the [iOS reference app](./implementations/ios-sdk/README.md). Treat this surface as alpha implementation work rather than a stable public native SDK. -The following native and framework SDKs are planned but are not published from this repository: +Native Android support is also present as a pre-release Kotlin Android library under +[`packages/android`](./packages/android/README.md), published as the Maven AAR +`com.contentful.java:optimization-android`, backed by the shared +[`@contentful/optimization-js-bridge`](./packages/universal/optimization-js-bridge/README.md) +adapter and the [Android reference app](./implementations/android-sdk/README.md). Treat this surface +as alpha implementation work rather than a stable public native SDK. + +The following framework SDKs are planned but are not published from this repository: -- Android Kotlin SDK -- Android Java SDK - Nest.js SDK - Angular SDK - Svelte SDK @@ -123,7 +128,9 @@ patterns with intentionally minimal application code. - [React Native](./implementations/react-native-sdk/README.md) - mobile application integration for Android and iOS targets - [iOS Reference App](./implementations/ios-sdk/README.md) - native app and XCUITest surface for - current iOS bridge and preview-panel scenarios; this is not a published iOS SDK package + current iOS bridge and preview-panel scenarios +- [Android Reference App](./implementations/android-sdk/README.md) - native Compose and XML Views + app shells plus Maestro E2E coverage for Android bridge and preview-panel scenarios ## Repository layout diff --git a/documentation/concepts/README.md b/documentation/concepts/README.md index 3a55ce65..14d307f5 100644 --- a/documentation/concepts/README.md +++ b/documentation/concepts/README.md @@ -8,9 +8,8 @@ children: - ./interaction-tracking-in-node-and-stateless-environments.md - ./profile-synchronization-between-client-and-server.md - ./react-native-sdk-interaction-tracking-mechanics.md - - ./native-mobile-sdk-architecture.md - - ./ios-sdk-bridge.md - - ./android-sdk-bridge.md + - ./ios-sdk-runtime-and-interaction-mechanics.md + - ./android-sdk-runtime-and-interaction-mechanics.md --- # Concepts @@ -46,13 +45,11 @@ they are not the first stop for installation or setup commands. explains how the React Native SDK observes, gates, and emits tracking events, covering event types, the viewport state machine, default visibility and timing, consent gating, scroll context, screen tracking paths, and the configuration resolution order -- [Native mobile SDK architecture](./native-mobile-sdk-architecture.md) - explains how one - TypeScript core runs inside JavaScriptCore on iOS and QuickJS on Android, how polyfills are - prepended into the UMD bundle at build time, and how identify, screen, and personalizeEntry - round-trip across the JS-native bridge. -- [iOS SDK bridge](./ios-sdk-bridge.md) - explains the JavaScriptCore context lifecycle, native - polyfill bindings, async callback registration, the threading model, and the diagnostics channels - (exception handler, fetch signposts) used by the iOS SDK. -- [Android SDK bridge](./android-sdk-bridge.md) - explains the QuickJS engine wrapper, the - single-threaded dispatcher that serializes JS evaluation, the `__native.log`-routing mechanism - that delivers async callbacks to Kotlin, and the coroutine integration on the public API. +- [iOS SDK runtime and interaction mechanics](./ios-sdk-runtime-and-interaction-mechanics.md) - + explains how the iOS SDK runs shared optimization behavior through a native bridge, how SwiftUI + and UIKit integrations share runtime state, and how consent, personalization, tracking, preview + overrides, and offline delivery work +- [Android SDK runtime and interaction mechanics](./android-sdk-runtime-and-interaction-mechanics.md) - + explains how the Android SDK runs shared optimization behavior through QuickJS, how Compose and + XML Views integrations share runtime state, and how consent, personalization, tracking, preview + overrides, and offline delivery work diff --git a/documentation/concepts/android-sdk-bridge.md b/documentation/concepts/android-sdk-bridge.md deleted file mode 100644 index 0f631e78..00000000 --- a/documentation/concepts/android-sdk-bridge.md +++ /dev/null @@ -1,161 +0,0 @@ ---- -title: Android SDK bridge ---- - -# Android SDK bridge - -Use this document when you need the Android-specific detail behind the QuickJS bridge — the -`quickjs-kt` engine wrapper, the single-threaded executor that serializes JS evaluation, the -`__native.log`-routing trick that delivers async callbacks back to Kotlin, and how coroutines fold -into the public API. For the cross-platform architecture and the identify / screen / -personalizeEntry call flow, read -[Native mobile SDK architecture](./native-mobile-sdk-architecture.md) first. - -
- Table of Contents - -- [1. The QuickJS engine and dispatcher](#1-the-quickjs-engine-and-dispatcher) -- [2. Initialization sequence](#2-initialization-sequence) -- [3. Native polyfill bindings](#3-native-polyfill-bindings) -- [4. Async callback delivery via \_\_native.log](#4-async-callback-delivery-via-__nativelog) -- [5. Coroutine integration on the public API](#5-coroutine-integration-on-the-public-api) -- [6. Asset packaging](#6-asset-packaging) - -
- -## 1. The QuickJS engine and dispatcher - -The Android SDK embeds QuickJS through -[`io.github.dokar3:quickjs-kt:1.0.5`](https://github.com/dokar3/quickjs-kt), declared in -[`packages/android/ContentfulOptimization/build.gradle.kts`](../../packages/android/ContentfulOptimization/build.gradle.kts). -`QuickJs.create(dispatcher)` returns a context that must be touched only from the dispatcher that -created it. - -[`QuickJsContextManager`](../../packages/android/ContentfulOptimization/src/main/kotlin/com/contentful/optimization/bridge/QuickJsContextManager.kt) -creates that dispatcher up front: - -```kotlin -private val quickJsThread = Executors.newSingleThreadExecutor { r -> - Thread(r, "contentful-quickjs").apply { isDaemon = true } -} -val quickJsDispatcher: CoroutineDispatcher = quickJsThread.asCoroutineDispatcher() -``` - -Every JS evaluation — sync, async, teardown, and the fetch / timer continuations that re-enter the -engine — runs through `withContext(quickJsDispatcher)`. There is no concurrent access to `QuickJs` -from any other thread. - -## 2. Initialization sequence - -`initialize(config, assets)` is a suspending function that runs entirely on `quickJsDispatcher`: - -1. **Create the engine** with `QuickJs.create(quickJsDispatcher)`. -2. **Construct `NativeImpl`** with the dispatcher-bound `CoroutineScope`, a `TimerStore`, an - `evaluateJS` callback that re-enters `qjs.evaluate` on the same dispatcher, and the manager's - `onLog` handler. -3. **Register the native object** via - `qjs.define("__native") { function("log") { ... }; function("setTimeout") { ... }; ... }`. This - installs a single `__native` object on `globalThis` with five methods. -4. **Evaluate `NativeImpl.BOOTSTRAP_SCRIPT`**, a five-line script that aliases - `__native.log/setTimeout/clearTimeout/randomUUID/fetch` to the matching `__nativeLog`, - `__nativeSetTimeout`, ... globals the prepended polyfills expect. -5. **Evaluate the UMD bundle** loaded from `assets.open("optimization-android-bridge.umd.js")`. The - eight polyfills are already prepended. -6. **Sanity check** `typeof __bridge`; anything other than `"object"` closes the engine and throws - `OptimizationError.BridgeError`. -7. **Register the three push-back globals** — `__nativeOnStateChange`, `__nativeOnEventEmitted`, - `__nativeOnOverridesChanged`. Each is installed in JS as a one-line shim that routes the JSON - payload through `__native.log` with a sentinel level name (see § 4). -8. **Call `__bridge.initialize()`**. - -The matching `destroy()` runs `timerStore.cancelAll()`, evaluates `__bridge.destroy()`, calls -`qjs.close()`, and cancels the dispatcher-bound `CoroutineScope`. - -## 3. Native polyfill bindings - -All five `__native*` bindings live on `NativeImpl` -([`polyfills/NativePolyfills.kt`](../../packages/android/ContentfulOptimization/src/main/kotlin/com/contentful/optimization/polyfills/NativePolyfills.kt)): - -| Binding | Implementation | -| ---------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `__nativeLog` | Forwards `(level, message)` to the manager's `onLog`. `DiagnosticLogger` receives normal log levels. | -| `__nativeSetTimeout` | `scope.launch { delay(delayMs.coerceAtLeast(0)); timerStore.fired(id); evaluateJS("__timerFired($id)") }`. `Job` stored in `TimerStore`. | -| `__nativeClearTimeout` | `timerStore.cancel(id)` cancels the `Job` and removes it. | -| `__nativeRandomUUID` | `UUID.randomUUID().toString()`. | -| `__nativeFetch` | `OkHttpClient.newCall(request).enqueue(callback)`. On response, `scope.launch { evaluateJS("__fetchComplete($id, $status, \"$headers\", \"$body\", \"\")") }` re-enters the engine on the dispatcher. | - -`escapeForJS` (also in `NativePolyfills.kt`) is used to safely interpolate response bodies and -headers into the `__fetchComplete` call. The `TimerStore` is a `ConcurrentHashMap`, which -is overkill given the single-threaded dispatcher but cheap and defensive. - -## 4. Async callback delivery via `__native.log` - -The async-call contract — JS calls a named function global — exists on both platforms. On Android -the **delivery mechanism** is different: `quickjs-kt`'s binding API can call from JS into Kotlin -only through methods you registered via `qjs.define(...)`. Rather than register one Kotlin binding -per async call, the manager re-uses `__native.log` as a transport. - -When `callAsync(method, payload, completion)` runs -([`QuickJsContextManager.kt:129–207`](../../packages/android/ContentfulOptimization/src/main/kotlin/com/contentful/optimization/bridge/QuickJsContextManager.kt)): - -1. `BridgeCallbackManager.registerCallback` mints unique names like `__identifyCallback_3_success` - and `__identifyCallback_3_error` and stores their Kotlin closures in an internal map. -2. The manager evaluates a small JS shim that installs both names as globals whose body is - `__native.log("__callback__", json)`. -3. The manager **temporarily swaps `onLog`** with a wrapping handler that intercepts the - `"__callback__"` level codes, invokes the corresponding closure from the callback map, and - restores the original `onLog`. Any other log level falls through to the original handler. -4. The manager evaluates `__bridge.(, , )`. - -When the JS bridge resolves the promise, it calls the success global, which calls -`__native.log("__callback__", json)`, which is intercepted by the swapped `onLog`, -which invokes the registered Kotlin closure, which `post`s back to `Dispatchers.Main` to resume the -suspended coroutine. The same routing is used for the three push-back globals (`__stateChange__`, -`__eventEmitted__`, `__overridesChanged__`), installed once at initialization time rather than per -call. - -The trade-off is that all callback traffic shares the `__native.log` binding, so a Kotlin handler in -the chain has to look at the level code to dispatch — but it avoids a per-call binding registration -and keeps the JS side identical to iOS. - -## 5. Coroutine integration on the public API - -`OptimizationClient` -([`core/OptimizationClient.kt`](../../packages/android/ContentfulOptimization/src/main/kotlin/com/contentful/optimization/core/OptimizationClient.kt)) -funnels every async call through `bridgeCallAsyncJSON` / `bridgeCallAsyncVoid`, which: - -1. Switch to `Dispatchers.Main`. -2. `suspendCoroutine { continuation -> ... }` launches a coroutine on `quickJsDispatcher` that calls - `bridge.callAsync(method, payload) { result -> continuation.resume(...) }`. - -The Main → quickJs → Main hop is intentional: callers can `await` from any dispatcher, the JS -evaluation always happens on the JS dispatcher, and the resumed value lands back on Main. - -Sync calls take a different shape. `bridgeCallSyncWhenInitialized` is `fun` rather than -`suspend fun` (it must be callable from non-suspending UI code such as Compose effects), so it uses -`runBlocking(bridge.quickJsDispatcher) { bridge.callSync(...) }`. The comment in -[`QuickJsContextManager.kt:260–280`](../../packages/android/ContentfulOptimization/src/main/kotlin/com/contentful/optimization/bridge/QuickJsContextManager.kt) -explains why this matters: `__nativeOnStateChange` fires **synchronously** inside the JS call, and -the `StateFlow` mutations it triggers must have settled before the sync call returns. Hopping off -the JS dispatcher in the middle of that flow would re-introduce the preview-panel race documented in -`PreviewPanelOverridesTests` scenario 3. - -## 6. Asset packaging - -The UMD bundle is checked into `packages/android/ContentfulOptimization/src/main/assets/`, which is -the default AGP source set for raw assets. `AssetManager.open("optimization-android-bridge.umd.js")` -reads it at runtime. No manifest entry, asset filter, or `aaptOptions` block is needed because the -file is already under `assets/`. - -The reference implementation pulls in the SDK module via Gradle composite build -([`implementations/android-sdk/settings.gradle.kts`](../../implementations/android-sdk/settings.gradle.kts)), -so a fresh `./gradlew :app:assembleDebug` rebuilds `:ContentfulOptimization` from source and packs -the up-to-date asset into the test APK. - -## Related - -- [Native mobile SDK architecture](./native-mobile-sdk-architecture.md) -- [iOS SDK bridge](./ios-sdk-bridge.md) -- [`packages/android` README](../../packages/android/README.md) -- [Android reference implementation README](../../implementations/android-sdk/README.md) -- [Contributing to the Android SDK](../guides/contributing-to-the-android-sdk.md) diff --git a/documentation/concepts/android-sdk-runtime-and-interaction-mechanics.md b/documentation/concepts/android-sdk-runtime-and-interaction-mechanics.md new file mode 100644 index 00000000..57f02bca --- /dev/null +++ b/documentation/concepts/android-sdk-runtime-and-interaction-mechanics.md @@ -0,0 +1,259 @@ +--- +title: Android SDK runtime and interaction mechanics +--- + +# Android SDK runtime and interaction mechanics + +Use this concept document to understand how the Optimization Android SDK runs shared optimization +behavior in a native app, how Compose and XML Views integrations share the same client model, and +how consent, state, entry resolution, tracking, preview overrides, and offline delivery work. + +For step-by-step setup, see +[Integrating the Optimization Android SDK in a Jetpack Compose app](../guides/integrating-the-optimization-android-sdk-in-a-compose-app.md) +and +[Integrating the Optimization Android SDK in an XML Views app](../guides/integrating-the-optimization-android-sdk-in-a-views-app.md). +For the full Contentful entry contract, see +[Entry personalization and variant resolution](./entry-personalization-and-variant-resolution.md). + +
+ Table of Contents + + +- [Runtime boundary](#runtime-boundary) +- [Lifecycle and coroutines](#lifecycle-and-coroutines) +- [Configuration and locale handoff](#configuration-and-locale-handoff) +- [State and persistence](#state-and-persistence) +- [Consent and event gates](#consent-and-event-gates) +- [Entry personalization boundary](#entry-personalization-boundary) +- [Adapter surfaces](#adapter-surfaces) +- [Tracking mechanics](#tracking-mechanics) +- [Live updates and preview behavior](#live-updates-and-preview-behavior) +- [Offline and app lifecycle delivery](#offline-and-app-lifecycle-delivery) +- [Related documentation](#related-documentation) + + +
+ +## Runtime boundary + +The Android SDK is a native Kotlin Android library published as +`com.contentful.java:optimization-android`. Kotlin owns native app concerns such as persistence, +networking, lifecycle handling, Compose helpers, XML Views helpers, preview-panel UI, and app-facing +public APIs. + +Shared optimization behavior runs inside a local QuickJS context. That bridge lets the Android SDK +use the same personalization, profile, consent, and event-delivery behavior as the JavaScript SDKs +while exposing a Kotlin API to the application. + +Applications do not call the JavaScript layer directly. The public boundary is Kotlin: + +- `OptimizationClient` is the main facade for initialization, state, personalization, tracking, and + preview controls. +- `OptimizationRoot`, `OptimizedEntry`, `OptimizationLazyColumn`, and `ScreenTrackingEffect` provide + Compose integration helpers. +- `OptimizationManager`, `OptimizedEntryView`, `TrackingRecyclerView`, and `ScreenTracker` provide + XML Views integration helpers. +- `PreviewPanelConfig` wires the in-app preview panel into Compose and XML Views integrations. + +This split also defines what the SDK does not own. The application still fetches Contentful entries, +manages consent UX, controls routing, decides identity policy, and renders the final UI. + +## Lifecycle and coroutines + +`OptimizationClient` has two phases: + +| Phase | Behavior | +| ------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Uninitialized | The client exists, but the bridge is not loaded. Suspend APIs throw or return safe fallbacks depending on the call, and sync APIs no-op where appropriate. | +| Initialized | The bridge is loaded, persisted state has been merged into configuration, SDK state is available, and lifecycle/network observers are active. | + +Compose apps usually let `OptimizationRoot` create the client and call `initialize(config)`. XML +Views apps usually call `OptimizationManager.initialize(...)` from `Application.onCreate` before +reading `OptimizationManager.client` from activities or fragments. + +`OptimizationClient` exposes async work as `suspend` functions. Call those methods from Compose +effects, View event-handler coroutine scopes, lifecycle-aware coroutines, or another app-owned +coroutine scope. + +The QuickJS runtime runs on a dedicated single-thread dispatcher owned by the SDK. Application code +must use the public Kotlin APIs instead of trying to access the bridge directly. + +Typical apps keep one client alive for the app process lifetime. Use `destroy()` for test teardown +or deliberate SDK reset flows. + +## Configuration and locale handoff + +Every Android integration builds an `OptimizationConfig`: + +```kotlin +OptimizationConfig( + clientId = "your-client-id", + environment = "master", + contentfulLocales = ContentfulLocales(default = "en-US"), + locale = "en-US", + defaults = StorageDefaults(consent = true), + debug = BuildConfig.DEBUG, +) +``` + +Only `clientId` is required. `environment` defaults to `"master"`. Base URL overrides belong only in +integrations that need non-default Experience API or Insights API endpoints. + +Use `contentfulLocales` and `locale` when the application renders localized Contentful entries. The +resolved `client.locale` belongs in the app-owned Contentful Delivery API request before entries are +passed to `OptimizedEntry`, `OptimizedEntryView`, or `personalizeEntry(...)`. + +`OptimizationApiConfig.locale` is an explicit Experience API locale override. It does not replace +the CDA locale used to fetch entries. For the full locale model, see +[Locale handling in the Optimization SDK Suite](./locale-handling-in-the-optimization-sdk-suite.md). + +## State and persistence + +`OptimizationClient` publishes runtime state through Kotlin flows: + +| Surface | Description | +| -------------------------- | ----------------------------------------------------------------------------- | +| `state` | Snapshot of profile, consent, personalization readiness, and pending changes. | +| `isInitialized` | `true` after initialization completes. | +| `selectedPersonalizations` | The personalizations the visitor qualifies for. | +| `isPreviewPanelOpen` | `true` while the in-app preview panel is visible. | +| `previewState` | Preview override state used by the in-app preview panel. | +| `events` | Raw event stream for debug surfaces and tests. | + +Compose code reads these values through `collectAsState()` or effects. XML Views code usually +collects them from lifecycle-aware coroutines. + +The SDK persists state with `SharedPreferences`. `StorageDefaults` can seed values such as consent, +profile, selected changes, and personalizations on first launch. Seeds are applied only when no +persisted value exists, so an existing user choice is not overwritten. + +## Consent and event gates + +Consent is a three-state value: `true`, `false`, or unset. Until consent is granted, the SDK blocks +most Analytics events. `identify` and `screen` are allowed before consent so the mobile journey can +establish profile context and anonymous screen analytics. + +| Consent state | Event behavior | +| ------------- | ----------------------------------------------------------- | +| Unset | `identify` and `screen` can emit; other events are blocked. | +| `true` | All event types can emit. | +| `false` | `identify` and `screen` can emit; other events are blocked. | + +Call `client.consent(true)` when the visitor grants consent and `client.consent(false)` when the +visitor rejects it. The value is persisted and restored on later launches. + +## Entry personalization boundary + +Entry personalization is a local decision once the app has both Contentful entry data and selected +personalizations. + +The application provides: + +- A single-locale Contentful entry map. +- Linked optimization references and variant entries in the Contentful payload. +- The current `selectedPersonalizations` value from the client, when resolving directly. + +The SDK returns either the baseline entry or the resolved variant entry: + +```kotlin +val result = client.personalizeEntry( + baseline = entry, + personalizations = client.selectedPersonalizations.value, +) + +val resolvedEntry = result.entry +val personalization = result.personalization +``` + +`personalizeEntry(...)` does not fetch Contentful entries, evaluate audiences, call the Experience +API, or mutate state. Compose `OptimizedEntry` and XML Views `OptimizedEntryView` wrap the same +boundary and add component-level behavior such as variant locking, live updates, and interaction +tracking. + +For the full data model and fallback behavior, see +[Entry personalization and variant resolution](./entry-personalization-and-variant-resolution.md). + +## Adapter surfaces + +The Android SDK exposes two public UI adapter packages over the same core client: + +| App style | Initialization path | Entry rendering path | Screen tracking path | Scroll tracking helper | +| --------- | -------------------------------- | -------------------- | ---------------------- | ------------------------ | +| Compose | `OptimizationRoot` | `OptimizedEntry` | `ScreenTrackingEffect` | `OptimizationLazyColumn` | +| XML Views | `OptimizationManager.initialize` | `OptimizedEntryView` | `ScreenTracker` | `TrackingRecyclerView` | + +Both adapters use the same `OptimizationClient`, persistence model, bridge runtime, event gates, +locale resolution, and preview override state. Choose the adapter that matches the UI framework of +the screen you are integrating. + +## Tracking mechanics + +The Android SDK emits mobile screen events and Contentful entry interaction events: + +| Event type | Compose path | XML Views path | +| ---------- | ------------------------------ | ---------------------------------- | +| Screen | `ScreenTrackingEffect` | `ScreenTracker.trackScreen(...)` | +| Entry view | `OptimizedEntry` view tracking | `OptimizedEntryView` view tracking | +| Entry tap | `OptimizedEntry` tap tracking | `OptimizedEntryView` tap tracking | + +Entry view tracking uses these defaults: + +- Initial view event after 2 seconds at 80% visibility. +- Periodic duration updates every 5 seconds while the entry remains visible. +- Final duration update when the entry leaves view after a view event has already fired. + +`OptimizedEntry` and `OptimizedEntryView` can tune the visibility threshold, initial time, and +update interval per entry. Use `OptimizationLazyColumn` in Compose and `TrackingRecyclerView` in XML +Views when view timing needs scroll-aware visibility updates. + +Applications can also call `trackView(...)` and `trackClick(...)` directly when they need to emit +events from a custom UI abstraction. + +## Live updates and preview behavior + +`OptimizedEntry` and `OptimizedEntryView` lock to the first resolved variant by default. Locking +prevents content from changing while a visitor is reading it. Enable live updates when a component +needs to react to profile changes or preview overrides without a reload. + +Android live-update precedence is: + +| Preview panel | Global default | Per-entry override | Result | +| ------------- | -------------- | ------------------ | ------ | +| Open | Any | Any | Live | +| Closed | `true` | `null` | Live | +| Closed | `false` | `true` | Live | +| Closed | `true` | `false` | Locked | +| Closed | `false` | `null` | Locked | + +When the preview panel is open, all `OptimizedEntry` and `OptimizedEntryView` components update live +so audience and variant overrides apply immediately. When the panel closes, components keep the +previewed variant as the locked value. + +Compose apps pass `PreviewPanelConfig` to `OptimizationRoot`. XML Views apps pass the optional +preview content client to `OptimizationManager.initialize(...)` and call +`OptimizationManager.attachPreviewPanel(...)` from activities that display the floating preview +entry point. + +## Offline and app lifecycle delivery + +The Android SDK monitors network reachability and app lifecycle events: + +- When the device is offline, events queue in memory. +- When connectivity returns, queued events flush automatically. +- When the app moves toward the background, the SDK flushes queued events to reduce data loss. + +No configuration is required for this behavior. Queueing and flushing use the same event-delivery +model for Compose and XML Views integrations. + +## Related documentation + +- [Optimization Android SDK README](../../packages/android/README.md) - Package installation, quick + start, and published package status. +- [Integrating the Optimization Android SDK in a Jetpack Compose app](../guides/integrating-the-optimization-android-sdk-in-a-compose-app.md) - + Compose setup flow for `OptimizationRoot`, `OptimizedEntry`, screen tracking, and preview panel + mounting. +- [Integrating the Optimization Android SDK in an XML Views app](../guides/integrating-the-optimization-android-sdk-in-a-views-app.md) - + XML Views setup flow for `OptimizationManager`, `OptimizedEntryView`, screen tracking, and preview + panel mounting. +- [Android reference implementation](../../implementations/android-sdk/README.md) - Native Android + validation app with Compose and XML Views shells. diff --git a/documentation/concepts/entry-personalization-and-variant-resolution.md b/documentation/concepts/entry-personalization-and-variant-resolution.md index b08527cc..a6ca9a27 100644 --- a/documentation/concepts/entry-personalization-and-variant-resolution.md +++ b/documentation/concepts/entry-personalization-and-variant-resolution.md @@ -316,6 +316,19 @@ server-provided or request-local data because it avoids depending on ambient SDK Provide optimization data before expecting personalized content. `page()`, `identify()`, `screen()`, `track()`, and sticky `trackView()` can return the selected optimization data used by this method. +The iOS SDK exposes the same local boundary through +`OptimizationClient.personalizeEntry(baseline:personalizations:)`. UIKit apps usually pass +`client.selectedPersonalizations` during cell or view configuration. The method returns a +`PersonalizedResult` containing the resolved `entry` and optional `personalization` metadata, and +returns the baseline unchanged when no selected personalization matches the entry. + +The Android SDK exposes the same local boundary through +`OptimizationClient.personalizeEntry(baseline = ..., personalizations = ...)`. XML Views apps +usually rely on `OptimizedEntryView` or pass `client.selectedPersonalizations.value` when resolving +directly. The method returns a `PersonalizedResult` containing the resolved `entry` and optional +`personalization` metadata, and returns the baseline unchanged when no selected personalization +matches the entry. + ### Render with framework components Use framework components when rendering is already inside a supported React tree. They subscribe to @@ -347,6 +360,17 @@ React Native uses the same resolver inside its `OptimizedEntry` component. The c React Native does not render DOM data attributes. It passes the resolved entry and optimization metadata directly into its viewport and tap tracking hooks. +SwiftUI uses the same resolver inside `OptimizedEntry`. The component passes non-optimized entries +through unchanged, resolves optimized entries from the client's selected personalizations, can lock +to the first resolved variant, can re-resolve when live updates are enabled, and can attach iOS view +and tap tracking for the resolved entry. + +Android Compose uses the same resolver inside `OptimizedEntry`, and Android XML Views use it inside +`OptimizedEntryView`. Both adapters pass non-optimized entries through unchanged, resolve optimized +entries from the client's selected personalizations, can lock to the first resolved variant, can +re-resolve when live updates are enabled, and can attach Android view and tap tracking for the +resolved entry. + ### Preview selected variants Preview tooling changes selected variants by overriding `variantIndex` in `selectedOptimizations`. diff --git a/documentation/concepts/ios-sdk-bridge.md b/documentation/concepts/ios-sdk-bridge.md deleted file mode 100644 index 3bd98fdc..00000000 --- a/documentation/concepts/ios-sdk-bridge.md +++ /dev/null @@ -1,156 +0,0 @@ ---- -title: iOS SDK bridge ---- - -# iOS SDK bridge - -Use this document when you need the iOS-specific detail behind the JavaScriptCore bridge — context -lifecycle, exception handling, callback closure registration, signpost markers, the Swift Package -resource declaration. For the cross-platform architecture and the identify / screen / -personalizeEntry call flow, read -[Native mobile SDK architecture](./native-mobile-sdk-architecture.md) first. - -
- Table of Contents - -- [1. The JavaScriptCore context](#1-the-javascriptcore-context) -- [2. Native polyfill bindings](#2-native-polyfill-bindings) -- [3. Async callback registration](#3-async-callback-registration) -- [4. Threading model](#4-threading-model) -- [5. Bundle resource declaration](#5-bundle-resource-declaration) -- [6. Diagnostics: exceptions and signposts](#6-diagnostics-exceptions-and-signposts) - -
- -## 1. The JavaScriptCore context - -[`JSContextManager`](../../packages/ios/ContentfulOptimization/Sources/ContentfulOptimization/Bridge/JSContextManager.swift) -owns the `JSContext` for the lifetime of one `OptimizationClient`. Initialization runs in this fixed -order: - -1. **Create the context** with `JSContext()` and install a global exception handler that surfaces - uncaught JS errors via `onLog("exception", message)`. -2. **Enable remote inspection** (`ctx.isInspectable = true`) only when `config.debug` is `true` and - the deployment target is iOS 16.4 / macOS 13.3 or newer. Release builds never expose this. -3. **Register the five native polyfill bindings** via `NativePolyfills.register(in: ctx, logger:)`. - The call returns a `TimerStore` that the manager retains for the lifetime of the context and uses - for `cancelAll()` on teardown. -4. **Evaluate the UMD bundle** loaded from - `Bundle.module.url(forResource: "optimization-ios-bridge.umd", withExtension: "js")`. The eight - polyfills are already prepended into the bundle text at build time, so a single `evaluateScript` - call installs both the polyfills and `globalThis.__bridge`. -5. **Sanity check** `typeof __bridge` — anything other than `"object"` throws - `OptimizationError.bridgeError`. -6. **Register the three push-back globals**: `__nativeOnStateChange`, `__nativeOnEventEmitted`, and - `__nativeOnOverridesChanged`. Each is a Swift `@convention(block) (String) -> Void` closure that - the JS bundle invokes whenever the relevant signal changes; the manager parses the JSON and - re-emits on `DispatchQueue.main`. -7. **Call `__bridge.initialize()`** with the merged configuration (consent, profile, - changes, personalizations restored from `UserDefaultsStore` before this point). - -## 2. Native polyfill bindings - -All five `__native*` globals are registered as `@convention(block)` closures so JavaScriptCore can -call them as JS functions. Implementations live in -[`Polyfills/NativePolyfills.swift`](../../packages/ios/ContentfulOptimization/Sources/ContentfulOptimization/Polyfills/NativePolyfills.swift): - -| Binding | Signature | Implementation | -| ---------------------- | ------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| `__nativeLog` | `(String, String) -> Void` | Forwards `(level, message)` to the manager's `onLog` handler, which routes to `DiagnosticLogger`. | -| `__nativeSetTimeout` | `(Int, Int) -> Void` | Schedules a `DispatchWorkItem` on `DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(delayMs))`. The work item calls `__timerFired(id)` and clears its store entry. | -| `__nativeClearTimeout` | `(Int) -> Void` | `TimerStore.cancel(id)` cancels the `DispatchWorkItem` and removes it. | -| `__nativeRandomUUID` | `() -> String` | `UUID().uuidString.lowercased()`. | -| `__nativeFetch` | `(String, String, String, JSValue, Int) -> Void` | Builds a `URLRequest` from `(urlString, method, headersJSON, body, callbackId)` and dispatches via `URLSession.shared.dataTask`. Delivers the response on the main queue by calling `ctx.objectForKeyedSubscript("__fetchComplete")?.call(withArguments: [callbackId, status, headers, body, error])`. | - -The `TimerStore` class keeps a `[Int: DispatchWorkItem]` map so per-context timer ids cannot collide -with another context's. `JSContextManager.destroy` calls `timerStore.cancelAll()` before evaluating -`__bridge.destroy()` and dropping the context reference. - -For the JS-side polyfills that consume these bindings (load order, what each one installs on -`globalThis`), see -[Native mobile SDK architecture § 2](./native-mobile-sdk-architecture.md#2-polyfills-are-prepended-at-build-time). - -## 3. Async callback registration - -`__bridge.identify`, `__bridge.screen`, `__bridge.page`, `__bridge.flush`, `__bridge.trackView`, and -`__bridge.trackClick` are `Promise`-returning JS functions. Because `JSContext.evaluateScript` -cannot await a JS promise from Swift, the bridge passes two registered global function names instead -and lets the JS side invoke whichever fires: - -[`BridgeCallbackManager`](../../packages/ios/ContentfulOptimization/Sources/ContentfulOptimization/Bridge/BridgeCallbackManager.swift) -mints unique callback names of the form `__Callback__success` and -`__Callback__error`. For each call, two `@convention(block) (String) -> Void` closures -are installed in the `JSContext` under those names. Each closure forwards its argument to the Swift -completion handler **and** sets both globals back to `nil`, so a single resolve / reject leaves no -lingering state on the JS side. - -`JSContextManager.callAsync` -([`JSContextManager.swift:88–141`](../../packages/ios/ContentfulOptimization/Sources/ContentfulOptimization/Bridge/JSContextManager.swift)) -also installs a per-call exception handler so a synchronous JS throw inside -`__bridge.method(payload, success, error)` is captured and surfaced as -`OptimizationError.bridgeError` rather than being silently logged. After the call returns the -exception handler is restored to whatever was installed before the call. - -`OptimizationClient` wraps this in `withCheckedThrowingContinuation` so the public API is -`async throws`. The continuation resumes on the main queue because the success / error closures -dispatch with `DispatchQueue.main.async` before calling the completion handler. - -## 4. Threading model - -`JSContextManager` does no thread management of its own. JavaScriptCore is thread-safe as long as a -single `JSContext` is not used concurrently from multiple threads; `OptimizationClient` is annotated -`@MainActor` so all public API entry points are forced onto the main actor before the bridge call -issues. That means: - -- `evaluateScript` runs on the main thread for every public client call. -- `__nativeFetch` schedules its `URLSession` work off the main queue (default), but delivers - responses back via `DispatchQueue.main.async` before invoking `__fetchComplete`. -- `__nativeSetTimeout` fires on `DispatchQueue.main` so JS timer callbacks run on the same thread as - bridge calls. -- The push-back handlers parse JSON on the calling thread and re-publish via - `DispatchQueue.main.async` inside `handleStateChange`, `handleEvent`, and - `handleOverridesChanged`. - -## 5. Bundle resource declaration - -`Package.swift` declares the UMD bundle as a `.copy` resource (verbatim, no Swift Resource Bundle -processing), and links the `JavaScriptCore` framework so the embedding app does not need to add it -manually: - -```swift -.target( - name: "ContentfulOptimization", - resources: [ - .copy("Resources/optimization-ios-bridge.umd.js"), - ], - linkerSettings: [ - .linkedFramework("JavaScriptCore"), - ] -) -``` - -`JSContextManager.loadBundleSource()` reads the file via `Bundle.module.url(forResource:)`. If the -resource cannot be located the call throws `OptimizationError.resourceLoadError` rather than -returning an empty bundle. - -## 6. Diagnostics: exceptions and signposts - -Two channels feed back to the host app from inside the bridge: - -- **JS exceptions** — `ctx.exceptionHandler` invokes `onLog("exception", message)`. The default - client wiring routes this to `DiagnosticLogger.debug`; tests can install - `testOnlySetLogHandler(_:)` to intercept exceptions verbatim without losing the diagnostic log - trail. -- **Fetch signposts** — every `__nativeFetch` call brackets the round trip with - `os_signpost(.begin/.end, ...)` against the `"Fetch Bridge Crossing"` name in the - `com.contentful.optimization` / `Performance` log. Instruments' "Points of Interest" track shows - the begin/end pair tagged with method + URL on entry and status code on exit, so you can measure - bridge round-trip cost without injecting timing into the SDK code itself. - -## Related - -- [Native mobile SDK architecture](./native-mobile-sdk-architecture.md) -- [Android SDK bridge](./android-sdk-bridge.md) -- [`packages/ios` README](../../packages/ios/README.md) -- [iOS reference implementation README](../../implementations/ios-sdk/README.md) -- [Contributing to the iOS SDK](../guides/contributing-to-the-ios-sdk.md) diff --git a/documentation/concepts/ios-sdk-runtime-and-interaction-mechanics.md b/documentation/concepts/ios-sdk-runtime-and-interaction-mechanics.md new file mode 100644 index 00000000..4f1b4cce --- /dev/null +++ b/documentation/concepts/ios-sdk-runtime-and-interaction-mechanics.md @@ -0,0 +1,236 @@ +--- +title: iOS SDK runtime and interaction mechanics +--- + +# iOS SDK runtime and interaction mechanics + +Use this concept document to understand how the Optimization iOS SDK runs shared optimization +behavior in a native app, how SwiftUI and UIKit integrations share the same client, and how consent, +state, entry resolution, tracking, preview overrides, and offline delivery work. + +For step-by-step setup, see +[Integrating the Optimization iOS SDK in a SwiftUI app](../guides/integrating-the-optimization-ios-sdk-in-a-swiftui-app.md) +and +[Integrating the Optimization iOS SDK in a UIKit app](../guides/integrating-the-optimization-ios-sdk-in-a-uikit-app.md). +For the full Contentful entry contract, see +[Entry personalization and variant resolution](./entry-personalization-and-variant-resolution.md). + +
+ Table of Contents + + +- [Runtime boundary](#runtime-boundary) +- [Lifecycle and main actor](#lifecycle-and-main-actor) +- [Configuration and locale handoff](#configuration-and-locale-handoff) +- [State and persistence](#state-and-persistence) +- [Consent and event gates](#consent-and-event-gates) +- [Entry personalization boundary](#entry-personalization-boundary) +- [Tracking mechanics](#tracking-mechanics) +- [Live updates and preview behavior](#live-updates-and-preview-behavior) +- [Offline and app lifecycle delivery](#offline-and-app-lifecycle-delivery) +- [Related documentation](#related-documentation) + + +
+ +## Runtime boundary + +The iOS SDK is a native Swift Package named `ContentfulOptimization`. Swift owns native app concerns +such as persistence, networking, lifecycle handling, SwiftUI helpers, UIKit preview-panel +presentation, and app-facing public APIs. + +Shared optimization behavior runs inside a local JavaScriptCore context. That bridge lets the iOS +SDK use the same personalization, profile, consent, and event-delivery behavior as the JavaScript +SDKs while exposing a Swift API to the application. + +Applications do not call the JavaScript layer directly. The public boundary is Swift: + +- `OptimizationClient` is the main facade for initialization, state, personalization, tracking, and + preview controls. +- `OptimizationRoot`, `OptimizedEntry`, `OptimizationScrollView`, and `.trackScreen(name:)` provide + SwiftUI integration helpers. +- `PreviewPanelViewController` provides the UIKit preview-panel host. + +This split also defines what the SDK does not own. The application still fetches Contentful entries, +manages consent UX, controls routing, decides identity policy, and renders the final UI. + +## Lifecycle and main actor + +`OptimizationClient` has two phases: + +| Phase | Behavior | +| ------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Uninitialized | The client exists, but the bridge is not loaded. Async APIs throw `OptimizationError.notInitialized`; sync APIs return safe defaults or no-op where appropriate. | +| Initialized | The bridge is loaded, persisted state has been merged into configuration, SDK state is available, and lifecycle/network observers are active. | + +SwiftUI apps usually let `OptimizationRoot` call `initialize(config:)`. UIKit apps usually call +`initialize(config:)` from scene or app startup before passing the client into view controllers. + +`OptimizationClient` is `@MainActor`. Call it from main-thread contexts such as SwiftUI view tasks, +SwiftUI event handlers, view-controller lifecycle methods, or `Task { @MainActor in ... }` blocks. +The compiler can flag background calls as concurrency errors. + +Typical apps keep one `OptimizationClient` alive for the app or scene lifetime. Use `destroy()` for +test teardown or deliberate SDK reset flows. + +## Configuration and locale handoff + +Every iOS integration builds an `OptimizationConfig`: + +```swift +OptimizationConfig( + clientId: "your-client-id", + environment: "master", + contentfulLocales: ContentfulLocales(default: "en-US"), + locale: "en-US", + defaults: StorageDefaults(consent: true), + debug: true +) +``` + +Only `clientId` is required. `environment` defaults to `"master"`. Base URL overrides belong only in +integrations that need non-default Experience API or Insights API endpoints. + +Use `contentfulLocales` and `locale` when the application renders localized Contentful entries. The +resolved `client.locale` belongs in the app-owned Contentful Delivery API request before entries are +passed to `OptimizedEntry` or `personalizeEntry(...)`. + +`OptimizationApiConfig.locale` is an explicit Experience API locale override. It does not replace +the CDA locale used to fetch entries. For the full locale model, see +[Locale handling in the Optimization SDK Suite](./locale-handling-in-the-optimization-sdk-suite.md). + +## State and persistence + +`OptimizationClient` is an `ObservableObject`. It publishes runtime state that SwiftUI and UIKit +code can observe: + +| Surface | Description | +| -------------------------- | ----------------------------------------------------------------------------- | +| `state` | Snapshot of profile, consent, personalization readiness, and pending changes. | +| `isInitialized` | `true` after initialization completes. | +| `selectedPersonalizations` | The personalizations the visitor qualifies for. | +| `isPreviewPanelOpen` | `true` while the in-app preview panel is visible. | +| `eventPublisher` | Raw event stream for debug surfaces and tests. | + +SwiftUI code reads these values through `@EnvironmentObject`. UIKit code can subscribe through +Combine publishers such as `client.$state` and `client.$selectedPersonalizations`. + +The SDK persists state with `UserDefaults`. `StorageDefaults` can seed values such as consent, +profile, selected changes, and personalizations on first launch. Seeds are applied only when no +persisted value exists, so an existing user choice is not overwritten. + +## Consent and event gates + +Consent is a three-state value: `true`, `false`, or unset. Until consent is granted, the SDK blocks +most Analytics events. `identify` and `screen` are allowed before consent so the mobile journey can +establish profile context and anonymous screen analytics. + +| Consent state | Event behavior | +| ------------- | ----------------------------------------------------------- | +| Unset | `identify` and `screen` can emit; other events are blocked. | +| `true` | All event types can emit. | +| `false` | `identify` and `screen` can emit; other events are blocked. | + +Call `client.consent(true)` when the visitor grants consent and `client.consent(false)` when the +visitor rejects it. The value is persisted and restored on later launches. + +## Entry personalization boundary + +Entry personalization is a local, synchronous decision once the app has both Contentful entry data +and selected personalizations. + +The application provides: + +- A single-locale Contentful entry dictionary. +- Linked optimization references and variant entries in the Contentful payload. +- The current `selectedPersonalizations` value from the client, when resolving directly. + +The SDK returns either the baseline entry or the resolved variant entry: + +```swift +let result = client.personalizeEntry( + baseline: entry, + personalizations: client.selectedPersonalizations +) + +let resolvedEntry = result.entry +let personalization = result.personalization +``` + +`personalizeEntry` does not fetch Contentful entries, evaluate audiences, call the Experience API, +or mutate state. SwiftUI `OptimizedEntry` wraps the same boundary and adds component-level behavior +such as variant locking, live updates, and interaction tracking. + +For the full data model and fallback behavior, see +[Entry personalization and variant resolution](./entry-personalization-and-variant-resolution.md). + +## Tracking mechanics + +The iOS SDK emits mobile screen events and Contentful entry interaction events: + +| Event type | SwiftUI path | UIKit path | +| ---------- | ------------------------------ | ------------------------------- | +| Screen | `.trackScreen(name:)` | `client.screen(name:)` | +| Entry view | `OptimizedEntry` view tracking | App-computed `TrackViewPayload` | +| Entry tap | `OptimizedEntry` tap tracking | App-emitted `TrackClickPayload` | + +SwiftUI entry view tracking uses these defaults: + +- Initial view event after 2 seconds at 80% visibility. +- Periodic duration updates every 5 seconds while the entry remains visible. +- Final duration update when the entry leaves view after a view event has already fired. + +`OptimizedEntry` can tune the visibility threshold, initial time, and update interval per entry. +Wrap scrollable content in `OptimizationScrollView` when view timing needs an accurate viewport. + +UIKit does not have automatic component visibility tracking. UIKit apps compute visibility and +duration through their own table, collection, or view-controller callbacks, then emit +`TrackViewPayload` and `TrackClickPayload` directly. + +## Live updates and preview behavior + +SwiftUI `OptimizedEntry` locks to the first resolved variant by default. Locking prevents content +from changing while a visitor is reading it. Enable live updates when a component needs to react to +profile changes or preview overrides without a reload. + +SwiftUI live-update precedence is: + +| Preview panel | Global default | Per-entry override | Result | +| ------------- | -------------- | ------------------ | ------ | +| Open | Any | Any | Live | +| Closed | `true` | `nil` | Live | +| Closed | `false` | `true` | Live | +| Closed | `true` | `false` | Locked | +| Closed | `false` | `nil` | Locked | + +When the preview panel is open, all SwiftUI `OptimizedEntry` components update live so audience and +variant overrides apply immediately. When the panel closes, SwiftUI components keep the previewed +variant as the locked value. + +UIKit apps choose their own live-update policy. Redraw views when `client.selectedPersonalizations` +changes for live behavior, or keep a selected-personalizations snapshot for locked behavior. Use +`client.isPreviewPanelOpen` when the app needs to redraw in live mode only while previewing. + +## Offline and app lifecycle delivery + +The iOS SDK monitors network reachability and app lifecycle events: + +- When the device is offline, events queue in memory. +- When connectivity returns, queued events flush automatically. +- When the app moves toward the background, the SDK flushes queued events to reduce data loss. + +No configuration is required for this behavior. Queueing and flushing use the same event-delivery +model for SwiftUI and UIKit integrations. + +## Related documentation + +- [Optimization iOS SDK README](../../packages/ios/ContentfulOptimization/README.md) - Package + installation, quick start, and published package status. +- [Integrating the Optimization iOS SDK in a SwiftUI app](../guides/integrating-the-optimization-ios-sdk-in-a-swiftui-app.md) - + SwiftUI setup flow for `OptimizationRoot`, `OptimizedEntry`, screen tracking, and preview panel + mounting. +- [Integrating the Optimization iOS SDK in a UIKit app](../guides/integrating-the-optimization-ios-sdk-in-a-uikit-app.md) - + UIKit setup flow for direct `OptimizationClient` usage, manual entry resolution, tracking, and + preview panel mounting. +- [iOS reference implementation](../../implementations/ios-sdk/README.md) - Native iOS validation + app with SwiftUI and UIKit shells. diff --git a/documentation/concepts/native-mobile-sdk-architecture.md b/documentation/concepts/native-mobile-sdk-architecture.md deleted file mode 100644 index f707af66..00000000 --- a/documentation/concepts/native-mobile-sdk-architecture.md +++ /dev/null @@ -1,257 +0,0 @@ ---- -title: Native mobile SDK architecture ---- - -# Native mobile SDK architecture - -Use this document to understand how the native iOS and Android SDKs share one TypeScript core by -running it inside an embedded JavaScript engine on the device, and how that JavaScript core reaches -back out to native primitives — `URLSession`, `OkHttp`, `DispatchQueue`, `Handler`, `OSLog`, -`Logcat` — through a small set of polyfill bindings registered before the bundle evaluates. - -For platform-specific nuances, see [iOS SDK bridge](./ios-sdk-bridge.md) and -[Android SDK bridge](./android-sdk-bridge.md). For step-by-step contributor onboarding, see -[Contributing to the iOS SDK](../guides/contributing-to-the-ios-sdk.md) and -[Contributing to the Android SDK](../guides/contributing-to-the-android-sdk.md). - -
- Table of Contents - -- [1. One TypeScript core, two UMD bundles](#1-one-typescript-core-two-umd-bundles) -- [2. Polyfills are prepended at build time](#2-polyfills-are-prepended-at-build-time) -- [3. Native polyfill bindings](#3-native-polyfill-bindings) -- [4. End-to-end call flow](#4-end-to-end-call-flow) - - [Sequence diagram](#sequence-diagram) - - [Async path: identify and screen](#async-path-identify-and-screen) - - [Sync path: personalizeEntry](#sync-path-personalizeentry) -- [5. State push-back from JS to native](#5-state-push-back-from-js-to-native) -- [6. Threading and lifecycle](#6-threading-and-lifecycle) -- [Where to go next](#where-to-go-next) - -
- -## 1. One TypeScript core, two UMD bundles - -The SDK is one TypeScript source tree under -[`packages/universal/optimization-js-bridge/src/`](../../packages/universal/optimization-js-bridge/), -compiled by Rslib into two UMD bundles that differ only in which package name they stamp into -analytics `library.name`: - -| Bundle | Consumer | Engine | -| ------------------------------------ | --------------------------------------------- | --------------------------------------- | -| `optimization-ios-bridge.umd.js` | [`packages/ios`](../../packages/ios/) | JavaScriptCore (`JSContext`) | -| `optimization-android-bridge.umd.js` | [`packages/android`](../../packages/android/) | QuickJS (`io.github.dokar3:quickjs-kt`) | - -The bridge package's `postbuild` script copies each UMD into the corresponding native package — the -iOS bundle into `Sources/ContentfulOptimization/Resources/`, the Android bundle into -`src/main/assets/`. From there it is packaged as a Swift Package resource or an Android asset and -read into memory at runtime by the platform's context manager. - -The UMD exposes a single `globalThis.__bridge` object with methods like `identify`, `screen`, -`personalizeEntry`, `flush`, `consent`, `reset`, `getProfile`, `getPreviewState`, and the preview- -panel override mutators. Native code never imports a JavaScript symbol by any other path. - -## 2. Polyfills are prepended at build time - -The bundle assumes a browser-ish global environment — `console`, `setTimeout`, `fetch`, -`crypto.randomUUID`, `URL`, `URLSearchParams`, `AbortController`, `TextEncoder`. Neither -JavaScriptCore nor QuickJS ships those. The bridge build prepends eight polyfill scripts to the -emitted UMD as raw text before the IIFE, so each polyfill's top-level `var` and `function` -declarations bind on the global object exactly as if they had been loaded as separate scripts. - -The prepend is implemented by a custom rspack plugin in -[`rslib.config.ts`](../../packages/universal/optimization-js-bridge/rslib.config.ts) (the built-in -`BannerPlugin` is unsuitable because it would template-substitute `[id]` inside polyfill code such -as `__timerCallbacks[id]`). The concatenation helper lives in -[`lib/build-tools/src/rslib.ts`](../../lib/build-tools/src/rslib.ts) as `concatPolyfills`. - -The load order is load-bearing — `timers` must precede `abort-controller`, `console` must come first -so anything that logs during its own initialization can do so: - -| Order | Polyfill | Installs on `globalThis` | Native binding it consumes | -| ----- | ------------------- | ---------------------------------------------------------------------------- | -------------------------------------------- | -| 1 | `console` | `console.log/warn/error/info/debug` | `__nativeLog` | -| 2 | `timers` | `setTimeout`, `clearTimeout`, `setInterval`, `clearInterval`, `__timerFired` | `__nativeSetTimeout`, `__nativeClearTimeout` | -| 3 | `fetch` | `fetch`, `__fetchComplete` | `__nativeFetch` | -| 4 | `crypto` | `crypto.randomUUID`, `crypto.getRandomValues` | `__nativeRandomUUID` | -| 5 | `url` | `URL`, `URLSearchParams` | — | -| 6 | `abort-controller` | `AbortController`, `AbortSignal.timeout` | — | -| 7 | `promise-utilities` | `queueMicrotask`, `Promise.withResolvers` (when missing) | — | -| 8 | `text-encoding` | `TextEncoder`, `TextDecoder` (when missing) | — | - -The native polyfill bindings — the `__native*` globals in the right column — must already exist on -the global object when the prepended polyfills evaluate. That ordering is the responsibility of the -per-platform context manager described next. - -## 3. Native polyfill bindings - -Each platform's context manager registers the same five `__native*` globals into the JS engine -before the UMD evaluates. The contract is identical; the implementation is platform-native: - -| Binding | iOS implementation | Android implementation | -| ---------------------- | ----------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------ | -| `__nativeLog` | `@convention(block)` closure → `JSContextManager.onLog` | `__native.log` (via `qjs.define`) → `NativeImpl.log` | -| `__nativeSetTimeout` | `DispatchQueue.main.asyncAfter`, work item stored in `TimerStore`, fires `__timerFired(id)` in JS | `scope.launch { delay(ms); evaluateJS("__timerFired(id)") }`, job stored in `TimerStore` | -| `__nativeClearTimeout` | Cancels the `DispatchWorkItem` in `TimerStore` | Cancels the `Job` in `TimerStore` | -| `__nativeRandomUUID` | `UUID().uuidString.lowercased()` | `UUID.randomUUID().toString()` | -| `__nativeFetch` | `URLSession.shared.dataTask`, response delivered to `__fetchComplete(callbackId, status, headers, body, error)` on main queue | `OkHttpClient.newCall(...).enqueue`, response delivered to `__fetchComplete(...)` via `scope.launch { evaluateJS(...) }` | - -On iOS the five globals are set directly on the `JSContext` via `setObject(_:forKeyedSubscript:)`. -On Android the `quickjs-kt` `qjs.define("__native") { function(...) }` DSL exposes the methods on a -`__native` object, and a five-line bootstrap script aliases each one to the matching `__native*` -global — that way the prepended polyfills can call `__nativeFetch(...)` on either platform without -caring how the binding was installed. - -Detail beyond this point — JavaScriptCore exception handlers, `os_signpost` markers, the -`quickjs-kt` single-threaded executor — is platform-specific. See -[iOS SDK bridge](./ios-sdk-bridge.md) and [Android SDK bridge](./android-sdk-bridge.md). - -## 4. End-to-end call flow - -The flow below is illustrated for iOS. Android uses the same flow with the engine substitutions -listed above (`JSContext.evaluateScript` ↔ `qjs.evaluate`, `URLSession` ↔ `OkHttp`, -`DispatchQueue.main` ↔ a coroutine on `Dispatchers.Main`). - -### Sequence diagram - -```mermaid -sequenceDiagram - autonumber - participant App as Swift caller - participant Client as OptimizationClient - participant CBM as BridgeCallbackManager - participant Ctx as JSContextManager - participant Bridge as __bridge (JS) - participant Fetch as fetch polyfill (JS) - participant Native as __nativeFetch (Swift) - participant URL as URLSession - - App->>Client: identify(userId, traits) - Client->>CBM: registerCallback(prefix: "identify") - CBM-->>Ctx: setObject(success/error closures) - CBM-->>Client: ("__identifyCallback_N_success", "..._error") - Client->>Ctx: evaluateScript("__bridge.identify(payload, success, error)") - Ctx->>Bridge: __bridge.identify(payload, onSuccess, onError) - Bridge->>Fetch: fetch(insightsUrl, { ... }) - Fetch->>Native: __nativeFetch(url, method, headers, body, callbackId) - Native->>URL: URLSession.shared.dataTask(...) - URL-->>Native: (data, response, error) - Native->>Ctx: __fetchComplete(callbackId, status, headers, body, err) - Ctx->>Fetch: __fetchComplete(...) - Fetch-->>Bridge: resolve(Response) - Bridge->>Ctx: onSuccess(JSON.stringify(result)) - Ctx->>CBM: success closure fires - CBM->>CBM: setObject(nil) for both names - CBM-->>Client: completion(.success(json)) - Client-->>App: returns [String: Any]? - - Note over App,Client: personalizeEntry skips the Fetch / URLSession lanes:
evaluateScript returns the JSON result synchronously. -``` - -### Async path: identify and screen - -The methods that round-trip through the Insights API — `identify`, `page`, `screen`, `trackView`, -`trackClick`, `flush` — are all async on the Swift / Kotlin surface because the JS-side handlers -return a `Promise`. The bridge cannot pass JS function objects back through `evaluateScript`'s -return value, so it uses a name-passing convention instead: - -1. `OptimizationClient.identify(userId, traits)` builds a JSON payload and calls - `bridgeCallAsyncJSON(method: "identify")` - ([`OptimizationClient.swift:120–131`](../../packages/ios/ContentfulOptimization/Sources/ContentfulOptimization/Core/OptimizationClient.swift)). -2. `BridgeCallbackManager.registerCallback` mints a unique id `N` and registers two Swift closures - on the JS global as `__identifyCallback_N_success` and `__identifyCallback_N_error` - ([`BridgeCallbackManager.swift:21–47`](../../packages/ios/ContentfulOptimization/Sources/ContentfulOptimization/Bridge/BridgeCallbackManager.swift)). -3. `JSContextManager.callAsync` evaluates - `__bridge.identify({"userId":...,"traits":...}, __identifyCallback_N_success, __identifyCallback_N_error)` - ([`JSContextManager.swift:88–141`](../../packages/ios/ContentfulOptimization/Sources/ContentfulOptimization/Bridge/JSContextManager.swift)). -4. The JS bridge calls into `CoreStateful.identify(payload)`, which eventually invokes the `fetch` - polyfill. `fetch` allocates a callback id, registers its own resolver, and calls - `__nativeFetch(url, method, headersJSON, body, callbackId)`. -5. The Swift fetch binding runs `URLSession.shared.dataTask`, then delivers the response on the main - queue by calling - `ctx.objectForKeyedSubscript("__fetchComplete")?.call(withArguments: [callbackId, status, headers, body, errorMsg])` - ([`NativePolyfills.swift:117–175`](../../packages/ios/ContentfulOptimization/Sources/ContentfulOptimization/Polyfills/NativePolyfills.swift)). -6. `__fetchComplete` resolves the JS-side fetch promise; the resulting chain inside `__bridge` runs - `onSuccess(JSON.stringify(result))` (or `onError(message)`), which invokes the matching Swift - closure registered in step 2. -7. The Swift closure clears both registered globals and resumes the awaiting - `withCheckedThrowingContinuation`, returning a `[String: Any]?` to the original caller. - -The error path is symmetric. If `__bridge.identify` rejects, the JS bundle calls -`__identifyCallback_N_error(message)`, the Swift error closure surfaces an -`OptimizationError.bridgeError`, and both globals are cleared. - -`screen(name, properties)` follows exactly the same pattern with a different method name in step 3. - -### Sync path: personalizeEntry - -`personalizeEntry` is purely a local resolution against the in-memory profile and the -personalizations already loaded; it does no network I/O. The bridge implements it as a synchronous -function that returns a JSON string, so the call shape collapses: - -1. `OptimizationClient.personalizeEntry(baseline, personalizations)` JSON-encodes its arguments and - calls `bridge.callSync(method: "personalizeEntry", args: ...)` - ([`OptimizationClient.swift:188–223`](../../packages/ios/ContentfulOptimization/Sources/ContentfulOptimization/Core/OptimizationClient.swift)). -2. `JSContextManager.callSync` runs - `ctx.evaluateScript("__bridge.personalizeEntry(, )")` and returns the - `JSValue` - ([`JSContextManager.swift:145–165`](../../packages/ios/ContentfulOptimization/Sources/ContentfulOptimization/Bridge/JSContextManager.swift)). -3. The bridge returns a JSON-encoded `{ entry, personalization }` object; the client decodes it into - a `PersonalizedResult`. - -The other sync methods — `consent`, `reset`, `setOnline`, `flag`, `getProfile`, `getMergeTagValue`, -`getPreviewState`, `loadDefinitions`, and the preview override mutators — follow the same shape. - -## 5. State push-back from JS to native - -Some state needs to be observed by the host app without being pulled. The bridge installs three -state-change globals at the end of `JSContextManager.initialize` (and the equivalent point in -`QuickJsContextManager.initialize`) that the JS bundle invokes whenever the relevant signal changes: - -- `__nativeOnStateChange(json)` — fires whenever profile, consent, `canPersonalize`, `changes`, or - `selectedPersonalizations` move; the iOS client republishes via `@Published` properties, the - Android client via `StateFlow`. -- `__nativeOnEventEmitted(json)` — fires for every analytics/personalization event the bridge - produces; consumed by the `eventPublisher` (Combine) / `events` (`SharedFlow`). -- `__nativeOnOverridesChanged(json)` — fires whenever `PreviewOverrideManager` mutates audience or - variant overrides. This is the push model that keeps preview-panel UIs in sync without polling - `getPreviewState()` after each action. - -The push happens **synchronously inside an in-flight bridge call** on both platforms. That is -deliberate: it guarantees that a UI that flips `setPreviewPanelOpen(false)` and then reads -`previewState` immediately afterward observes the post-close snapshot, not the pre-close one -([`QuickJsContextManager.kt:260–280`](../../packages/android/ContentfulOptimization/src/main/kotlin/com/contentful/optimization/bridge/QuickJsContextManager.kt) -documents this constraint inline). The native handlers then re-dispatch to the main thread before -publishing observable state to subscribers. - -## 6. Threading and lifecycle - -On iOS the `JSContext` is created on the calling thread; `JSContextManager` itself does no thread -hopping. Bridge calls execute on the caller, fetch responses and timer fires marshal back via -`DispatchQueue.main.async`, and async-call continuations resume on `MainActor` via -`OptimizationClient`'s `@MainActor` annotation. - -On Android the `QuickJs` instance is pinned to a single-threaded executor named -`"contentful-quickjs"`, exposed as `quickJsDispatcher` -([`QuickJsContextManager.kt:27–30`](../../packages/android/ContentfulOptimization/src/main/kotlin/com/contentful/optimization/bridge/QuickJsContextManager.kt)). -Every call into the bridge — sync or async — runs through `withContext(quickJsDispatcher)`, which -serializes JS evaluation. Async completions post back to `Dispatchers.Main` before resuming the -suspended coroutine. Sync calls from a non-suspending Kotlin caller use -`runBlocking(quickJsDispatcher)` so that the synchronous `__nativeOnStateChange` push has settled -into the `StateFlow` by the time the method returns. - -Teardown is symmetric on both platforms: cancel all pending timers from the `TimerStore`, evaluate -`__bridge.destroy()` to let the JS side release listeners, then close / null out the context. - -## Where to go next - -- [iOS SDK bridge](./ios-sdk-bridge.md) — JavaScriptCore-specific lifecycle, exception handling, - Swift Package resource declaration, signposts. -- [Android SDK bridge](./android-sdk-bridge.md) — QuickJS lifecycle, the `__native.log` callback - routing trick that delivers async results, coroutine integration. -- [Contributing to the iOS SDK](../guides/contributing-to-the-ios-sdk.md) — fresh-clone bootstrap - through a debuggable change in Xcode. -- [Contributing to the Android SDK](../guides/contributing-to-the-android-sdk.md) — fresh-clone - bootstrap through a debuggable change in Android Studio. -- [`@contentful/optimization-js-bridge` README](../../packages/universal/optimization-js-bridge/README.md) - — bridge package internals. diff --git a/documentation/drafts/integrating-the-ios-sdk-fundamentals.md b/documentation/drafts/integrating-the-ios-sdk-fundamentals.md deleted file mode 100644 index 04852227..00000000 --- a/documentation/drafts/integrating-the-ios-sdk-fundamentals.md +++ /dev/null @@ -1,304 +0,0 @@ -# iOS SDK fundamentals - -This document is the shared reference for the Contentful Optimization iOS SDK. It describes what the -SDK is, how it is architected, and the concepts that apply regardless of whether your app is built -with SwiftUI or UIKit. - -Read this first, then move on to the UI-framework-specific guide: - -- [Integrating the Optimization iOS SDK in a SwiftUI app](./integrating-the-ios-sdk-in-a-swiftui-app.md) -- [Integrating the Optimization iOS SDK in a UIKit app](./integrating-the-ios-sdk-in-a-uikit-app.md) - -## What the SDK is - -The iOS SDK (`ContentfulOptimization`, distributed via Swift Package Manager from -[`packages/ios`](../../packages/ios)) is a native Swift layer that lets iOS apps render personalized -Contentful content and report analytics back to the Optimization platform. - -Under the hood it runs the same JavaScript optimization core used by the Node, Web, and React Native -SDKs inside a **JavaScriptCore** context, bridged by a TypeScript adapter. Swift handles native -concerns — persistence via `UserDefaults`, networking, app lifecycle, SwiftUI/UIKit integration — -while the JS engine handles personalization logic, profile management, and analytics batching. You -never interact with the JS layer directly; every public API is Swift. - -See [`packages/ios/CODE_MAP.md`](../../packages/ios/CODE_MAP.md) for the full architecture diagram. - -## Reference app - -A working demo of both integration styles lives at -[Colorful-Team-Org/OptimizationiOSSDKDemo](https://github.com/Colorful-Team-Org/OptimizationiOSSDKDemo) -(local checkout at [`../../../optimization-ios-demo`](../../../optimization-ios-demo)): - -- **`SwiftUIDemo/`** — idiomatic SwiftUI integration using `OptimizationRoot`, `OptimizedEntry`, and - the `.trackScreen(name:)` modifier. -- **`UIKitDemo/`** — UIKit integration that initializes `OptimizationClient` manually in - `SceneDelegate`, calls `personalizeEntry` directly from cell configuration, and tracks screens in - `viewDidAppear`. - -Both demos are functionally and visually identical — same Contentful content, same home screen with -a personalized CTA banner, same blog detail screen, same preview panel FAB — which makes them a -useful A/B reference when deciding how to structure your own integration. The demo repo's -`README.md` also documents the Contentful space setup, credentials, and setup script, so it is a -good primer for wiring an app end-to-end. - -## Installation - -Add the SDK to your Xcode project as a Swift Package dependency pointing at -[`packages/ios/ContentfulOptimization`](../../packages/ios/ContentfulOptimization). The demo repo -uses a local path via `xcodegen`; production apps typically point at a Git ref of this monorepo. - -Minimum platforms: iOS 15 / macOS 12. - -> [!NOTE] -> -> The SDK ships a compiled JavaScript bridge bundle (`optimization-ios-bridge.umd.js`) as a -> resource. When consuming the SDK from a source checkout, you must build the JS bridge first (the -> demo's `./scripts/setup.sh` handles this). Consumers of a released package get the bundle -> prebuilt. - -## Core types - -The SDK's public surface is small. Most integrations use five types: - -| Type | Role | -| ---------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------ | -| `OptimizationClient` | `@MainActor` `ObservableObject`. The main facade — publishes state, drives all bridge calls. | -| `OptimizationConfig` | Value type: `clientId`, `environment`, API base URLs, `StorageDefaults`, `debug` flag. | -| `OptimizationRoot` (SwiftUI) | Top-level SwiftUI view that initializes the client and injects it into the environment. | -| `OptimizedEntry` (SwiftUI) | Wraps a Contentful entry, resolves the personalized variant, and attaches view and tap tracking. | -| `PreviewPanelOverlay` (SwiftUI) / `PreviewPanelViewController` (UIKit) | Developer-only preview panel for overriding audiences and variants. | - -The full type list also includes `OptimizationScrollView`, `OptimizationState`, -`PersonalizedResult`, `TrackViewPayload`, `TrackClickPayload`, `PreviewContentfulClient`, and the -`ContentfulHTTPPreviewClient` helper. - -## Configuration - -Every integration ultimately builds an `OptimizationConfig`: - -```swift -OptimizationConfig( - clientId: "your-optimization-client-id", - environment: "master", - experienceBaseUrl: nil, // optional override for the Experience API - insightsBaseUrl: nil, // optional override for the Insights API - contentfulLocales: ContentfulLocales(default: "en-US", supported: ["en-US", "de-DE"]), - locale: "en-US", // app/content locale candidate used to resolve client.locale - defaults: StorageDefaults(consent: true), - debug: true // emits os.Logger output under com.contentful.optimization -) -``` - -Only `clientId` is required; `environment` defaults to `"master"`. Leave the base URLs as `nil` to -hit production endpoints. When the same screen renders Contentful entries with MergeTags, configure -`contentfulLocales` from the Contentful space and set `locale` to the app/content locale candidate -that the SDK resolves to `client.locale`. Use `api.locale` only when an integration needs an -explicit Experience API locale override. - -`debug: true` enables structured logging to Xcode console and `Console.app` under the subsystem -`com.contentful.optimization`. Leave it off in production. - -### StorageDefaults - -`StorageDefaults` lets you seed the SDK's persisted state on first launch. The most common use is -pre-granting consent for demos or tests: - -```swift -defaults: StorageDefaults(consent: true) -``` - -Other seedable values are `profile`, `changes`, and `personalizations`. Seeds are only applied when -no value is already persisted in `UserDefaults`, so a real user choice is never overwritten. - -## Lifecycle - -The SDK has two lifecycle phases that matter for any app: - -1. **Uninitialized**: `OptimizationClient` exists but has not yet loaded the JS bridge. All async - methods (`identify`, `page`, `screen`, `flush`, `trackView`, `trackClick`) throw - `OptimizationError.notInitialized`; sync methods (`consent`, `reset`, `setOnline`, override - methods) no-op. -2. **Initialized**: The JS bridge is loaded, persisted state has been merged into the config, - `isInitialized == true`, and `AppStateHandler` + `NetworkMonitor` are running. All APIs are - usable. - -Initialization is a single `try client.initialize(config:)` call. In SwiftUI, `OptimizationRoot` -does this for you in `.task {}`. In UIKit, do it in your `SceneDelegate`'s -`scene(_:willConnectTo:options:)`. - -Call `destroy()` only in test teardown or when you need to hard-reset the SDK — typical apps leave a -single `OptimizationClient` alive for the app's lifetime. - -> [!IMPORTANT] -> -> `OptimizationClient` is `@MainActor`. Call it from main-thread contexts (view lifecycle methods, -> `Task { @MainActor in ... }` blocks, SwiftUI `.onAppear`). Calling from a background thread is a -> concurrency error that the compiler will flag. - -## Consent - -By default the SDK blocks analytics events until the user expresses a consent choice. The one -exception is `identify` and `screen`, which are always allowed so that anonymous screen-level -analytics continue even before a consent UI has been shown. - -Record consent with: - -```swift -client.consent(true) // accept — unblocks all event types -client.consent(false) // reject — blocks non-allowed events -``` - -Consent is persisted via `UserDefaults`, so the user's choice is restored on the next launch. You -only need to prompt once per install. - -For demos where you do not want a consent UI at all, pre-grant consent via `StorageDefaults`: - -```swift -OptimizationConfig( - clientId: "...", - defaults: StorageDefaults(consent: true) -) -``` - -Both demo apps (SwiftUI and UIKit) use this shortcut. - -Consent state is exposed reactively as `client.state.consent` (see below). - -## Reactive state - -`OptimizationClient` is an `ObservableObject`. Several properties are `@Published` and update as the -JS bridge pushes signals: - -| Property | Type | Description | -| -------------------------- | ------------------- | ----------------------------------------------------------------------- | -| `state` | `OptimizationState` | Reactive snapshot of `profile`, `consent`, `canPersonalize`, `changes`. | -| `isInitialized` | `Bool` | `true` once `initialize(config:)` has returned successfully. | -| `selectedPersonalizations` | `[[String: Any]]?` | The personalizations the current user qualifies for. | -| `isPreviewPanelOpen` | `Bool` | `true` while the preview panel is on screen. | - -There is also `eventPublisher: AnyPublisher<[String: Any], Never>` for subscribing to raw -analytics/personalization events emitted by the JS bridge. Useful for debug overlays and tests. - -In SwiftUI, consume these with `@EnvironmentObject` + property wrappers; in UIKit, subscribe via -Combine (`client.$selectedPersonalizations.sink { ... }`). - -## Personalizing Contentful entries - -Fetch entries from Contentful as `[String: Any]` dictionaries (e.g. via `URLSession` or any -Contentful client that returns JSON-shaped output) and include linked optimization references by -passing `include: 10` to the Delivery API. That dictionary is the format the SDK expects. - -To resolve the correct variant for the current user: - -```swift -let result = client.personalizeEntry( - baseline: entry, - personalizations: client.selectedPersonalizations -) -let resolvedEntry = result.entry -let personalization = result.personalization // nil when baseline was used -``` - -`personalizeEntry` is synchronous and safe to call from view code. If the SDK is not yet -initialized, or the entry has no `nt_experiences` field, it returns the baseline unchanged. - -In SwiftUI, `OptimizedEntry` wraps this call for you and also handles variant locking, view -tracking, and tap tracking. In UIKit, you call `personalizeEntry` yourself — typically in a cell -configuration method — and attach tracking manually. - -## Tracking model - -The iOS SDK tracks three kinds of events, each with a corresponding API: - -| Event | Method | Emitted by | -| ----------------- | ----------------------- | ------------------------------------------------- | -| Screen view | `client.screen(name:)` | `.trackScreen(name:)` (SwiftUI) or manual (UIKit) | -| Entry view | `client.trackView(_:)` | `OptimizedEntry` (SwiftUI) or manual (UIKit) | -| Entry click / tap | `client.trackClick(_:)` | `OptimizedEntry` (SwiftUI) or manual (UIKit) | - -You will also see `identify(userId:traits:)` and `page(properties:)`. On mobile, screen events are -usually preferred over page events. - -### Entry view tracking thresholds - -For entry views, the SDK fires an event when the entry has been at least **80% visible for 2 -seconds**, then emits periodic duration updates every 5 seconds while it remains visible, and a -final event when it disappears. Both thresholds are configurable per-entry in SwiftUI via -`OptimizedEntry(..., viewTimeMs:, threshold:, viewDurationUpdateIntervalMs:)`. In UIKit, you compute -duration yourself and send a `TrackViewPayload`. - -## Live updates - -`OptimizedEntry` in SwiftUI (and any UIKit code that reads `client.selectedPersonalizations`) can -either **lock to the first variant it resolves** or **update live** when the selected -personalizations change mid-session. Default is locked, which prevents content from swapping under a -user who is already reading it. - -Three layers control the behavior, from broadest to narrowest: - -1. **Preview panel open** — always forces live updates so variant overrides apply immediately. -2. **`OptimizationRoot(liveUpdates:)`** (SwiftUI) or your own "global" flag (UIKit) — default for - the whole app. -3. **`OptimizedEntry(liveUpdates:)`** (SwiftUI) — per-component override. - -The resolution priority is: - -| Preview panel | Global | Per-entry | Result | -| ------------- | ------- | --------- | ------ | -| Open | any | any | Live | -| Closed | `true` | `nil` | Live | -| Closed | `false` | `true` | Live | -| Closed | `true` | `false` | Locked | -| Closed | `false` | `nil` | Locked | - -When the preview panel closes, SwiftUI's `OptimizedEntry` snapshots the current variants so that any -overrides applied during the preview session become the new "locked" baseline. - -## Preview panel - -The preview panel is an in-app developer tool that lets authors and engineers override audience -membership and variant selections locally without touching production state. It is shipped as part -of the SDK and is gated by the caller — typically with `#if DEBUG` or a build configuration flag — -so that production users never see it. - -Both UI frameworks have a floating "slider" FAB in the bottom-trailing corner that opens the panel: - -- SwiftUI: wrap content in `PreviewPanelOverlay(contentfulClient:) { ... }`. -- UIKit: call - `PreviewPanelViewController.addFloatingButton(to: ..., client: ..., contentfulClient: ...)`. - -The `contentfulClient` parameter is a `PreviewContentfulClient`-conforming type used to fetch -`nt_audience` and `nt_experience` entries so audiences and experiences render by name. The SDK ships -`ContentfulHTTPPreviewClient`, a ready-made URLSession-based implementation: - -```swift -let contentfulClient = ContentfulHTTPPreviewClient( - spaceId: AppConfig.contentfulSpaceId, - accessToken: AppConfig.contentfulAccessToken, - environment: AppConfig.contentfulEnvironment -) -``` - -While the panel is open, `client.isPreviewPanelOpen` is `true` and all `OptimizedEntry` components -switch to live update mode. - -## Offline behavior - -The SDK monitors network reachability via `NWPathMonitor`. When offline: - -- Events are queued in memory. -- On reconnect, the queue is flushed automatically. -- On app backgrounding, queued events are flushed proactively to minimize data loss - (`AppStateHandler` hooks into `UIApplication.willResignActiveNotification`). - -No configuration is required. This behavior is identical across SwiftUI and UIKit integrations. - -## Where to go next - -- Building a SwiftUI app? Continue to - [Integrating the Optimization iOS SDK in a SwiftUI app](./integrating-the-ios-sdk-in-a-swiftui-app.md). -- Building a UIKit app? Continue to - [Integrating the Optimization iOS SDK in a UIKit app](./integrating-the-ios-sdk-in-a-uikit-app.md). -- Mixing both UI frameworks in one app? The SwiftUI views work inside `UIHostingController`, and - `OptimizationClient` is the shared underlying type — pass the same instance into both halves of - your app. diff --git a/documentation/drafts/integrating-the-ios-sdk-in-a-swiftui-app.md b/documentation/drafts/integrating-the-ios-sdk-in-a-swiftui-app.md deleted file mode 100644 index b4e2e595..00000000 --- a/documentation/drafts/integrating-the-ios-sdk-in-a-swiftui-app.md +++ /dev/null @@ -1,463 +0,0 @@ -# Integrating the Optimization iOS SDK in a SwiftUI app - -Use this guide when you want to add personalization and analytics to a SwiftUI application using the -Contentful Optimization iOS SDK. - -This guide assumes familiarity with the shared concepts covered in -[iOS SDK fundamentals](./integrating-the-ios-sdk-fundamentals.md) — installation, configuration, -consent, reactive state, the tracking model, live updates, and the preview panel. Read that first if -you have not already. - -Use the UIKit guide instead if your app is UIKit-based: -[Integrating the Optimization iOS SDK in a UIKit app](./integrating-the-ios-sdk-in-a-uikit-app.md). - -
- Table of Contents - - -- [Scope and capabilities](#scope-and-capabilities) -- [Reference app](#reference-app) -- [The integration flow](#the-integration-flow) -- [1. Initialize with OptimizationRoot](#1-initialize-with-optimizationroot) -- [2. Handle consent](#2-handle-consent) -- [3. Personalize entries with OptimizedEntry](#3-personalize-entries-with-optimizedentry) - - [Basic usage](#basic-usage) - - [Render prop signature](#render-prop-signature) - - [OptimizationScrollView for scrollable content](#optimizationscrollview-for-scrollable-content) - - [Tuning visibility thresholds](#tuning-visibility-thresholds) -- [4. Track entry interactions](#4-track-entry-interactions) - - [Global defaults on OptimizationRoot](#global-defaults-on-optimizationroot) - - [Per-entry overrides](#per-entry-overrides) -- [5. Enable or disable live updates](#5-enable-or-disable-live-updates) -- [6. Track screen views](#6-track-screen-views) -- [7. Preview panel](#7-preview-panel) -- [A complete example](#a-complete-example) - - -
- -## Scope and capabilities - -The SwiftUI integration uses the SDK's SwiftUI-native API surface: - -- `OptimizationRoot` initializes `OptimizationClient`, injects it as an `@EnvironmentObject`, and - seeds global tracking defaults. -- `OptimizedEntry` renders a personalized Contentful entry and attaches view and tap tracking. -- `OptimizationScrollView` provides an accurate viewport context for view tracking inside scroll - views. -- `.trackScreen(name:)` emits a screen event when a view appears. -- `PreviewPanelOverlay` renders a developer-only FAB that opens the preview panel sheet. - -## Reference app - -See the SwiftUI demo at -[Colorful-Team-Org/OptimizationiOSSDKDemo — SwiftUIDemo](https://github.com/Colorful-Team-Org/OptimizationiOSSDKDemo) -(local checkout at -[`../../../optimization-ios-demo/SwiftUIDemo`](../../../optimization-ios-demo/SwiftUIDemo)). It -exercises every pattern in this guide end-to-end against real Contentful content and is worth -reading alongside this document. - -## The integration flow - -A typical SwiftUI integration is: - -1. Install the SDK and build an `OptimizationConfig`. -2. Wrap the app's root content in `OptimizationRoot`. -3. Collect consent (or pre-grant it for demos). -4. Fetch Contentful entries with `include: 10`. -5. Render each entry through `OptimizedEntry`, optionally inside `OptimizationScrollView`. -6. Opt views/taps tracking on or off via `OptimizationRoot(trackViews:trackTaps:)` and - `OptimizedEntry(trackViews:trackTaps:)`. -7. Mark each screen with `.trackScreen(name:)`. -8. Gate `PreviewPanelOverlay` on a debug flag. - -## 1. Initialize with OptimizationRoot - -`OptimizationRoot` owns the `OptimizationClient` instance, initializes it in a `.task {}` block, and -shows a `ProgressView` until `isInitialized` flips to `true`. All SwiftUI views in the tree can then -read the client via `@EnvironmentObject`. - -```swift -import ContentfulOptimization -import SwiftUI - -@main -struct MyApp: App { - var body: some Scene { - WindowGroup { - OptimizationRoot( - config: OptimizationConfig( - clientId: "your-client-id", - environment: "master", - contentfulLocales: ContentfulLocales(default: "en-US"), - locale: "en-US", - defaults: StorageDefaults(consent: true), // demo: pre-grant - debug: true - ), - trackViews: true, - trackTaps: false, - liveUpdates: true - ) { - RootView() - } - } - } -} -``` - -Available arguments: - -| Argument | Type | Default | Description | -| ------------- | -------------------- | ------- | ---------------------------------------------------------------- | -| `config` | `OptimizationConfig` | — | Client ID, environment, API base URLs, debug flag, and defaults. | -| `trackViews` | `Bool` | `true` | Default for `OptimizedEntry` view tracking. | -| `trackTaps` | `Bool` | `false` | Default for `OptimizedEntry` tap tracking. | -| `liveUpdates` | `Bool` | `false` | Default for `OptimizedEntry` live update behavior. | -| `content` | `@ViewBuilder` | — | App content that gets the injected client. | - -Elsewhere, read the client with: - -```swift -struct SomeView: View { - @EnvironmentObject private var client: OptimizationClient - // client.state, client.selectedPersonalizations, client.identify(...), ... -} -``` - -## 2. Handle consent - -See [Consent](./integrating-the-ios-sdk-fundamentals.md#consent) in the fundamentals guide for the -consent model. In SwiftUI, a minimal banner looks like: - -```swift -struct ConsentBanner: View { - @EnvironmentObject private var client: OptimizationClient - - var body: some View { - VStack { - Text("We use analytics to personalize content.") - HStack { - Button("Accept") { client.consent(true) } - Button("Reject") { client.consent(false) } - } - } - } -} -``` - -To gate the banner on whether a choice has been made, observe `client.state.consent`: - -```swift -struct ConsentGate: View { - @EnvironmentObject private var client: OptimizationClient - @ViewBuilder var content: () -> Content - - var body: some View { - if client.state.consent == nil { - ConsentBanner() - } else { - content() - } - } -} -``` - -For demos, pre-grant consent with `StorageDefaults(consent: true)` on the config you pass to -`OptimizationRoot` and skip the banner entirely. - -## 3. Personalize entries with OptimizedEntry - -`OptimizedEntry` is the SwiftUI view you render each Contentful entry through. It: - -- detects whether the entry is personalized (has `nt_experiences`) -- resolves the correct variant based on `client.selectedPersonalizations` -- passes non-personalized entries through unchanged -- attaches view tracking (visibility + time-based) and tap tracking (gesture-based) -- locks to the first resolved variant unless live updates are on - -### Basic usage - -```swift -import ContentfulOptimization -import SwiftUI - -struct CTASection: View { - let entry: [String: Any] - - var body: some View { - OptimizedEntry(entry: entry, trackTaps: true) { resolvedEntry in - CTAHeader(entry: resolvedEntry) - } - } -} -``` - -The render closure receives `[String: Any]` — the resolved entry dictionary. Pull fields out with -`entry["fields"] as? [String: Any]`. The demo app's `CTAHeader` and `BlogPostCardContent` views are -good references for destructuring. - -### Render prop signature - -```swift -OptimizedEntry( - entry: [String: Any], - viewTimeMs: Int = 2000, - threshold: Double = 0.8, - viewDurationUpdateIntervalMs: Int = 5000, - liveUpdates: Bool? = nil, - trackViews: Bool? = nil, - trackTaps: Bool? = nil, - accessibilityIdentifier: String? = nil, - onTap: (([String: Any]) -> Void)? = nil, - content: @escaping ([String: Any]) -> Content -) -``` - -All tracking and live-update flags are `Optional` — `nil` means "inherit from -`OptimizationRoot`". - -### OptimizationScrollView for scrollable content - -Inside a plain `ScrollView`, `OptimizedEntry` falls back to "always visible" because it cannot read -scroll position. Wrap the scroll region with `OptimizationScrollView` so view tracking reflects the -actual viewport: - -```swift -OptimizationScrollView { - LazyVStack(alignment: .leading, spacing: 10) { - ForEach(posts, id: \.id) { post in - OptimizedEntry(entry: post) { resolved in - BlogPostCardContent(post: resolved) - } - } - } -} -.refreshable { await refresh() } -``` - -For full-screen content (heroes, modal cards, single-screen layouts), a plain container is fine — -the entry is treated as always on screen. - -### Tuning visibility thresholds - -The 80% / 2 seconds / 5 second update defaults are good for feed-style content. Override per entry -when a specific component needs different behavior: - -```swift -OptimizedEntry( - entry: largeBanner, - viewTimeMs: 3000, - threshold: 0.9 -) { resolved in - LargeBannerView(entry: resolved) -} -``` - -## 4. Track entry interactions - -### Global defaults on OptimizationRoot - -```swift -OptimizationRoot( - config: config, - trackViews: true, // track visibility for every OptimizedEntry - trackTaps: true // track taps for every OptimizedEntry (opt-in) -) { - RootView() -} -``` - -The SDK defaults are `trackViews: true, trackTaps: false`. Views are safe to turn on everywhere; -taps are opt-in because they are more application-specific. - -### Per-entry overrides - -```swift -// Opt a specific entry out of view tracking -OptimizedEntry(entry: hidden, trackViews: false) { resolved in - HiddenView(entry: resolved) -} - -// Enable taps for a single CTA -OptimizedEntry(entry: cta, trackTaps: true) { resolved in - CTAHeader(entry: resolved) -} - -// Tap callback implicitly enables tap tracking -OptimizedEntry(entry: cta, onTap: { resolved in - navigate(to: resolved) -}) { resolved in - CTAHeader(entry: resolved) -} -``` - -Passing `trackTaps: false` always wins — even if `onTap` is provided. - -## 5. Enable or disable live updates - -See [Live Updates](./integrating-the-ios-sdk-fundamentals.md#live-updates) in the fundamentals for -the resolution rules. In SwiftUI: - -```swift -// Global default -OptimizationRoot(config: config, liveUpdates: true) { - RootView() -} - -// Per-entry overrides -OptimizedEntry(entry: hero, liveUpdates: false) { resolved in - Hero(entry: resolved) // always locked -} -OptimizedEntry(entry: dashboard, liveUpdates: true) { resolved in - Dashboard(entry: resolved) // always live -} -OptimizedEntry(entry: card) { resolved in - Card(entry: resolved) // inherits global -} -``` - -While the preview panel is open, every `OptimizedEntry` in the tree switches to live mode regardless -of these flags. - -## 6. Track screen views - -Attach `.trackScreen(name:)` to any view — typically the root view of a screen: - -```swift -struct HomeScreen: View { - var body: some View { - Group { - // screen content - } - .trackScreen(name: "Home") - } -} -``` - -`.trackScreen(name:)` emits a `client.screen(name:)` event once, when the view first appears. For -dynamic screen names or delayed tracking (e.g. after data has loaded), call the client directly: - -```swift -struct DetailsScreen: View { - @EnvironmentObject private var client: OptimizationClient - let postTitle: String - - var body: some View { - Content() - .task { - try? await client.screen( - name: "BlogPostDetail", - properties: ["postTitle": postTitle] - ) - } - } -} -``` - -## 7. Preview panel - -Wrap your content in `PreviewPanelOverlay`, gated on a debug flag, to expose the developer FAB: - -```swift -#if DEBUG -let shouldShowPreview = true -#else -let shouldShowPreview = false -#endif - -let contentfulClient = ContentfulHTTPPreviewClient( - spaceId: AppConfig.contentfulSpaceId, - accessToken: AppConfig.contentfulAccessToken, - environment: AppConfig.contentfulEnvironment -) - -OptimizationRoot(config: config, liveUpdates: true) { - Group { - if shouldShowPreview { - PreviewPanelOverlay(contentfulClient: contentfulClient) { - RootView() - } - } else { - RootView() - } - } -} -``` - -Tapping the FAB presents the panel as a sheet. While it is open, `client.isPreviewPanelOpen` is -`true` and all `OptimizedEntry` components switch to live mode so overrides apply immediately. - -The `contentfulClient` parameter is optional — without it the panel shows audiences and experiences -by ID. Passing it enables rich names, variant labels, and traffic percentages. - -## A complete example - -The SwiftUI demo's app entry point ties all of this together — `OptimizationRoot` with pre-granted -consent and live updates enabled, `PreviewPanelOverlay` wrapping a `NavigationStack`, and a home -screen that uses `OptimizationScrollView` + `OptimizedEntry` to render a personalized CTA card -interleaved after the first blog post: - -```swift -// SwiftUIDemo/SwiftUIDemo/SwiftUIDemoApp.swift -@main -struct SwiftUIDemoApp: App { - private let contentfulClient = ContentfulHTTPPreviewClient( - spaceId: AppConfig.contentfulSpaceId, - accessToken: AppConfig.contentfulAccessToken, - environment: AppConfig.contentfulEnvironment - ) - - var body: some Scene { - WindowGroup { - OptimizationRoot( - config: OptimizationConfig( - clientId: AppConfig.optimizationClientId, - environment: AppConfig.optimizationEnvironment, - contentfulLocales: ContentfulLocales(default: "en-US"), - locale: "en-US", - defaults: StorageDefaults(consent: true), - debug: true - ), - trackViews: true, - trackTaps: false, - liveUpdates: true - ) { - PreviewPanelOverlay(contentfulClient: contentfulClient) { - NavigationStack { HomeScreen() } - } - } - } - } -} -``` - -```swift -// SwiftUIDemo/SwiftUIDemo/Screens/HomeScreen.swift (excerpt) -struct HomeScreen: View { - @EnvironmentObject private var client: OptimizationClient - @State private var cta: [String: Any]? - @State private var posts: [[String: Any]] = [] - - var body: some View { - OptimizationScrollView { - LazyVStack { - ForEach(Array(posts.enumerated()), id: \.offset) { index, post in - OptimizedEntry(entry: post) { _ in - NavigationLink(value: /* ... */) { BlogPostCardContent(post: post) } - } - if index == 0, let cta { - OptimizedEntry(entry: cta, trackTaps: true) { resolved in - CTAHeader(entry: resolved) - } - } - } - } - } - .trackScreen(name: "Home") - .task { await fetchData() } - } -} -``` - -Clone the demo, run the `scripts/setup.sh` helper, and open the `.xcworkspace` to step through the -rest of the code alongside the SDK sources. diff --git a/documentation/drafts/integrating-the-ios-sdk-in-a-uikit-app.md b/documentation/drafts/integrating-the-ios-sdk-in-a-uikit-app.md deleted file mode 100644 index 9404a6bc..00000000 --- a/documentation/drafts/integrating-the-ios-sdk-in-a-uikit-app.md +++ /dev/null @@ -1,430 +0,0 @@ -# Integrating the Optimization iOS SDK in a UIKit app - -Use this guide when you want to add personalization and analytics to a UIKit application using the -Contentful Optimization iOS SDK. - -This guide assumes familiarity with the shared concepts covered in -[iOS SDK fundamentals](./integrating-the-ios-sdk-fundamentals.md) — installation, configuration, -consent, reactive state, the tracking model, live updates, and the preview panel. Read that first if -you have not already. - -Use the SwiftUI guide instead if your app is SwiftUI-based: -[Integrating the Optimization iOS SDK in a SwiftUI app](./integrating-the-ios-sdk-in-a-swiftui-app.md). - -
- Table of Contents - - -- [Scope and capabilities](#scope-and-capabilities) -- [Reference app](#reference-app) -- [The integration flow](#the-integration-flow) -- [1. Initialize in SceneDelegate](#1-initialize-in-scenedelegate) -- [2. Handle consent](#2-handle-consent) -- [3. Personalize entries](#3-personalize-entries) - - [Calling personalizeEntry](#calling-personalizeentry) - - [Reloading on selectedPersonalizations changes](#reloading-on-selectedpersonalizations-changes) - - [Live updates vs locked variants](#live-updates-vs-locked-variants) -- [4. Track entry interactions](#4-track-entry-interactions) - - [Click tracking](#click-tracking) - - [View tracking](#view-tracking) -- [5. Track screen views](#5-track-screen-views) -- [6. Preview panel](#6-preview-panel) -- [A complete example](#a-complete-example) - - -
- -## Scope and capabilities - -The UIKit integration is more explicit than the SwiftUI one: the SDK does not ship UIKit-native -views equivalent to `OptimizedEntry` or `OptimizationScrollView`. Instead, you work with -`OptimizationClient` directly and attach tracking yourself. - -UIKit apps typically use: - -- `OptimizationClient` as a long-lived property on the `SceneDelegate`, passed down into view - controllers. -- `client.personalizeEntry(baseline:personalizations:)` called in cell configuration or view - controller setup. -- `client.trackView(_:)` and `client.trackClick(_:)` called from visibility callbacks and - `UIControl` actions. -- `client.screen(name:)` called from `viewDidAppear(_:)`. -- `PreviewPanelViewController` (a `UIHostingController` subclass) mounted behind - `PreviewPanelViewController.addFloatingButton(to:client:contentfulClient:)` for developer - overrides. - -The preview panel's UI is itself SwiftUI, but `PreviewPanelViewController` wraps it in a -`UIHostingController` so it drops cleanly into a UIKit navigation stack. - -## Reference app - -See the UIKit demo at -[Colorful-Team-Org/OptimizationiOSSDKDemo — UIKitDemo](https://github.com/Colorful-Team-Org/OptimizationiOSSDKDemo) -(local checkout at -[`../../../optimization-ios-demo/UIKitDemo`](../../../optimization-ios-demo/UIKitDemo)). It is -functionally identical to the SwiftUI demo so you can compare side-by-side. - -## The integration flow - -A typical UIKit integration is: - -1. Install the SDK and build an `OptimizationConfig`. -2. Create a shared `OptimizationClient` in `SceneDelegate` and call `initialize(config:)`. -3. Collect consent (or pre-grant it for demos). -4. Pass the client into root view controllers. -5. Fetch Contentful entries with `include: 10`. -6. In cell configuration, call `client.personalizeEntry(baseline:personalizations:)` and render the - resolved entry. -7. Track clicks from `UIControl` actions with `TrackClickPayload`; track views by reporting visible - duration to `TrackViewPayload`. -8. Call `client.screen(name:)` from `viewDidAppear(_:)`. -9. Mount the preview panel behind a debug flag with - `PreviewPanelViewController.addFloatingButton(...)`. - -## 1. Initialize in SceneDelegate - -Own the `OptimizationClient` from `SceneDelegate` so its lifetime matches the scene and its instance -is straightforward to pass into view controllers. - -```swift -import ContentfulOptimization -import UIKit - -class SceneDelegate: UIResponder, UIWindowSceneDelegate { - var window: UIWindow? - let client = OptimizationClient() - let contentfulClient = ContentfulHTTPPreviewClient( - spaceId: AppConfig.contentfulSpaceId, - accessToken: AppConfig.contentfulAccessToken, - environment: AppConfig.contentfulEnvironment - ) - - func scene( - _ scene: UIScene, - willConnectTo session: UISceneSession, - options connectionOptions: UIScene.ConnectionOptions - ) { - guard let windowScene = scene as? UIWindowScene else { return } - - try? client.initialize(config: OptimizationConfig( - clientId: AppConfig.optimizationClientId, - environment: AppConfig.optimizationEnvironment, - contentfulLocales: ContentfulLocales(default: "en-US"), - locale: "en-US", - defaults: StorageDefaults(consent: true), // demo pre-grant - debug: true - )) - - let home = HomeViewController(client: client) - let nav = UINavigationController(rootViewController: home) - - window = UIWindow(windowScene: windowScene) - window?.rootViewController = nav - window?.makeKeyAndVisible() - } -} -``` - -> [!IMPORTANT] -> -> `OptimizationClient` is `@MainActor`, so `initialize(config:)` must be called on the main thread. -> `scene(_:willConnectTo:options:)` already runs on the main thread, so the call above is safe. - -Pass `client` into each view controller's initializer. This gives every screen access to the -singleton instance for calling `personalizeEntry`, tracking events, and observing -`selectedPersonalizations`. - -## 2. Handle consent - -See [Consent](./integrating-the-ios-sdk-fundamentals.md#consent) in the fundamentals for the consent -model. In UIKit, typical patterns are: - -- A dedicated consent view controller shown before the main navigation stack. -- An inline banner pushed into the first screen. -- A pre-grant via `StorageDefaults(consent: true)` for demos. - -Example consent actions: - -```swift -@objc private func acceptTapped() { client.consent(true) } -@objc private func rejectTapped() { client.consent(false) } -``` - -To observe the consent state reactively, subscribe to `client.$state`: - -```swift -client.$state - .map(\.consent) - .removeDuplicates() - .receive(on: RunLoop.main) - .sink { [weak self] value in - self?.updateConsentUI(value) - } - .store(in: &cancellables) -``` - -## 3. Personalize entries - -### Calling personalizeEntry - -Fetch Contentful entries into `[String: Any]` dictionaries (the demo app's `ContentfulService` uses -raw `URLSession`; any JSON-returning Contentful client works). Call -`personalizeEntry(baseline:personalizations:)` wherever you render the entry — typically in -`tableView(_:cellForRowAt:)`: - -```swift -func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { - let cell = tableView.dequeueReusableCell( - withIdentifier: BlogPostCardCell.reuseIdentifier, - for: indexPath - ) as! BlogPostCardCell - - let baseline = posts[indexPath.row] - let resolved = client.personalizeEntry( - baseline: baseline, - personalizations: client.selectedPersonalizations - ) - cell.configure(with: resolved.entry) - return cell -} -``` - -`personalizeEntry` is synchronous and returns a `PersonalizedResult`: - -| Field | Type | Description | -| ----------------- | ---------------- | ---------------------------------------------------------------- | -| `entry` | `[String: Any]` | The resolved variant entry (or the baseline if nothing matched). | -| `personalization` | `[String: Any]?` | The matched personalization metadata, or `nil` when baseline. | - -Use `personalization != nil` to decide whether a user saw a personalized variant — useful when -composing tracking payloads. - -### Reloading on selectedPersonalizations changes - -When `client.selectedPersonalizations` changes (for example, after the user's audience qualification -shifts), re-resolve and redraw affected cells. Observe the property via Combine: - -```swift -client.$selectedPersonalizations - .dropFirst() - .receive(on: RunLoop.main) - .sink { [weak self] _ in - guard let self else { return } - self.tableView.reloadData() - } - .store(in: &cancellables) -``` - -### Live updates vs locked variants - -UIKit does not have an automatic "lock to first variant" mechanism — you decide when to re-resolve -based on whether you want to stay locked or update live. Two common patterns: - -- **Live updates**: call `personalizeEntry` inside `cellForRowAt` and reload the table when - `selectedPersonalizations` changes (as above). The user sees the current best variant. -- **Locked variants**: capture `client.selectedPersonalizations` at the time your screen loads, - store it in the view controller, and pass that snapshot into every `personalizeEntry` call. Do not - reload on change. - -A common compromise is to live-update while the preview panel is open (for developer feedback) and -lock in production. You can check `client.isPreviewPanelOpen` to decide. - -## 4. Track entry interactions - -### Click tracking - -Wire up a tap action on the control and call `client.trackClick(_:)` with a `TrackClickPayload`: - -```swift -ctaView.onButtonTap = { [weak self] in - guard let self else { return } - let sys = cta["sys"] as? [String: Any] ?? [:] - let componentId = sys["id"] as? String ?? "" - Task { - try? await self.client.trackClick(TrackClickPayload( - componentId: componentId, - variantIndex: resolved.personalization != nil ? 1 : 0 - )) - } -} -``` - -`TrackClickPayload` fields: - -| Field | Type | Description | -| -------------- | --------- | -------------------------------------------------- | -| `componentId` | `String` | Typically `entry.sys.id`. | -| `experienceId` | `String?` | The ID of the matching experience, if any. | -| `variantIndex` | `Int` | `0` for baseline; `1+` for a personalized variant. | - -### View tracking - -UIKit does not have a visibility modifier, so you detect visibility yourself (e.g. via -`collectionView(_:willDisplay:forItemAt:)` / `didEndDisplaying` or by observing cell visibility -changes) and call `client.trackView(_:)` with a `TrackViewPayload`: - -```swift -try? await client.trackView(TrackViewPayload( - componentId: entryId, - viewId: UUID().uuidString, - experienceId: experienceId, - variantIndex: variantIndex, - viewDurationMs: durationMs, - sticky: nil -)) -``` - -Strategy for computing `viewDurationMs`: - -1. In `willDisplay`, record the timestamp and start a periodic timer. -2. On each timer tick (e.g. every 5 seconds), send a `TrackViewPayload` with the running duration. -3. In `didEndDisplaying`, send a final payload and cancel the timer. - -If this level of visibility accounting is more than you need, the simpler path is to send one event -per display with a short configurable duration. The SwiftUI `OptimizedEntry` uses the -threshold-based algorithm described in the fundamentals; UIKit apps that want parity can port that -logic or read `ViewTrackingController` in the SDK source as a reference. - -## 5. Track screen views - -Call `client.screen(name:)` from `viewDidAppear(_:)`: - -```swift -override func viewDidAppear(_ animated: Bool) { - super.viewDidAppear(animated) - Task { try? await client.screen(name: "Home") } -} -``` - -`screen` is async and throws, which is why it runs inside a `Task`. Use `try?` to silence errors -unless you need to handle them. - -To include extra properties: - -```swift -Task { - try? await client.screen( - name: "BlogPostDetail", - properties: ["postId": postId] - ) -} -``` - -## 6. Preview panel - -Attach the floating action button in the scene delegate (or from a root view controller's -`viewDidLoad`), gated on a debug flag: - -```swift -#if DEBUG -PreviewPanelViewController.addFloatingButton( - to: homeVC, - client: client, - contentfulClient: contentfulClient -) -#endif -``` - -`addFloatingButton(to:client:contentfulClient:)` adds a pinned button in the bottom-trailing corner -of the host view controller and wires it up to present `PreviewPanelViewController` on tap. The -preview panel's UI is SwiftUI wrapped in a `UIHostingController`, so it lives happily inside a UIKit -navigation stack. - -While the panel is open, `client.isPreviewPanelOpen` is `true`. `PreviewPanelViewController` updates -this for you in `viewDidAppear` / `viewWillDisappear`. Use it to decide whether to re-resolve -entries live: - -```swift -client.$isPreviewPanelOpen - .receive(on: RunLoop.main) - .sink { [weak self] _ in self?.tableView.reloadData() } - .store(in: &cancellables) -``` - -The `contentfulClient` parameter is optional — without it the panel displays audiences and -experiences by ID. Passing `ContentfulHTTPPreviewClient` enables rich names, variant labels, and -traffic percentages. You can also implement `PreviewContentfulClient` directly if you already have a -Contentful client you want to reuse. - -## A complete example - -The UIKit demo's scene delegate and home view controller together show the full pattern — SDK init -in `scene(_:willConnectTo:options:)`, a `UITableView` that calls `personalizeEntry` in -`cellForRowAt`, click tracking on the CTA button, screen tracking in `viewDidAppear`, and the -preview panel FAB attached behind `debug: true`: - -```swift -// UIKitDemo/UIKitDemo/SceneDelegate.swift -class SceneDelegate: UIResponder, UIWindowSceneDelegate { - var window: UIWindow? - let client = OptimizationClient() - let contentfulClient = ContentfulHTTPPreviewClient( - spaceId: AppConfig.contentfulSpaceId, - accessToken: AppConfig.contentfulAccessToken, - environment: AppConfig.contentfulEnvironment - ) - - func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options: UIScene.ConnectionOptions) { - guard let windowScene = scene as? UIWindowScene else { return } - - try? client.initialize(config: OptimizationConfig( - clientId: AppConfig.optimizationClientId, - environment: AppConfig.optimizationEnvironment, - contentfulLocales: ContentfulLocales(default: "en-US"), - locale: "en-US", - defaults: StorageDefaults(consent: true), - debug: true - )) - - let homeVC = HomeViewController(client: client) - let nav = UINavigationController(rootViewController: homeVC) - - window = UIWindow(windowScene: windowScene) - window?.rootViewController = nav - window?.makeKeyAndVisible() - - PreviewPanelViewController.addFloatingButton( - to: homeVC, - client: client, - contentfulClient: contentfulClient - ) - } -} -``` - -```swift -// UIKitDemo/UIKitDemo/Screens/HomeViewController.swift (excerpt) -final class HomeViewController: UIViewController { - private let client: OptimizationClient - private let tableView = UITableView(frame: .zero, style: .grouped) - private var cancellables = Set() - - override func viewDidLoad() { - super.viewDidLoad() - // ... setup ... - client.$selectedPersonalizations - .dropFirst() - .receive(on: RunLoop.main) - .sink { [weak self] _ in self?.tableView.reloadData() } - .store(in: &cancellables) - } - - override func viewDidAppear(_ animated: Bool) { - super.viewDidAppear(animated) - Task { try? await client.screen(name: "Home") } - } - - func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { - let cell = tableView.dequeueReusableCell(/* ... */) as! BlogPostCardCell - let resolved = client.personalizeEntry( - baseline: posts[indexPath.row], - personalizations: client.selectedPersonalizations - ) - cell.configure(with: resolved.entry) - return cell - } -} -``` - -Clone the demo repo, run `./scripts/setup.sh`, and open `UIKitDemo.xcworkspace` to step through the -rest of the code alongside the SDK sources. diff --git a/documentation/guides/AGENTS.md b/documentation/guides/AGENTS.md index 53cd9c35..26c74e03 100644 --- a/documentation/guides/AGENTS.md +++ b/documentation/guides/AGENTS.md @@ -12,6 +12,10 @@ These instructions apply to public integration guides under `documentation/guide 2. Web 3. React Web 4. React Native + 5. iOS SwiftUI + 6. iOS UIKit + 7. Next.js SSR-primary + 8. Next.js hybrid SSR + CSR takeover ## Integration guide structure diff --git a/documentation/guides/README.md b/documentation/guides/README.md index 50662f54..8df84297 100644 --- a/documentation/guides/README.md +++ b/documentation/guides/README.md @@ -6,11 +6,13 @@ children: - ./integrating-the-web-sdk-in-a-web-app.md - ./integrating-the-react-web-sdk-in-a-react-app.md - ./integrating-the-react-native-sdk-in-a-react-native-app.md + - ./integrating-the-optimization-ios-sdk-in-a-swiftui-app.md + - ./integrating-the-optimization-ios-sdk-in-a-uikit-app.md + - ./integrating-the-optimization-android-sdk-in-a-compose-app.md + - ./integrating-the-optimization-android-sdk-in-a-views-app.md - ./integrating-the-optimization-sdk-in-a-nextjs-app-ssr.md - ./integrating-the-optimization-sdk-in-a-nextjs-app-ssr-csr.md - ./forwarding-optimization-sdk-context-to-analytics-and-tag-management-tools.md - - ./contributing-to-the-ios-sdk.md - - ./contributing-to-the-android-sdk.md --- # Guides @@ -22,7 +24,7 @@ inventory instead. ## Start here - [Choosing the right SDK](./choosing-the-right-sdk.md) - pick the narrowest published package layer - for a browser, React, Node, or React Native application + for a browser, React, Node, React Native, or native iOS application ## Integration guides @@ -38,6 +40,18 @@ inventory instead. - [Integrating the Optimization React Native SDK in a React Native app](./integrating-the-react-native-sdk-in-a-react-native-app.md) - step-by-step React Native / Expo integration guidance covering setup, consent, personalization and interaction tracking, screen tracking, live updates, and the in-app preview panel +- [Integrating the Optimization iOS SDK in a SwiftUI app](./integrating-the-optimization-ios-sdk-in-a-swiftui-app.md) - + step-by-step SwiftUI integration guidance covering setup, consent, entry personalization, + interaction tracking, screen tracking, live updates, and the in-app preview panel +- [Integrating the Optimization iOS SDK in a UIKit app](./integrating-the-optimization-ios-sdk-in-a-uikit-app.md) - + step-by-step UIKit integration guidance covering direct client setup, consent, manual entry + personalization, interaction tracking, screen tracking, live updates, and the in-app preview panel +- [Integrating the Optimization Android SDK in a Jetpack Compose app](./integrating-the-optimization-android-sdk-in-a-compose-app.md) - + step-by-step Compose integration guidance covering setup, consent, entry personalization, + interaction tracking, screen tracking, live updates, and the in-app preview panel +- [Integrating the Optimization Android SDK in an XML Views app](./integrating-the-optimization-android-sdk-in-a-views-app.md) - + step-by-step XML Views integration guidance covering `OptimizationManager`, consent, entry + personalization, interaction tracking, screen tracking, live updates, and the in-app preview panel - [Integrating the Optimization SDK in a Next.js app (SSR-primary)](./integrating-the-optimization-sdk-in-a-nextjs-app-ssr.md) - step-by-step Next.js App Router guidance for the SSR-primary pattern where the Node SDK resolves entries server-side and the React Web SDK handles client-side tracking and interactive controls @@ -51,18 +65,3 @@ inventory instead. - [Forwarding Optimization SDK context to analytics and tag-management tools](./forwarding-optimization-sdk-context-to-analytics-and-tag-management-tools.md) - guidance for forwarding Contentful optimization context to analytics, tag-management, customer-data, and product-analytics systems - -## Contributor guides - -- [Contributing to the iOS SDK](./contributing-to-the-ios-sdk.md) - fresh-clone bootstrap through a - debuggable change in Xcode, including how the reference impl rebuilds the SDK package and the JS - bridge automatically on Build -- [Contributing to the Android SDK](./contributing-to-the-android-sdk.md) - fresh-clone bootstrap - through a debuggable change in Android Studio, including how the composite-build SDK module and - the `buildJsBridge` Gradle task keep both Kotlin and TypeScript layers in sync on every build - -## Supplemental guides - -- [Forwarding Optimization SDK context to analytics and tag-management tools](./forwarding-optimization-sdk-context-to-analytics-and-tag-management-tools.md) - - guidance for forwarding Contentful optimization context to analytics, tag-management, - customer-data, and product-analytics systems diff --git a/documentation/guides/choosing-the-right-sdk.md b/documentation/guides/choosing-the-right-sdk.md index 15eeecc4..28c5b104 100644 --- a/documentation/guides/choosing-the-right-sdk.md +++ b/documentation/guides/choosing-the-right-sdk.md @@ -11,6 +11,8 @@ Use this guide to choose the narrowest package layer that matches the runtime yo - [React applications on the web](#react-applications-on-the-web) - [Node servers and server-side rendering](#node-servers-and-server-side-rendering) - [React Native applications](#react-native-applications) + - [Native iOS applications](#native-ios-applications) + - [Native Android applications](#native-android-applications) - [Lower-level building blocks](#lower-level-building-blocks) - [`@contentful/optimization-core`](#contentfuloptimization-core) - [`@contentful/optimization-api-client`](#contentfuloptimization-api-client) @@ -54,6 +56,26 @@ Choose `@contentful/optimization-react-native` for React Native applications tha optimization behavior on mobile, including offline-aware event handling and React Native-specific tracking utilities. +### Native iOS applications + +Choose the `ContentfulOptimization` Swift Package for native SwiftUI or UIKit applications that need +stateful optimization behavior on iOS, including native persistence, screen tracking, entry +personalization, interaction tracking, and the in-app preview panel. + +The native iOS SDK is separate from `@contentful/optimization-react-native`. Use React Native when +the application is built with React Native, and use the Swift Package when the application is built +with SwiftUI or UIKit. + +### Native Android applications + +Choose `com.contentful.java:optimization-android` for native Android applications that need stateful +optimization behavior on Android, including native persistence, screen tracking, entry +personalization, interaction tracking, and the in-app preview panel. + +The native Android SDK is separate from `@contentful/optimization-react-native`. Use React Native +when the application is built with React Native, and use the Android SDK when the application is +built with Jetpack Compose or XML Views. + ## Lower-level building blocks Choose one of the lower layers only when the environment SDKs are too opinionated for the use case @@ -82,6 +104,9 @@ Optimization APIs and Contentful entry-shape helpers. - Browser application with author preview: `@contentful/optimization-web` and `@contentful/optimization-web-preview-panel` - React browser application: `@contentful/optimization-react-web` +- Native mobile application: `@contentful/optimization-react-native` for React Native, + `ContentfulOptimization` for SwiftUI and UIKit, or `com.contentful.java:optimization-android` for + Jetpack Compose and XML Views - Server-rendered application with browser follow-up tracking: `@contentful/optimization-node` on the server and `@contentful/optimization-web` in the browser - Custom internal SDK layer: `@contentful/optimization-core`, optionally with diff --git a/documentation/guides/contributing-to-the-android-sdk.md b/documentation/guides/contributing-to-the-android-sdk.md deleted file mode 100644 index 0f092fb8..00000000 --- a/documentation/guides/contributing-to-the-android-sdk.md +++ /dev/null @@ -1,217 +0,0 @@ -# Contributing to the Android SDK - -Use this guide when you want to work on the native Android SDK -([`packages/android/ContentfulOptimization`](../../packages/android/ContentfulOptimization)) or the -shared JS bridge -([`packages/universal/optimization-js-bridge`](../../packages/universal/optimization-js-bridge)) and -validate your changes in the reference app at -[`implementations/android-sdk/`](../../implementations/android-sdk/). For an explanation of how the -bridge works at runtime, read -[Native mobile SDK architecture](../concepts/native-mobile-sdk-architecture.md) and -[Android SDK bridge](../concepts/android-sdk-bridge.md) first. - -
- Table of Contents - -- [1. Prerequisites](#1-prerequisites) -- [2. Fresh-clone bootstrap](#2-fresh-clone-bootstrap) -- [3. How the IDE build chain wires together](#3-how-the-ide-build-chain-wires-together) -- [4. The daily edit loop](#4-the-daily-edit-loop) -- [5. Running the reference app](#5-running-the-reference-app) -- [6. Validation cheatsheet](#6-validation-cheatsheet) -- [7. Common pitfalls](#7-common-pitfalls) - -
- -## 1. Prerequisites - -- The Node version pinned in [`.nvmrc`](../../.nvmrc) at the repo root. -- `pnpm` (the pinned `packageManager` version is in the root [`package.json`](../../package.json); - install via Corepack). -- Android Studio (any recent stable release) with the Android SDK platform tools installed. -- `JAVA_HOME` pointing at a JDK 17+ (AGP 8.7 requires it). -- `ANDROID_HOME` exported and `adb` on `PATH`. An emulator image or a connected device. - -## 2. Fresh-clone bootstrap - -From the repository root: - -```sh -pnpm install -pnpm build:pkgs -``` - -`pnpm build:pkgs` builds every workspace package, including `@contentful/optimization-js-bridge`. -Its `postbuild` script copies the freshly built `optimization-android-bridge.umd.js` into -`packages/android/ContentfulOptimization/src/main/assets/`, which is where -`AssetManager.open("optimization-android-bridge.umd.js")` expects to find it at runtime. - -Then open the reference impl in Android Studio: - -```sh -cd implementations/android-sdk -# Either launch Android Studio with this directory, or use the bootstrap script: -./scripts/bootstrap.sh -``` - -`./scripts/bootstrap.sh` is the one-shot path: it starts the mock server, runs -`./gradlew :app:assembleDebug`, installs the APK, and launches `MainActivity` on the connected -device or emulator. - -To open in the IDE instead: launch Android Studio → **Open** → `implementations/android-sdk/` and -let Gradle sync. Three run configurations appear in the toolbar once sync completes: **App**, **All -UI Tests**, **Prepare Env**. - -## 3. How the IDE build chain wires together - -The reference impl wires the SDK module as a Gradle composite-build local module: - -```kotlin -// implementations/android-sdk/settings.gradle.kts -include(":ContentfulOptimization") -project(":ContentfulOptimization").projectDir = - file("../../packages/android/ContentfulOptimization") -``` - -`:app` depends on `project(":ContentfulOptimization")`, so a Gradle build of `:app` rebuilds the SDK -module from source as a transitive task. There is no published AAR involved. - -For the JS bridge, -[`packages/android/ContentfulOptimization/build.gradle.kts`](../../packages/android/ContentfulOptimization/build.gradle.kts) -registers a `buildJsBridge` task that invokes -`pnpm --filter @contentful/optimization-js-bridge build` and wires it as a dependency of `preBuild`: - -```kotlin -val buildJsBridge = tasks.register("buildJsBridge") { - workingDir = rootProject.projectDir.resolve("../..") - commandLine("pnpm", "--filter", "@contentful/optimization-js-bridge", "build") - inputs.dir(rootProject.projectDir.resolve("../../packages/universal/optimization-js-bridge/src")) - outputs.file(layout.projectDirectory.file("src/main/assets/optimization-android-bridge.umd.js")) -} -tasks.named("preBuild").configure { dependsOn(buildJsBridge) } -``` - -The `inputs` / `outputs` declarations are load-bearing. With them, Gradle's up-to-date check skips -the task when the asset is already newer than the bridge source — so a no-op rebuild reports -`:ContentfulOptimization:buildJsBridge UP-TO-DATE` rather than re-running pnpm on every build. - -The end-state is: edit Kotlin in `packages/android/...` **or** TypeScript in -`packages/universal/optimization-js-bridge/src/`, run **App** in Android Studio (or -`./gradlew :app:assembleDebug` from the impl directory), and both layers pick up the change without -any manual pnpm or asset-copy step. - -## 4. The daily edit loop - -1. Make your change in `packages/android/ContentfulOptimization/src/main/kotlin/...` (Kotlin) or - `packages/universal/optimization-js-bridge/src/...` (TypeScript). -2. From Android Studio, run **App** (or **All UI Tests**). Gradle rebuilds the SDK module; the - `buildJsBridge` task regenerates the UMD asset only when TS sources changed. -3. Validate with the targeted UI Automator test or app flow (see § 6). - -From the command line: - -```sh -cd implementations/android-sdk -./gradlew :app:assembleDebug -adb install -r app/build/outputs/apk/debug/app-debug.apk -adb shell am start -n com.contentful.optimization.app/.MainActivity -``` - -## 5. Running the reference app - -Before launching, start the mock server from the repo root **and** forward the port to the -emulator/device: - -```sh -# Terminal 1 (repo root): -pnpm serve:mocks - -# Terminal 2 (anywhere): -adb reverse tcp:8000 tcp:8000 -``` - -Then run **App** from Android Studio, or use the bootstrap path: - -```sh -cd implementations/android-sdk -./scripts/bootstrap.sh -``` - -`./scripts/bootstrap.sh` handles `adb reverse`, the gradle assemble, the install, and the launch in -one step. - -To run the full UI Automator 2 suite from the command line: - -```sh -./gradlew :uitests:connectedAndroidTest -``` - -A single test class: - -```sh -./gradlew :uitests:connectedAndroidTest \ - -Pandroid.testInstrumentationRunnerArguments.class=com.contentful.optimization.uitests.tests.AnalyticsTests -``` - -Keep `testTag` values and `contentDescription`-based identifiers in sync with the iOS XCUITest suite -— see -[`implementations/PREVIEW_PANEL_SCENARIOS.md`](../../implementations/PREVIEW_PANEL_SCENARIOS.md) and -[`implementations/android-sdk/AGENTS.md`](../../implementations/android-sdk/AGENTS.md) for the -contract. - -## 6. Validation cheatsheet - -Repo-wide checks (run from the repo root): - -| Command | What it covers | -| -------------------- | --------------------------------------------------------- | -| `pnpm lint` | ESLint for `lib/` and `packages/`. | -| `pnpm typecheck` | `tsc --noEmit` across every workspace package. | -| `pnpm test:unit` | Unit tests for `lib/` and the `@contentful/*` packages. | -| `pnpm format:check` | Prettier check on the entire repo. | -| `pnpm size:check` | Bundle size budgets for built artifacts. | -| `pnpm docs:generate` | TypeDoc, which also picks up `documentation/**` markdown. | - -Impl-side checks: - -| Command | What it covers | -| ---------------------------------------- | ----------------------------------------------------------------------------- | -| `pnpm implementation:lint` | ESLint across reference implementations. | -| `./gradlew :app:lint` | Android Lint against the reference app (from `implementations/android-sdk/`). | -| `./gradlew :ContentfulOptimization:lint` | Android Lint against the SDK module. | - -For a change that only edits TypeScript bridge source, -`pnpm lint && pnpm typecheck && pnpm test:unit` is the right minimum. For Kotlin changes that touch -the SDK module, add `./gradlew :ContentfulOptimization:assembleDebug` (and a targeted UI Automator -scenario when the change is observable through the app). - -## 7. Common pitfalls - -- **`pnpm` not on Gradle's `PATH`** — Android Studio inherits its environment from the shell that - launched it. If `buildJsBridge` fails with `command not found`, launch Android Studio from a shell - where `pnpm --version` works, or symlink the binary into a standard location (e.g. - `sudo ln -s "$(which pnpm)" /usr/local/bin/pnpm`). Restart the IDE after. -- **Stale bridge bundle after a `git checkout` between branches** — the `inputs`/`outputs` check is - mtime-based. After a branch switch that touches bridge source, run - `pnpm --filter @contentful/optimization-js-bridge build` once, or - `touch packages/universal/optimization-js-bridge/src/index.ts` so the next Gradle build re-runs - `buildJsBridge`. -- **`__bridge not found after bundle evaluation`** — the Android asset is missing or empty. Most - likely cause: a failed bridge build left an empty `dist/` and the `postbuild` copy never ran. - Rerun the bridge build and inspect output. The reference app's **Prepare Env** run configuration - also checks for this and fails fast. -- **Mock server unreachable** — the app expects `http://localhost:8000` and the emulator routes that - to the host via `adb reverse tcp:8000 tcp:8000`. After an emulator restart you must re-run - `adb reverse`. -- **AGP / Gradle JDK mismatch** — AGP 8.7.3 (pinned in - [`implementations/android-sdk/build.gradle.kts`](../../implementations/android-sdk/build.gradle.kts)) - needs JDK 17+. In Android Studio: **Settings → Build, Execution, Deployment → Build Tools → Gradle - → Gradle JDK**. - -## Related - -- [Native mobile SDK architecture](../concepts/native-mobile-sdk-architecture.md) -- [Android SDK bridge](../concepts/android-sdk-bridge.md) -- [Android reference implementation README](../../implementations/android-sdk/README.md) -- [`packages/android` README](../../packages/android/README.md) -- [Contributing to the iOS SDK](./contributing-to-the-ios-sdk.md) diff --git a/documentation/guides/contributing-to-the-ios-sdk.md b/documentation/guides/contributing-to-the-ios-sdk.md deleted file mode 100644 index 2681b247..00000000 --- a/documentation/guides/contributing-to-the-ios-sdk.md +++ /dev/null @@ -1,227 +0,0 @@ -# Contributing to the iOS SDK - -Use this guide when you want to work on the native iOS SDK -([`packages/ios/ContentfulOptimization`](../../packages/ios/ContentfulOptimization)) or the shared -JS bridge -([`packages/universal/optimization-js-bridge`](../../packages/universal/optimization-js-bridge)) and -validate your changes in the reference app at -[`implementations/ios-sdk/`](../../implementations/ios-sdk/). For an explanation of how the bridge -works at runtime, read -[Native mobile SDK architecture](../concepts/native-mobile-sdk-architecture.md) and -[iOS SDK bridge](../concepts/ios-sdk-bridge.md) first. - -
- Table of Contents - -- [1. Prerequisites](#1-prerequisites) -- [2. Fresh-clone bootstrap](#2-fresh-clone-bootstrap) -- [3. How the IDE build chain wires together](#3-how-the-ide-build-chain-wires-together) -- [4. The daily edit loop](#4-the-daily-edit-loop) -- [5. Running the reference app](#5-running-the-reference-app) -- [6. Validation cheatsheet](#6-validation-cheatsheet) -- [7. Common pitfalls](#7-common-pitfalls) - -
- -## 1. Prerequisites - -- The Node version pinned in [`.nvmrc`](../../.nvmrc) at the repo root. The repository's - `engines.node` constraint lives in the root [`package.json`](../../package.json). -- `pnpm` (the pinned `packageManager` version is in the root `package.json`; install via Corepack: - `corepack enable && corepack prepare pnpm@ --activate`). -- Xcode with an iOS Simulator runtime available. -- `xcodegen` — installable via Homebrew: `brew install xcodegen`. Required because - `OptimizationApp.xcodeproj` is generated from - [`implementations/ios-sdk/project.yml`](../../implementations/ios-sdk/project.yml). - -## 2. Fresh-clone bootstrap - -From the repository root: - -```sh -pnpm install -pnpm build:pkgs -``` - -`pnpm build:pkgs` builds every package in the workspace, including -`@contentful/optimization-js-bridge`. Its `postbuild` script copies the freshly built -`optimization-ios-bridge.umd.js` into -`packages/ios/ContentfulOptimization/Sources/ContentfulOptimization/Resources/`, which is where the -Swift Package resource declaration in -[`Package.swift`](../../packages/ios/ContentfulOptimization/Package.swift) expects to find it. - -Then generate the Xcode project and open it: - -```sh -cd implementations/ios-sdk -xcodegen generate -open OptimizationApp.xcodeproj -``` - -`xcodegen generate` regenerates the project from `project.yml`. Run it again any time you change -`project.yml`, add a Swift file to the reference app, or move sources between targets. - -## 3. How the IDE build chain wires together - -The reference app references the SDK as a local Swift package: - -```yaml -# implementations/ios-sdk/project.yml -packages: - ContentfulOptimization: - path: ../../packages/ios/ContentfulOptimization -``` - -Each app target lists `ContentfulOptimization` as a dependency. Because it is a `path:` package (not -a tarball from `pkgs/`), every Xcode Build of `OptimizationAppSwiftUI` or `OptimizationAppUIKit` -recompiles the SDK package from source. There is no extra "rebuild SDK" step. - -For the JS bridge, each app target's scheme declares a **scheme pre-action** that invokes the bridge -build before any target — including the `ContentfulOptimization` Swift package dependency — is -compiled. A per-target `preBuildScripts` entry would fire too late, after the Swift package -dependency has already compiled against a possibly-stale UMD; scheme pre-actions are the only build -hook that runs strictly before SwiftPM resource resolution. - -```yaml -# implementations/ios-sdk/project.yml (excerpt) -scheme: - preActions: - - name: Build JS bridge - settingsTarget: OptimizationAppSwiftUI - script: | - cd "$SRCROOT/../.." - bundle="packages/ios/ContentfulOptimization/Sources/ContentfulOptimization/Resources/optimization-ios-bridge.umd.js" - if [ -f "$bundle" ] && [ -z "$(find packages/universal/optimization-js-bridge/src -type f -newer "$bundle" -print -quit)" ]; then - echo "JS bridge bundle is up to date; skipping rebuild." - exit 0 - fi - pnpm --filter @contentful/optimization-js-bridge build -``` - -The `find -newer` guard skips the rebuild when the bundle is already newer than every source file -under the bridge's `src/` directory. Edit a `.ts` file under -`packages/universal/optimization-js-bridge/src/` and the next Xcode Build re-runs `pnpm build`, -which refreshes the UMD asset before the Swift package compiles against it. - -Scheme pre-actions write their output to a system log rather than to Xcode's build log. The script -mirrors its output to `/tmp/optimization-ios-build-js-bridge.log`, so when in doubt, `cat` that file -after a build to confirm the rebuild ran (or correctly reported "up to date; skipping"). - -The end-state is: edit Swift in `packages/ios/...` **or** TypeScript in -`packages/universal/optimization-js-bridge/src/`, hit Cmd+B, run the simulator — both layers pick up -the change without any manual pnpm or asset-copy step. - -## 4. The daily edit loop - -1. Make your change in `packages/ios/ContentfulOptimization/Sources/...` (Swift) or - `packages/universal/optimization-js-bridge/src/...` (TypeScript). -2. In Xcode, pick the `OptimizationAppSwiftUI` or `OptimizationAppUIKit` scheme and hit Build (or - Run). The scheme pre-action regenerates the UMD only if the TS source is newer; the package - target recompiles only if Swift sources changed. -3. Validate with the targeted test or UI flow (see § 6). - -If you prefer the command line, build and run the simulator entirely from the impl directory: - -```sh -cd implementations/ios-sdk -xcodebuild build -project OptimizationApp.xcodeproj \ - -scheme OptimizationAppSwiftUI \ - -destination 'platform=iOS Simulator,name=iPhone 16' -``` - -## 5. Running the reference app - -Before launching the app or running UI tests, start the shared mock server from the repo root: - -```sh -pnpm serve:mocks -``` - -The reference app talks to `http://localhost:8000` for both the Experience and Insights APIs. - -Then hit Run on the `OptimizationAppSwiftUI` or `OptimizationAppUIKit` scheme. The app exercises -`OptimizationRoot`, `OptimizedEntry`, the preview panel, and analytics flows against the mock -server. - -To run the full XCUITest suite from the command line: - -```sh -xcodebuild test \ - -project OptimizationApp.xcodeproj \ - -scheme OptimizationAppSwiftUI \ - -destination 'platform=iOS Simulator,name=iPhone 16' -``` - -A single XCUITest class: - -```sh -xcodebuild test \ - -project OptimizationApp.xcodeproj \ - -scheme OptimizationAppSwiftUI \ - -destination 'platform=iOS Simulator,name=iPhone 16' \ - -only-testing:OptimizationAppUITestsSwiftUI/PreviewPanelOverridesTests -``` - -Keep accessibility identifiers and scenario names in sync with -[`implementations/PREVIEW_PANEL_SCENARIOS.md`](../../implementations/PREVIEW_PANEL_SCENARIOS.md) so -cross-platform regressions are visible in CI diffs. - -## 6. Validation cheatsheet - -Repo-wide checks (run from the repo root): - -| Command | What it covers | -| -------------------- | --------------------------------------------------------------------------- | -| `pnpm lint` | ESLint for `lib/` and `packages/` (TS sources, build configs, bridge code). | -| `pnpm typecheck` | `tsc --noEmit` across every workspace package. | -| `pnpm test:unit` | Unit tests for `lib/` and the `@contentful/*` packages. | -| `pnpm format:check` | Prettier check on the entire repo. | -| `pnpm size:check` | Bundle size budgets for built artifacts. | -| `pnpm docs:generate` | TypeDoc, which also picks up `documentation/**` markdown. | - -Impl-side checks: - -| Command | What it covers | -| -------------------------- | ---------------------------------------- | -| `pnpm implementation:lint` | ESLint across reference implementations. | - -The root [`AGENTS.md`](../../AGENTS.md) calls out the smallest meaningful validation policy: prefer -`pnpm lint` after the first meaningful patch, broaden when the change grows or touches exports / -build tooling. For a change that only edits TypeScript bridge source, -`pnpm lint && pnpm typecheck && pnpm test:unit` is the right minimum; for a change that also touches -Swift, add a targeted `xcodebuild test` for the affected XCUITest scenario. - -## 7. Common pitfalls - -- **`xcodegen` not installed** — `xcodebuild` will work against an existing - `OptimizationApp.xcodeproj`, but any change to `project.yml` (including the scheme pre-action - edits in § 3) needs `xcodegen generate` to take effect. -- **`pnpm` not on Xcode's `PATH`** — Xcode launched from Spotlight does not always inherit the shell - `PATH` Homebrew installed `pnpm` into. The pre-action probes `/opt/homebrew/bin`, - `/usr/local/bin`, and `~/.local/share/pnpm`; if your `pnpm` lives elsewhere the simplest fix is - `sudo ln -s "$(which pnpm)" /usr/local/bin/pnpm`. Restart Xcode after. Check - `/tmp/optimization-ios-build-js-bridge.log` for the actual error. -- **Build silently uses a stale bundle (no scheme used)** — scheme pre-actions only fire when - `xcodebuild` is invoked with `-scheme`. If you build a single target directly with - `xcodebuild -target ContentfulOptimization`, the pre-action does not run. Always go through the - app scheme (`-scheme OptimizationAppSwiftUI` or `-scheme OptimizationAppUIKit`). -- **Stale bridge bundle after a `git checkout` between branches** — the pre-action's freshness check - uses mtime, not content hash. If a branch switch leaves a newer-mtime bundle paired with older - source, the pre-action correctly skips, but the bundle may not match the source you have checked - out. Run `pnpm --filter @contentful/optimization-js-bridge build` once to force a clean rebuild, - or `touch packages/universal/optimization-js-bridge/src/index.ts` so the next Xcode build - regenerates. -- **`__bridge not found after bundle evaluation`** — the iOS bundle resource is missing or empty. - Most likely cause: a failed bridge build left an empty `dist/` and the `postbuild` copy never ran. - Rerun `pnpm --filter @contentful/optimization-js-bridge build` and inspect the script output. -- **Simulator selection** — `-destination 'platform=iOS Simulator,name=iPhone 16'` matches whichever - iPhone 16 the runtime offers. If your local Xcode does not have iPhone 16, substitute another - device the simulator catalogs (`xcrun simctl list devices`). - -## Related - -- [Native mobile SDK architecture](../concepts/native-mobile-sdk-architecture.md) -- [iOS SDK bridge](../concepts/ios-sdk-bridge.md) -- [iOS reference implementation README](../../implementations/ios-sdk/README.md) -- [`packages/ios` README](../../packages/ios/README.md) -- [Contributing to the Android SDK](./contributing-to-the-android-sdk.md) diff --git a/documentation/guides/integrating-the-optimization-android-sdk-in-a-compose-app.md b/documentation/guides/integrating-the-optimization-android-sdk-in-a-compose-app.md new file mode 100644 index 00000000..01784b41 --- /dev/null +++ b/documentation/guides/integrating-the-optimization-android-sdk-in-a-compose-app.md @@ -0,0 +1,417 @@ +# Integrating the Optimization Android SDK in a Jetpack Compose app + +Use this guide when you want to add Personalization, Analytics, screen tracking, and preview +overrides to a native Android application built with Jetpack Compose. + +For shared runtime behavior, consent gates, tracking thresholds, live-update precedence, and offline +delivery, see +[Android SDK runtime and interaction mechanics](../concepts/android-sdk-runtime-and-interaction-mechanics.md). +Use the XML Views guide instead if your app is View-based: +[Integrating the Optimization Android SDK in an XML Views app](./integrating-the-optimization-android-sdk-in-a-views-app.md). + +
+ Table of Contents + + +- [Scope and capabilities](#scope-and-capabilities) +- [The integration flow](#the-integration-flow) +- [1. Add the package and create the config](#1-add-the-package-and-create-the-config) +- [2. Initialize with OptimizationRoot](#2-initialize-with-optimizationroot) +- [3. Handle consent](#3-handle-consent) +- [4. Personalize entries with OptimizedEntry](#4-personalize-entries-with-optimizedentry) + - [Fetch entries in the expected shape](#fetch-entries-in-the-expected-shape) + - [Render resolved entries](#render-resolved-entries) + - [Use OptimizationLazyColumn for scrollable content](#use-optimizationlazycolumn-for-scrollable-content) +- [5. Track entry interactions](#5-track-entry-interactions) + - [Set global tracking defaults](#set-global-tracking-defaults) + - [Override tracking per entry](#override-tracking-per-entry) +- [6. Track screen views](#6-track-screen-views) +- [Live updates](#live-updates) +- [Preview panel](#preview-panel) +- [Complete example](#complete-example) +- [Reference implementations to compare against](#reference-implementations-to-compare-against) + + +
+ +## Scope and capabilities + +The Compose integration uses the SDK's Compose-native API surface: + +- `OptimizationRoot` initializes `OptimizationClient`, provides it through Compose locals, and + defines global tracking and live-update defaults. +- `OptimizedEntry` resolves a personalized Contentful entry and can attach view and tap tracking. +- `OptimizationLazyColumn` provides viewport context for view tracking inside lazy lists. +- `ScreenTrackingEffect` emits screen events from a composable screen. +- `PreviewPanelConfig` enables a developer-only preview panel entry point. + +The SDK does not replace your Contentful delivery client. Your application still owns Contentful +fetching, consent UX, identity policy, navigation, and rendering. + +## The integration flow + +Most Compose integrations follow this sequence: + +1. Add the Maven dependency and create an `OptimizationConfig`. +2. Wrap the app's root content in `OptimizationRoot`. +3. Collect consent, or seed consent for demos and trusted internal contexts. +4. Fetch Contentful entries with linked optimization references. +5. Render each Contentful entry through `OptimizedEntry`. +6. Enable view and tap tracking where they fit the screen. +7. Mark screens with `ScreenTrackingEffect`. + +Optional additions include live updates when entries need to react to optimization state changes +after initial render, and the preview panel when authors or engineers need local audience and +variant overrides. + +The Android reference implementation in this repository demonstrates the same SDK behavior in +Compose and XML Views shells: + +- [Android reference implementation](../../implementations/android-sdk/README.md) + +## 1. Add the package and create the config + +Add the Android SDK from Maven Central as described in the +[Optimization Android SDK README](../../packages/android/README.md). In an Android application +module, the dependency looks like this: + +```kotlin +dependencies { + implementation("com.contentful.java:optimization-android:") +} +``` + +Then create an `OptimizationConfig` with the Optimization client ID and the Contentful locale +information your app uses when fetching entries: + +```kotlin +val optimizationConfig = OptimizationConfig( + clientId = "your-client-id", + environment = "master", + contentfulLocales = ContentfulLocales(default = "en-US"), + locale = "en-US", + defaults = StorageDefaults(consent = true), + debug = BuildConfig.DEBUG, +) +``` + +Only `clientId` is required. Use `defaults = StorageDefaults(consent = true)` only when the app can +start with consent already granted, such as a demo or an internal validation app. For production +apps, connect `client.consent(true)` and `client.consent(false)` to the app's consent UI. + +Use `contentfulLocales` and `locale` when the same screen renders localized Contentful entries. For +the full locale model, see +[Locale handling in the Optimization SDK Suite](../concepts/locale-handling-in-the-optimization-sdk-suite.md). + +## 2. Initialize with OptimizationRoot + +Wrap your root Compose content in `OptimizationRoot`. It owns the `OptimizationClient`, initializes +the SDK, and provides the ready client to descendant composables through `LocalOptimizationClient`. + +```kotlin +@Composable +fun AppRoot() { + OptimizationRoot( + config = optimizationConfig, + trackViews = true, + trackTaps = false, + liveUpdates = false, + ) { + AppNavGraph() + } +} +``` + +Inside the provider tree, read the client from `LocalOptimizationClient`: + +```kotlin +@Composable +fun AccountControls() { + val client = LocalOptimizationClient.current + val scope = rememberCoroutineScope() + + Button( + onClick = { + scope.launch { + client.identify( + userId = "user-123", + traits = mapOf("plan" to "pro"), + ) + } + }, + ) { + Text("Identify") + } +} +``` + +`OptimizationClient` exposes async work as `suspend` functions and state as `StateFlow`. Call +suspending methods from Compose effects, event-handler coroutine scopes, or lifecycle-aware +coroutines. For lifecycle details, see +[Android SDK runtime and interaction mechanics](../concepts/android-sdk-runtime-and-interaction-mechanics.md#lifecycle-and-coroutines). + +## 3. Handle consent + +The SDK blocks most Analytics events until consent is granted. `identify` and `screen` remain +allowed before consent so a mobile journey can establish profile context and anonymous screen +analytics. + +```kotlin +@Composable +fun ConsentGate(content: @Composable () -> Unit) { + val client = LocalOptimizationClient.current + val state by client.state.collectAsState() + + if (state.consent == null) { + Column { + Text("We use analytics to personalize content.") + Row { + Button(onClick = { client.consent(true) }) { + Text("Accept") + } + Button(onClick = { client.consent(false) }) { + Text("Reject") + } + } + } + } else { + content() + } +} +``` + +The consent value is persisted and restored on later launches. Use the app's consent policy to +decide whether a stored value remains valid. + +## 4. Personalize entries with OptimizedEntry + +`OptimizedEntry` is the Compose component for rendering Contentful entries through the Optimization +resolver. It passes non-personalized entries through unchanged, resolves personalized entries +against the selected variants for the visitor, and can attach view and tap tracking. + +### Fetch entries in the expected shape + +Fetch entries from Contentful as single-locale JSON-shaped maps and include linked optimization +references in the payload. Pass those maps to `OptimizedEntry`. + +Use the resolved `client.locale` value for app-owned Contentful Delivery API requests that feed SDK +entry resolution: + +```kotlin +@Composable +fun HomeScreen(contentfulClient: ContentfulDeliveryClient) { + val client = LocalOptimizationClient.current + var entries by remember { mutableStateOf>>(emptyList()) } + + LaunchedEffect(client.locale) { + val locale = client.locale ?: "en-US" + entries = contentfulClient.fetchHomeEntries(locale = locale) + } + + HomeContent(entries = entries) +} +``` + +The resolver expects the same single-locale CDA entry contract used by the other SDK runtimes. For +details, see +[Entry personalization and variant resolution](../concepts/entry-personalization-and-variant-resolution.md#single-locale-cda-entry-contract). + +### Render resolved entries + +```kotlin +@Composable +fun HeroSection(entry: Map) { + OptimizedEntry( + entry = entry, + trackTaps = true, + accessibilityIdentifier = "home-hero-personalization", + ) { resolvedEntry -> + HeroCard(entry = resolvedEntry) + } +} +``` + +The render lambda receives the resolved entry map. The application owns converting fields from that +map into the view model or Compose hierarchy it wants to render. + +### Use OptimizationLazyColumn for scrollable content + +Inside a plain `LazyColumn`, `OptimizedEntry` cannot read the list viewport. Use +`OptimizationLazyColumn` when view tracking needs viewport-aware timing. + +```kotlin +OptimizationLazyColumn { + items(entries) { entry -> + OptimizedEntry(entry = entry) { resolvedEntry -> + BlogPostCard(entry = resolvedEntry) + } + } +} +``` + +For full-screen heroes, modal content, or single-screen layouts, a regular container is enough. + +## 5. Track entry interactions + +### Set global tracking defaults + +`OptimizationRoot` defines defaults for every `OptimizedEntry` in its tree: + +```kotlin +OptimizationRoot( + config = optimizationConfig, + trackViews = true, + trackTaps = false, +) { + AppNavGraph() +} +``` + +View tracking defaults to on. Tap tracking defaults to off because taps are usually tied to +application-specific navigation or business actions. + +### Override tracking per entry + +```kotlin +OptimizedEntry(entry = hero, trackViews = false) { resolvedEntry -> + HeroCard(entry = resolvedEntry) +} + +OptimizedEntry(entry = cta, trackTaps = true) { resolvedEntry -> + CtaCard(entry = resolvedEntry) +} + +OptimizedEntry( + entry = cta, + onTap = { resolvedEntry -> navigateToEntry(resolvedEntry) }, +) { resolvedEntry -> + CtaCard(entry = resolvedEntry) +} +``` + +Passing `trackTaps = false` disables tap tracking even when `onTap` is present. For timing +thresholds and event delivery behavior, see +[Android SDK runtime and interaction mechanics](../concepts/android-sdk-runtime-and-interaction-mechanics.md#tracking-mechanics). + +## 6. Track screen views + +Call `ScreenTrackingEffect` from the root composable for each screen: + +```kotlin +@Composable +fun HomeScreen() { + ScreenTrackingEffect(screenName = "Home") + HomeContent() +} +``` + +For dynamic names or tracking after data loads, call the client directly: + +```kotlin +@Composable +fun DetailsScreen(postId: String) { + val client = LocalOptimizationClient.current + + LaunchedEffect(postId) { + client.screen( + name = "BlogPostDetail", + properties = mapOf("postId" to postId), + ) + } + + DetailsContent(postId = postId) +} +``` + +## Live updates + +By default, `OptimizedEntry` locks to the first variant it resolves so content does not change while +a visitor is reading it. Enable live updates when a screen needs to react to profile or preview +changes without a reload: + +```kotlin +OptimizationRoot(config = optimizationConfig, liveUpdates = true) { + AppNavGraph() +} + +OptimizedEntry(entry = dashboard, liveUpdates = true) { resolvedEntry -> + Dashboard(entry = resolvedEntry) +} +``` + +The preview panel forces live updates while it is open. For precedence rules, see +[Android SDK runtime and interaction mechanics](../concepts/android-sdk-runtime-and-interaction-mechanics.md#live-updates-and-preview-behavior). + +## Preview panel + +Gate the preview panel behind a debug or internal-build flag. In Compose, pass `PreviewPanelConfig` +to `OptimizationRoot` to render a floating button that opens the panel. + +```kotlin +OptimizationRoot( + config = optimizationConfig, + previewPanel = if (BuildConfig.DEBUG) { + PreviewPanelConfig(contentfulClient = previewContentfulClient) + } else { + null + }, +) { + AppNavGraph() +} +``` + +The `contentfulClient` parameter is optional. Passing a `PreviewContentfulClient` enables audience +and experience names in the panel; without it, the panel displays identifiers. + +## Complete example + +This example combines initialization, preview-panel gating, screen tracking, viewport-aware entry +tracking, and tap tracking: + +```kotlin +@Composable +fun AppRoot(previewContentfulClient: PreviewContentfulClient?) { + OptimizationRoot( + config = optimizationConfig, + trackViews = true, + trackTaps = false, + previewPanel = if (BuildConfig.DEBUG) { + PreviewPanelConfig(contentfulClient = previewContentfulClient) + } else { + null + }, + ) { + HomeScreen() + } +} + +@Composable +fun HomeScreen() { + val client = LocalOptimizationClient.current + var entries by remember { mutableStateOf>>(emptyList()) } + + ScreenTrackingEffect(screenName = "Home") + + LaunchedEffect(client.locale) { + entries = fetchHomeEntries(locale = client.locale ?: "en-US") + } + + OptimizationLazyColumn { + items(entries) { entry -> + OptimizedEntry( + entry = entry, + trackTaps = true, + ) { resolvedEntry -> + ContentEntryCard(entry = resolvedEntry) + } + } + } +} +``` + +## Reference implementations to compare against + +- [Android reference implementation](../../implementations/android-sdk/README.md) - Demonstrates + Compose and XML Views shells that exercise native Android bridge behavior, entry resolution, + interaction tracking, screen tracking, live updates, and preview-panel overrides against the same + mock API. diff --git a/documentation/guides/integrating-the-optimization-android-sdk-in-a-views-app.md b/documentation/guides/integrating-the-optimization-android-sdk-in-a-views-app.md new file mode 100644 index 00000000..b359bd59 --- /dev/null +++ b/documentation/guides/integrating-the-optimization-android-sdk-in-a-views-app.md @@ -0,0 +1,428 @@ +# Integrating the Optimization Android SDK in an XML Views app + +Use this guide when you want to add Personalization, Analytics, screen tracking, and preview +overrides to a native Android application built with XML layouts or Android Views. + +For shared runtime behavior, consent gates, tracking thresholds, live-update precedence, and offline +delivery, see +[Android SDK runtime and interaction mechanics](../concepts/android-sdk-runtime-and-interaction-mechanics.md). +Use the Compose guide instead if your app is Compose-based: +[Integrating the Optimization Android SDK in a Jetpack Compose app](./integrating-the-optimization-android-sdk-in-a-compose-app.md). + +
+ Table of Contents + + +- [Scope and capabilities](#scope-and-capabilities) +- [The integration flow](#the-integration-flow) +- [1. Add the package and create the config](#1-add-the-package-and-create-the-config) +- [2. Initialize with OptimizationManager](#2-initialize-with-optimizationmanager) +- [3. Handle consent](#3-handle-consent) +- [4. Personalize entries with OptimizedEntryView](#4-personalize-entries-with-optimizedentryview) + - [Fetch entries in the expected shape](#fetch-entries-in-the-expected-shape) + - [Render resolved entries](#render-resolved-entries) + - [Use TrackingRecyclerView for scrollable content](#use-trackingrecyclerview-for-scrollable-content) +- [5. Track entry interactions](#5-track-entry-interactions) + - [Set global tracking defaults](#set-global-tracking-defaults) + - [Override tracking per entry](#override-tracking-per-entry) +- [6. Track screen views](#6-track-screen-views) +- [Live updates](#live-updates) +- [Preview panel](#preview-panel) +- [Complete example](#complete-example) +- [Reference implementations to compare against](#reference-implementations-to-compare-against) + + +
+ +## Scope and capabilities + +The XML Views integration uses the SDK's View-based adapter surface: + +- `OptimizationManager` initializes one process-wide `OptimizationClient` and stores global tracking + and live-update defaults. +- `OptimizedEntryView` resolves a personalized Contentful entry and can attach view and tap + tracking. +- `TrackingRecyclerView` nudges descendant `OptimizedEntryView` instances to re-check visibility + while lists scroll. +- `ScreenTracker` emits screen events from Activity or Fragment lifecycle methods. +- `OptimizationManager.attachPreviewPanel(...)` mounts the developer-only preview panel entry point. + +The SDK does not replace your Contentful delivery client. Your application still owns Contentful +fetching, consent UX, identity policy, navigation, and rendering. + +## The integration flow + +Most XML Views integrations follow this sequence: + +1. Add the Maven dependency and create an `OptimizationConfig`. +2. Initialize `OptimizationManager` from `Application.onCreate`. +3. Collect consent, or seed consent for demos and trusted internal contexts. +4. Read `OptimizationManager.client` from activities or fragments that render Contentful content. +5. Fetch Contentful entries with linked optimization references. +6. Render each Contentful entry through `OptimizedEntryView`. +7. Enable view and tap tracking where they fit the screen. +8. Emit screen events from Activity or Fragment lifecycle methods. + +Optional additions include live updates when entries need to react to optimization state changes +after initial render, and the preview panel when authors or engineers need local audience and +variant overrides. + +The Android reference implementation in this repository demonstrates the same SDK behavior in +Compose and XML Views shells: + +- [Android reference implementation](../../implementations/android-sdk/README.md) + +## 1. Add the package and create the config + +Add the Android SDK from Maven Central as described in the +[Optimization Android SDK README](../../packages/android/README.md). In an Android application +module, the dependency looks like this: + +```kotlin +dependencies { + implementation("com.contentful.java:optimization-android:") +} +``` + +Then create an `OptimizationConfig` with the Optimization client ID and the Contentful locale +information your app uses when fetching entries: + +```kotlin +val optimizationConfig = OptimizationConfig( + clientId = "your-client-id", + environment = "master", + contentfulLocales = ContentfulLocales(default = "en-US"), + locale = "en-US", + defaults = StorageDefaults(consent = true), + debug = BuildConfig.DEBUG, +) +``` + +Only `clientId` is required. Use `defaults = StorageDefaults(consent = true)` only when the app can +start with consent already granted, such as a demo or an internal validation app. For production +apps, connect `client.consent(true)` and `client.consent(false)` to the app's consent UI. + +Use `contentfulLocales` and `locale` when the same screen renders localized Contentful entries. For +the full locale model, see +[Locale handling in the Optimization SDK Suite](../concepts/locale-handling-in-the-optimization-sdk-suite.md). + +## 2. Initialize with OptimizationManager + +Initialize the SDK once from `Application.onCreate`. `OptimizationManager` owns the process-wide +client used by View-based activities and fragments. + +```kotlin +class MyApplication : Application() { + override fun onCreate() { + super.onCreate() + + OptimizationManager.initialize( + context = this, + config = optimizationConfig, + trackViews = true, + trackTaps = false, + liveUpdates = false, + previewPanel = PreviewPanelConfig( + contentfulClient = previewContentfulClient, + ), + ) + } +} +``` + +Read the client from any Activity or Fragment after initialization: + +```kotlin +class HomeActivity : AppCompatActivity() { + private val client: OptimizationClient + get() = OptimizationManager.client +} +``` + +`OptimizationManager.initialize(...)` starts SDK initialization asynchronously. Observe +`client.isInitialized` before running work that depends on the bridge being ready. For lifecycle +details, see +[Android SDK runtime and interaction mechanics](../concepts/android-sdk-runtime-and-interaction-mechanics.md#lifecycle-and-coroutines). + +## 3. Handle consent + +The SDK blocks most Analytics events until consent is granted. `identify` and `screen` remain +allowed before consent so a mobile journey can establish profile context and anonymous screen +analytics. + +```kotlin +class ConsentActivity : AppCompatActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + acceptButton.setOnClickListener { + OptimizationManager.client.consent(true) + } + + rejectButton.setOnClickListener { + OptimizationManager.client.consent(false) + } + + lifecycleScope.launch { + repeatOnLifecycle(Lifecycle.State.STARTED) { + OptimizationManager.client.state.collect { state -> + consentBanner.isVisible = state.consent == null + } + } + } + } +} +``` + +The consent value is persisted and restored on later launches. Use the app's consent policy to +decide whether a stored value remains valid. + +## 4. Personalize entries with OptimizedEntryView + +`OptimizedEntryView` is the View-based component for rendering Contentful entries through the +Optimization resolver. It passes non-personalized entries through unchanged, resolves personalized +entries against the selected variants for the visitor, and can attach view and tap tracking. + +### Fetch entries in the expected shape + +Fetch entries from Contentful as single-locale JSON-shaped maps and include linked optimization +references in the payload. Pass those maps to `OptimizedEntryView`. + +Use the resolved `client.locale` value for app-owned Contentful Delivery API requests that feed SDK +entry resolution: + +```kotlin +lifecycleScope.launch { + repeatOnLifecycle(Lifecycle.State.STARTED) { + OptimizationManager.client.isInitialized.collect { isInitialized -> + if (isInitialized) { + val locale = OptimizationManager.client.locale ?: "en-US" + val entries = contentfulClient.fetchHomeEntries(locale = locale) + renderEntries(entries) + } + } + } +} +``` + +The resolver expects the same single-locale CDA entry contract used by the other SDK runtimes. For +details, see +[Entry personalization and variant resolution](../concepts/entry-personalization-and-variant-resolution.md#single-locale-cda-entry-contract). + +### Render resolved entries + +```kotlin +fun createHeroView(entry: Map): View { + return OptimizedEntryView(this).apply { + trackTaps = true + accessibilityIdentifier = "home-hero-personalization" + setContentRenderer { resolvedEntry -> + HeroBinder.create(context, resolvedEntry) + } + setEntry(entry) + } +} +``` + +The renderer receives the resolved entry map. The application owns converting fields from that map +into the view model or View hierarchy it wants to render. + +### Use TrackingRecyclerView for scrollable content + +`OptimizedEntryView` checks visibility from its own layout callbacks. Use `TrackingRecyclerView` +when a scrolling list needs an additional signal on every scroll frame. + +```kotlin +val recyclerView = TrackingRecyclerView(this).apply { + layoutManager = LinearLayoutManager(this@HomeActivity) + adapter = ContentEntryAdapter(entries) +} +``` + +In each item view, wrap the rendered Contentful entry with `OptimizedEntryView`. + +## 5. Track entry interactions + +### Set global tracking defaults + +`OptimizationManager.initialize(...)` defines defaults for every `OptimizedEntryView`: + +```kotlin +OptimizationManager.initialize( + context = this, + config = optimizationConfig, + trackViews = true, + trackTaps = false, +) +``` + +View tracking defaults to on. Tap tracking defaults to off because taps are usually tied to +application-specific navigation or business actions. + +### Override tracking per entry + +```kotlin +OptimizedEntryView(context).apply { + trackViews = false + setContentRenderer { resolvedEntry -> HeroBinder.create(context, resolvedEntry) } + setEntry(hero) +} + +OptimizedEntryView(context).apply { + trackTaps = true + setContentRenderer { resolvedEntry -> CtaBinder.create(context, resolvedEntry) } + setEntry(cta) +} + +OptimizedEntryView(context).apply { + onTap = { resolvedEntry -> navigateToEntry(resolvedEntry) } + setContentRenderer { resolvedEntry -> CtaBinder.create(context, resolvedEntry) } + setEntry(cta) +} +``` + +Setting `trackTaps = false` disables tap tracking even when `onTap` is present. For timing +thresholds and event delivery behavior, see +[Android SDK runtime and interaction mechanics](../concepts/android-sdk-runtime-and-interaction-mechanics.md#tracking-mechanics). + +## 6. Track screen views + +Call `ScreenTracker.trackScreen(...)` from `Activity.onResume` or `Fragment.onResume`: + +```kotlin +override fun onResume() { + super.onResume() + ScreenTracker.trackScreen("Home") +} +``` + +For dynamic names or tracking after data loads, call the client directly: + +```kotlin +lifecycleScope.launch { + OptimizationManager.client.screen( + name = "BlogPostDetail", + properties = mapOf("postId" to postId), + ) +} +``` + +## Live updates + +By default, `OptimizedEntryView` locks to the first variant it resolves so content does not change +while a visitor is reading it. Enable live updates when a screen needs to react to profile or +preview changes without a reload: + +```kotlin +OptimizationManager.initialize( + context = this, + config = optimizationConfig, + liveUpdates = true, +) + +OptimizedEntryView(context).apply { + liveUpdates = true + setContentRenderer { resolvedEntry -> DashboardBinder.create(context, resolvedEntry) } + setEntry(dashboard) +} +``` + +The preview panel forces live updates while it is open. For precedence rules, see +[Android SDK runtime and interaction mechanics](../concepts/android-sdk-runtime-and-interaction-mechanics.md#live-updates-and-preview-behavior). + +## Preview panel + +Gate the preview panel behind a debug or internal-build flag. In XML Views apps, call +`OptimizationManager.attachPreviewPanel(...)` from each Activity that displays the floating entry +point. + +```kotlin +class HomeActivity : AppCompatActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContentView(R.layout.home) + + if (BuildConfig.DEBUG) { + OptimizationManager.attachPreviewPanel(this) + } + } +} +``` + +Pass `PreviewPanelConfig(contentfulClient = previewContentfulClient)` to +`OptimizationManager.initialize(...)` when the panel needs to display audience and experience names. +Without a `PreviewContentfulClient`, the panel displays identifiers. + +## Complete example + +This example combines application-level initialization, preview-panel gating, screen tracking, entry +rendering, and tap tracking: + +```kotlin +class MyApplication : Application() { + override fun onCreate() { + super.onCreate() + + OptimizationManager.initialize( + context = this, + config = optimizationConfig, + trackViews = true, + trackTaps = false, + previewPanel = PreviewPanelConfig( + contentfulClient = previewContentfulClient, + ), + ) + } +} + +class HomeActivity : AppCompatActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + val list = TrackingRecyclerView(this).apply { + layoutManager = LinearLayoutManager(this@HomeActivity) + } + setContentView(list) + + if (BuildConfig.DEBUG) { + OptimizationManager.attachPreviewPanel(this) + } + + lifecycleScope.launch { + repeatOnLifecycle(Lifecycle.State.STARTED) { + OptimizationManager.client.isInitialized.collect { isInitialized -> + if (isInitialized) { + val locale = OptimizationManager.client.locale ?: "en-US" + val entries = fetchHomeEntries(locale = locale) + list.adapter = ContentEntryAdapter(entries) + } + } + } + } + } + + override fun onResume() { + super.onResume() + ScreenTracker.trackScreen("Home") + } +} + +fun bindEntry(parent: ViewGroup, entry: Map) { + parent.addView( + OptimizedEntryView(parent.context).apply { + trackTaps = true + setContentRenderer { resolvedEntry -> + ContentEntryBinder.create(context, resolvedEntry) + } + setEntry(entry) + }, + ) +} +``` + +## Reference implementations to compare against + +- [Android reference implementation](../../implementations/android-sdk/README.md) - Demonstrates + Compose and XML Views shells that exercise native Android bridge behavior, entry resolution, + interaction tracking, screen tracking, live updates, and preview-panel overrides against the same + mock API. diff --git a/documentation/guides/integrating-the-optimization-ios-sdk-in-a-swiftui-app.md b/documentation/guides/integrating-the-optimization-ios-sdk-in-a-swiftui-app.md new file mode 100644 index 00000000..efbedecb --- /dev/null +++ b/documentation/guides/integrating-the-optimization-ios-sdk-in-a-swiftui-app.md @@ -0,0 +1,410 @@ +# Integrating the Optimization iOS SDK in a SwiftUI app + +Use this guide when you want to add Personalization, Analytics, screen tracking, and preview +overrides to a SwiftUI application using the Optimization iOS SDK. + +For shared runtime behavior, consent gates, tracking thresholds, live-update precedence, and offline +delivery, see +[iOS SDK runtime and interaction mechanics](../concepts/ios-sdk-runtime-and-interaction-mechanics.md). +Use the UIKit guide instead if your app is UIKit-based: +[Integrating the Optimization iOS SDK in a UIKit app](./integrating-the-optimization-ios-sdk-in-a-uikit-app.md). + +
+ Table of Contents + + +- [Scope and capabilities](#scope-and-capabilities) +- [The integration flow](#the-integration-flow) +- [1. Add the package and create the config](#1-add-the-package-and-create-the-config) +- [2. Initialize with OptimizationRoot](#2-initialize-with-optimizationroot) +- [3. Handle consent](#3-handle-consent) +- [4. Personalize entries with OptimizedEntry](#4-personalize-entries-with-optimizedentry) + - [Fetch entries in the expected shape](#fetch-entries-in-the-expected-shape) + - [Render resolved entries](#render-resolved-entries) + - [Use OptimizationScrollView for scrollable content](#use-optimizationscrollview-for-scrollable-content) +- [5. Track entry interactions](#5-track-entry-interactions) + - [Set global tracking defaults](#set-global-tracking-defaults) + - [Override tracking per entry](#override-tracking-per-entry) +- [6. Track screen views](#6-track-screen-views) +- [Live updates](#live-updates) +- [Preview panel](#preview-panel) +- [Complete example](#complete-example) +- [Reference implementations to compare against](#reference-implementations-to-compare-against) + + +
+ +## Scope and capabilities + +The SwiftUI integration uses the SDK's SwiftUI-native API surface: + +- `OptimizationRoot` initializes `OptimizationClient`, injects it into the environment, and defines + global tracking and live-update defaults. +- `OptimizedEntry` resolves a personalized Contentful entry and can attach view and tap tracking. +- `OptimizationScrollView` provides viewport context for view tracking inside scrollable content. +- `.trackScreen(name:)` emits screen events when SwiftUI views appear. +- `PreviewPanelOverlay` renders a developer-only preview panel entry point. + +The SDK does not replace your Contentful delivery client. Your application still owns Contentful +fetching, consent UX, identity policy, navigation, and rendering. + +## The integration flow + +Most SwiftUI integrations follow this sequence: + +1. Add the Swift Package and create an `OptimizationConfig`. +2. Wrap the app's root content in `OptimizationRoot`. +3. Collect consent, or seed consent for demos and trusted internal contexts. +4. Fetch Contentful entries with linked optimization references. +5. Render each Contentful entry through `OptimizedEntry`. +6. Enable view and tap tracking where they fit the screen. +7. Mark screens with `.trackScreen(name:)`. + +Optional additions include live updates when entries need to react to optimization state changes +after initial render, and the preview panel when authors or engineers need local audience and +variant overrides. + +The iOS reference implementation in this repository demonstrates the same SDK behavior in SwiftUI +and UIKit shells: + +- [iOS reference implementation](../../implementations/ios-sdk/README.md) + +## 1. Add the package and create the config + +Add `ContentfulOptimization` through Swift Package Manager as described in the +[Optimization iOS SDK README](../../packages/ios/ContentfulOptimization/README.md). Then create an +`OptimizationConfig` with the Optimization client ID and the Contentful locale information your app +uses when fetching entries: + +```swift +let config = OptimizationConfig( + clientId: "your-client-id", + environment: "master", + contentfulLocales: ContentfulLocales(default: "en-US"), + locale: "en-US", + defaults: StorageDefaults(consent: true), + debug: true +) +``` + +Only `clientId` is required. Use `defaults: StorageDefaults(consent: true)` only when the app can +start with consent already granted, such as a demo or an internal validation app. For production +apps, connect `client.consent(true)` and `client.consent(false)` to the app's consent UI. + +Use `contentfulLocales` and `locale` when the same screen renders localized Contentful entries. For +the full locale model, see +[Locale handling in the Optimization SDK Suite](../concepts/locale-handling-in-the-optimization-sdk-suite.md). + +## 2. Initialize with OptimizationRoot + +Wrap your root SwiftUI content in `OptimizationRoot`. It owns the `OptimizationClient`, initializes +the SDK, and provides the ready client to descendant views as an environment object. + +```swift +import ContentfulOptimization +import SwiftUI + +@main +struct MyApp: App { + var body: some Scene { + WindowGroup { + OptimizationRoot( + config: config, + trackViews: true, + trackTaps: false, + liveUpdates: false + ) { + RootView() + } + } + } +} +``` + +Inside the provider tree, read the client from the environment: + +```swift +struct AccountControls: View { + @EnvironmentObject private var client: OptimizationClient + + var body: some View { + Button("Identify") { + Task { + try? await client.identify(userId: "user-123", traits: ["plan": "pro"]) + } + } + } +} +``` + +`OptimizationClient` is `@MainActor`. Call it from SwiftUI view tasks, event handlers, or explicit +main-actor tasks. For lifecycle details, see +[iOS SDK runtime and interaction mechanics](../concepts/ios-sdk-runtime-and-interaction-mechanics.md#lifecycle-and-main-actor). + +## 3. Handle consent + +The SDK blocks most Analytics events until consent is granted. `identify` and `screen` remain +allowed before consent so a mobile journey can establish profile context and anonymous screen +analytics. + +```swift +struct ConsentBanner: View { + @EnvironmentObject private var client: OptimizationClient + + var body: some View { + VStack { + Text("We use analytics to personalize content.") + HStack { + Button("Accept") { client.consent(true) } + Button("Reject") { client.consent(false) } + } + } + } +} +``` + +Use `client.state.consent` to decide whether the consent UI still needs to render: + +```swift +struct ConsentGate: View { + @EnvironmentObject private var client: OptimizationClient + @ViewBuilder var content: () -> Content + + var body: some View { + if client.state.consent == nil { + ConsentBanner() + } else { + content() + } + } +} +``` + +## 4. Personalize entries with OptimizedEntry + +`OptimizedEntry` is the SwiftUI component for rendering Contentful entries through the Optimization +resolver. It passes non-personalized entries through unchanged, resolves personalized entries +against the selected variants for the visitor, and can attach view and tap tracking. + +### Fetch entries in the expected shape + +Fetch entries from Contentful as single-locale JSON-shaped dictionaries and include linked +optimization references in the payload. Pass those dictionaries to `OptimizedEntry`. + +The resolver expects the same single-locale CDA entry contract used by the other SDK runtimes. For +details, see +[Entry personalization and variant resolution](../concepts/entry-personalization-and-variant-resolution.md#single-locale-cda-entry-contract). + +### Render resolved entries + +```swift +import ContentfulOptimization +import SwiftUI + +struct CTASection: View { + let entry: [String: Any] + + var body: some View { + OptimizedEntry(entry: entry, trackTaps: true) { resolvedEntry in + CTAHeader(entry: resolvedEntry) + } + } +} +``` + +The render closure receives the resolved entry dictionary. The application owns converting fields +from that dictionary into the view model or SwiftUI view hierarchy it wants to render. + +### Use OptimizationScrollView for scrollable content + +Inside a plain `ScrollView`, `OptimizedEntry` treats entries as visible because it cannot read the +scroll viewport. Wrap scrollable content in `OptimizationScrollView` when view tracking needs +viewport-aware timing. + +```swift +OptimizationScrollView { + LazyVStack(alignment: .leading, spacing: 12) { + ForEach(posts, id: \.id) { post in + OptimizedEntry(entry: post) { resolved in + BlogPostCard(entry: resolved) + } + } + } +} +``` + +For full-screen heroes, modal content, or single-screen layouts, a regular container is enough. + +## 5. Track entry interactions + +### Set global tracking defaults + +`OptimizationRoot` defines defaults for every `OptimizedEntry` in its tree: + +```swift +OptimizationRoot( + config: config, + trackViews: true, + trackTaps: false +) { + RootView() +} +``` + +View tracking defaults to on. Tap tracking defaults to off because taps are usually tied to +application-specific navigation or business actions. + +### Override tracking per entry + +```swift +OptimizedEntry(entry: hero, trackViews: false) { resolved in + Hero(entry: resolved) +} + +OptimizedEntry(entry: cta, trackTaps: true) { resolved in + CTAHeader(entry: resolved) +} + +OptimizedEntry(entry: cta, onTap: { resolved in + navigate(to: resolved) +}) { resolved in + CTAHeader(entry: resolved) +} +``` + +Passing `trackTaps: false` disables tap tracking even when `onTap` is present. For timing thresholds +and event delivery behavior, see +[iOS SDK runtime and interaction mechanics](../concepts/ios-sdk-runtime-and-interaction-mechanics.md#tracking-mechanics). + +## 6. Track screen views + +Attach `.trackScreen(name:)` to the root view for each screen: + +```swift +struct HomeScreen: View { + var body: some View { + HomeContent() + .trackScreen(name: "Home") + } +} +``` + +For dynamic names or tracking after data loads, call the client directly: + +```swift +struct DetailsScreen: View { + @EnvironmentObject private var client: OptimizationClient + let postId: String + + var body: some View { + DetailsContent() + .task { + try? await client.screen( + name: "BlogPostDetail", + properties: ["postId": postId] + ) + } + } +} +``` + +## Live updates + +By default, `OptimizedEntry` locks to the first variant it resolves so content does not change while +a visitor is reading it. Enable live updates when a screen needs to react to profile or preview +changes without a reload: + +```swift +OptimizationRoot(config: config, liveUpdates: true) { + RootView() +} + +OptimizedEntry(entry: dashboard, liveUpdates: true) { resolved in + Dashboard(entry: resolved) +} +``` + +The preview panel forces live updates while it is open. For precedence rules, see +[iOS SDK runtime and interaction mechanics](../concepts/ios-sdk-runtime-and-interaction-mechanics.md#live-updates-and-preview-behavior). + +## Preview panel + +Gate the preview panel behind a debug or internal-build flag. `PreviewPanelOverlay` renders a +floating button and presents the panel when tapped. + +```swift +#if DEBUG +let showPreviewPanel = true +#else +let showPreviewPanel = false +#endif + +OptimizationRoot(config: config) { + if showPreviewPanel { + PreviewPanelOverlay(contentfulClient: contentfulClient) { + RootView() + } + } else { + RootView() + } +} +``` + +The `contentfulClient` parameter is optional. Passing a `PreviewContentfulClient` enables audience +and experience names in the panel; without it, the panel displays identifiers. + +## Complete example + +This example combines initialization, preview-panel gating, screen tracking, viewport-aware entry +tracking, and tap tracking: + +```swift +@main +struct MyApp: App { + var body: some Scene { + WindowGroup { + OptimizationRoot( + config: config, + trackViews: true, + trackTaps: false + ) { + PreviewPanelOverlay(contentfulClient: contentfulClient) { + NavigationStack { + HomeScreen() + } + } + } + } + } +} + +struct HomeScreen: View { + let posts: [[String: Any]] + let cta: [String: Any]? + + var body: some View { + OptimizationScrollView { + LazyVStack { + ForEach(Array(posts.enumerated()), id: \.offset) { index, post in + OptimizedEntry(entry: post) { resolved in + BlogPostCard(entry: resolved) + } + + if index == 0, let cta { + OptimizedEntry(entry: cta, trackTaps: true) { resolved in + CTAHeader(entry: resolved) + } + } + } + } + } + .trackScreen(name: "Home") + } +} +``` + +## Reference implementations to compare against + +- [iOS reference implementation](../../implementations/ios-sdk/README.md) - Demonstrates SwiftUI and + UIKit shells that exercise shared native iOS bridge behavior, entry resolution, interaction + tracking, screen tracking, and preview-panel overrides against the same mock API. diff --git a/documentation/guides/integrating-the-optimization-ios-sdk-in-a-uikit-app.md b/documentation/guides/integrating-the-optimization-ios-sdk-in-a-uikit-app.md new file mode 100644 index 00000000..d180c62c --- /dev/null +++ b/documentation/guides/integrating-the-optimization-ios-sdk-in-a-uikit-app.md @@ -0,0 +1,384 @@ +# Integrating the Optimization iOS SDK in a UIKit app + +Use this guide when you want to add Personalization, Analytics, screen tracking, and preview +overrides to a UIKit application using the Optimization iOS SDK. + +For shared runtime behavior, consent gates, tracking thresholds, live-update precedence, and offline +delivery, see +[iOS SDK runtime and interaction mechanics](../concepts/ios-sdk-runtime-and-interaction-mechanics.md). +Use the SwiftUI guide instead if your app is SwiftUI-based: +[Integrating the Optimization iOS SDK in a SwiftUI app](./integrating-the-optimization-ios-sdk-in-a-swiftui-app.md). + +
+ Table of Contents + + +- [Scope and capabilities](#scope-and-capabilities) +- [The integration flow](#the-integration-flow) +- [1. Add the package and create the config](#1-add-the-package-and-create-the-config) +- [2. Initialize in SceneDelegate](#2-initialize-in-scenedelegate) +- [3. Handle consent](#3-handle-consent) +- [4. Personalize entries](#4-personalize-entries) + - [Resolve entries in view code](#resolve-entries-in-view-code) + - [React to selected personalization changes](#react-to-selected-personalization-changes) +- [5. Track entry interactions](#5-track-entry-interactions) + - [Track taps](#track-taps) + - [Track views](#track-views) +- [6. Track screen views](#6-track-screen-views) +- [Live updates](#live-updates) +- [Preview panel](#preview-panel) +- [Complete example](#complete-example) +- [Reference implementations to compare against](#reference-implementations-to-compare-against) + + +
+ +## Scope and capabilities + +The UIKit integration uses `OptimizationClient` directly. The SDK does not provide UIKit-native view +equivalents for `OptimizedEntry` or `OptimizationScrollView`, so the application decides where to +resolve entries and when to emit interaction tracking. + +UIKit apps typically use: + +- `OptimizationClient` as a long-lived object owned by `SceneDelegate` or an app-level coordinator. +- `client.personalizeEntry(baseline:personalizations:)` during cell or view configuration. +- `client.trackView(_:)` and `client.trackClick(_:)` from visibility callbacks and control actions. +- `client.screen(name:)` from view-controller lifecycle methods. +- `PreviewPanelViewController` behind a debug or internal-build flag. + +The SDK does not replace your Contentful delivery client. Your application still owns Contentful +fetching, consent UX, identity policy, navigation, and rendering. + +## The integration flow + +Most UIKit integrations follow this sequence: + +1. Add the Swift Package and create an `OptimizationConfig`. +2. Create a shared `OptimizationClient` and call `initialize(config:)`. +3. Collect consent, or seed consent for demos and trusted internal contexts. +4. Pass the client into the view controllers that render Contentful content. +5. Fetch Contentful entries with linked optimization references. +6. Resolve entries in cell or view configuration with + `client.personalizeEntry(baseline:personalizations:)`. +7. Track taps from controls and track views from visibility-duration logic. +8. Emit screen events from view-controller lifecycle methods. + +Optional additions include live-update redraws when selected personalizations change, and the +preview panel when authors or engineers need local audience and variant overrides. + +The iOS reference implementation in this repository demonstrates the same SDK behavior in SwiftUI +and UIKit shells: + +- [iOS reference implementation](../../implementations/ios-sdk/README.md) + +## 1. Add the package and create the config + +Add `ContentfulOptimization` through Swift Package Manager as described in the +[Optimization iOS SDK README](../../packages/ios/ContentfulOptimization/README.md). Then create an +`OptimizationConfig` with the Optimization client ID and the Contentful locale information your app +uses when fetching entries: + +```swift +let config = OptimizationConfig( + clientId: "your-client-id", + environment: "master", + contentfulLocales: ContentfulLocales(default: "en-US"), + locale: "en-US", + defaults: StorageDefaults(consent: true), + debug: true +) +``` + +Only `clientId` is required. Use `defaults: StorageDefaults(consent: true)` only when the app can +start with consent already granted, such as a demo or an internal validation app. For production +apps, connect `client.consent(true)` and `client.consent(false)` to the app's consent UI. + +Use `contentfulLocales` and `locale` when the same screen renders localized Contentful entries. For +the full locale model, see +[Locale handling in the Optimization SDK Suite](../concepts/locale-handling-in-the-optimization-sdk-suite.md). + +## 2. Initialize in SceneDelegate + +Own the `OptimizationClient` from `SceneDelegate` when the client lifetime needs to match the scene. +Pass that same instance into the root view controller and any child controller that resolves entries +or tracks events. + +```swift +import ContentfulOptimization +import UIKit + +class SceneDelegate: UIResponder, UIWindowSceneDelegate { + var window: UIWindow? + let client = OptimizationClient() + + func scene( + _ scene: UIScene, + willConnectTo session: UISceneSession, + options connectionOptions: UIScene.ConnectionOptions + ) { + guard let windowScene = scene as? UIWindowScene else { return } + + try? client.initialize(config: config) + + let home = HomeViewController(client: client) + let navigation = UINavigationController(rootViewController: home) + + window = UIWindow(windowScene: windowScene) + window?.rootViewController = navigation + window?.makeKeyAndVisible() + } +} +``` + +`OptimizationClient` is `@MainActor`. View-controller lifecycle methods already run on the main +thread, but asynchronous callbacks that call the client must return to the main actor first. For +lifecycle details, see +[iOS SDK runtime and interaction mechanics](../concepts/ios-sdk-runtime-and-interaction-mechanics.md#lifecycle-and-main-actor). + +## 3. Handle consent + +The SDK blocks most Analytics events until consent is granted. `identify` and `screen` remain +allowed before consent so a mobile journey can establish profile context and anonymous screen +analytics. + +Connect the app's consent controls to the client: + +```swift +@objc private func acceptTapped() { + client.consent(true) +} + +@objc private func rejectTapped() { + client.consent(false) +} +``` + +To react to consent changes, subscribe to `client.$state`: + +```swift +client.$state + .map(\.consent) + .removeDuplicates() + .receive(on: RunLoop.main) + .sink { [weak self] value in + self?.updateConsentUI(value) + } + .store(in: &cancellables) +``` + +## 4. Personalize entries + +### Resolve entries in view code + +Fetch entries from Contentful as single-locale JSON-shaped dictionaries and include linked +optimization references in the payload. Then resolve each entry where the UIKit view configures its +content: + +```swift +func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + let cell = tableView.dequeueReusableCell( + withIdentifier: BlogPostCardCell.reuseIdentifier, + for: indexPath + ) as! BlogPostCardCell + + let resolved = client.personalizeEntry( + baseline: posts[indexPath.row], + personalizations: client.selectedPersonalizations + ) + + cell.configure(with: resolved.entry) + return cell +} +``` + +`personalizeEntry` is synchronous. It returns the baseline entry unchanged when the SDK has no +matching selected personalization, when the entry has no optimization references, or when the linked +variant data is not present in the Contentful payload. For details, see +[Entry personalization and variant resolution](../concepts/entry-personalization-and-variant-resolution.md). + +### React to selected personalization changes + +When `client.selectedPersonalizations` changes, the app decides whether visible UIKit views need to +re-resolve entries. A table or collection view can redraw affected cells: + +```swift +client.$selectedPersonalizations + .dropFirst() + .receive(on: RunLoop.main) + .sink { [weak self] _ in + self?.tableView.reloadData() + } + .store(in: &cancellables) +``` + +For locked content, capture `client.selectedPersonalizations` when the screen loads and pass that +snapshot into each `personalizeEntry` call. + +## 5. Track entry interactions + +### Track taps + +Emit tap events from `UIControl` actions or gesture handlers: + +```swift +ctaView.onButtonTap = { [weak self] in + guard let self else { return } + + Task { @MainActor in + try? await self.client.trackClick(TrackClickPayload( + componentId: ctaEntryId, + experienceId: experienceId, + variantIndex: variantIndex + )) + } +} +``` + +Use `entry.sys.id` as `componentId`. Set `variantIndex` to `0` for the baseline entry and to the +selected variant index when `personalizeEntry` returns personalization metadata. + +### Track views + +UIKit apps compute visibility and duration in application code, then send a `TrackViewPayload`: + +```swift +Task { @MainActor in + try? await client.trackView(TrackViewPayload( + componentId: entryId, + viewId: viewId, + experienceId: experienceId, + variantIndex: variantIndex, + viewDurationMs: durationMs, + sticky: nil + )) +} +``` + +A common table or collection view pattern is: + +1. Record a timestamp when a cell becomes visible. +2. Emit periodic duration updates while it remains visible. +3. Emit a final duration update when it stops being visible. + +For the default SwiftUI thresholds and shared event-delivery behavior, see +[iOS SDK runtime and interaction mechanics](../concepts/ios-sdk-runtime-and-interaction-mechanics.md#tracking-mechanics). + +## 6. Track screen views + +Call `client.screen(name:)` from `viewDidAppear(_:)`: + +```swift +override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + + Task { @MainActor in + try? await client.screen(name: "Home") + } +} +``` + +Include properties when the screen name needs additional context: + +```swift +Task { @MainActor in + try? await client.screen( + name: "BlogPostDetail", + properties: ["postId": postId] + ) +} +``` + +## Live updates + +UIKit does not lock or re-resolve entries automatically. The app chooses between two patterns: + +- **Live updates** - Resolve entries during cell or view configuration and redraw when + `selectedPersonalizations` changes. +- **Locked variants** - Capture selected personalizations when the screen loads and keep resolving + against that snapshot. + +The preview panel sets `client.isPreviewPanelOpen` while it is visible. Use that value when the app +needs to redraw in live mode for preview sessions and keep production screens locked. + +## Preview panel + +Gate the preview panel behind a debug or internal-build flag. `PreviewPanelViewController` adds a +floating button to a host view controller and presents the panel when tapped. + +```swift +#if DEBUG +PreviewPanelViewController.addFloatingButton( + to: home, + client: client, + contentfulClient: contentfulClient +) +#endif +``` + +The `contentfulClient` parameter is optional. Passing a `PreviewContentfulClient` enables audience +and experience names in the panel; without it, the panel displays identifiers. + +The preview panel's UI is SwiftUI wrapped for UIKit, so it can be presented from a UIKit navigation +stack without changing the rest of the app. + +## Complete example + +This example combines scene-level initialization, entry resolution in table-cell configuration, +screen tracking, selected-personalization redraws, and preview-panel mounting: + +```swift +final class HomeViewController: UIViewController { + private let client: OptimizationClient + private var posts: [[String: Any]] = [] + private var cancellables = Set() + + init(client: OptimizationClient) { + self.client = client + super.init(nibName: nil, bundle: nil) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() { + super.viewDidLoad() + + client.$selectedPersonalizations + .dropFirst() + .receive(on: RunLoop.main) + .sink { [weak self] _ in self?.tableView.reloadData() } + .store(in: &cancellables) + } + + override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + + Task { @MainActor in + try? await client.screen(name: "Home") + } + } + + func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + let cell = tableView.dequeueReusableCell( + withIdentifier: BlogPostCardCell.reuseIdentifier, + for: indexPath + ) as! BlogPostCardCell + + let resolved = client.personalizeEntry( + baseline: posts[indexPath.row], + personalizations: client.selectedPersonalizations + ) + + cell.configure(with: resolved.entry) + return cell + } +} +``` + +## Reference implementations to compare against + +- [iOS reference implementation](../../implementations/ios-sdk/README.md) - Demonstrates SwiftUI and + UIKit shells that exercise shared native iOS bridge behavior, entry resolution, interaction + tracking, screen tracking, and preview-panel overrides against the same mock API. diff --git a/implementations/android-sdk/README.md b/implementations/android-sdk/README.md index 09253056..ec3e4f89 100644 --- a/implementations/android-sdk/README.md +++ b/implementations/android-sdk/README.md @@ -117,9 +117,59 @@ pnpm --dir lib/mocks serve The E2E suite is [Maestro](https://maestro.dev), run from the command line rather than an IDE run configuration — `pnpm test:e2e` (both apps) or see [`maestro/README.md`](./maestro/README.md). +## Maintainer edit loop + +Use this app when you need a debuggable native Android surface for changes in +`packages/android/ContentfulOptimization` or the shared JS bridge. The Gradle project includes the +SDK module from the workspace through a composite build, so app builds compile the Kotlin source and +package the local bridge asset rather than a published AAR. + +The normal loop is: + +1. Edit Kotlin in `packages/android/ContentfulOptimization/src/main/kotlin/...` or bridge TypeScript + in `packages/universal/optimization-js-bridge/src/...`. +2. Build the changed app or both app shells from `implementations/android-sdk/`: + + ```sh + ./gradlew :compose:assembleDebug :views:assembleDebug + ``` + +3. Run the Compose or Views app locally, then validate with the matching Maestro flow. + +If bridge source changed, rebuild the bridge before treating app results as meaningful: + +```sh +pnpm --filter @contentful/optimization-js-bridge build +``` + +## Maintainer validation + +Run the smallest check that covers the changed surface: + +| Change area | Suggested validation | +| --------------------------------- | ------------------------------------------------------------------------------------------------------------------------- | +| Bridge TypeScript only | `pnpm --filter @contentful/optimization-js-bridge typecheck` and `pnpm --filter @contentful/optimization-js-bridge build` | +| Kotlin SDK or UI adapter behavior | `./gradlew :compose:assembleDebug :views:assembleDebug` | +| Compose or Views user flow | `pnpm test:e2e:compose -- --flow ` or `pnpm test:e2e:views -- --flow ` | +| Shared preview-panel behavior | Run the affected Maestro suite against both apps | +| Documentation-only README changes | Prettier on touched Markdown and `git diff --check` | + +Common local pitfalls: + +- Keep the Compose and Views apps in lock-step. New screens, controls, and test identifiers must + exist in both app shells. +- The apps reach the host mock through `http://10.0.2.2:8000`; no manual `adb reverse` setup is + required for normal local runs. +- The old UiAutomator module is dormant. Add or update Maestro flows instead. +- After switching branches, force a bridge rebuild if the copied Android UMD asset may not match the + checked-out bridge source. +- If an E2E regression appears in only one app shell, check app test-tag parity before changing SDK + behavior. + ## Related - [Android SDK](../../packages/android/README.md) +- [Native bridge architecture](../../packages/universal/optimization-js-bridge/BRIDGE_ARCHITECTURE.md) - [iOS SDK Reference Implementation](../ios-sdk/README.md) - [React Native Reference Implementation](../react-native-sdk/README.md) - [Preview Panel Scenarios](../PREVIEW_PANEL_SCENARIOS.md) diff --git a/implementations/ios-sdk/README.md b/implementations/ios-sdk/README.md index bc8e2ba4..3c9c32de 100644 --- a/implementations/ios-sdk/README.md +++ b/implementations/ios-sdk/README.md @@ -108,6 +108,57 @@ Useful environment variables: Unlike the Android app, no port forwarding is required: the iOS Simulator shares the host network, so the app reaches the mock server at `localhost:8000` directly. +## Maintainer edit loop + +Use this app when you need a debuggable native iOS surface for changes in +`packages/ios/ContentfulOptimization` or the shared JS bridge. The Xcode project references the SDK +as a local Swift Package, so app builds compile the package from workspace source rather than from a +published artifact. + +The app schemes include a pre-action that builds `@contentful/optimization-js-bridge` before Swift +Package resource resolution when bridge source is newer than the copied UMD resource. The pre-action +writes its output to `/tmp/optimization-ios-build-js-bridge.log`; check that file if Xcode appears +to use a stale bridge bundle or cannot find `pnpm`. + +The normal loop is: + +1. Edit Swift in `packages/ios/ContentfulOptimization/Sources/...` or bridge TypeScript in + `packages/universal/optimization-js-bridge/src/...`. +2. Build or run the `OptimizationAppSwiftUI` or `OptimizationAppUIKit` scheme. Xcode recompiles the + local Swift Package and refreshes the bridge bundle when needed. +3. Validate with the targeted app flow or XCUITest scenario that exercises the changed behavior. + +From the command line, build a shell from `implementations/ios-sdk/`: + +```sh +xcodebuild build \ + -project OptimizationApp.xcodeproj \ + -scheme OptimizationAppSwiftUI \ + -destination 'platform=iOS Simulator,name=iPhone 16' +``` + +## Maintainer validation + +Run the smallest check that covers the changed surface: + +| Change area | Suggested validation | +| ---------------------------------------- | ------------------------------------------------------------------------------------------------------------------------- | +| Bridge TypeScript only | `pnpm --filter @contentful/optimization-js-bridge typecheck` and `pnpm --filter @contentful/optimization-js-bridge build` | +| Swift package behavior | Targeted `xcodebuild test` for the affected app shell or XCUITest class | +| Preview-panel or cross-platform behavior | Targeted preview-panel XCUITest plus the matching shared scenario contract review | +| Documentation-only README changes | Prettier on touched Markdown and `git diff --check` | + +Common local pitfalls: + +- Run `xcodegen generate` after changing `project.yml` or adding, moving, or renaming iOS sources. +- Build through a scheme, not a raw target, so the bridge pre-action runs before Swift Package + resource resolution. +- If Xcode cannot find `pnpm`, launch Xcode from a shell where `pnpm --version` works or check the + pre-action log for the exact PATH issue. +- After switching branches, force a bridge rebuild if the copied UMD resource has a newer timestamp + than the checked-out bridge source. +- Substitute another available simulator if the local Xcode runtime does not include `iPhone 16`. + ## Running E2E tests Run the full suite against both app shells from `implementations/ios-sdk/`: @@ -149,6 +200,10 @@ IDs identical across platforms so cross-platform regressions are visible in CI d ## Related - [iOS SDK package status](../../packages/ios/README.md) - Planned native iOS SDK status marker +- [iOS SDK code map](../../packages/ios/CODE_MAP.md) - Maintainer architecture map for the native + iOS package +- [Native bridge architecture](../../packages/universal/optimization-js-bridge/BRIDGE_ARCHITECTURE.md) - + Shared bridge runtime and build notes - [Mocks package](../../lib/mocks/README.md) - Shared mock API server and fixtures - [Preview panel scenario contract](../PREVIEW_PANEL_SCENARIOS.md) - Cross-platform preview-panel scenario source of truth diff --git a/packages/android/README.md b/packages/android/README.md index 80ab4c50..14275467 100644 --- a/packages/android/README.md +++ b/packages/android/README.md @@ -1,67 +1,268 @@ -# Optimization Android SDK - -Native Android (Kotlin) SDK for the Contentful Optimization SDK Suite. Uses a hybrid -native-JavaScript architecture where Kotlin owns UI, persistence, and lifecycle while a shared -JavaScript core (via QuickJS, embedded through `io.github.dokar3:quickjs-kt`) handles -personalization logic, audience qualification, event batching, and preview overrides. - -## Current status - -> [!CAUTION] Pre-release. API surface is not yet stable. - -- Kotlin Android library module under `ContentfulOptimization/` -- QuickJS JavaScript engine integration via `quickjs-kt` -- Shared TypeScript bridge under `packages/universal/optimization-js-bridge/` -- Jetpack Compose UI layer (OptimizationRoot, OptimizedEntry, scroll/view/click tracking) -- Preview panel with audience/experience overrides, variant selection, and Contentful integration -- View-based app support via `PreviewPanelActivity` -- `OptimizationConfig.locale` is the app/content locale candidate used to resolve `client.locale`. - `OptimizationApiConfig.locale` is the explicit Experience API locale override. Runtime locale - changes use `OptimizationClient.setLocale(locale)`. Explicit invalid locale values throw, and - invalid ambient device locale candidates are ignored. -- `ContentfulLocales(default = "en-US")` is enough for single-locale apps. Add `supported` only when - the app needs device-locale matching across multiple Contentful locales. -- Use `client.locale` for app-owned CDA fetches that feed SDK entry resolution. Do not pass - all-locale CDA responses from `withAllLocales` or `locale=*`; see - [Entry personalization and variant resolution](../../documentation/concepts/entry-personalization-and-variant-resolution.md#single-locale-cda-entry-contract). - For the broader locale model, see - [Locale handling in the Optimization SDK Suite](../../documentation/concepts/locale-handling-in-the-optimization-sdk-suite.md). - -## Architecture - -The SDK mirrors the iOS SDK architecture: - -- **QuickJS** (via `io.github.dokar3:quickjs-kt`) replaces JavaScriptCore as the JavaScript engine -- **`QuickJsContextManager`** manages the JS runtime on a dedicated single-thread dispatcher -- **`NativePolyfills`** provides native Kotlin implementations for fetch, timers, crypto, console, - and URL — the same polyfill JS scripts are shared with iOS -- **`OptimizationClient`** exposes reactive state via `StateFlow` and async operations via `suspend` - functions -- **`SharedPreferencesStore`** persists SDK state across app launches -- **`ViewTrackingController`** implements the three-phase viewport tracking state machine -- **Compose UI layer** provides `OptimizationRoot`, `OptimizedEntry`, `OptimizationLazyColumn`, - `ScreenTrackingEffect`, and click/view tracking modifiers -- **Preview panel** provides a debug overlay with audience toggles, variant selectors, profile - inspection, and override management - -## Key differences from iOS - -| Aspect | iOS | Android | -| -------------- | ---------------------- | ---------------------------------------- | -| JS engine | JavaScriptCore | QuickJS (via `quickjs-kt`) | -| Threading | Main thread | Dedicated single-thread dispatcher | -| Reactive state | `@Published` / Combine | `StateFlow` / `SharedFlow` | -| Async | `async`/`await` | `suspend` functions | -| Persistence | `UserDefaults` | `SharedPreferences` | -| Lifecycle | `NotificationCenter` | `ProcessLifecycleOwner` | -| Network | `NWPathMonitor` | `ConnectivityManager` | -| HTTP | `URLSession` | `OkHttp` | -| UI framework | SwiftUI | Jetpack Compose | -| DI / context | `@EnvironmentObject` | `CompositionLocal` | -| Preview panel | `.sheet` + UIHosting | `ModalBottomSheet` + `ComponentActivity` | - -## When to use this directory - -Use this SDK when building a native Android application that needs Contentful personalization and -analytics. For React Native applications, use -[@contentful/optimization-react-native](../react-native-sdk/README.md) instead. +

+ + Contentful Logo + +

+ +

Contentful Personalization & Analytics

+ +

Optimization Android SDK

+ +
+ +[Guides](https://contentful.github.io/optimization/documents/Documentation.Guides.html) · +[Reference](https://contentful.github.io/optimization) · [Contributing](../../CONTRIBUTING.md) + +
+ +> [!WARNING] +> +> The Optimization SDK Suite is pre-release (alpha). Breaking changes can be published at any time. + +The Optimization Android SDK is a pre-release Kotlin Android library for native Android +applications. It is part of the [Contentful Optimization SDK Suite](../../README.md) and runs shared +optimization behavior through a local QuickJS bridge while Kotlin code owns native app concerns such +as persistence, networking, lifecycle handling, Jetpack Compose UI, XML Views UI, and preview-panel +UI. + +
+ Table of Contents + + +- [Getting started](#getting-started) + - [Requirements](#requirements) + - [Add the dependency](#add-the-dependency) + - [Compose quick start](#compose-quick-start) + - [XML Views quick start](#xml-views-quick-start) +- [When to use this package](#when-to-use-this-package) +- [Reference implementation](#reference-implementation) +- [Configuration](#configuration) + - [Common options](#common-options) + - [Locale handling](#locale-handling) +- [Runtime notes](#runtime-notes) +- [Related](#related) + + +
+ +## Getting started + +### Requirements + +- Android `minSdk` 24 or later. +- Java 11 bytecode support in the consuming Android project. +- A Kotlin Android application built with Jetpack Compose or XML Views. +- Maven Central configured in the consuming build. + +### Add the dependency + +Add the SDK to your Android application module: + +```kotlin +repositories { + mavenCentral() +} + +dependencies { + implementation("com.contentful.java:optimization-android:") +} +``` + +Use the version that matches the Optimization SDK Suite release you are adopting. The package is +published to Maven Central as `com.contentful.java:optimization-android`. + +### Compose quick start + +Compose apps usually initialize the SDK with `OptimizationRoot`, render Contentful entries with +`OptimizedEntry`, and emit screen events with `ScreenTrackingEffect`. + +```kotlin +val optimizationConfig = OptimizationConfig( + clientId = "your-client-id", + environment = "master", + contentfulLocales = ContentfulLocales(default = "en-US"), + defaults = StorageDefaults(consent = true), + debug = BuildConfig.DEBUG, +) + +@Composable +fun AppRoot(heroEntry: Map) { + OptimizationRoot( + config = optimizationConfig, + trackViews = true, + trackTaps = false, + previewPanel = if (BuildConfig.DEBUG) PreviewPanelConfig() else null, + ) { + HomeScreen(heroEntry = heroEntry) + } +} + +@Composable +fun HomeScreen(heroEntry: Map) { + ScreenTrackingEffect(screenName = "Home") + + OptimizedEntry( + entry = heroEntry, + trackTaps = true, + ) { resolvedEntry -> + HeroCard(entry = resolvedEntry) + } +} +``` + +For the full Compose flow, see +[Integrating the Optimization Android SDK in a Jetpack Compose app](../../documentation/guides/integrating-the-optimization-android-sdk-in-a-compose-app.md). + +### XML Views quick start + +XML Views apps usually initialize the SDK once from `Application.onCreate`, render Contentful +entries with `OptimizedEntryView`, and emit screen events with `ScreenTracker`. + +```kotlin +class MyApplication : Application() { + override fun onCreate() { + super.onCreate() + + OptimizationManager.initialize( + context = this, + config = optimizationConfig, + trackViews = true, + trackTaps = false, + previewPanel = PreviewPanelConfig(), + ) + } +} + +class HomeActivity : AppCompatActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + if (BuildConfig.DEBUG) { + OptimizationManager.attachPreviewPanel(this) + } + } + + override fun onResume() { + super.onResume() + ScreenTracker.trackScreen("Home") + } + + fun renderHero(entry: Map): View { + return OptimizedEntryView(this).apply { + trackTaps = true + setContentRenderer { resolvedEntry -> + HeroBinder.create(context, resolvedEntry) + } + setEntry(entry) + } + } +} +``` + +For the full XML Views flow, see +[Integrating the Optimization Android SDK in an XML Views app](../../documentation/guides/integrating-the-optimization-android-sdk-in-a-views-app.md). + +## When to use this package + +Use this package when building a native Android application that needs Contentful Personalization +and Analytics through Kotlin APIs. The same AAR includes: + +- `com.contentful.optimization.core` for the stateful `OptimizationClient`, configuration, entry + personalization, event tracking, and preview controls. +- `com.contentful.optimization.compose` for Jetpack Compose apps. +- `com.contentful.optimization.views` for XML Views apps. + +For React Native applications, use +[`@contentful/optimization-react-native`](../react-native-sdk/README.md) instead. + +## Reference implementation + +The [Android reference implementation](../../implementations/android-sdk/README.md) demonstrates the +same SDK behavior in Compose and XML Views shells. It exercises entry resolution, interaction +tracking, screen tracking, live updates, preview-panel overrides, and shared mock API flows. + +## Configuration + +### Common options + +| Option | Required? | Default | Description | +| ------------------- | --------- | -------- | --------------------------------------------------------------------------------------------------- | +| `clientId` | Yes | None | Optimization client identifier used for Experience API and Insights API calls. | +| `environment` | No | `master` | Contentful environment name used by the Optimization APIs. | +| `contentfulLocales` | No | `null` | Contentful locale configuration used to resolve `client.locale` for app-owned CDA requests. | +| `locale` | No | Runtime | Initial app/content locale candidate. When omitted, the SDK can resolve from `LocaleList`. | +| `api.locale` | No | `null` | Explicit Experience API locale override for localized profile fields. | +| `defaults` | No | `null` | Initial persisted-state seeds such as consent or profile values, applied only when no value exists. | +| `debug` | No | `false` | Enables SDK diagnostic logging. | + +`OptimizationRoot` and `OptimizationManager.initialize(...)` also accept global `trackViews`, +`trackTaps`, and `liveUpdates` defaults. `OptimizedEntry` and `OptimizedEntryView` can override +those defaults per entry. + +### Locale handling + +For a single-locale app, configure the Contentful locale default only: + +```kotlin +val config = OptimizationConfig( + clientId = "your-client-id", + environment = "master", + contentfulLocales = ContentfulLocales(default = "en-US"), +) +``` + +For an app that matches the user's runtime locale to multiple Contentful locales, add `supported` +with the locale codes configured in your Contentful space: + +```kotlin +val config = OptimizationConfig( + clientId = "your-client-id", + environment = "master", + contentfulLocales = ContentfulLocales( + default = "en-US", + supported = listOf("en-US", "de-DE", "fr-FR"), + ), +) +``` + +Use `client.locale` when your app-owned Contentful Delivery API client fetches entries that will be +passed to `OptimizedEntry`, `OptimizedEntryView`, or `client.personalizeEntry(...)`. The native SDK +does not fetch Contentful entries for your app layer, so this value belongs in your CDA request +code. + +`OptimizationApiConfig.locale` is an explicit Experience API override for localized profile fields. +It does not replace the CDA locale used to fetch Contentful entries. + +For the full locale model, see +[Locale handling in the Optimization SDK Suite](../../documentation/concepts/locale-handling-in-the-optimization-sdk-suite.md). +For the single-locale CDA entry contract, see +[Entry personalization and variant resolution](../../documentation/concepts/entry-personalization-and-variant-resolution.md#single-locale-cda-entry-contract). + +## Runtime notes + +- The SDK library manifest declares the required network permissions and the non-exported preview + panel Activity. +- No JavaScript bridge setup is required in consuming applications. The generated QuickJS bundle is + packaged inside the AAR. +- SDK state is persisted with `SharedPreferences`. +- Analytics events queue while the device is offline and flush when connectivity returns or the app + moves toward the background. +- For bridge architecture and maintainer build details, see + [Native bridge architecture](../universal/optimization-js-bridge/BRIDGE_ARCHITECTURE.md). + +## Related + +- [Compose integration guide](../../documentation/guides/integrating-the-optimization-android-sdk-in-a-compose-app.md) - + step-by-step setup for `OptimizationRoot`, `OptimizedEntry`, screen tracking, and preview panel + mounting. +- [XML Views integration guide](../../documentation/guides/integrating-the-optimization-android-sdk-in-a-views-app.md) - + step-by-step setup for `OptimizationManager`, `OptimizedEntryView`, screen tracking, and preview + panel mounting. +- [Android SDK runtime and interaction mechanics](../../documentation/concepts/android-sdk-runtime-and-interaction-mechanics.md) - + explains runtime state, consent, tracking, live updates, preview overrides, and offline delivery. +- [Android reference implementation](../../implementations/android-sdk/README.md) - Compose and XML + Views apps that validate the SDK against the shared mock API. +- [React Native SDK](../react-native-sdk/README.md) - Mobile integration for React Native + applications. +- [Core SDK](../universal/core-sdk/README.md) - Shared optimization foundation used through the + native bridge. diff --git a/packages/ios/CODE_MAP.md b/packages/ios/CODE_MAP.md index 644e5d3c..d87ae88b 100644 --- a/packages/ios/CODE_MAP.md +++ b/packages/ios/CODE_MAP.md @@ -116,6 +116,42 @@ sequenceDiagram JSM-->>Swift: Result ``` +### JavaScriptCore bridge runtime notes + +The shared bridge contract is documented in +[`packages/universal/optimization-js-bridge/BRIDGE_ARCHITECTURE.md`](../universal/optimization-js-bridge/BRIDGE_ARCHITECTURE.md). +The iOS package owns the JavaScriptCore-specific host side of that contract: + +- `JSContextManager` creates one `JSContext` for the lifetime of an `OptimizationClient`, registers + native bindings, evaluates the UMD bundle, validates `globalThis.__bridge`, registers push-back + globals, and calls `__bridge.initialize(configJSON)`. +- `NativePolyfills` registers Swift-backed `__nativeLog`, `__nativeSetTimeout`, + `__nativeClearTimeout`, `__nativeRandomUUID`, and `__nativeFetch` globals before the UMD bundle + evaluates. +- `BridgeCallbackManager` generates the success/error callback-name pairs that async bridge methods + use when JavaScript promises settle. +- `OptimizationClient` is `@MainActor`, so public bridge calls enter JavaScriptCore from the main + actor. Fetch and timer completions marshal back to the main queue before re-entering JS. +- The push-back globals (`__nativeOnStateChange`, `__nativeOnEventEmitted`, and + `__nativeOnOverridesChanged`) republish bridge state into Swift observables after decoding JSON. + +### Bundle resource and diagnostics notes + +`Package.swift` declares `optimization-ios-bridge.umd.js` as a copied Swift Package resource and +links JavaScriptCore for consuming apps. `JSContextManager.loadBundleSource()` reads that resource +from `Bundle.module` and throws `OptimizationError.resourceLoadError` if the bundle is missing. + +The copied UMD bundle is generated from `packages/universal/optimization-js-bridge`. Keep the flow +one-way: edit the TypeScript bridge or polyfill source, build the bridge package, and let the bridge +build refresh the Swift Package resource. Do not hand-edit the copied UMD resource. + +Diagnostics flow through two channels: + +- JavaScriptCore exceptions route through the context exception handler and the SDK diagnostic + logger. +- Native fetch crossings are bracketed with signposts under the `com.contentful.optimization` + performance log so Instruments can measure bridge round-trip cost without SDK-code timing hooks. + ### Data flow: view tracking lifecycle ```mermaid diff --git a/packages/ios/README.md b/packages/ios/README.md index 5b7327ae..7dcb8a55 100644 --- a/packages/ios/README.md +++ b/packages/ios/README.md @@ -90,6 +90,9 @@ locally before `swift build`/`swift test` with `pnpm run ios:bridge`, or use the ## Related +- [iOS SDK code map](./CODE_MAP.md) - Maintainer architecture map for the native iOS package +- [Native bridge architecture](../universal/optimization-js-bridge/BRIDGE_ARCHITECTURE.md) - Shared + bridge runtime and build notes - [iOS reference app](../../implementations/ios-sdk/README.md) - Native app and XCUITest surface for bridge and preview-panel validation - [React Native SDK](../react-native-sdk/README.md) - Current stable mobile-facing JavaScript SDK diff --git a/packages/universal/optimization-js-bridge/BRIDGE_ARCHITECTURE.md b/packages/universal/optimization-js-bridge/BRIDGE_ARCHITECTURE.md new file mode 100644 index 00000000..98ad7faf --- /dev/null +++ b/packages/universal/optimization-js-bridge/BRIDGE_ARCHITECTURE.md @@ -0,0 +1,115 @@ +# Native bridge architecture + +This document is for maintainers working on the internal `@contentful/optimization-js-bridge` +package and the native iOS and Android SDKs that consume it. It is not an application integration +guide. + +The bridge lets the native SDKs share one TypeScript optimization core while Swift and Kotlin own +native runtime concerns such as persistence, networking, lifecycle handling, UI adapters, and +preview-panel presentation. + +## Ownership + +The bridge package owns one TypeScript adapter at `src/index.ts`. That adapter wraps +`@contentful/optimization-core`, exposes a small `globalThis.__bridge` object, and keeps the shared +optimization state machine available to JavaScriptCore on iOS and QuickJS on Android. + +Native packages own the engine-specific context managers, host bindings, model decoding, and public +Swift/Kotlin APIs: + +| Layer | Owns | +| ------------------------------------------- | ---------------------------------------------------------------------------------- | +| `packages/universal/optimization-js-bridge` | Bridge methods, callback payloads, preview override calls, polyfills, UMD outputs. | +| `packages/ios` | JavaScriptCore context lifecycle, Swift models, SwiftUI adapters, persistence. | +| `packages/android` | QuickJS lifecycle, Kotlin models, Compose and Views adapters, persistence. | + +## Build output + +`rslib.config.ts` builds the same bridge source into two UMD bundles: + +| Bundle | Native consumer | Engine | +| ------------------------------------ | ---------------------- | ---------------------------- | +| `optimization-ios-bridge.umd.js` | iOS Swift Package | JavaScriptCore (`JSContext`) | +| `optimization-android-bridge.umd.js` | Android library assets | QuickJS (`quickjs-kt`) | + +The bundles differ only in the package name stamped into analytics `library.name`. Keep that +platform-specific define intact so iOS and Android events remain distinguishable. + +The package `postbuild` step copies the emitted UMD bundles into the native SDK resource locations. +Do not hand-edit `dist/` output or copied native bundles. Update bridge source or polyfills, then +run: + +```sh +pnpm --filter @contentful/optimization-js-bridge build +``` + +## Polyfills and native bindings + +The bridge bundle expects browser-like globals that JavaScriptCore and QuickJS do not provide: +`console`, `setTimeout`, `fetch`, `crypto.randomUUID`, `URL`, `URLSearchParams`, `AbortController`, +`queueMicrotask`, `Promise.withResolvers`, `TextEncoder`, and `TextDecoder`. + +The JS polyfills live in `src/polyfills/` and are prepended to each UMD bundle before the bridge +IIFE. The native SDK must register the host-side `__native*` bindings before evaluating the bundle. + +| Binding | iOS implementation | Android implementation | +| ---------------------- | ----------------------------- | ------------------------------ | +| `__nativeLog` | Routes to diagnostics. | Routes through `__native.log`. | +| `__nativeSetTimeout` | Schedules on the main queue. | Schedules a coroutine delay. | +| `__nativeClearTimeout` | Cancels the stored work item. | Cancels the stored job. | +| `__nativeRandomUUID` | Uses `UUID()`. | Uses `UUID.randomUUID()`. | +| `__nativeFetch` | Uses `URLSession`. | Uses `OkHttp`. | + +Polyfills must stay platform-agnostic. If a future bridge feature needs platform-specific behavior, +prefer a build-time define in the bridge entry over forking polyfill files. + +## Bridge method contract + +Native code calls only through `globalThis.__bridge`. The bridge exposes methods such as +`initialize`, `identify`, `screen`, `page`, `trackView`, `trackClick`, `flush`, `consent`, `reset`, +`personalizeEntry`, `getProfile`, `getState`, `getPreviewState`, and preview override mutators. + +Async methods receive a payload plus success and error callback names. The JS bridge invokes one of +those global callbacks when the underlying promise settles. This keeps the JS side identical across +JavaScriptCore and QuickJS even though the native callback transport differs by platform. + +Synchronous methods, including `personalizeEntry`, `consent`, `reset`, `setOnline`, +`getMergeTagValue`, `getProfile`, and preview override mutators, return JSON-compatible values +directly from engine evaluation. + +## State push-back and lifecycle + +During initialization, the native context manager installs three push-back globals: + +- `__nativeOnStateChange(json)` for profile, consent, locale, personalization, and optimization + state snapshots. +- `__nativeOnEventEmitted(json)` for emitted Experience and Insights events. +- `__nativeOnOverridesChanged(json)` for preview-panel audience and variant overrides. + +These callbacks fire synchronously inside bridge calls. Native handlers may republish on the main +thread, but they should not defer the underlying state mutation past the method return when the +public API expects an immediate state snapshot. + +Destroy paths should cancel timers, remove subscriptions and preview override state, evaluate +`__bridge.destroy()`, and close or release the JavaScript engine. + +## Platform notes + +- iOS bridge details and the JavaScriptCore package-resource flow live in + [packages/ios/CODE_MAP.md](../../ios/CODE_MAP.md). +- Android bridge details, QuickJS dispatcher constraints, and asset packaging notes live in + [packages/android/README.md](../../android/README.md). +- Reference app bootstrap and local validation flows live in the iOS and Android reference app + READMEs under `implementations/`. + +## Validation + +For bridge contract, payload-shape, preview, or lifecycle changes: + +```sh +pnpm --filter @contentful/optimization-js-bridge typecheck +pnpm --filter @contentful/optimization-js-bridge build +``` + +Then validate the affected native SDK and reference-app flow. Rebuild the bridge before Swift, +Kotlin, XCUITest, or Maestro results are treated as meaningful. diff --git a/packages/universal/optimization-js-bridge/README.md b/packages/universal/optimization-js-bridge/README.md index d23c7f46..50209781 100644 --- a/packages/universal/optimization-js-bridge/README.md +++ b/packages/universal/optimization-js-bridge/README.md @@ -43,6 +43,11 @@ pnpm --filter @contentful/optimization-js-bridge build Do not hand-edit `dist/` output or the copied native bundles. Regenerate them through the build flow. +## Architecture notes + +For the bridge runtime contract, UMD bundle flow, prepended polyfills, native bindings, callback +shape, and lifecycle constraints, see [Native bridge architecture](./BRIDGE_ARCHITECTURE.md). + ## Commands Run commands from the monorepo root: @@ -57,6 +62,7 @@ Android SDKs and the reference apps that exercise the changed behavior. ## Related +- [Native bridge architecture](./BRIDGE_ARCHITECTURE.md) - Shared bridge runtime and build notes - [iOS SDK package](../../ios/README.md) - Native iOS SDK status and package layout - [Android SDK package](../../android/README.md) - Native Android SDK status and package layout - [Core preview support](../core-sdk/src/preview-support/README.md) - Shared preview override