From 181a841d256110539c21d22905f9b10defcf1071 Mon Sep 17 00:00:00 2001 From: Tim Beyer Date: Mon, 20 Jul 2026 16:57:26 +0200 Subject: [PATCH 1/6] feat(docs): bootstrap Android SDK knowledge base Add native/android.md, the verified fact base for the beta com.contentful.java:optimization-android Kotlin library, feeding both Android guides. Like iOS, the Android SDK runs the shared CoreStateful runtime through a JS bridge (QuickJS here), so behavioral facts anchor to the resolvable optimization-js-bridge/core-sdk keys and concept docs; Kotlin-only surface uses extern: pointers naming the exact file+symbol, since knowledge:check cannot resolve Kotlin #symbol pointers (no package.json/src). Records the Android-vs-iOS divergences: suspend initialize/resolveOptimizedEntry, StateFlow/SharedFlow (not Combine), replay-64 event streams, and the 10.0.2.2 emulator localhost. Passes pnpm knowledge:check. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../internal/sdk-knowledge/native/android.md | 352 ++++++++++++++++++ 1 file changed, 352 insertions(+) create mode 100644 documentation/internal/sdk-knowledge/native/android.md diff --git a/documentation/internal/sdk-knowledge/native/android.md b/documentation/internal/sdk-knowledge/native/android.md new file mode 100644 index 00000000..9ebba2fe --- /dev/null +++ b/documentation/internal/sdk-knowledge/native/android.md @@ -0,0 +1,352 @@ +# Android (`com.contentful.java:optimization-android` Kotlin library) — SDK knowledge + + + +> Internal, verified reference. Not a guide. Facts only, each with a source pointer verified against +> packages/\*\*/src. + +Shared vocabulary and SDK-neutral concepts: see [`../shared/vocabulary.md`](../shared/vocabulary.md) +and [`../shared/concepts.md`](../shared/concepts.md). The concepts they capture — consent/persistence +axes, baseline fallback, entry-source managed-vs-manual, live updates, single-locale entry contract, +experience-response payload — are SDK-neutral and apply to the whole suite via the shared `core-sdk`. + +This is a **beta** native Kotlin/Gradle library, not a TypeScript package: its source root is +`packages/android/ContentfulOptimization/src/main/kotlin/com/contentful/optimization` with a +`build.gradle.kts` and no `package.json`/`src/`, so `knowledge:check` has no `android` SDK key and +cannot resolve Kotlin `#symbol` pointers. Kotlin-specific facts therefore use extern-prefixed +pointers whose free text names the exact Kotlin file and symbol; behavior that actually executes in +the shared JS layer uses the real resolvable keys — the js-bridge adapter, the shared core/api +packages, concept docs, the reference implementation, and cross-links into this base. The Android SDK +runs the **same `CoreStateful` runtime as the web/React-Native SDKs** inside a per-client **QuickJS** +context (iOS uses JavaScriptCore), bridged by the TypeScript adapter in +`packages/universal/optimization-js-bridge/src/index.ts`; most optimization/consent/event/queue +behavior parallels [iOS](./ios.md) and [React Native](./react-native.md). Kotlin owns native +concerns: persistence (`SharedPreferences`), networking (`OkHttp`/`ConnectivityManager`), app +lifecycle (`ProcessLifecycleOwner`), Compose + XML Views UI, and preview-panel UI. Android's +behavioral reference is [`concept:android-sdk-runtime-and-interaction-mechanics`](../../concepts/android-sdk-runtime-and-interaction-mechanics.md). + +## Package & entry points + +One AAR (`com.contentful.java:optimization-android`, Maven Central), `minSdk` 24 / Java 11, with the +QuickJS UMD bundle packaged in the AAR assets. Three Kotlin packages ship in the same artifact; both +guides consume it — Compose apps use `.compose`, XML Views apps use `.views`, and both sit on the +imperative `.core` client. + +| Package | Public symbol or purpose | source | +| --------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| Compose surface | `OptimizationRoot`, `OptimizedEntry`, `ScreenTrackingEffect`, `OptimizationLazyColumn`, `Modifier.trackViews`, `Modifier.trackClicks`, composition locals | extern:Compose composables/modifiers — packages/android/ContentfulOptimization/src/main/kotlin/com/contentful/optimization/compose/OptimizationRoot.kt#OptimizationRoot; extern:packages/android/ContentfulOptimization/src/main/kotlin/com/contentful/optimization/compose/OptimizedEntry.kt#OptimizedEntry | +| XML Views surface | `OptimizationManager` (object singleton), `OptimizedEntryView`, `ScreenTracker` (object), `TrackingRecyclerView` | extern:OptimizationManager application-scoped singleton facade — packages/android/ContentfulOptimization/src/main/kotlin/com/contentful/optimization/views/OptimizationManager.kt#OptimizationManager; extern:packages/android/ContentfulOptimization/src/main/kotlin/com/contentful/optimization/views/OptimizedEntryView.kt#OptimizedEntryView | +| Imperative client | `OptimizationClient` (state as `StateFlow`/`SharedFlow`); `EventEmissionResult` | extern:OptimizationClient is the stateful facade wrapping the QuickJS bridge — packages/android/ContentfulOptimization/src/main/kotlin/com/contentful/optimization/core/OptimizationClient.kt#OptimizationClient | +| Config | `OptimizationConfig`, `OptimizationApiConfig`, `StorageDefaults`, `OptimizationLogLevel`, `QueuePolicy`, `QueueFlushPolicy`, `QueueEvent`/`QueueEventType`, `BlockedEvent` | extern:packages/android/ContentfulOptimization/src/main/kotlin/com/contentful/optimization/core/OptimizationConfig.kt#OptimizationConfig; extern:packages/android/ContentfulOptimization/src/main/kotlin/com/contentful/optimization/core/OptimizationConfig.kt#StorageDefaults | +| Event payloads | `IdentifyPayload`, `PageEventPayload`, `ScreenEventPayload`, `TrackEventPayload`, `TrackViewPayload`, `TrackClickPayload` | extern:packages/android/ContentfulOptimization/src/main/kotlin/com/contentful/optimization/core/EventPayloads.kt#ScreenEventPayload; extern:packages/android/ContentfulOptimization/src/main/kotlin/com/contentful/optimization/core/TrackViewPayload.kt#TrackViewPayload | +| State / result types | `OptimizationState`, `ResolvedOptimizedEntry`, `PreviewState` (+ DTOs), `JSONValue`, `OptimizationError` | extern:packages/android/ContentfulOptimization/src/main/kotlin/com/contentful/optimization/core/OptimizationState.kt#OptimizationState; extern:packages/android/ContentfulOptimization/src/main/kotlin/com/contentful/optimization/core/OptimizationError.kt#OptimizationError | +| Tracking (imperative) | `ViewTrackingController`, `TrackingMetadata` (both `internal`) | extern:ViewTrackingController is the internal view-timing engine both adapters build on — packages/android/ContentfulOptimization/src/main/kotlin/com/contentful/optimization/tracking/ViewTrackingController.kt#ViewTrackingController | +| Preview panel | `PreviewPanelConfig`, `PreviewPanelOverlay` (Compose), `PreviewContentfulClient`/`ContentfulHTTPPreviewClient`, `PreviewPanelActivity` (non-exported) | extern:packages/android/ContentfulOptimization/src/main/kotlin/com/contentful/optimization/preview/PreviewPanelConfig.kt#PreviewPanelConfig; extern:packages/android/ContentfulOptimization/src/main/kotlin/com/contentful/optimization/preview/PreviewContentfulClient.kt#ContentfulHTTPPreviewClient | + +- The AAR manifest declares `INTERNET` + `ACCESS_NETWORK_STATE` permissions and the `PreviewPanelActivity` + as `android:exported="false"`; no bridge setup is required in consuming apps (the QuickJS bundle + ships inside the AAR assets). source: extern:library manifest declares network permissions + non-exported preview Activity — packages/android/ContentfulOptimization/src/main/AndroidManifest.xml +- Every optimization/consent/event/queue call ultimately runs `CoreStateful` through the bridge's + `globalThis.__bridge`; the Kotlin `OptimizationClient` is a thin native facade over it. + source: optimization-js-bridge#index.ts#Bridge; core-sdk#CoreStateful.ts#CoreStatefulConfig + +## Setup / factory + +- Two usage modes for one client. **Compose**: `OptimizationRoot(config, …)` owns the client + (`remember { OptimizationClient(...) }`), calls `initialize(config)` in a `LaunchedEffect`, provides + it via `LocalOptimizationClient` plus a `LocalTrackingConfig`, and renders a + `CircularProgressIndicator()` gate until `client.isInitialized`. **XML Views**: the app calls + `OptimizationManager.initialize(context, config, …)` (typically from `Application.onCreate`) and + reads `OptimizationManager.client` from activities/fragments. source: extern:OptimizationRoot remember + LaunchedEffect initialize + progress gate — packages/android/ContentfulOptimization/src/main/kotlin/com/contentful/optimization/compose/OptimizationRoot.kt#OptimizationRoot; impl:android-sdk#compose/src/main/kotlin/com/contentful/optimization/app/MainActivity.kt; impl:android-sdk#views/src/main/kotlin/com/contentful/optimization/app/views/MainActivity.kt +- `initialize(config)` is a **`suspend` function** (Android diverges from iOS's synchronous-throws + init): it loads persisted consent, resolves defaults, evaluates the UMD bundle and runs bridge init + on the dedicated QuickJS dispatcher, then flips `isInitialized`. Compose calls it inside a + `LaunchedEffect`; `OptimizationManager.initialize` launches it fire-and-forget on an internal + `SupervisorJob + Dispatchers.Main` scope, so Views callers making direct suspend calls must first + await `OptimizationManager.client.isInitialized.first { it }`. source: extern:initialize is suspend and runs bridge init on the QuickJS dispatcher — packages/android/ContentfulOptimization/src/main/kotlin/com/contentful/optimization/core/OptimizationClient.kt#OptimizationClient; extern:OptimizationManager.initialize launches initialize on an internal scope — packages/android/ContentfulOptimization/src/main/kotlin/com/contentful/optimization/views/OptimizationManager.kt#OptimizationManager +- `OptimizationManager` is an **application-scoped object singleton**: `initialize(...)` is idempotent + — the first call constructs and initializes the client, later calls only update the global + `trackViews`/`trackTaps`/`liveUpdates` defaults and the retained preview `contentfulClient`, and do + not recreate the client. `client` throws (with the same "wrap in OptimizationRoot / call + OptimizationManager.initialize" message the Compose `LocalOptimizationClient` default uses) if read + before `initialize`. source: extern:OptimizationManager.initialize idempotent, retains preview client, updates global defaults — packages/android/ContentfulOptimization/src/main/kotlin/com/contentful/optimization/views/OptimizationManager.kt#OptimizationManager +- Startup state resolution order in `initialize`: (1) `store.loadConsentState()` reads persisted + consent from `SharedPreferences`; (2) `resolveStatefulDefaults(configured, persisted)` merges + configured over persisted; (3) profile-continuity is loaded from disk **only** when the resolved + `persistenceConsent == true` (`canLoadPersistedContinuity`), otherwise continuity is cleared when it + resolves to `false`; (4) the merged `defaults` plus a separate `anonymousId` are serialized into the + bridge config. source: extern:initialize resolves consent → resolveStatefulDefaults → conditional profile-continuity load — packages/android/ContentfulOptimization/src/main/kotlin/com/contentful/optimization/core/OptimizationClient.kt#OptimizationClient; extern:resolveStatefulDefaults + canLoadPersistedContinuity — packages/android/ContentfulOptimization/src/main/kotlin/com/contentful/optimization/core/StatefulPolicy.kt#resolveStatefulDefaults +- `OptimizationConfig`: `clientId` required; `environment` has a **Kotlin-side default `"main"`** in + the data class (unlike the JS SDKs, which fall back to the api-client `DEFAULT_ENVIRONMENT`); + `logLevel` default `OptimizationLogLevel.error`; `locale`, `api` + (`experienceBaseUrl`/`insightsBaseUrl`/`enabledFeatures`/`preflight`), `allowedEventTypes`, + `queuePolicy`, `defaults: StorageDefaults`, `onEventBlocked` all optional/nullable. + source: extern:environment default "main", logLevel default error, clientId required — packages/android/ContentfulOptimization/src/main/kotlin/com/contentful/optimization/core/OptimizationConfig.kt#OptimizationConfig +- `config.toJSON(anonymousId)` serializes to the bridge `BridgeConfig` shape, omitting null URLs and + empty sub-objects (`api`/`queuePolicy` skipped when empty; `defaults` object emitted only when + non-empty); the bridge maps it into `CoreStatefulConfig` via `resolveStatefulDefaults`, defaulting + `allowedEventTypes` to `DEFAULT_NATIVE_ALLOWED_EVENT_TYPES` and installing `queuePolicy` callbacks + that push back through `__nativeOnQueueEvent`. source: extern:OptimizationConfig.toJSON → BridgeConfig — packages/android/ContentfulOptimization/src/main/kotlin/com/contentful/optimization/core/OptimizationConfig.kt#OptimizationConfig; optimization-js-bridge#index.ts#BridgeConfig; optimization-js-bridge#index.ts#initialize +- `StorageDefaults` are startup defaults, not one-time seeds: configured values take precedence over + persisted `SharedPreferences` values every launch, so a configured `consent`/`persistenceConsent` + can replace a stored choice. Apps that persist user choices leave these unset and call `consent(...)` + from resolved app policy instead. source: extern:StorageDefaults precedence resolved in resolveStatefulDefaults — packages/android/ContentfulOptimization/src/main/kotlin/com/contentful/optimization/core/StatefulPolicy.kt#resolveStatefulDefaults; concept:android-sdk-runtime-and-interaction-mechanics +- Locale is normalized before init: `normalizeLocale` trims, maps `_`→`-`, and rejects empty / `*` / + `und` / non-BCP-47-shaped values; an explicit invalid `locale` (config or `setLocale`) **throws** + `OptimizationError.ConfigError` rather than being silently dropped. source: extern:normalizeLocale + normalizeExplicitLocale throw ConfigError on invalid — packages/android/ContentfulOptimization/src/main/kotlin/com/contentful/optimization/core/OptimizationConfig.kt#normalizedLocale; concept:locale-handling-in-the-optimization-sdk-suite + +## Components & hooks + +For Android these are Compose composables/modifiers, XML Views classes, and the imperative +`OptimizationClient`. Read exact signatures from the Kotlin types; below is a navigation index with +behavioral facts as sourced bullets. + +| Name | Kind | source | +| -------------------------------------- | ---------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `OptimizationRoot` | Compose root composable | extern:packages/android/ContentfulOptimization/src/main/kotlin/com/contentful/optimization/compose/OptimizationRoot.kt#OptimizationRoot | +| `OptimizedEntry` | Compose composable (render lambda) | extern:packages/android/ContentfulOptimization/src/main/kotlin/com/contentful/optimization/compose/OptimizedEntry.kt#OptimizedEntry | +| `ScreenTrackingEffect` | Compose effect | extern:packages/android/ContentfulOptimization/src/main/kotlin/com/contentful/optimization/compose/ScreenTrackingEffect.kt#ScreenTrackingEffect | +| `OptimizationLazyColumn` | Compose scroll wrapper providing `ScrollContext` | extern:packages/android/ContentfulOptimization/src/main/kotlin/com/contentful/optimization/compose/OptimizationLazyColumn.kt#OptimizationLazyColumn | +| `Modifier.trackViews` / `.trackClicks` | Compose modifiers wiring view/tap tracking | extern:packages/android/ContentfulOptimization/src/main/kotlin/com/contentful/optimization/compose/ViewTrackingLayout.kt#trackViews; extern:packages/android/ContentfulOptimization/src/main/kotlin/com/contentful/optimization/compose/ClickTrackingModifier.kt#trackClicks | +| `OptimizationManager` | XML Views app-scoped singleton | extern:packages/android/ContentfulOptimization/src/main/kotlin/com/contentful/optimization/views/OptimizationManager.kt#OptimizationManager | +| `OptimizedEntryView` | XML Views `FrameLayout` (render via `setContentRenderer` + `setEntry`) | extern:packages/android/ContentfulOptimization/src/main/kotlin/com/contentful/optimization/views/OptimizedEntryView.kt#OptimizedEntryView | +| `ScreenTracker` | XML Views object (`trackScreen(name)`) | extern:packages/android/ContentfulOptimization/src/main/kotlin/com/contentful/optimization/views/ScreenTracker.kt#ScreenTracker | +| `TrackingRecyclerView` | XML Views `RecyclerView` re-checking child visibility on scroll | extern:packages/android/ContentfulOptimization/src/main/kotlin/com/contentful/optimization/views/TrackingRecyclerView.kt#TrackingRecyclerView | +| `ViewTrackingController` | internal view-timing engine | extern:packages/android/ContentfulOptimization/src/main/kotlin/com/contentful/optimization/tracking/ViewTrackingController.kt#ViewTrackingController | +| `OptimizationClient` API | imperative facade | extern:packages/android/ContentfulOptimization/src/main/kotlin/com/contentful/optimization/core/OptimizationClient.kt#OptimizationClient | + +- `OptimizedEntry` (Compose) and `OptimizedEntryView` (Views) detect an optimized entry by the + presence of `fields.nt_experiences`; only optimized entries observe `selectedOptimizations` and call + `resolveOptimizedEntry`, non-optimized entries render the baseline once with no live observation. + Both wrap the rendered content with the same view-tracking + tap-tracking machinery so a Contentful + entry behaves identically across the two adapters. source: extern:OptimizedEntry isOptimized checks fields.nt_experiences — packages/android/ContentfulOptimization/src/main/kotlin/com/contentful/optimization/compose/OptimizedEntry.kt#OptimizedEntry; extern:OptimizedEntryView mirrors the same isOptimized/observation logic — packages/android/ContentfulOptimization/src/main/kotlin/com/contentful/optimization/views/OptimizedEntryView.kt#OptimizedEntryView +- Interaction enablement: view tracking uses per-entry `trackViews ?? global trackViews`; tap tracking + resolves as `trackTaps == false` disables, an explicit `trackTaps` or a non-null `onTap` enables, + else the global `trackTaps` default. Both default enabled (Compose `TrackingConfig` and + `OptimizationManager` both default `trackViews=true`, `trackTaps=true`, `liveUpdates=false`). + source: extern:OptimizedEntry viewsEnabled/tapsEnabled resolution — packages/android/ContentfulOptimization/src/main/kotlin/com/contentful/optimization/compose/OptimizedEntry.kt#OptimizedEntry; extern:TrackingConfig defaults — packages/android/ContentfulOptimization/src/main/kotlin/com/contentful/optimization/compose/CompositionLocals.kt#TrackingConfig; extern:OptimizedEntryView resolveTrackViews/resolveTrackTaps — packages/android/ContentfulOptimization/src/main/kotlin/com/contentful/optimization/views/OptimizedEntryView.kt#OptimizedEntryView +- Scroll-aware visibility: Compose `OptimizationLazyColumn` publishes a `ScrollContext { scrollY, +viewportHeight }` via `LocalScrollContext` that descendant `Modifier.trackViews` reads; without it, + view tracking falls back to `Resources.getSystem().displayMetrics.heightPixels` for the viewport and + assumes `scrollY = 0`. `OptimizedEntryView` instead derives its visible ratio from + `getGlobalVisibleRect`, and `TrackingRecyclerView` nudges descendant `OptimizedEntryView`s to + re-check on each scroll frame (redundant with each view's own `OnPreDrawListener`). source: extern:Modifier.trackViews reads LocalScrollContext, else system display metrics — packages/android/ContentfulOptimization/src/main/kotlin/com/contentful/optimization/compose/ViewTrackingLayout.kt#trackViews; extern:OptimizedEntryView.updateVisibility uses getGlobalVisibleRect — packages/android/ContentfulOptimization/src/main/kotlin/com/contentful/optimization/views/OptimizedEntryView.kt#OptimizedEntryView +- The `OptimizationClient` API splits by Kotlin call shape: **`suspend`** for events + resolution + + profile reads (`identify`/`page`/`screen`/`track`/`trackCurrentScreen`/`flush`/`trackView`/`trackClick`/ + `resolveOptimizedEntry`/`getMergeTagValue`/`getProfile`), and **synchronous** (some blocking on the + QuickJS dispatcher via `runBlocking`) for `consent`/`reset`/`setOnline`/`setLocale`/`getFlag`/ + `observeFlag`/`getState`/`hasConsent` and the preview-override methods. Reactive surfaces are + `StateFlow`/`SharedFlow`, not Combine publishers. source: extern:OptimizationClient suspend event/resolve methods vs synchronous reads exposed as Flows — packages/android/ContentfulOptimization/src/main/kotlin/com/contentful/optimization/core/OptimizationClient.kt#OptimizationClient + +## Render / entry resolution + +- `resolveOptimizedEntry(baseline, selectedOptimizations?)` is a **`suspend` function** (iOS's is + synchronous — Android must suspend because bridge calls hop to the QuickJS dispatcher) and is + **fail-soft**: it serializes the baseline map to JSON, calls the bridge, and returns a + `ResolvedOptimizedEntry`. If the client is not initialized, serialization fails, the bridge returns + `null`/`undefined`, or the result cannot be parsed, it returns the baseline entry unchanged (with + `selectedOptimization`/`optimizationContextId` null) and continues — it never throws or breaks the + UI. source: extern:resolveOptimizedEntry suspend, returns baseline on any failure — packages/android/ContentfulOptimization/src/main/kotlin/com/contentful/optimization/core/OptimizationClient.kt#OptimizationClient; core-sdk#CoreBase.ts#resolveOptimizedEntry +- `selectedOptimizations` argument semantics: passing `null` **omits the arg to the bridge**, so the + bridge resolves against the SDK's current selection state; passing an explicit `List` snapshot + resolves against exactly that (used by locked entries and by callers driving their own optimization + state via `OptimizedEntryView.setEntry(entry, selectedOptimizations)`). source: extern:resolveOptimizedEntry omits selectedOptimizations arg when null — packages/android/ContentfulOptimization/src/main/kotlin/com/contentful/optimization/core/OptimizationClient.kt#OptimizationClient; optimization-js-bridge#index.ts#Bridge +- `OptimizedEntry`/`OptimizedEntryView` lock to the first resolved variant by default: they collect + the `selectedOptimizations` `StateFlow` directly (not a Compose snapshot, so the rapid emission burst + from `identify()` is not coalesced), and on the first non-null value when not live and not yet locked + they snapshot it into `lockedOptimizations` and resolve against the locked value thereafter. + `shouldLiveUpdate` precedence: preview panel open (`isPreviewPanelOpen.value`) forces live + (overriding an explicit `liveUpdates = false`) → per-entry `liveUpdates` → global `liveUpdates` + default → locked. When the panel closes, `resolvePreviewCloseLockState` snapshots the current + selections so applied overrides persist (unless the entry is live or has a caller-supplied + selected-optimizations override). source: extern:OptimizedEntry collects selectedOptimizations, first-variant locking + shouldLiveUpdate precedence — packages/android/ContentfulOptimization/src/main/kotlin/com/contentful/optimization/compose/OptimizedEntry.kt#OptimizedEntry; extern:resolvePreviewCloseLockState — packages/android/ContentfulOptimization/src/main/kotlin/com/contentful/optimization/core/OptimizedEntryPreviewCloseLock.kt#resolvePreviewCloseLockState; kb:shared/concepts.md +- Single-locale CDA contract and include-depth requirement are shared and app-owned: fetch with one + concrete locale and `include` deep enough to resolve `nt_experiences` → `nt_experience` → + `nt_variants`/`nt_audience` (`nt_config` is a JSON field, not a link). All-locale payloads fall back + to baseline. The Android SDK does not fetch app CDA entries itself (except the preview panel's own + definition fetch); the app passes fetched entries to `OptimizedEntry`/`OptimizedEntryView`/ + `resolveOptimizedEntry`. source: core-sdk#resolvers/OptimizedEntryResolver.ts#OptimizedEntryResolver; api-schemas#contentful/OptimizedEntry.ts#OptimizedEntryFields; concept:entry-personalization-and-variant-resolution; kb:shared/concepts.md +- Merge tags resolve separately from variant swap: `getMergeTagValue(mergeTagEntry)` (suspend) passes + the expanded inline `nt_mergetag` entry to the bridge, which reads the selector against the current + profile and returns the resolved string or `null` (fallback, also returned pre-init). The app owns + extracting the embedded entry from Rich Text before calling it. source: extern:getMergeTagValue passes the mergetag entry to the bridge, returns null on fallback/pre-init — packages/android/ContentfulOptimization/src/main/kotlin/com/contentful/optimization/core/OptimizationClient.kt#OptimizationClient; core-sdk#resolvers/MergeTagValueResolver.ts#resolve; kb:shared/concepts.md + +## Identifier ownership + +| Identifier | Owner | Notes | source | +| ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `com.contentful.optimization.consent`, `…persistenceConsent` (`SharedPreferences` file `com.contentful.optimization`, key prefix `com.contentful.optimization.`) | SDK | Consent state, persisted on every state change. Stored as the strings `"accepted"`/`"denied"` (not booleans) via `ConsentStoragePolicy`; a missing value decodes to `null` (undecided). | extern:SharedPreferencesStore file/keyPrefix, ConsentStoragePolicy encodes accepted/denied — packages/android/ContentfulOptimization/src/main/kotlin/com/contentful/optimization/storage/SharedPreferencesStore.kt#SharedPreferencesStore; extern:packages/android/ContentfulOptimization/src/main/kotlin/com/contentful/optimization/core/StatefulPolicy.kt#ConsentStoragePolicy | +| `com.contentful.optimization.profile`, `…changes`, `…selectedOptimizations`, `…anonymousId` (`SharedPreferences`) | SDK | Profile-continuity cache; written only while `persistenceConsent == true`, cleared when it becomes `false`. `anonymousId` is set to `profile.id`. | extern:SharedPreferencesStore profileContinuityKeys written only when persistenceConsent true — packages/android/ContentfulOptimization/src/main/kotlin/com/contentful/optimization/storage/SharedPreferencesStore.kt#SharedPreferencesStore | +| anonymous/profile id (in JS core) | SDK | The bridge's `getAnonymousId` returns the stored id only when `signals.persistenceConsent.value === true`, else `undefined`; the id continuity is the same cross-suite `profile` the other SDKs use. | optimization-js-bridge#index.ts#initialize; concept:profile-synchronization-between-client-and-server | +| `nt_experience`, `nt_audience` (content types); `nt_experiences`, `nt_variants`, `nt_audience`, `nt_config`, `nt_experience_id`, `nt_mergetag_id`, `nt_fallback` (fields) | SDK | SDK-fixed Optimization content-model identifiers the resolver keys off — not reader-chosen; shared with the whole suite. | api-schemas#contentful/OptimizedEntry.ts#OptimizedEntrySkeleton; api-schemas#contentful/OptimizationEntry.ts#OptimizationEntrySkeleton; api-schemas#contentful/AudienceEntry.ts#AudienceEntrySkeleton | +| Contentful CDA locale | reader | App chooses the CDA locale and passes it to its own CDA fetch feeding entry resolution; distinct from the SDK Experience/event `locale`. | concept:locale-handling-in-the-optimization-sdk-suite | +| consent record / CMP choice | reader | App records the user's decision and calls `consent(...)`; the SDK reflects only what is passed. | concept:consent-management-in-the-optimization-sdk-suite; core-sdk#CoreStateful.ts#consent | + +- Android persists to a private `SharedPreferences` file `com.contentful.optimization` with keys + prefixed `com.contentful.optimization.`, not the browser `ctfl-opt-*` cookies the web SDKs use; + it shares the key-value continuity model with iOS (`UserDefaults`) and React Native (AsyncStorage + `__…__` keys). There is no built-in cross-platform id handoff. source: extern:SharedPreferencesStore file/keyPrefix model — packages/android/ContentfulOptimization/src/main/kotlin/com/contentful/optimization/storage/SharedPreferencesStore.kt#SharedPreferencesStore; kb:native/ios.md; kb:native/react-native.md + +## Events & tracking + +- `EventEmissionResult` (`{ accepted, data? }`) is returned by the suspend emitters `identify`, `page`, + `screen`, `track`, `trackCurrentScreen`, and `trackView`; `flush` and `trackClick` return `Unit`. + source: extern:EventEmissionResult + which methods return it — packages/android/ContentfulOptimization/src/main/kotlin/com/contentful/optimization/core/OptimizationClient.kt#EventEmissionResult +- Screen events: Compose `ScreenTrackingEffect(screenName)` fires from a `LaunchedEffect` keyed on + `screenName` and `state.consent` — so on first composition, screen-name change, and consent change — + calling `trackCurrentScreen(name)`. Views `ScreenTracker.trackScreen(name)` sets the current name, + observes `client.state`, and re-calls `trackCurrentScreen` on each state emission. `trackCurrentScreen` + dedupes in the bridge by `routeKey` (defaulting to `name`) through an `AcceptedCurrentStateTracker`, + so a repeat of the same current screen is skipped and a blocked attempt is retried once consent + allows. Plain `screen(name)` calls core `screen()` with no dedupe. source: extern:ScreenTrackingEffect keyed on screenName+consent → trackCurrentScreen — packages/android/ContentfulOptimization/src/main/kotlin/com/contentful/optimization/compose/ScreenTrackingEffect.kt#ScreenTrackingEffect; extern:ScreenTracker re-tracks on state change — packages/android/ContentfulOptimization/src/main/kotlin/com/contentful/optimization/views/ScreenTracker.kt#ScreenTracker; optimization-js-bridge#index.ts#Bridge; core-sdk#tracking/AcceptedCurrentStateTracker.ts#AcceptedCurrentStateTracker +- Entry view tracking timing (`ViewTrackingController`, shared by both adapters): defaults + `minVisibleRatio = 0.8`, `dwellTimeMs = 2000`, `viewDurationUpdateIntervalMs = 5000` (tunable per + entry). Three-phase cycle: initial `trackView` after accumulated visible time ≥ dwell, periodic + duration updates every interval while visible, and a final duration event when visibility ends (only + if ≥1 event already fired). Gated on `hasConsent("trackView")` (→ core wire type `component`). + source: extern:ViewTrackingController three-phase timing, defaults 2000/0.8/5000, isTrackingAllowed=hasConsent(trackView) — packages/android/ContentfulOptimization/src/main/kotlin/com/contentful/optimization/tracking/ViewTrackingController.kt#ViewTrackingController; core-sdk#consent/ConsentPolicy.ts#hasEventConsent +- Background handling of view cycles: `ViewTrackingController` is a `ProcessLifecycleOwner` + `DefaultLifecycleObserver`. On `onStop` (app backgrounded) it pauses accumulation, emits a final + event if `attempts > 0`, and resets the cycle; on `onStart` it re-evaluates visibility from the last + known geometry and starts a fresh cycle when still visible (so nothing needs to scroll to restart). + source: extern:ViewTrackingController pause/resume on ProcessLifecycleOwner onStop/onStart — packages/android/ContentfulOptimization/src/main/kotlin/com/contentful/optimization/tracking/ViewTrackingController.kt#ViewTrackingController +- Sticky entry-view dedupe lives in the bridge, not Kotlin: `trackView` keys sticky views by + `stickyTrackingKey ?? viewId` and only sends a sticky view once accepted, via + `shouldSendStickyEntryView` / `shouldRememberStickyEntryViewResult`. The Kotlin controller passes a + per-controller `stickyTrackingKey` (a fresh UUID) so a single mounted entry dedupes its own sticky + views. source: optimization-js-bridge#index.ts#Bridge; core-sdk#tracking/EntryViewTracking.ts#shouldSendStickyEntryView; core-sdk#tracking/EntryViewTracking.ts#shouldRememberStickyEntryViewResult +- Entry tap tracking: Compose `Modifier.trackClicks` uses Compose's `clickable {}` (not a raw gesture + like RN, or a `TapGesture` like iOS); Views `OptimizedEntryView` uses `setOnClickListener`. Both emit + wire type `component_click` via `trackClick` when `hasConsent("trackClick")`, then invoke `onTap(entry)` + if provided. source: extern:Modifier.trackClicks uses clickable, gated by hasConsent trackClick — packages/android/ContentfulOptimization/src/main/kotlin/com/contentful/optimization/compose/ClickTrackingModifier.kt#trackClicks; extern:OptimizedEntryView.fireTrackClick via setOnClickListener — packages/android/ContentfulOptimization/src/main/kotlin/com/contentful/optimization/views/OptimizedEntryView.kt#OptimizedEntryView; core-sdk#consent/ConsentPolicy.ts#hasEventConsent +- Flags: `getFlag(name)` is a non-reactive JSON read (blocking on the QuickJS dispatcher, returns + `null` pre-init); `observeFlag(name)` registers an `observeFlag` bridge subscription (deduping per + name) and returns a `StateFlow` (Android idiom; iOS uses a Combine publisher). Subscribing + to a flag observable emits a `component` flag-view event through the core stream when consent and + profile allow, so flag delivery is an analytics exposure. source: extern:getFlag vs observeFlag returning StateFlow — packages/android/ContentfulOptimization/src/main/kotlin/com/contentful/optimization/core/OptimizationClient.kt#OptimizationClient; optimization-js-bridge#index.ts#Bridge +- `eventStream` and `blockedEventStream` are both `MutableSharedFlow(replay = 64, extraBufferCapacity += 64)` exposed as `SharedFlow` — so unlike iOS (passthrough, no replay) **Android replays up to the + last 64 events to a late subscriber**. `eventStream` carries accepted events (fed by the bridge's + `onEventEmitted`); `blockedEventStream` carries consent/allow-list-blocked events and also fires the + `onEventBlocked` config callback. source: extern:eventStream/blockedEventStream are replay-64 SharedFlows — packages/android/ContentfulOptimization/src/main/kotlin/com/contentful/optimization/core/OptimizationClient.kt#OptimizationClient; concept:android-sdk-runtime-and-interaction-mechanics +- Experience-response payload → published state: an accepted Experience call returns + `{ profile, selectedOptimizations, changes }`; the bridge applies it to core signals and pushes + `readBridgeState()` through `__nativeOnStateChange`, which `OptimizationClient.handleStateUpdate` + decodes into the `state`/`selectedOptimizations`/`optimizationPossible`/`experienceRequestState` + StateFlows (and writes continuity to `SharedPreferences` when `persistenceConsent == true`). + source: optimization-js-bridge#index.ts#initialize; core-sdk#state/applyOptimizationDataToSignals.ts#applyOptimizationDataToSignals; extern:handleStateUpdate decodes pushed bridge state into StateFlows — packages/android/ContentfulOptimization/src/main/kotlin/com/contentful/optimization/core/OptimizationClient.kt#OptimizationClient; kb:shared/concepts.md + +## Consent & persistence + +- Two independent axes — `consent` (may personalize + emit events) and `persistenceConsent` (may store + profile continuity); see [`../shared/concepts.md`](../shared/concepts.md#consent--persistence). + Boolean `consent(accept)` sets both; `consent(events, persistence)` sets them independently + (serialized to the core `ConsentInput` object form). `consent(false)` clears event consent and + persistence consent, purges queues, and clears durable continuity while in-memory state stays usable + until reset/teardown; `consent(events = false)` withdraws event consent and purges queues but does + not clear persistence unless `persistence = false` is also passed. source: extern:consent(Boolean) sets both, consent(events,persistence) split — packages/android/ContentfulOptimization/src/main/kotlin/com/contentful/optimization/core/OptimizationClient.kt#OptimizationClient; core-sdk#consent/Consent.ts#ConsentInput; core-sdk#CoreStateful.ts#consent +- Native default pre-consent allow-list = `['identify', 'screen']` + (`DEFAULT_NATIVE_ALLOWED_EVENT_TYPES` in the bridge, used when `allowedEventTypes` is unset), + overriding Core's fail-closed `DEFAULT_ALLOWED_EVENT_TYPES = []`. Before event consent, `identify` + and `screen` emit; entry views (`component`), taps (`component_click`), `page`, and custom `track` + are blocked. Pass `allowedEventTypes = emptyList()` for strict opt-in, or a narrow custom list. + source: optimization-js-bridge#index.ts#DEFAULT_NATIVE_ALLOWED_EVENT_TYPES; core-sdk#events/EventType.ts#DEFAULT_ALLOWED_EVENT_TYPES; core-sdk#consent/ConsentPolicy.ts#hasEventConsent +- `reset()` clears profile continuity (bridge `reset()` — which also clears overrides, the anonymous + id, the current-screen dedupe tracker, and sticky-view keys — plus `store.clearProfileContinuity()`) + but **preserves consent state**. `reset()` no-ops before initialization. source: extern:reset() clears continuity via bridge + store, keeps consent, no-op pre-init — packages/android/ContentfulOptimization/src/main/kotlin/com/contentful/optimization/core/OptimizationClient.kt#OptimizationClient; optimization-js-bridge#index.ts#Bridge +- `SharedPreferencesStore` is a write-through in-memory cache: getters read only the in-memory `cache` + map, and setters both update the cache and write to `SharedPreferences` using **`commit()`** + (synchronous, not `apply()`), so a continuity write settles to disk before the corresponding client + state is published — E2E tests can wait for SDK-derived state instead of storage-timing delays. + Persisted disk values are read **only at startup** (`loadConsentState`/`loadProfileContinuity`); + runtime SDK state comes from bridge state pushes, never from re-reading disk. Consent stores always; + continuity stores only when `persistenceConsent == true` and is cleared when `false`. Queues are + never persisted (in-memory). source: extern:SharedPreferencesStore write-through cache, commit() writes, disk read only at load — packages/android/ContentfulOptimization/src/main/kotlin/com/contentful/optimization/storage/SharedPreferencesStore.kt#SharedPreferencesStore +- Persisted persistence-consent falls back to persisted event consent when unset + (`ConsentStoragePolicy.resolvePersistedPersistenceConsent`), mirroring the core storage-decode + behavior. source: extern:resolvePersistedPersistenceConsent falls back to consent — packages/android/ContentfulOptimization/src/main/kotlin/com/contentful/optimization/core/StatefulPolicy.kt#ConsentStoragePolicy; core-sdk#consent/ConsentStorage.ts#resolvePersistedPersistenceConsent + +## Version / runtime quirks + +- Beta: the API, setup flow, and bridge contract are subject to change. source: extern:beta status — packages/android/README.md +- QuickJS bridge: `QuickJsContextManager` creates **one `QuickJs` instance per `OptimizationClient` + lifetime** on a dedicated single-thread dispatcher, defines the `__native` object bindings, evaluates + a bootstrap script installing the polyfill globals, evaluates the UMD bundle + (`optimization-android-bridge.umd.js`) read from the AAR `assets`, verifies `typeof __bridge === +"object"` (else closes and throws `OptimizationError.BridgeError`), registers the push-back native + globals, and calls `__bridge.initialize(configJSON)`. A missing/unreadable bundle asset throws + `OptimizationError.ResourceLoadError`. source: extern:QuickJsContextManager one QuickJs per client, loads UMD from assets, validates \_\_bridge — packages/android/ContentfulOptimization/src/main/kotlin/com/contentful/optimization/bridge/QuickJsContextManager.kt#QuickJsContextManager +- Native polyfills (`NativeImpl`) register `__native.log`/`setTimeout`/`clearTimeout`/`randomUUID`/ + `fetch` and a bootstrap that installs `__nativeLog`, `__nativeSetTimeout`, `__nativeClearTimeout`, + `__nativeRandomUUID`, `__nativeFetch`. `__nativeFetch` uses **OkHttp** (async `enqueue`) and calls + back into JS via `__fetchComplete(...)`; timers are coroutine `delay` jobs that fire `__timerFired`. + source: extern:NativeImpl registers native fetch/timers/uuid/log, fetch uses OkHttp — packages/android/ContentfulOptimization/src/main/kotlin/com/contentful/optimization/polyfills/NativePolyfills.kt#NativeImpl; optimization-js-bridge#index.ts#NativeGlobal +- Push-back callback routing quirk: the bridge's `__nativeOnStateChange`/`__nativeOnEventEmitted`/ + `__nativeOnOverridesChanged`/`__nativeOnEventBlocked`/`__nativeOnFlagValueChanged`/`__nativeOnQueueEvent` + globals, and per-call async success/error callbacks, are implemented on the JS side as calls to + `__native.log("____", json)`; the Kotlin `onLog` handler dispatches by the pseudo-level tag. + These handlers run **synchronously on the QuickJS thread while the originating call is still in + flight**, which is what guarantees a synchronous `callSync` has landed its StateFlow mutations before + returning (`StateFlow`/`SharedFlow`/`SharedPreferences` are safe to touch from any thread). + source: extern:callback routing through \_\_native.log pseudo-levels, handled synchronously on the QuickJS thread — packages/android/ContentfulOptimization/src/main/kotlin/com/contentful/optimization/bridge/QuickJsContextManager.kt#QuickJsContextManager +- Threading model (diverges from iOS's `@MainActor`): all bridge access is serialized on a dedicated + single-thread `quickJsDispatcher`. Suspend calls hop from `Dispatchers.Main` onto that dispatcher; + synchronous calls block on it via `runBlocking(bridge.quickJsDispatcher)`. There is no main-thread + confinement of the JS engine. source: extern:quickJsDispatcher single-thread, sync calls runBlocking on it — packages/android/ContentfulOptimization/src/main/kotlin/com/contentful/optimization/bridge/QuickJsContextManager.kt#QuickJsContextManager; concept:android-sdk-runtime-and-interaction-mechanics +- `setLocale(locale)` updates the SDK Experience-API and event locale (and republishes `client.locale`) + but does **not** refetch Contentful entries or refresh profile state; the app must re-fetch CDA and + re-render. It `requireInitialized()` (throws `NotInitialized` pre-init) and throws + `OptimizationError.ConfigError` when the bridge rejects the locale. source: extern:setLocale updates SDK locale only, throws pre-init/invalid — packages/android/ContentfulOptimization/src/main/kotlin/com/contentful/optimization/core/OptimizationClient.kt#OptimizationClient; concept:locale-handling-in-the-optimization-sdk-suite +- Live-updates / preview precedence: an open preview panel forces live updates in both `OptimizedEntry` + and `OptimizedEntryView` (overriding an explicit `liveUpdates = false`); otherwise per-entry + `liveUpdates` > global default > locked. source: extern:OptimizedEntry preview-open forces live — packages/android/ContentfulOptimization/src/main/kotlin/com/contentful/optimization/compose/OptimizedEntry.kt#OptimizedEntry; concept:android-sdk-runtime-and-interaction-mechanics +- Offline / lifecycle delivery: `NetworkMonitor` (`ConnectivityManager.registerNetworkCallback`) calls + `setOnline(...)` on connectivity change and `flush()` on reconnect; `AppLifecycleHandler` + (`ProcessLifecycleOwner` observer) calls `flush()` on `onStop` (app moving to background) for a + best-effort drain. Both are created after bridge init. Queues are in-memory only (no durable outbox), + capped at 100 offline Experience events by default (`OFFLINE_QUEUE_MAX_EVENTS`, tunable via + `QueuePolicy.offlineMaxEvents`); they do not survive process death. source: extern:NetworkMonitor + AppLifecycleHandler background flush — packages/android/ContentfulOptimization/src/main/kotlin/com/contentful/optimization/handlers/NetworkMonitor.kt#NetworkMonitor; extern:packages/android/ContentfulOptimization/src/main/kotlin/com/contentful/optimization/handlers/AppLifecycleHandler.kt#AppLifecycleHandler; core-sdk#CoreStateful.ts#QueuePolicy +- Android emulator localhost: the SDK has **no** localhost rewrite (contrast the React Native SDK, + whose reference app rewrites `localhost`→`10.0.2.2` at runtime). The Android reference app instead + hardcodes the emulator's stable host-loopback alias `http://10.0.2.2:8000` as its base host, avoiding + the non-persistent `adb reverse` forward that `localhost` would require inside the emulator (iOS + Simulator reaches host `localhost` directly). source: impl:android-sdk#shared/src/main/kotlin/com/contentful/optimization/shared/AppConfig.kt; kb:native/react-native.md; kb:native/ios.md +- Preview panel: `PreviewPanelConfig(enabled, contentfulClient?)`. Compose mounts it via + `OptimizationRoot(previewPanel = …)`, which wraps content in `PreviewPanelOverlay` (a FAB + + `ModalBottomSheet`). Views calls `OptimizationManager.attachPreviewPanel(activity)`, which adds an + in-activity `ComposeView` overlay (same FAB + sheet) to the activity's decor view via + `PreviewPanelActivity.addFloatingButton` — the non-exported `PreviewPanelActivity` is retained for + backward compatibility but is no longer the runtime path (keeping the panel inside the host activity + so UiAutomator can resolve its nodes). Opening/closing toggles `client.isPreviewPanelOpen` and the + bridge `previewPanelOpen` signal, and (with a `contentfulClient`) fetches `nt_audience`/`nt_experience` + definitions in 100-entry pages via `fetchAllEntries` and calls `loadDefinitions` so the bridge bakes a + preview model. source: extern:PreviewPanelActivity.addFloatingButton adds an in-activity ComposeView overlay, onResume/onPause toggle preview open — packages/android/ContentfulOptimization/src/main/kotlin/com/contentful/optimization/preview/PreviewPanelActivity.kt#PreviewPanelActivity; extern:fetchAllEntries pages 100 at a time — packages/android/ContentfulOptimization/src/main/kotlin/com/contentful/optimization/preview/PreviewContentfulClient.kt#fetchAllEntries; optimization-js-bridge#index.ts#Bridge + +## Failure & fallback behavior + +- Baseline fallback (no matching selection / unresolved links / all-locale payload): resolution returns + the baseline entry and the UI does not break; denied/undecided consent surfaces as "no selections ⇒ + baseline", not a blocked resolve. source: core-sdk#resolvers/OptimizedEntryResolver.ts#OptimizedEntryResolver; concept:entry-personalization-and-variant-resolution; kb:shared/concepts.md +- `OptimizationError` is a sealed class with cases `NotInitialized`, `BridgeError(msg)`, + `ResourceLoadError(msg)` (UMD bundle asset missing/unreadable), and `ConfigError(msg)` + (locale/serialization failures). source: extern:OptimizationError sealed class cases — packages/android/ContentfulOptimization/src/main/kotlin/com/contentful/optimization/core/OptimizationError.kt#OptimizationError +- Before initialization: suspend event methods and `setLocale`/`observeFlag` throw + `OptimizationError.NotInitialized` (via `requireInitialized`); `consent`/`reset`/`setOnline` and the + preview-override methods no-op (guarded by `bridgeCallSyncWhenInitialized` / an `isInitialized` + check), `resolveOptimizedEntry` returns the baseline passthrough, and `getFlag`/`getMergeTagValue`/ + `getProfile` return `null`; `hasConsent` returns `false`. source: extern:pre-init suspend methods throw NotInitialized while sync APIs no-op or return baseline/null — packages/android/ContentfulOptimization/src/main/kotlin/com/contentful/optimization/core/OptimizationClient.kt#OptimizationClient +- QuickJS exceptions are routed, not crashed: `callSync` catches the exception, logs it via + `onLog("exception", …)`, and returns `null`; `callAsync` catches it and fails the callback with + `OptimizationError.BridgeError`. The bridge itself returns/reports `SDK not initialized. Call +initialize() first.` when its `CoreStateful` instance is absent. source: extern:callSync/callAsync capture QuickJS exceptions and route to log/BridgeError — packages/android/ContentfulOptimization/src/main/kotlin/com/contentful/optimization/bridge/QuickJsContextManager.kt#QuickJsContextManager; optimization-js-bridge#index.ts#getCurrentInstance +- `SharedPreferences` failures degrade quietly: unparseable stored values are skipped at load, and a + failed `commit()` logs a warning while the SDK continues from in-memory / bridge state. source: extern:SharedPreferencesStore skips unparseable values and warns on failed commit — packages/android/ContentfulOptimization/src/main/kotlin/com/contentful/optimization/storage/SharedPreferencesStore.kt#SharedPreferencesStore +- Preview panel without a `contentfulClient` (`PreviewPanelConfig(enabled = true)` / omitted client) + still opens, but **degraded, not disabled**: `PreviewViewModel.loadDefinitions()` early-returns on its + `contentfulClient ?: return` (no CDA fetch, `client.loadDefinitions` never called), so the bridge + keeps `audienceDefinitions`/`experienceDefinitions` `null` and `computePreviewModel` returns empty + audience/experience name maps. Consequences: the "Audiences & Experiences" section renders "No + audience data" (empty `audiencesWithExperiences` ⇒ no rows ⇒ no audience-qualify / variant-select + controls for _setting_ overrides); Profile/Debug sections and the reset-to-actual footer still render + from bridge state; any pre-existing overrides still list, but their summaries fall back to raw + audience/experience ids (`audienceNameMap[id] ?: id` / `experienceNameMap[id] ?: id`). source: extern:PreviewViewModel.loadDefinitions guards on contentfulClient producing an empty audience section and raw-id override summaries — packages/android/ContentfulOptimization/src/main/kotlin/com/contentful/optimization/preview/PreviewViewModel.kt#PreviewViewModel; extern:PreviewPanelContent renders "No audience data" and nameMap[id] ?: id fallbacks — packages/android/ContentfulOptimization/src/main/kotlin/com/contentful/optimization/preview/PreviewPanelContent.kt#PreviewPanelContent; optimization-js-bridge#previewStateHelpers.ts#computePreviewModel From e85f35e48fb98add9ba6559c1500fe4b338bbdd4 Mon Sep 17 00:00:00 2001 From: Tim Beyer Date: Mon, 20 Jul 2026 16:57:53 +0200 Subject: [PATCH 2/6] feat(docs): add Android Compose and Views guide blueprints Two per-UI blueprints for the one Android SDK, parallel to the iOS pair. Both omit the personalization explainer's managed-fetch clause (no fetch-by-ID) and prove "init + one accepted screen event" rather than an entry render. They encode the Android divergences from iOS (suspend init, StateFlow, replay-64 event streams) and, for Views, drive a full restructure: split the screen/custom-events section, move Identity into Core, and require the Application-diff plus AndroidManifest registration in the quick start. Passes pnpm guides:check. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../authoring/blueprints/android-compose.md | 91 +++++++++++++++++++ .../authoring/blueprints/android-views.md | 91 +++++++++++++++++++ 2 files changed, 182 insertions(+) create mode 100644 documentation/authoring/blueprints/android-compose.md create mode 100644 documentation/authoring/blueprints/android-views.md diff --git a/documentation/authoring/blueprints/android-compose.md b/documentation/authoring/blueprints/android-compose.md new file mode 100644 index 00000000..9def363c --- /dev/null +++ b/documentation/authoring/blueprints/android-compose.md @@ -0,0 +1,91 @@ +--- +sdk: android-compose +archetype: integration +kb: ../../internal/sdk-knowledge/native/android.md +guide: ../../guides/integrating-the-optimization-android-sdk-in-a-compose-app.md +--- + +# Android Jetpack Compose guide blueprint + +## Quick-start contract + +- **Outcome:** The SDK initializes inside a Compose app and one accepted screen event is emitted. +- **Verification:** With `logLevel = debug`, run on a device/emulator and find the accepted screen + event in logcat by a named tag/token; the Custom events section later adds a programmatic + `eventStream` observer. +- **Reader shape:** A Compose `Activity` whose `setContent` wraps the app UI. +- **Required artifacts:** Maven Central dependency; `OptimizationRoot` diff around the app root; one + screen with `ScreenTrackingEffect`; a `logLevel` diagnostic; verification. +- **Deliberate simplifications:** Consent starts granted; entry rendering is deferred to Core because + the app must supply the Contentful fetch. +- **Explainer switches:** profile point = include; managed-fetch clause = omit. +- **Fact sources:** [setup](../../internal/sdk-knowledge/native/android.md#setup--factory), + [events](../../internal/sdk-knowledge/native/android.md#events--tracking), + [package](../../internal/sdk-knowledge/native/android.md#package--entry-points). + +## Milestone contract + +- **Milestone 1:** The SDK initialized and reporting one screen event (the quick start), plus entries + resolving through `OptimizedEntry` once the app passes fetched Contentful entries (the Core entry + sections). +- **Milestone 2:** Policy-dependent and optional layers — consent handoff, interaction tracking, + identity, Custom Flags, live updates, preview, strict event policy, offline delivery, and caching. +- **Boundary:** Mandatory provider wiring plus the first accepted event and entry render versus + opt-in application policy and capabilities. +- **Fact sources:** [runtime](../../internal/sdk-knowledge/native/android.md#version--runtime-quirks), + [render](../../internal/sdk-knowledge/native/android.md#render--entry-resolution). + +## Section map + +| Section | Category | Purpose | Must teach or show | Fact sources | +| ------------------------------------------------ | ------------------------------ | --------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| Install and initialize `OptimizationRoot` | Required for first integration | Establish the SDK-owned client and the Compose provider tree. | Maven Central dependency; `OptimizationRoot` diff; `LocalOptimizationClient` access; loading gate; suspend init and Flow surfaces. | [setup](../../internal/sdk-knowledge/native/android.md#setup--factory), [package](../../internal/sdk-knowledge/native/android.md#package--entry-points) | +| Consent and privacy-policy handoff | Common but policy-dependent | Replace the quick-start default-on shortcut and explain the two axes. | Two-axis example; boolean versus split consent; pre-consent allow-list; app-owned policy. | [consent](../../internal/sdk-knowledge/native/android.md#consent--persistence) | +| Contentful entry fetching and locale shape | Required for first integration | Establish the app-owned single-locale fetch that feeds resolution. | App owns the CDA fetch (no managed path); single-locale and include depth; SDK locale versus CDA locale. | [render](../../internal/sdk-knowledge/native/android.md#render--entry-resolution), [identifiers](../../internal/sdk-knowledge/native/android.md#identifier-ownership) | +| Entry resolution and fallback rendering | Required for first integration | Complete the entry half of Milestone 1 through `OptimizedEntry`. | Render-lambda example; direct suspend `resolveOptimizedEntry` alternative; baseline fallback; loading treatment. | [render](../../internal/sdk-knowledge/native/android.md#render--entry-resolution), [fallback](../../internal/sdk-knowledge/native/android.md#failure--fallback-behavior) | +| Screen and navigation tracking | Required for first integration | Deepen the quick-start screen proof across Compose navigation. | `ScreenTrackingEffect` placement; `trackCurrentScreen` for dynamic names and route keys; dedupe; one-path warning. | [events](../../internal/sdk-knowledge/native/android.md#events--tracking) | +| Entry interaction tracking | Common but policy-dependent | Explain viewport-based views and taps and their opt-outs. | `OptimizationLazyColumn`; thresholds; tap versus app click; `trackViews`/`trackTaps` opt-outs; `onTap` baseline note. | [events](../../internal/sdk-knowledge/native/android.md#events--tracking) | +| Identity, profile continuity, and reset | Common but policy-dependent | Integrate login/logout and profile continuity. | Identify/reset example; `SharedPreferences` continuity; reset preserves consent. | [consent](../../internal/sdk-knowledge/native/android.md#consent--persistence), [identifiers](../../internal/sdk-knowledge/native/android.md#identifier-ownership) | +| Custom events and analytics diagnostics | Optional | Cover business events and the debug/forwarding seam. | `track` example; `SharedFlow` `eventStream` replay-buffer semantics; `blockedEventStream`; forwarding link. | [events](../../internal/sdk-knowledge/native/android.md#events--tracking) | +| Custom Flags and MergeTag rendering | Optional | Cover content-model-dependent reads. | `getFlag` versus `observeFlag` `StateFlow`; `getMergeTagValue`; flag-view exposure governance. | [events](../../internal/sdk-knowledge/native/android.md#events--tracking), [render](../../internal/sdk-knowledge/native/android.md#render--entry-resolution) | +| Live updates | Optional | Allow mounted entries to re-resolve. | Root and per-entry scopes; locked-by-default explanation; preview forces live; panel-close snapshot. | [render](../../internal/sdk-knowledge/native/android.md#render--entry-resolution), [runtime](../../internal/sdk-knowledge/native/android.md#version--runtime-quirks) | +| Preview panel | Optional | Add the debug/authoring surface. | Debug gating; `PreviewPanelConfig`; `PreviewContentfulClient` for names; overlay in the Compose tree. | [runtime](../../internal/sdk-knowledge/native/android.md#version--runtime-quirks) | +| Strict event policy and queue controls | Advanced or production-only | Expose production privacy and delivery posture. | `allowedEventTypes = emptyList()`; `onEventBlocked`/`blockedEventStream` proof; queue policy; blocked not replayed. | [consent](../../internal/sdk-knowledge/native/android.md#consent--persistence), [setup](../../internal/sdk-knowledge/native/android.md#setup--factory) | +| Offline delivery and lifecycle flushing | Advanced or production-only | Explain constrained-network delivery. | Reachability and background flush; in-memory queue plus 100-event cap; no durable outbox; deterministic controls. | [runtime](../../internal/sdk-knowledge/native/android.md#version--runtime-quirks), [events](../../internal/sdk-knowledge/native/android.md#events--tracking) | +| Content caching and hybrid continuity boundaries | Advanced or production-only | Bound app-owned caching against SDK persistence. | App owns CDA caching and locale-aware keys; native has no server/browser continuity handoff. | [identifiers](../../internal/sdk-knowledge/native/android.md#identifier-ownership), [runtime](../../internal/sdk-knowledge/native/android.md#version--runtime-quirks) | + +## SDK-specific authoring overrides + +- Omit the personalization-explainer managed-fetch clause: the Android SDK has no fetch-by-ID path, + so the app always owns the Contentful fetch. Facts: + [render](../../internal/sdk-knowledge/native/android.md#render--entry-resolution). +- Prove "init plus one accepted screen event" rather than an entry render in the quick start: Android + has no managed fetch, so an entry-render proof would force an app-specific Kotlin CDA fetch into the + quick start. Entry rendering moves to Core. Facts: + [setup](../../internal/sdk-knowledge/native/android.md#setup--factory), + [events](../../internal/sdk-knowledge/native/android.md#events--tracking). +- Keep the dependency/build step inline in the quick start: add the Maven Central dependency and run a + Gradle build; there is no separate native prebuild. Facts: + [package](../../internal/sdk-knowledge/native/android.md#package--entry-points). +- Use screen/tap/viewport vocabulary throughout; never import hover vocabulary from web, and do not + put `page` in pre-consent blocked-event lists unless the guide first teaches `client.page`. Facts: + [events](../../internal/sdk-knowledge/native/android.md#events--tracking). +- Note that `eventStream`/`blockedEventStream` are replay-buffered `SharedFlow`s (late subscribers + receive recent events), unlike the iOS passthrough streams. Facts: + [events](../../internal/sdk-knowledge/native/android.md#events--tracking). + +## Troubleshooting scope + +| Reader symptom | Why it belongs | Fact sources | +| ------------------------------------- | ----------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `No OptimizationClient provided` | Calling SDK helpers outside `OptimizationRoot`. | [setup](../../internal/sdk-knowledge/native/android.md#setup--factory) | +| Entries always render baseline | Common payload, consent, and locale symptoms for entry rendering. | [render](../../internal/sdk-knowledge/native/android.md#render--entry-resolution), [consent](../../internal/sdk-knowledge/native/android.md#consent--persistence) | +| Entry view or tap events are missing | Makes viewport thresholds, list tracking, and consent actionable. | [events](../../internal/sdk-knowledge/native/android.md#events--tracking) | +| Screen events duplicate or go missing | Effect placement and route-key dedupe. | [events](../../internal/sdk-knowledge/native/android.md#events--tracking) | +| Preview panel shows identifiers only | Missing preview Contentful client. | [runtime](../../internal/sdk-knowledge/native/android.md#version--runtime-quirks) | + +## Link roles + +- [The Android Views integration guide](../../guides/integrating-the-optimization-android-sdk-in-a-views-app.md). +- [The analytics-forwarding supplemental guide](../../guides/forwarding-optimization-sdk-context-to-analytics-and-tag-management-tools.md). +- [The maintained Android reference implementation](../../../implementations/android-sdk/README.md). diff --git a/documentation/authoring/blueprints/android-views.md b/documentation/authoring/blueprints/android-views.md new file mode 100644 index 00000000..0807028e --- /dev/null +++ b/documentation/authoring/blueprints/android-views.md @@ -0,0 +1,91 @@ +--- +sdk: android-views +archetype: integration +kb: ../../internal/sdk-knowledge/native/android.md +guide: ../../guides/integrating-the-optimization-android-sdk-in-a-views-app.md +--- + +# Android XML Views guide blueprint + +## Quick-start contract + +- **Outcome:** The SDK initializes from the application and one accepted screen event is emitted from + a visible activity. +- **Verification:** With `logLevel = debug`, run on a device/emulator and find the accepted screen + event in logcat by a named tag/token; a diagnostic explains a blocked outcome. +- **Reader shape:** An `Application` subclass and one `Activity`. +- **Required artifacts:** Maven Central dependency; an `Application` diff that initializes one + manager plus the `AndroidManifest.xml` `android:name` registration; an activity that tracks the + current screen in `onResume`; a `logLevel` diagnostic; verification. +- **Deliberate simplifications:** Consent starts granted; entry rendering is deferred to Core because + the app must supply the Contentful fetch. +- **Explainer switches:** profile point = include; managed-fetch clause = omit. +- **Fact sources:** [setup](../../internal/sdk-knowledge/native/android.md#setup--factory), + [events](../../internal/sdk-knowledge/native/android.md#events--tracking). + +## Milestone contract + +- **Milestone 1:** The SDK initialized from the application and one accepted screen event (the quick + start), plus entries resolving through `OptimizedEntryView`/`resolveOptimizedEntry` once the app + passes fetched Contentful entries (the Core entry section). +- **Milestone 2:** Policy-dependent and optional layers — consent handoff, interaction tracking, + identity, Custom Flags, live updates, preview, and offline delivery. +- **Boundary:** Mandatory application/activity wiring plus the first accepted event and entry render + versus opt-in application policy and capabilities. +- **Fact sources:** [setup](../../internal/sdk-knowledge/native/android.md#setup--factory), + [runtime](../../internal/sdk-knowledge/native/android.md#version--runtime-quirks). + +## Section map + +| Section | Category | Purpose | Must teach or show | Fact sources | +| ------------------------------------------------------------ | ------------------------------ | --------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| SDK installation and process-wide client | Required for first integration | Establish the package and the app-scoped `OptimizationManager`. | Maven Central dependency; `Application` init diff plus manifest registration; readiness await; required versus defaulted config keys. | [setup](../../internal/sdk-knowledge/native/android.md#setup--factory), [package](../../internal/sdk-knowledge/native/android.md#package--entry-points) | +| Consent and privacy-policy handoff | Common but policy-dependent | Replace the quick-start default-on shortcut and explain the two axes. | Two-axis example; boolean versus split consent; pre-consent allow-list; app-owned policy. | [consent](../../internal/sdk-knowledge/native/android.md#consent--persistence) | +| Contentful fetching and entry resolution | Required for first integration | Establish the app-owned fetch and the view-based resolver. | App owns the CDA fetch (no managed path); single-locale and include depth; `OptimizedEntryView` renderer; baseline fallback. | [render](../../internal/sdk-knowledge/native/android.md#render--entry-resolution), [fallback](../../internal/sdk-knowledge/native/android.md#failure--fallback-behavior) | +| Screen and navigation tracking | Required for first integration | Deepen the quick-start screen proof across activity navigation. | `ScreenTracker.trackScreen` from `onResume`; route-key dedupe; `screen` versus `trackCurrentScreen` choice. | [events](../../internal/sdk-knowledge/native/android.md#events--tracking) | +| Entry interaction tracking | Common but policy-dependent | Explain view- and tap-tracking and list integration. | `OptimizedEntryView` view/tap tracking; `TrackingRecyclerView` for lists; opt-outs; duplicate prevention. | [events](../../internal/sdk-knowledge/native/android.md#events--tracking) | +| Identity, profile continuity, and reset | Common but policy-dependent | Integrate login/logout and profile continuity. | Identify/reset example; `SharedPreferences` continuity; reset preserves consent. | [consent](../../internal/sdk-knowledge/native/android.md#consent--persistence), [identifiers](../../internal/sdk-knowledge/native/android.md#identifier-ownership) | +| Custom events and analytics diagnostics | Optional | Cover business events and the debug/forwarding seam. | `track` example; `SharedFlow` `eventStream` replay-buffer semantics; `blockedEventStream`; forwarding link. | [events](../../internal/sdk-knowledge/native/android.md#events--tracking) | +| Custom Flags and MergeTag rendering | Optional | Cover content-model-dependent reads. | `getFlag` versus `observeFlag` `StateFlow`; `getMergeTagValue`; flag-view exposure governance. | [events](../../internal/sdk-knowledge/native/android.md#events--tracking), [render](../../internal/sdk-knowledge/native/android.md#render--entry-resolution) | +| Live updates and locked variants | Optional | Let Views apps choose live or locked content. | Locked snapshot versus live subscription; `null` versus explicit selections; preview forces redraw. | [render](../../internal/sdk-knowledge/native/android.md#render--entry-resolution), [runtime](../../internal/sdk-knowledge/native/android.md#version--runtime-quirks) | +| Preview panel | Optional | Add the Views debug/authoring surface. | Debug gating; `OptimizationManager.attachPreviewPanel`; `PreviewContentfulClient` for names; same client. | [runtime](../../internal/sdk-knowledge/native/android.md#version--runtime-quirks) | +| Offline delivery, queue observability, and app-owned caching | Advanced or production-only | Expose production delivery posture and the app cache boundary. | Default offline path; queue policy and callbacks; in-memory 100-event cap; app-owned content caching. | [runtime](../../internal/sdk-knowledge/native/android.md#version--runtime-quirks), [events](../../internal/sdk-knowledge/native/android.md#events--tracking) | + +## SDK-specific authoring overrides + +- Omit the personalization-explainer managed-fetch clause: the Android SDK has no fetch-by-ID path, + so the app always owns the Contentful fetch. Facts: + [render](../../internal/sdk-knowledge/native/android.md#render--entry-resolution). +- Prove "init plus one accepted screen event" in the quick start, with a distinguishable success + signal and a failure diagnostic — do not use an unfalsifiable "renders baseline or variant" check. + Entry rendering moves to Core because Android has no managed fetch. Facts: + [setup](../../internal/sdk-knowledge/native/android.md#setup--factory), + [events](../../internal/sdk-knowledge/native/android.md#events--tracking). +- Show the `Application` runtime root as a `+`/`-` diff and include the `AndroidManifest.xml` + `android:name` registration inline: without it `onCreate` never runs and the SDK never initializes. + Facts: [setup](../../internal/sdk-knowledge/native/android.md#setup--factory). +- Split screen tracking from custom events into distinct sections with distinct categories (screen is + Required/Core; custom events are Optional); do not bundle them under one section. Facts: + [events](../../internal/sdk-knowledge/native/android.md#events--tracking). +- Use screen/tap/viewport vocabulary throughout; never import hover vocabulary from web, and do not + put `page` in pre-consent blocked-event lists unless the guide first teaches `client.page`. Facts: + [events](../../internal/sdk-knowledge/native/android.md#events--tracking). +- Note that `eventStream`/`blockedEventStream` are replay-buffered `SharedFlow`s (late subscribers + receive recent events), unlike the iOS passthrough streams. Facts: + [events](../../internal/sdk-knowledge/native/android.md#events--tracking). + +## Troubleshooting scope + +| Reader symptom | Why it belongs | Fact sources | +| ----------------------------------------- | ---------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| SDK never initializes / events never emit | Missing manifest `android:name` registration or readiness await. | [setup](../../internal/sdk-knowledge/native/android.md#setup--factory) | +| Entries always render the baseline | Common payload and resolver symptoms. | [render](../../internal/sdk-knowledge/native/android.md#render--entry-resolution), [identifiers](../../internal/sdk-knowledge/native/android.md#identifier-ownership) | +| Tap or view events do not appear | Makes consent, list wiring, and visibility actionable. | [events](../../internal/sdk-knowledge/native/android.md#events--tracking) | +| Screen events appear more than once | Activity lifecycle repeats and route-key dedupe. | [events](../../internal/sdk-knowledge/native/android.md#events--tracking) | +| Preview panel opens but shows identifiers | Missing preview Contentful client. | [runtime](../../internal/sdk-knowledge/native/android.md#version--runtime-quirks) | + +## Link roles + +- [The Jetpack Compose integration guide](../../guides/integrating-the-optimization-android-sdk-in-a-compose-app.md). +- [The analytics-forwarding supplemental guide](../../guides/forwarding-optimization-sdk-context-to-analytics-and-tag-management-tools.md). +- [The maintained Android reference implementation](../../../implementations/android-sdk/README.md). From 14aaf57fb79a9b8facba0957320b1548cf8a2dff Mon Sep 17 00:00:00 2001 From: Tim Beyer Date: Mon, 20 Jul 2026 17:11:48 +0200 Subject: [PATCH 3/6] fix(docs): record Android eventStream emitted event type discriminator Review escalation: the Views guide filtered eventStream on event["type"] == "screen" || "screenViewEvent", but the KB had no fact for the emitted event map's type field. Tracing source showed screenViewEvent is never emitted by core (it exists only as dead/defensive arms in the reference apps); screen views carry exactly type == "screen". Add the fact so the guide's corrected single-value check is grounded. Passes pnpm knowledge:check. Co-Authored-By: Claude Opus 4.8 (1M context) --- documentation/internal/sdk-knowledge/native/android.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/documentation/internal/sdk-knowledge/native/android.md b/documentation/internal/sdk-knowledge/native/android.md index 9ebba2fe..41df49ff 100644 --- a/documentation/internal/sdk-knowledge/native/android.md +++ b/documentation/internal/sdk-knowledge/native/android.md @@ -229,6 +229,15 @@ viewportHeight }` via `LocalScrollContext` that descendant `Modifier.trackViews` last 64 events to a late subscriber**. `eventStream` carries accepted events (fed by the bridge's `onEventEmitted`); `blockedEventStream` carries consent/allow-list-blocked events and also fires the `onEventBlocked` config callback. source: extern:eventStream/blockedEventStream are replay-64 SharedFlows — packages/android/ContentfulOptimization/src/main/kotlin/com/contentful/optimization/core/OptimizationClient.kt#OptimizationClient; concept:android-sdk-runtime-and-interaction-mechanics +- `eventStream` elements are JSON-decoded `OptimizationEventStreamEvent` maps keyed by a `type` string + discriminator (the accepted `InsightsEvent | ExperienceEvent` the bridge forwards via + `__nativeOnEventEmitted`). Screen events carry exactly `type == "screen"` — the single value + `buildScreenView` emits (`ScreenViewEvent` schema `type: z.literal('screen')`) — with the screen name + at the top-level `name` key. **`screenViewEvent` is never emitted by core**: it is not a wire/insights + type, and the reference apps' `type == "screen" || type == "screenViewEvent"` screen filter matches + only through `"screen"`; the `screenViewEvent` arm is dead/defensive (no SDK event uses it). Other + emitted discriminators are the wire types documented above (`identify`/`page`/`track`, `component` + entry views, `component_click`). source: core-sdk#events/OptimizationEventStreamEvent.ts#OptimizationEventStreamEvent; core-sdk#events/EventBuilder.ts#buildScreenView; api-schemas#experience/event/ScreenViewEvent.ts#ScreenViewEvent; optimization-js-bridge#index.ts#initialize; impl:android-sdk#views/src/main/kotlin/com/contentful/optimization/app/views/NavigationTestActivity.kt - Experience-response payload → published state: an accepted Experience call returns `{ profile, selectedOptimizations, changes }`; the bridge applies it to core signals and pushes `readBridgeState()` through `__nativeOnStateChange`, which `OptimizationClient.handleStateUpdate` From b35017447568f0b760c3617cbf44595407e4ad10 Mon Sep 17 00:00:00 2001 From: Tim Beyer Date: Mon, 20 Jul 2026 17:41:00 +0200 Subject: [PATCH 4/6] feat(docs): reconcile Android Compose and Views guides to the pipeline Rewrite both Android integration guides to the current archetype from their new blueprints and the Android knowledge base. Both gain the personalization explainer (managed-fetch clause omitted), two-milestone framing, and a prose "Before you start" replacing the old setup-inventory table. Quick starts now prove "init + one accepted screen event" with performable logcat verification and honest scoping, and describe the Kotlin reactive idiom (StateFlow/SharedFlow, replay-64 event streams) rather than iOS Combine semantics. Views additionally undergoes a full restructure: section reorder to the blueprint map, splitting screen tracking from custom events, moving Identity into Core, and a SceneDelegate-equivalent Application diff plus the AndroidManifest android:name registration inline with its silent-failure mode. Applies the three-role review: fixes the OptimizedEntry onTap baseline-vs-resolved mislabel, glosses selectedOptimizations/optimizationPossible/changes and the ResolvedOptimizedEntry wrapper, drops the untaught page from pre-consent blocked lists, and corrects the screenViewEvent filter (only type == "screen" is emitted). Passes pnpm guides:check and knowledge:check. Co-Authored-By: Claude Opus 4.8 (1M context) --- ...timization-android-sdk-in-a-compose-app.md | 967 +++++++----- ...optimization-android-sdk-in-a-views-app.md | 1384 +++++++++-------- 2 files changed, 1323 insertions(+), 1028 deletions(-) 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 index cfbda478..29fb7e3e 100644 --- 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 @@ -1,24 +1,73 @@ # Integrating the Optimization Android SDK in a Jetpack Compose app -Use this guide when you want to add Optimization, Analytics, screen tracking, entry interaction -tracking, Custom Flags, MergeTag rendering, and preview overrides to a native Android application -built with Jetpack Compose. - -The Compose integration uses `OptimizationRoot`, `OptimizedEntry`, `OptimizationLazyColumn`, and -`ScreenTrackingEffect`. Your application still owns Contentful entry fetching, consent policy, -identity policy, navigation, app-owned caching, and final rendering. Use the Android Views guide -instead when your app is View-based: -[Integrating the Optimization Android SDK in an Android Views app](./integrating-the-optimization-android-sdk-in-a-views-app.md). +Use this guide to add Contentful personalization to a Jetpack Compose app with the Optimization +Android SDK. By the end of the quick start, the SDK is initialized inside your Compose app and emits +one screen event that its consent gate accepts — the event Contentful uses to keep that visitor's +personalization consistent. + +**New to personalization?** Here is the whole idea in five points: + +- In Contentful you author **variants** of an entry and attach them to an **experience** — a rule + that decides which visitors see which variant. +- As the app runs, Contentful's **Experience API** looks at who the visitor is and picks the variant + for each experience. Swapping a fetched entry for its picked variant is called **resolving** the + entry. +- The Experience API also returns a **profile**: the anonymous, per-visitor identity and state used + to keep personalization consistent across requests or app launches. +- Your app hands a Contentful entry to the SDK at the point where that entry becomes output. The SDK + gives back the selected variant, or the original entry when no variant applies—the **baseline + fallback**. +- You render the returned entry with the same application components you already use. + +The Android SDK persists the profile to `SharedPreferences` across app launches when persistence +consent — the visitor's separate permission to store profile state on the device, explained in +[Consent and privacy-policy handoff](#consent-and-privacy-policy-handoff) — allows it. + +That is enough to start. The guide introduces policy and optional capabilities at the point you need +them. + +You will get there in two milestones: + +- **Milestone 1 — the SDK initialized, reporting one screen event, and resolving entries (the quick + start below plus the Core entry sections).** The quick start proves initialization and one accepted + screen event. The [Contentful entry fetching and locale shape](#contentful-entry-fetching-and-locale-shape) + and [Entry resolution and fallback rendering](#entry-resolution-and-fallback-rendering) sections + then add entries resolving through `OptimizedEntry` once your app passes it fetched Contentful + entries. This is complete and shippable on its own. +- **Milestone 2 — the opt-in layers (later).** Consent handoff, interaction tracking, identity, + Custom Flags, live updates, the preview panel, strict event policy, and offline delivery, each + introduced by the section that needs it. + +This guide uses the `com.contentful.java:optimization-android` library. You mount one +`OptimizationRoot` around the Compose tree that uses SDK helpers; it creates and initializes the SDK +client, restores state from `SharedPreferences`, and provides it to the components and effects below +it. Your app still owns its Contentful entry fetching, consent policy, identity policy, navigation, +app-owned caching, and final rendering. If your app is View-based, use +[the Android Views integration guide](./integrating-the-optimization-android-sdk-in-a-views-app.md) +instead. ## Quick start -Use this path when your application policy permits Optimization to start with accepted consent. If -your policy requires an end-user choice first, complete the consent handoff section before sending -an accepted SDK event. - -1. Add the Android SDK from Maven Central to your Android application module. +Most Compose + Contentful apps share one shape: a Compose `Activity` whose `setContent { }` wraps the +app UI. This quick start assumes that shape and proves the smallest result: **the SDK initializes and +emits one screen event that its consent gate accepts** — an "accepted" event is one the SDK's local +consent and allow-list checks let through to send, which is what you can observe on the device; it is +not a confirmation that Contentful received it. Entry rendering needs an app-specific Contentful +fetch, so it moves to [Entry resolution and fallback rendering](#entry-resolution-and-fallback-rendering) +in Core; here you wrap your app UI in `OptimizationRoot` and mark one screen with +`ScreenTrackingEffect`. + +This quick start assumes your application policy permits Optimization to start with accepted consent +and renders no end-user consent UI, so it sets `defaults = StorageDefaults(consent = true)` — the +SDK-owned holder of startup defaults, whose `consent` flag accepts both consent axes at once. If +personalization must wait for a consent decision, keep this structure and add the +[Consent and privacy-policy handoff](#consent-and-privacy-policy-handoff) step before you ship, which +explains the two axes and the split form that sets them separately. + +1. Add the Android SDK from Maven Central to your application module and run a Gradle build. There is + no separate native prebuild step; the SDK's JavaScript runtime ships inside the AAR. - **Copy this:** + **Adapt this to your use case:** ```kotlin repositories { @@ -30,53 +79,75 @@ an accepted SDK event. } ``` -2. Configure the SDK with accepted startup consent, mount `OptimizationRoot`, and emit one screen - event from the first Compose route. +2. Wrap your app UI in `OptimizationRoot`, pass your Optimization client ID, set + `logLevel = OptimizationLogLevel.debug` so the SDK logs its activity, and add `ScreenTrackingEffect` + to one screen you already render. **Adapt this to your use case:** - ```kotlin - import androidx.compose.runtime.Composable - import com.contentful.optimization.compose.OptimizationRoot - import com.contentful.optimization.compose.ScreenTrackingEffect - import com.contentful.optimization.core.OptimizationConfig - import com.contentful.optimization.core.OptimizationLogLevel - import com.contentful.optimization.core.StorageDefaults - - val optimizationConfig = OptimizationConfig( - clientId = "your-optimization-client-id", - environment = "main", - // Use this default only when your app policy permits accepted consent at startup. - defaults = StorageDefaults(consent = true), - logLevel = if (BuildConfig.DEBUG) OptimizationLogLevel.debug else OptimizationLogLevel.error, - ) - - @Composable - fun AppRoot() { - OptimizationRoot( - config = optimizationConfig, - // Own one SDK client for the Compose tree that emits Optimization events. - ) { - HomeScreen() - } - } + ```diff + import android.os.Bundle + import androidx.activity.ComponentActivity + import androidx.activity.compose.setContent + import androidx.compose.runtime.Composable + +import com.contentful.optimization.compose.OptimizationRoot + +import com.contentful.optimization.compose.ScreenTrackingEffect + +import com.contentful.optimization.core.OptimizationConfig + +import com.contentful.optimization.core.OptimizationLogLevel + +import com.contentful.optimization.core.StorageDefaults + + class MainActivity : ComponentActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContent { + - HomeScreen() + + // Wrap the tree that uses SDK helpers; one client stays alive for its lifetime. + + OptimizationRoot( + + config = OptimizationConfig( + + clientId = "", + + // Accepted startup consent; the Consent section replaces this with your policy. + + defaults = StorageDefaults(consent = true), + + // debug surfaces the accepted screen event in logcat. + + logLevel = OptimizationLogLevel.debug, + + ), + + ) { + + HomeScreen() + + } + } + } + } - @Composable - fun HomeScreen() { - // Emit one screen event from the route root, not from repeated child composables. - ScreenTrackingEffect(screenName = "Home") - HomeContent() - } + @Composable + fun HomeScreen() { + + // Emits one screen event; the SDK dedupes repeats of the same screen. + + ScreenTrackingEffect(screenName = "Home") + HomeContent() + } ``` -3. Verify the first run. Confirm one `screen` event for `Home` is accepted with consent marked as - accepted in your SDK diagnostics, mock server, or network logs. + The `MainActivity` and `HomeScreen` scaffolding above is illustrative context to match against your + own app, not a file to paste over yours. Wrap your existing `setContent { }` tree in + `OptimizationRoot` and add `ScreenTrackingEffect` to a screen you already render — keep the rest of + your composables as they are. + +3. Verify the first run. Launch the app on a device or emulator. Because + `logLevel = OptimizationLogLevel.debug` is set, the SDK logs its activity to logcat under the + `ContentfulOptimization` tag. `ScreenTrackingEffect` sends the screen event through + `trackCurrentScreen`, so filter logcat to the `ContentfulOptimization` tag and look for the pair of + bridge lines it logs — `[bridge] Calling trackCurrentScreen async` followed by + `[bridge] trackCurrentScreen succeeded`, whose result payload contains `"accepted":true`. That + `succeeded` line with `accepted` true is the proof the event passed the consent gate. If you see + `"accepted":false` instead — or configure `onEventBlocked` and see the screen event arrive there — + the event was blocked by consent or the allow-list rather than emitted; see + [Consent and privacy-policy handoff](#consent-and-privacy-policy-handoff). The + [Custom events and analytics diagnostics](#custom-events-and-analytics-diagnostics) section adds a + programmatic `eventStream` observer for asserting on events in code rather than reading logs.
Table of Contents -- [Required setup](#required-setup) +- [Before you start](#before-you-start) - [Core integration](#core-integration) - [Install and initialize `OptimizationRoot`](#install-and-initialize-optimizationroot) - [Consent and privacy-policy handoff](#consent-and-privacy-policy-handoff) @@ -101,34 +172,41 @@ an accepted SDK event.
-## Required setup - -Use this setup inventory before you move beyond the quick start: - -| Setup item | Category | Required for quick start | Where to configure | -| -------------------------------------------------------------------------------------------- | ------------------------------ | ------------------------ | ----------------------------------------------------------------------------------- | -| Android app module using Jetpack Compose, Android `minSdk` 24 or later, and Java 11 bytecode | Required for first integration | Yes | Android application Gradle configuration | -| Maven Central repository and `com.contentful.java:optimization-android` dependency | Required for first integration | Yes | Application Gradle repositories and dependencies | -| Optimization client ID and environment | Required for first integration | Yes | `OptimizationConfig`, usually from app runtime configuration | -| Experience API and Insights API endpoint overrides | Common but policy-dependent | No | `OptimizationApiConfig` for staging, mock, or non-default hosts | -| Contentful Delivery API client, space, environment, access token, and CDA host | Required for first integration | No | Application-owned Contentful fetching layer | -| Optimized Contentful entries with linked `nt_experiences` and variant data | Required for first integration | No | Contentful content model, entries, and CDA `include` depth | -| Single Contentful CDA locale and SDK Experience/event locale | Required for first integration | No | App locale policy, Contentful requests, and `OptimizationConfig.locale` | -| `OptimizationRoot` mounted around the Compose tree that uses SDK helpers | Required for first integration | Yes | Compose app root, navigation root, or feature root | -| Screen, route, or lifecycle tracking hook | Required for first integration | Yes | `ScreenTrackingEffect` or app-owned client calls in Compose screen lifecycle | -| Accepted consent startup policy or user-choice handoff | Common but policy-dependent | Yes | `StorageDefaults`, `allowedEventTypes`, and application consent UI or CMP callbacks | -| Entry view and tap tracking policy | Common but policy-dependent | No | `OptimizationRoot` tracking defaults and per-entry `OptimizedEntry` options | -| User identity, profile continuity, and reset policy | Common but policy-dependent | No | Account, session, or settings screens that call `identify(...)` and `reset()` | -| Custom business events and analytics diagnostics | Optional | No | `track(...)`, `eventStream`, `blockedEventStream`, and app-owned forwarding code | -| Custom Flags and MergeTag rendering | Optional | No | Components that call `getFlag(...)`, `observeFlag(...)`, or `getMergeTagValue(...)` | -| Live updates for mounted entries | Optional | No | `OptimizationRoot.liveUpdates` and per-entry `OptimizedEntry.liveUpdates` | -| Preview panel and preview-definition Contentful client | Optional | No | `PreviewPanelConfig`, `PreviewContentfulClient`, and debug or internal-build gates | -| Strict pre-consent allow-list, queue policy, and blocked-event diagnostics | Advanced or production-only | No | `OptimizationConfig.allowedEventTypes`, `queuePolicy`, and `onEventBlocked` | -| Offline, lifecycle, and local reference-app validation path | Advanced or production-only | No | Release checks, Android reference implementation, and targeted Maestro suites | - -The Android SDK does not fetch Contentful entries for your application UI. Fetch entries in the -application layer, then pass single-locale entry maps to `OptimizedEntry` or -`client.resolveOptimizedEntry(...)`. +## Before you start + +The sections below walk the integration in order. First, gather the few things you can only get from +outside this guide: + +- **A Jetpack Compose app you can build with Gradle**, targeting Android `minSdk` 24 or later with + Java 11 bytecode. The SDK ships as a single AAR on Maven Central, so you add the dependency and run + a normal Gradle build — there is no separate native prebuild step. The Android SDK does not fetch + Contentful entries for your application UI (only the preview panel fetches its own definitions): + your app fetches entries in its own layer and passes the resulting single-locale entry maps to + `OptimizedEntry` or `client.resolveOptimizedEntry(...)`. +- **Contentful delivery credentials** — space ID, delivery token, and environment — read from your + app's runtime configuration and used by your own Contentful fetching layer. +- **At least one entry with a variant attached to an experience**, authored in Contentful. Without + an authored variant, the integration can still run correctly while returning the baseline, so you + cannot yet distinguish working personalization from a content-authoring gap. For the first + personalized-content test, target all visitors so the test request or visitor matches automatically. +- **Your Optimization project values** — client ID and environment, from your Optimization project + settings. `environment` has a Kotlin-side default of `"main"`, so pass it only when your Contentful + environment differs. The Experience API (which picks variants) and the Insights API (which receives + event and interaction delivery) each have a base URL that defaults correctly; you set them through + `OptimizationApiConfig` only for mocks or non-default hosts (see + [Install and initialize `OptimizationRoot`](#install-and-initialize-optimizationroot)). + +You do not need a setup inventory up front. Everything else — consent, entry resolution, screen +tracking, interaction tracking, identity, live updates, preview, offline delivery — is introduced by +the section that needs it. + +> [!NOTE] +> +> Read the SDK and Contentful config from your app's runtime configuration. This guide's examples use +> inline placeholder strings for clarity; the reference implementation reads its values from a shared +> `AppConfig` object because it runs against shared mock defaults. Use whatever configuration +> convention your Android app already uses — `BuildConfig` fields, resources, or a config object — and +> keep it consistent. ## Core integration @@ -136,89 +214,106 @@ application layer, then pass single-locale entry maps to `OptimizedEntry` or **Integration category:** Required for first integration -`OptimizationRoot` is the normal Compose entry point. It creates an `OptimizationClient`, calls -`initialize(config)`, provides the initialized client through `LocalOptimizationClient`, applies -tracking defaults to descendant `OptimizedEntry` components, and renders a loading indicator until -the client is ready. For package status and installation details, see the -[Optimization Android SDK README](../../packages/android/README.md). - -1. Configure Maven Central in the consuming Android build. -2. Add `com.contentful.java:optimization-android` to the application module. -3. Create one `OptimizationConfig` with the Optimization client ID, environment, and SDK - Experience/event locale. -4. Pass endpoint overrides only when your app uses staging, mock, or non-default API hosts. -5. Wrap the Compose tree that uses SDK helpers in `OptimizationRoot`. -6. Read `LocalOptimizationClient.current` inside descendant composables that call SDK methods - directly. - -**Copy this:** - -```kotlin -dependencies { - implementation("com.contentful.java:optimization-android:") -} -``` +You wrapped your app UI in `OptimizationRoot` in the quick start; this section covers its full +configuration surface. `OptimizationRoot` is the normal Compose entry point: it creates one +`OptimizationClient` with `remember`, calls the client's `initialize(config)` in a `LaunchedEffect`, +provides the initialized client through the `LocalOptimizationClient` composition local (plus tracking +defaults through `LocalTrackingConfig`), and renders a `CircularProgressIndicator()` until the client +reports `isInitialized`. Descendant composables that call SDK methods directly read the client with +`LocalOptimizationClient.current`. + +`initialize(config)` is a `suspend` function — Android diverges from the iOS SDK's synchronous, +throwing init because bridge calls hop onto a dedicated QuickJS dispatcher. It loads persisted consent +from `SharedPreferences`, resolves defaults, evaluates the SDK's bundled JavaScript runtime, runs +bridge initialization on that dispatcher, then flips `isInitialized`; `OptimizationRoot` runs it inside +a `LaunchedEffect` for you. `OptimizationClient` exposes async work (events and entry resolution) as +`suspend` functions and its state as Kotlin `StateFlow`/`SharedFlow`, not Combine publishers. Call +suspending methods from Compose effects, event-handler coroutine scopes, or another app-owned coroutine +scope. + +1. Add `com.contentful.java:optimization-android` from Maven Central and build the app. +2. Create one `OptimizationConfig` with the Optimization client ID. `environment` defaults to `"main"`, + so pass it only when your Contentful environment differs. +3. Pass `locale` when Experience API responses and event context must use the same app locale as your + Contentful entry fetches. +4. Pass an `api` (`OptimizationApiConfig`) override only for staging, mocks, or non-default hosts; both + base URLs default correctly otherwise, so most apps omit `api`. +5. Read the initialized client with `LocalOptimizationClient.current` inside descendant composables + that call SDK methods directly. **Adapt this to your use case:** ```kotlin +import androidx.compose.material3.Button +import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.rememberCoroutineScope +import com.contentful.optimization.compose.LocalOptimizationClient import com.contentful.optimization.compose.OptimizationRoot -import com.contentful.optimization.core.OptimizationApiConfig import com.contentful.optimization.core.OptimizationConfig - -val config = OptimizationConfig( - clientId = "your-optimization-client-id", - environment = "main", - api = OptimizationApiConfig( - experienceBaseUrl = "https://experience.ninetailed.co/", - insightsBaseUrl = "https://ingest.insights.ninetailed.co/", - ), - locale = "en-US", -) +import com.contentful.optimization.core.OptimizationLogLevel +import kotlinx.coroutines.launch @Composable fun AppRoot() { - // Mount once around the Compose subtree that shares this SDK client. + // One SDK-owned client stays alive for the Compose tree that uses Optimization. OptimizationRoot( - config = config, + config = OptimizationConfig( + clientId = "", + // environment defaults to "main"; set it only when your Contentful environment differs. + locale = "en-US", + logLevel = OptimizationLogLevel.warn, + ), ) { AppNavGraph() } } + +@Composable +fun PurchaseButton() { + // Descendant composables read the client OptimizationRoot created and initialized. + val client = LocalOptimizationClient.current + val scope = rememberCoroutineScope() + + Button(onClick = { + scope.launch { + // Event methods are suspend; call them from a coroutine scope. + client.track(event = "Purchase Completed", properties = mapOf("sku" to "sku-1")) + } + }) { + Text("Purchase") + } +} ``` -`OptimizationClient` exposes async work as `suspend` functions and state as Kotlin flows. Call -suspending methods from Compose effects, event-handler coroutine scopes, lifecycle-aware coroutines, -or another app-owned coroutine scope. For lifecycle details, see +`logLevel` defaults to `OptimizationLogLevel.error`; `debug` and `log` surface the bridge activity +lines you used in the quick start. For lifecycle details, see [Android SDK runtime and interaction mechanics](../concepts/android-sdk-runtime-and-interaction-mechanics.md#lifecycle-and-coroutines). +For package status and installation details, see +[the Optimization Android SDK README](../../packages/android/README.md). ### Consent and privacy-policy handoff **Integration category:** Common but policy-dependent -Consent policy remains application-owned. Use the default accepted startup path only when -application policy permits Optimization by default and no end-user consent UI is rendered. -Otherwise, leave consent unset and connect your CMP, account preference, or in-app banner to the -SDK. - -1. Seed accepted consent with `StorageDefaults(consent = true)` when policy permits default-on - Optimization. -2. Leave consent unset when the app needs to collect a user decision first. -3. Call `client.consent(true)` after the visitor accepts and `client.consent(false)` after the - visitor rejects. -4. Use split consent when events can emit but durable profile continuity must stay session-only. -5. Read `client.state` when consent UI needs to reflect SDK state across app launches. - -**Copy this:** - -```kotlin -val defaultOnConfig = OptimizationConfig( - clientId = "your-optimization-client-id", - // Use default accepted consent only when your app does not need a prior user choice. - defaults = StorageDefaults(consent = true), -) -``` +Consent policy stays application-owned. Consent has two independent axes: **event consent** (may the +SDK personalize and emit events) and **persistence consent** (may the SDK store profile continuity in +`SharedPreferences`). The boolean call `client.consent(accept)` sets both at once; the split call +`client.consent(events, persistence)` sets them independently. `StorageDefaults(consent = true)` seeds +accepted event and persistence consent at startup — use it only when application policy permits +Optimization by default and you render no consent UI. + +`StorageDefaults` values are startup defaults, not one-time seeds: a configured value takes precedence +over the stored `SharedPreferences` value every launch, so a configured `consent` can replace a stored +choice. Apps that persist a user's own decision leave `StorageDefaults.consent` unset and call +`client.consent(...)` from resolved app policy instead. + +1. Seed accepted consent with `StorageDefaults(consent = true)` only when policy permits default-on + Optimization and no consent UI is shown. +2. Otherwise leave consent unset and call `client.consent(true)` after the visitor accepts, + `client.consent(false)` after they reject. +3. Use the split form when events are allowed but durable profile continuity must stay session-only. +4. Read `client.state` when consent UI must reflect SDK state across app launches. **Adapt this to your use case:** @@ -231,12 +326,8 @@ fun ConsentControls(content: @Composable () -> Unit) { if (state.consent == null) { Row { // Wire these calls to your CMP or privacy UI, not to SDK-owned policy. - Button(onClick = { client.consent(true) }) { - Text("Accept") - } - Button(onClick = { client.consent(false) }) { - Text("Reject") - } + Button(onClick = { client.consent(true) }) { Text("Accept") } + Button(onClick = { client.consent(false) }) { Text("Reject") } } } else { content() @@ -244,79 +335,114 @@ fun ConsentControls(content: @Composable () -> Unit) { } ``` -By default, mobile `identify` and screen events can emit before event consent is accepted. Entry -views, entry taps, `page`, and custom `track` events are blocked until consent is accepted or their -event types are allow-listed. Boolean consent calls control both event emission and durable profile -continuity. Use this form when events can emit but profile, selected optimizations, changes, and -anonymous identity must stay session-only: - **Copy this:** ```kotlin +// Allows events but keeps profile continuity session-only. client.consent(events = true, persistence = false) ``` -For cross-SDK policy details, see +The split form above keeps the profile, its selected optimizations — the per-experience variant +selections the Experience API returned for this visitor — and `changes` — the inline field and flag +values it returned — in memory only, so nothing is written to `SharedPreferences`. Before event consent is accepted, the native default allow-list lets `identify` +and `screen` events emit; entry-view events (wire type `component`), tap events (`component_click`), +and custom `track` events are blocked until consent is accepted or you allow-list them. +`client.consent(false)` clears event and persistence consent, purges queued events, and clears durable +profile continuity, while in-memory state stays usable until reset or teardown. To block every SDK +event before consent — including `identify` and `screen` — set `allowedEventTypes = emptyList()`; see +[Strict event policy and queue controls](#strict-event-policy-and-queue-controls). For the cross-SDK +consent model, see [Consent management in the Optimization SDK Suite](../concepts/consent-management-in-the-optimization-sdk-suite.md). ### Contentful entry fetching and locale shape **Integration category:** Required for first integration -Your app owns Contentful fetching. The SDK resolver expects standard single-locale Contentful CDA -entry maps with direct field values and linked optimization entries already resolved in the payload. - -1. Choose the application Contentful locale from your native app state, navigation, i18n, or account - preference layer. -2. Pass the same locale to `OptimizationConfig.locale` when Experience API responses and event - context need to stay aligned with rendered Contentful content. -3. Fetch optimized entries with one concrete CDA locale, not `locale=*`. -4. Include linked entries deeply enough for `fields.nt_experiences`, optimization config, and linked - variant entries. -5. Pass the resulting `Map` entry objects to SDK helpers. +The Android SDK does not fetch Contentful entries for your application UI — only the preview panel +fetches its own audience and experience definitions. Your app fetches entries from the Contentful +Delivery API and passes the resulting single-locale entry maps (`Map`) to `OptimizedEntry` +or `client.resolveOptimizedEntry(...)`. There is no fetch-by-ID path in the Android SDK, so the +Contentful client and its request options stay entirely yours. + +Fetch with one concrete locale and enough `include` depth to resolve the linked optimization data. +`nt_experiences` is the SDK-owned link field the resolver reads on an optimized entry; it links that +entry's `nt_experience` entries, and each experience links its `nt_variants` (and `nt_audience`). These +are fixed Optimization content-model identifiers you do not choose. `nt_config` is a JSON field on the +experience, not a link, so it needs no extra include depth. Fetch deep enough to pull the linked +entries back in one payload — the reference implementation uses `include=10`. Do not pass all-locale +CDA responses such as `locale=*`; the resolver expects direct single-locale field values and falls +back to baseline on an all-locale payload. + +The SDK Experience/event `locale` is distinct from the Contentful CDA locale: your app chooses the CDA +locale for its own fetch, and `OptimizationConfig(locale = ...)` sets the locale the Experience API and +events use. Keep them aligned when rendered content and Experience responses must match. + +1. Choose the application Contentful locale in your app's navigation, i18n, or account layer. +2. Pass the same locale to `OptimizationConfig(locale = ...)` when Experience responses and event + context must align with rendered content. +3. Fetch entries with a concrete locale and enough include depth for `nt_experiences` → + `nt_experience` → `nt_variants`/`nt_audience`. +4. When the app locale changes, call `client.setLocale(...)`, refetch entries with the new locale, and + re-render. `setLocale(...)` updates only the SDK Experience/event locale; it does not refetch + Contentful or refresh profile state, and it throws before initialization or on an invalid locale. **Adapt this to your use case:** ```kotlin @Composable fun HomeScreen(contentfulClient: ContentfulDeliveryClient) { - val appLocale = getAppLocale() - var entries by remember { mutableStateOf>>(emptyList()) } + // selectedAppLocale() and contentfulClient are your app's own locale source and fetching layer. + val appLocale = selectedAppLocale() + var heroEntry by remember { mutableStateOf?>(null) } LaunchedEffect(appLocale) { - // Fetch one CDA locale and enough linked entries for nt_experiences and variants. - entries = contentfulClient.fetchEntries( - ids = listOf("hero-entry-id", "cta-entry-id"), - include = 10, + // Your own CDA fetch: one concrete locale, include depth for linked experiences and variants. + heroEntry = contentfulClient.fetchEntry( + id = "", locale = appLocale, + include = 10, ) } - HomeContent(entries = entries) + HomeContent(heroEntry = heroEntry) } ``` -The SDK top-level `locale` does not modify your Contentful client or refetch content after locale -changes. For the full locale model, see +For the full data shape and locale boundary, see +[Entry optimization and variant resolution](../concepts/entry-personalization-and-variant-resolution.md#single-locale-cda-entry-contract) +and [Locale handling in the Optimization SDK Suite](../concepts/locale-handling-in-the-optimization-sdk-suite.md). -For the single-locale CDA shape and fallback rules, see -[Entry optimization and variant resolution](../concepts/entry-personalization-and-variant-resolution.md#single-locale-cda-entry-contract). ### Entry resolution and fallback rendering **Integration category:** Required for first integration -`OptimizedEntry` resolves a Contentful entry against the selected variants for the visitor, passes -non-optimized entries through unchanged, and falls back to the baseline entry when optimization data -is missing, unresolved, out of range, or not selected for the visitor. The render lambda receives -the entry map your app must convert into its own view model or Compose hierarchy. - -1. Render each optimized Contentful entry through `OptimizedEntry`. -2. Keep field parsing and UI rendering in application components. -3. Use `accessibilityIdentifier` when tests or accessibility tooling need a stable wrapper - identifier. -4. Use `client.resolveOptimizedEntry(...)` directly only when a custom UI abstraction needs the same - local resolver without the Compose wrapper. +`OptimizedEntry` renders a Contentful entry through the resolver. It detects an optimized entry by the +presence of the `fields.nt_experiences` field; a non-optimized entry passes through unchanged and +renders the baseline once, and an optimized entry resolves against the visitor's selected variants. The +render lambda receives the resolved entry map — the selected variant, or the baseline entry when no +variant matches — with the same field shape as the baseline, so your renderer reads fields without +branching on whether a variant was applied. + +Resolution is fail-soft, and `client.resolveOptimizedEntry(baseline, selectedOptimizations)` is a +`suspend` function on Android (the iOS SDK's is synchronous; Android must suspend because the bridge +call hops to the QuickJS dispatcher). It returns a `ResolvedOptimizedEntry` — the SDK-owned result +wrapper holding the resolved `entry` and the `selectedOptimization` (singular) applied to it. If the +client is not initialized, serialization fails, or the bridge result cannot be parsed, it returns the +baseline entry unchanged (with `selectedOptimization` null) and continues rather than throwing or +breaking the UI. The `selectedOptimizations` argument (plural) is the visitor's current per-experience +selections: pass `null` (the default) to omit the argument and resolve against the SDK's live selection +state, or pass an explicit snapshot to resolve against exactly that. `client.selectedOptimizations` is +the `StateFlow` that publishes that plural set as it changes. + +1. Pass the baseline Contentful entry map to `OptimizedEntry` and read fields from the resolved entry + in the render lambda. +2. Keep field parsing and view rendering in your own composables; the resolved entry keeps the baseline + field shape. +3. Provide your own loading treatment while the app-owned fetch is pending — `OptimizedEntry` needs an + entry to render, so gate it on your fetched state. +4. Use `client.resolveOptimizedEntry(...)` directly only when a component must separate resolution from + rendering. **Adapt this to your use case:** @@ -327,7 +453,7 @@ fun HeroSection(entry: Map) { entry = entry, accessibilityIdentifier = "home-hero-optimization", ) { resolvedEntry -> - // Always render the resolved entry; the SDK returns the baseline when no variant is usable. + // resolvedEntry is the selected variant, or the baseline entry when none matches. HeroCard(entry = resolvedEntry) } } @@ -336,48 +462,61 @@ fun HeroSection(entry: Map) { **Follow this pattern:** ```kotlin -LaunchedEffect(entry) { - // Direct resolution is for custom abstractions that cannot use the Compose wrapper. - client.selectedOptimizations.collect { selectedOptimizations -> - val result = client.resolveOptimizedEntry( - baseline = entry, - selectedOptimizations = selectedOptimizations, - ) - - resolvedEntry = result.entry +@Composable +fun DirectResolution(entry: Map) { + val client = LocalOptimizationClient.current + var resolvedEntry by remember(entry) { mutableStateOf(entry) } + + LaunchedEffect(entry) { + // Collect the flow so re-resolution follows every profile, preview, or consent change; + // reading selectedOptimizations.value would capture only the current snapshot. + client.selectedOptimizations.collect { selectedOptimizations -> + resolvedEntry = client.resolveOptimizedEntry( + baseline = entry, + selectedOptimizations = selectedOptimizations, + ).entry + } } + + CtaHeader(entry = resolvedEntry) } ``` -Collect `selectedOptimizations` inside the effect when custom UI must re-resolve after profile, -preview, or consent-driven selection changes. Reading `client.selectedOptimizations.value` only -captures the current snapshot. - -The resolver does not fetch Contentful entries, evaluate audiences, call the Experience API, or -mutate state. It joins the current selected optimization metadata with linked entries already -present in the Contentful payload. For deeper mechanics, see -[Entry optimization and variant resolution](../concepts/entry-personalization-and-variant-resolution.md). +The resolver does not fetch Contentful entries, evaluate audiences, call the Experience API, or mutate +state; it joins the current selected optimization metadata with linked entries already present in the +Contentful payload. For the fallback rules, see +[Entry optimization and variant resolution](../concepts/entry-personalization-and-variant-resolution.md#fallback-behavior). ### Screen and navigation tracking **Integration category:** Required for first integration -Compose apps track native screens from composable lifecycle. `ScreenTrackingEffect` emits the -current screen through the SDK and rechecks consent state, so an active screen can emit after -tracking becomes allowed. - -1. Call `ScreenTrackingEffect` once from the root composable for each route or screen destination. -2. Use stable screen names that match your analytics taxonomy. -3. For dynamic names or data-dependent properties, call `client.trackCurrentScreen(...)` from a - `LaunchedEffect`. -4. Keep screen tracking at the route root to avoid duplicate events from repeated child composition. +You added `ScreenTrackingEffect` in the quick start. It fires from a `LaunchedEffect` keyed on the +screen name and `state.consent`, so it calls `client.trackCurrentScreen(name)` on first composition, +when the screen name changes, and when a consent change allows a previously blocked screen to emit. +`trackCurrentScreen` dedupes in the bridge by route key (defaulting to the name), so a repeat of the +same current screen is skipped and a blocked attempt is retried once consent allows. Plain +`client.screen(name)` emits with no dedupe. + +Place `ScreenTrackingEffect` once at the root composable of each route or destination. For a dynamic +screen name or an app-defined route key — for example a detail screen whose name depends on loaded data +— call `client.trackCurrentScreen(name, properties, routeKey)` from a `LaunchedEffect` instead. Track a +given route through one path only: do not both place `ScreenTrackingEffect` and call +`trackCurrentScreen`/`screen` for the same route, or you will emit duplicate or conflicting events. + +1. Place `ScreenTrackingEffect` once at the route or destination root of each screen that maps to an + analytics screen. +2. Use stable names for navigation destinations so downstream reporting can group events. +3. For dynamic names or an explicit route key, call `client.trackCurrentScreen(...)` from a + `LaunchedEffect` once the data is available. +4. Use one screen-tracking path per route to avoid duplicate events from repeated child composition. -**Copy this:** +**Follow this pattern:** ```kotlin @Composable fun HomeScreen() { - // Put screen tracking at the destination root to prevent duplicate screen events. + // Place at the destination root to prevent duplicate screen events. ScreenTrackingEffect(screenName = "Home") HomeContent() } @@ -391,7 +530,7 @@ fun DetailsScreen(postId: String) { val client = LocalOptimizationClient.current LaunchedEffect(postId) { - // Key dynamic screens by route identity so navigation changes emit in sequence. + // Key the dynamic screen by route identity so navigation changes emit in sequence. client.trackCurrentScreen( name = "BlogPostDetail", properties = mapOf("postId" to postId), @@ -403,32 +542,49 @@ fun DetailsScreen(postId: String) { } ``` -The Android reference implementation exercises screen tracking with Compose Navigation and asserts -the visited sequence through the SDK event stream. +The Android reference implementation exercises screen tracking with Compose Navigation and asserts the +visited sequence through the SDK event stream. ### Entry interaction tracking **Integration category:** Common but policy-dependent -`OptimizationRoot` defines global tracking defaults. `OptimizedEntry` can override those defaults -per entry. View and tap tracking default to on; pass `trackViews = false` or `trackTaps = false` -globally or per entry when a surface must opt out. +`OptimizedEntry` tracks two interactions for the entry it wraps: entry views and entry taps (there is +no hover on Android). Both default to enabled. `OptimizationRoot` sets the tree-wide defaults through +its `trackViews` and `trackTaps` parameters, and each `OptimizedEntry` can override them per entry. +`trackViews` and `trackTaps` are the configuration switches; on the wire an entry view is delivered as +a `component` event and a tap as a `component_click` event. Delivery is gated on consent: view tracking +checks `hasConsent("trackView")` and tap tracking checks `hasConsent("trackClick")`, so both stay +blocked until event consent (or an allow-list entry) permits them. + +View tracking is viewport-based. Wrap scrollable content in `OptimizationLazyColumn` so view timing +uses the real scroll position; without an enclosing scroll context, tracking assumes `scrollY` is `0` +and uses the system display height as the viewport, which suits only non-scrolling or already-visible +layouts. The default view threshold is 80% visibility (`minVisibleRatio` `0.8`) held for 2000 ms +(`dwellTimeMs`); after the first view event, duration updates emit every 5000 ms +(`viewDurationUpdateIntervalMs`) while the entry stays visible. + +A tap uses Compose's `clickable {}` on the `OptimizedEntry` wrapper: it emits the `component_click` +event, then calls the optional `onTap` lambda. That lambda receives the **baseline** entry you passed +in, not the resolved variant — only the render lambda receives the resolved entry — so do not read +variant-dependent fields from it. Because `onTap` runs through that same click handler, setting +`trackTaps = false` disables both the tap event and `onTap`. For variant-dependent navigation, drive it +from the resolved entry the render lambda provides. 1. Leave view and tap tracking enabled for entries that need exposure and interaction analytics. -2. Disable tap tracking where the wrapped entry does not represent a meaningful interaction. -3. Use `OptimizationLazyColumn` for `LazyColumn` content so entry visibility uses the list viewport. -4. Tune `minVisibleRatio`, `dwellTimeMs`, or `viewDurationUpdateIntervalMs` per entry only when the - default timing does not match the component. -5. Avoid wrapping the same clickable surface with both SDK tap tracking and another SDK tap call. +2. Set `trackViews = false` or `trackTaps = false` on `OptimizationRoot` for a tree-wide opt-out, or on + an individual `OptimizedEntry` for one surface. +3. Wrap scrollable entry lists in `OptimizationLazyColumn` for accurate viewport timing. +4. Tune `dwellTimeMs`, `minVisibleRatio`, and `viewDurationUpdateIntervalMs` per entry only when + analytics requirements differ from the defaults. +5. Drive navigation from the resolved entry in the render lambda, and use `onTap` only for side effects + the SDK tap event should also trigger. -**Copy this:** +**Adapt this to your use case:** ```kotlin -OptimizationRoot( - config = config, - // Opt out globally only when this screen must not emit tap analytics. - trackTaps = false, -) { +OptimizationRoot(config = config, trackTaps = false) { + // Tree-wide tap opt-out: no OptimizedEntry below emits component_click. AppNavGraph() } ``` @@ -438,10 +594,11 @@ OptimizationRoot( ```kotlin OptimizationLazyColumn { items(entries) { entry -> + // onTap fires after component_click and receives the baseline entry, not the resolved + // variant — use it for side effects that need no variant fields. OptimizedEntry( entry = entry, - // Avoid adding another SDK tap call around this same clickable surface. - onTap = { resolvedEntry -> navigateToEntry(resolvedEntry) }, + onTap = { _ -> analytics.log("cta-tapped") }, ) { resolvedEntry -> CtaCard(entry = resolvedEntry) } @@ -449,25 +606,51 @@ OptimizationLazyColumn { } ``` -Entry view tracking emits after 2 seconds at 80% visibility, sends duration updates every 5 seconds -while visible, and sends a final duration update when the entry leaves view after a view event has -already fired. For timing and event-delivery details, see +**Adapt this to your use case:** + +```kotlin +OptimizedEntry( + entry = cta, + // Disables the SDK tap event and onTap; navigation is app-owned here. + trackTaps = false, +) { resolvedEntry -> + // Navigate with the resolved entry so navigation uses the variant's fields. + Button(onClick = { navigateToEntry(resolvedEntry) }) { + CtaCard(entry = resolvedEntry) + } +} +``` + +For timing thresholds, scroll context, and delivery behavior, see [Android SDK runtime and interaction mechanics](../concepts/android-sdk-runtime-and-interaction-mechanics.md#tracking-mechanics). ### Identity, profile continuity, and reset **Integration category:** Common but policy-dependent -Identity policy belongs to your application. Use SDK identity methods only after your product, -privacy, and account logic decides which user ID and traits can be sent. Android persists consent -and profile-continuity state in `SharedPreferences` when persistence consent permits it. - -1. Call `identify(...)` from an authenticated account flow or another approved identity moment. -2. Read `client.state`, `selectedOptimizations`, and `optimizationPossible` from Compose state when - UI needs to reflect SDK state. -3. Call `reset()` when the active SDK profile must be cleared from the current session. -4. Keep application-owned account IDs, server cookies, and third-party destination identifiers in - the systems that own them. +Identify a user when your product has an application-owned identity to associate with the profile. +`client.identify(userId, traits)` is a suspend call that links that identity to the current profile. +The SDK publishes its state reactively as Kotlin flows: `client.state` is a `StateFlow` whose snapshot +carries the profile, consent, and `changes`, and `client.selectedOptimizations` and +`client.optimizationPossible` (a `StateFlow` that is `true` when the current consent and +allow-list configuration can produce optimizations at all) are also `StateFlow`s. Compose reads any of +them with `collectAsState()`. +Keep traits limited to values approved for Optimization profile use. + +When persistence consent allows it, the SDK stores profile continuity — profile, changes, selected +optimizations, and the anonymous id — in `SharedPreferences` across app launches, writing with +`commit()` so the continuity write settles to disk before the corresponding client state is published +(tests can wait for SDK-derived state instead of adding storage-timing sleeps before relaunching). +`client.reset()` clears that continuity (profile, changes, selected optimizations, anonymous id, and +the current-screen dedupe) but preserves the stored consent decision, so the next SDK activity still +follows the visitor's existing consent. `reset()` no-ops before initialization. + +1. Call `identify(...)` from the authenticated flow or account state change that owns identity. +2. Read `client.state` (and `selectedOptimizations`/`optimizationPossible`) from Compose state when UI + must reflect SDK state. +3. Call `client.reset()` on sign-out or a privacy reset that must clear profile continuity. +4. Keep application-owned account IDs, server cookies, and third-party destination identifiers in the + systems that own them. **Adapt this to your use case:** @@ -480,45 +663,46 @@ fun AccountControls() { Column { Text("Consent: ${state.consent}") - Button( - onClick = { - scope.launch { - // Send identity only after your app's account and privacy policy approves it. - client.identify( - userId = "user-123", - traits = mapOf("plan" to "pro"), - ) - } - }, - ) { + Button(onClick = { + scope.launch { + // Send identity only after your app's account and privacy policy approves it. + client.identify(userId = "user-123", traits = mapOf("plan" to "pro")) + } + }) { Text("Identify") } - Button(onClick = { client.reset() }) { + Button(onClick = { + // Clears SDK-managed profile continuity; the stored consent decision survives. + client.reset() + }) { Text("Reset") } } } ``` -When durable profile continuity is allowed, the SDK stores profile, selected optimizations, changes, -and anonymous ID before it publishes the corresponding state update. Tests can wait for SDK state -instead of adding storage-delay sleeps before relaunching the app. - ## Optional integrations ### Custom events and analytics diagnostics **Integration category:** Optional -Use `track(...)` for app-owned business events. Use `eventStream` and `blockedEventStream` for debug -surfaces, tests, and application-owned analytics forwarding. The SDK does not configure third-party -analytics destinations for you. - -1. Call `track(...)` from the application event handler that owns the business action after event - consent is accepted or an approved `allowedEventTypes` policy permits `track`. -2. Subscribe to `eventStream` in debug surfaces or test-only views that need to inspect emitted - events. -3. Subscribe to `blockedEventStream` or pass `onEventBlocked` when validating consent gates. +Use `client.track(event, properties)` for application-owned business events, and the SDK event streams +for debug surfaces, local validation, or forwarding to your analytics pipeline. The SDK does not +configure third-party analytics destinations for you. + +`client.eventStream` and `client.blockedEventStream` are both replay-buffered `SharedFlow`s +(`replay = 64`) — so unlike the iOS SDK's passthrough Combine publisher, a late subscriber on Android +still receives the recent events already emitted, up to the last 64. `eventStream` carries accepted +events; `blockedEventStream` carries events blocked by consent or the allow-list and also fires the +`onEventBlocked` config callback. This is the programmatic observer the quick start pointed to: +collect `eventStream` to assert on the accepted `screen` event in code instead of reading logcat. Keep +any downstream destination consent checks in your app before forwarding. + +1. Call `client.track(...)` from the composable event handler that owns the business action, after + event consent is accepted or an approved `allowedEventTypes` policy permits `track`. +2. Collect `client.eventStream` in debug surfaces or test-only composables that inspect emitted events. +3. Collect `client.blockedEventStream` or pass `onEventBlocked` when validating consent gates. 4. Apply your destination consent policy before forwarding SDK context to another analytics tool. **Adapt this to your use case:** @@ -529,18 +713,15 @@ fun PurchaseButton() { val client = LocalOptimizationClient.current val state by client.state.collectAsState() val scope = rememberCoroutineScope() - // Replace this gate with your app-owned policy if `track` is explicitly allow-listed. + // Replace this gate with your app-owned policy if track is explicitly allow-listed. val canTrackPurchase = state.consent == true Button( enabled = canTrackPurchase, onClick = { scope.launch { - client.track( - // Custom events are blocked until event consent is accepted unless allow-listed. - event = "Purchase Completed", - properties = mapOf("sku" to "sku-1"), - ) + // Custom events are blocked until event consent is accepted unless allow-listed. + client.track(event = "Purchase Completed", properties = mapOf("sku" to "sku-1")) } }, ) { @@ -555,37 +736,46 @@ fun PurchaseButton() { @Composable fun AnalyticsDebugPanel() { val client = LocalOptimizationClient.current - var latestEvent by remember { mutableStateOf?>(null) } + var latestType by remember { mutableStateOf("none") } LaunchedEffect(client) { - // Use this stream for diagnostics or app-owned forwarding after downstream consent checks. + // eventStream replays its recent buffer, so a collector that attaches after an event + // fired still receives it; forward downstream only after your own consent checks. client.eventStream.collect { event -> - latestEvent = event + latestType = event["type"] as? String ?: "unknown" } } - val latestType = latestEvent?.get("type") ?: "none" Text("Most recent SDK event: $latestType") } ``` -For destination mapping patterns, see +For cross-SDK forwarding patterns, see [Forwarding Optimization SDK context to analytics and tag management tools](./forwarding-optimization-sdk-context-to-analytics-and-tag-management-tools.md). ### Custom Flags and MergeTag rendering **Integration category:** Optional -Custom Flags and MergeTag helpers read profile-backed values from the same initialized -`OptimizationClient`. Use them inside components that already run under `OptimizationRoot`. - -1. Use `client.getFlag(name)` for a one-time flag read. -2. Use `client.observeFlag(name)` when a component needs a `StateFlow` that updates with flag value - changes. -3. Use `client.getMergeTagValue(mergeTagEntry)` while rendering Contentful Rich Text that includes - resolved `nt_mergetag` entries. -4. Provide application fallback text when a merge tag is unresolved, missing, or unavailable for the - active profile. +Custom Flags and merge tags read profile-backed values the Experience API returns, separately from +entry variant selection. `client.getFlag(name)` is a one-time, non-reactive JSON read that returns +`null` before initialization; `client.observeFlag(name)` returns a `StateFlow` (the Android +idiom — the iOS SDK uses a Combine publisher) that updates as the flag value changes. Subscribing to a +flag registers an observation that emits a `component` flag-view event through the event stream when +consent and profile allow, so flag delivery is an analytics exposure — apply the same governance you +use for other SDK events. + +`client.getMergeTagValue(mergeTagEntry)` is a suspend call that resolves an inline `nt_mergetag` entry +— the SDK-owned merge-tag content-model identifier — against the current profile and returns the +resolved string, or `null` when it cannot resolve (also `null` before initialization). Your app owns +extracting the embedded `nt_mergetag` entry from Rich Text before calling it, and owns where the value +renders. + +1. Use `client.getFlag(name)` for a one-time flag read after the SDK is initialized. +2. Use `client.observeFlag(name)` when Compose state must follow flag changes. +3. Resolve Rich Text `nt_mergetag` entries with `client.getMergeTagValue(mergeTagEntry)` after your + fetcher has inlined the target entry. +4. Provide app-owned fallback rendering when a flag or merge-tag value is missing. **Adapt this to your use case:** @@ -593,7 +783,7 @@ Custom Flags and MergeTag helpers read profile-backed values from the same initi @Composable fun BooleanFlagBadge() { val client = LocalOptimizationClient.current - // Observed flags emit flag-view events for delivered values. + // Observing a flag emits a flag-view component event for delivered values. val flagValue = remember { client.observeFlag("boolean") }.collectAsState() Text("Flag value: ${flagValue.value}") @@ -607,7 +797,7 @@ suspend fun resolveMergeTagText( client: OptimizationClient, mergeTagEntry: Map, ): String { - // Keep fallback copy in the app so unresolved merge tags do not break rendering. + // Keep fallback copy in the app so an unresolved merge tag does not break rendering. return client.getMergeTagValue(mergeTagEntry) ?: readFallbackValue(mergeTagEntry) ?: "[Merge Tag]" @@ -621,25 +811,22 @@ asserts the rendered text in the shared Maestro variant flows. **Integration category:** Optional -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 mounted entries to react to profile +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 mounted entries to react to profile changes or preview overrides without a reload. -1. Set `OptimizationRoot(liveUpdates = true)` when mounted entries inherit live updates by default. -2. Set `OptimizedEntry(liveUpdates = true)` for an entry that must update even when the global - default is locked. -3. Set `OptimizedEntry(liveUpdates = false)` for an entry that must stay locked even when the global - default is live. -4. Treat the preview panel as a live-update override while it is open. +1. Set `liveUpdates = true` on `OptimizationRoot` when most mounted entries in the tree must update as + SDK state changes. +2. Set `liveUpdates = true` on an individual `OptimizedEntry` for a localized live section. +3. Set `liveUpdates = false` on an individual `OptimizedEntry` to keep it locked even under a live + global default. +4. Expect the preview panel to force live updates while it is open so overrides apply immediately. -**Copy this:** +**Adapt this to your use case:** ```kotlin -OptimizationRoot( - config = config, +OptimizationRoot(config = config, liveUpdates = true) { // Mounted entries inherit live updates unless they set their own liveUpdates value. - liveUpdates = true, -) { AppNavGraph() } ``` @@ -656,28 +843,35 @@ OptimizedEntry( } ``` -When the preview panel closes, locked entries keep the previewed selected optimization as their -locked value. For precedence rules, see +The resolution order is: an open preview panel forces live updates (overriding an explicit +`liveUpdates = false`), then a per-entry `liveUpdates` value, then the `OptimizationRoot` `liveUpdates` +default, then the locked default. When the preview panel closes, a locked `OptimizedEntry` snapshots +the current selections so applied overrides persist. For the precedence rules, see [Android SDK runtime and interaction mechanics](../concepts/android-sdk-runtime-and-interaction-mechanics.md#live-updates-and-preview-behavior). ### Preview panel **Integration category:** Optional -The preview panel is a developer and internal-review tool. Gate it behind debug or internal-build -configuration, and keep preview credentials out of public release builds. +Use the preview panel only in debug or internal builds. `PreviewPanelConfig(enabled, contentfulClient)` +is the Compose path: `OptimizationRoot(previewPanel = ...)` wraps content in `PreviewPanelOverlay` (a +floating action button plus a `ModalBottomSheet`) for you. Pass a `PreviewContentfulClient` so the +panel can fetch the SDK-owned `nt_audience` and `nt_experience` definitions and show audience and +experience names; without one the panel still opens but is degraded — it shows "No audience data" and +falls back to raw identifiers in override summaries. 1. Pass `PreviewPanelConfig` to `OptimizationRoot` only for builds that can expose preview tooling. 2. Pass a `PreviewContentfulClient` when the panel needs audience and experience names. -3. Omit `contentfulClient` when identifier-only preview data is enough. +3. Use `ContentfulHTTPPreviewClient` for a direct CDA-backed panel, or implement `PreviewContentfulClient` + around your existing Contentful client. 4. Verify the floating action button is absent from production release variants. **Adapt this to your use case:** ```kotlin val previewContentfulClient = ContentfulHTTPPreviewClient( - spaceId = "your-space-id", - accessToken = "your-contentful-delivery-token", + spaceId = "", + accessToken = "", environment = "main", ) @@ -695,9 +889,8 @@ OptimizationRoot( ``` The panel's floating action button and sheet live in the Compose tree created by `OptimizationRoot`. -The Android reference implementation uses `PreviewPanelConfig(contentfulClient = ...)` and runs -shared Maestro flows for panel visibility, profile data, refresh behavior, and audience or variant -overrides. +The Android reference implementation uses `PreviewPanelConfig(contentfulClient = ...)` and runs shared +Maestro flows for panel visibility, profile data, refresh behavior, and audience or variant overrides. ## Advanced integrations @@ -705,17 +898,17 @@ overrides. **Integration category:** Advanced or production-only -Use strict event policy controls only after privacy review defines which events can emit before -consent and how blocked or queued events are observed. +Use strict event policy controls only after privacy review defines which events can emit before consent +and how blocked or queued events are observed. 1. Set `allowedEventTypes = emptyList()` when no Optimization events can emit before consent. 2. Configure a narrow allow-list only for event types approved by your product and privacy policy. -3. Use `onEventBlocked` or `blockedEventStream` to verify consent or `allowedEventTypes` blocks +3. Use `onEventBlocked` or collect `blockedEventStream` to verify consent or `allowedEventTypes` blocks during development. 4. Use `QueuePolicy` only when the default queue behavior needs production-specific limits or diagnostics. -Use these `allowedEventTypes` selectors exactly when allow-listing Android events: +Use these `allowedEventTypes` selectors when allow-listing Android events: | Selector | Allows | | ----------------- | ---------------------------------------------- | @@ -727,48 +920,47 @@ Use these `allowedEventTypes` selectors exactly when allow-listing Android event | `component_click` | Entry tap events | | `flag` | Custom Flag view tracking without all views | -Android does not expose hover tracking. `component_hover` applies to SDKs that support hover, such -as Web and Node. - -For the cross-SDK selector list and consent behavior, see +Android does not expose hover tracking; `component_hover` applies only to SDKs that support hover, such +as Web and Node. For the cross-SDK selector list and consent behavior, see [Consent management in the Optimization SDK Suite](../concepts/consent-management-in-the-optimization-sdk-suite.md#event-allow-lists-and-blocked-events). **Adapt this to your use case:** ```kotlin val strictConfig = OptimizationConfig( - clientId = "your-optimization-client-id", + clientId = "", // Empty means no SDK events are allowed before explicit consent. allowedEventTypes = emptyList(), queuePolicy = QueuePolicy( offlineMaxEvents = 100, - onOfflineDrop = { event -> - logDroppedOptimizationEvent(event) - }, + onOfflineDrop = { event -> logDroppedOptimizationEvent(event) }, ), - // Blocked events are for verification; they are not replayed after later consent. - onEventBlocked = { blockedEvent -> - logBlockedOptimizationEvent(blockedEvent) - }, + // Blocked events are for verification; they are not re-sent after later consent. + onEventBlocked = { blockedEvent -> logBlockedOptimizationEvent(blockedEvent) }, ) ``` -Blocked events are dropped at the SDK boundary and are not replayed after `consent(true)`. Keep any -application-owned retry or forwarding behavior aligned with your consent policy. +Blocked events are dropped at the SDK boundary and are not re-sent after `consent(true)`. The +`blockedEventStream` is a diagnostic only — being replay-buffered, a late subscriber still sees recent +blocked entries, but observing a blocked event does not deliver it. Keep any application-owned retry or +forwarding behavior aligned with your consent policy. ### Offline delivery and lifecycle flushing **Integration category:** Advanced or production-only -The Android SDK monitors network reachability and process lifecycle after initialization. Events -queue in memory while the device is offline, flush when connectivity returns, and flush when the app -moves toward the background. +After initialization the SDK monitors network reachability and app lifecycle. A `NetworkMonitor` +(`ConnectivityManager`) calls `setOnline(...)` on connectivity changes and `flush()` on reconnect; an +`AppLifecycleHandler` (a `ProcessLifecycleOwner` observer) calls `flush()` when the app moves to the +background for a best-effort drain. Queues are in-memory only — there is no durable outbox — and the +offline Experience buffer is capped at 100 events by default (tunable via `QueuePolicy.offlineMaxEvents`); +nothing survives process death. 1. Let the SDK manage normal network and lifecycle handling after `OptimizationRoot` initializes the client. -2. Call `flush()` only from explicit app-owned delivery checkpoints or tests. -3. Use `setOnline(...)` only for deterministic test or diagnostic flows, not as a replacement for - the SDK network monitor. +2. Call `client.flush()` only from explicit app-owned delivery checkpoints or tests. +3. Use `client.setOnline(...)` only for deterministic test or diagnostic flows, not as a replacement + for the SDK network monitor. 4. Validate offline-sensitive release behavior with deterministic SDK controls or platform tests instead of relying on unstable emulator network toggles. @@ -776,9 +968,8 @@ moves toward the background. ```kotlin LaunchedEffect(Unit) { - // Use deterministic network controls only in tests or diagnostics. - // Accept event consent before queueing a custom track event in a test flow. - client.consent(true) + // Deterministic network controls, for tests or diagnostics only. + client.consent(true) // Accept event consent before queueing a custom track event. client.setOnline(false) client.track(event = "Queued Event") client.setOnline(true) @@ -793,9 +984,10 @@ For runtime details, see **Integration category:** Advanced or production-only -Native Compose apps do not use the Node or browser hybrid-continuity model. The Android SDK stores -SDK consent and profile-continuity state, but it does not own app Contentful response caches, server -cookies, or SSR-to-browser anonymous ID handoff. +Native Compose apps do not use the Node or browser hybrid-continuity model. The Android SDK stores SDK +consent and profile-continuity state in `SharedPreferences`, but it does not own app Contentful response +caches, server cookies, or SSR-to-browser anonymous-id handoff, and there is no built-in cross-platform +id handoff. 1. Keep Contentful response caching in the application fetching layer. 2. Include the application Contentful locale and entry IDs in cache keys when localized content can @@ -804,51 +996,62 @@ cookies, or SSR-to-browser anonymous ID handoff. 4. Do not treat Android `StorageDefaults` as a replacement for server-side profile persistence or cross-device account identity. -For server and browser continuity patterns, use the web and Node guides instead of this native -Compose guide. +For server and browser continuity patterns, use the web and Node guides instead of this native Compose +guide. ## Production checks -Before releasing a Compose integration, verify these points: - -- **Credentials and runtime configuration** - The app uses the intended Optimization client ID, - Contentful environment, API hosts, SDK locale, Android `minSdk`, Java bytecode level, and Maven - artifact version for the release build. -- **Consent behavior** - Default-on accepted startup, explicit opt-in, denial, split - event/persistence consent, and withdrawal match the application's policy and remain consistent - across app relaunches. -- **Event delivery** - Screen, custom event, view, and tap events are accepted only when policy - permits them, blocked calls appear in diagnostics, queued events flush on reconnection or - backgrounding, and forwarded events honor downstream consent policy. -- **Content fallback behavior** - Entries are fetched with one CDA locale and enough include depth; - unresolved links, all-locale payloads, missing selected optimizations, and out-of-range variants - render baseline content instead of crashing. -- **Duplicate tracking prevention** - The app mounts one `OptimizationRoot` for the active SDK tree, - calls screen tracking at route roots, avoids nesting multiple SDK tap wrappers around the same - surface, and uses a scroll-aware helper for list entry view tracking. -- **Privacy and governance** - Preview tooling and preview credentials are gated to debug or - internal builds, SDK consent is not used as the consent record, and profile reset or consent - withdrawal clears the app-owned identifiers that policy requires. -- **Local validation path** - Run Android SDK unit tests with `./gradlew testDebugUnitTest` from - `packages/android/ContentfulOptimization` for changed SDK behavior. Run targeted Compose Maestro - suites with `pnpm implementation:run -- android-sdk test:e2e:compose -- --flow ` from the - repository root for user-visible tracking, preview, navigation, or live-update behavior. +Before releasing a Compose integration, verify these points against the target app build: + +- **Credentials and runtime configuration** — the app uses the intended Optimization client ID, + Contentful environment, SDK Experience/event locale, Android `minSdk`, Java bytecode level, Maven + artifact version, and any approved Experience API or Insights API endpoint overrides; mock or + localhost base URLs are absent from production configuration. +- **Consent behavior** — default-on accepted startup is used only when policy permits it; user-choice + flows call `consent(true | false)`; split event/persistence consent matches your persistence policy; + and rejected consent blocks non-allowed event types, consistently across app relaunches. +- **Event delivery** — screen, entry view, entry tap, Custom Flag, and custom business events are + accepted or blocked according to consent state, blocked calls appear in diagnostics, queued events + flush on reconnection or backgrounding, and forwarded events honor downstream consent policy. +- **Content fallback behavior** — entries are fetched with one CDA locale and enough include depth for + optimized entries, and unresolved links, all-locale payloads, missing selected optimizations, and + out-of-range variants render baseline content instead of crashing. +- **Duplicate-tracking prevention** — the app mounts one `OptimizationRoot` for the active SDK tree, + places screen tracking at route roots with one path per route, avoids nesting multiple SDK tap + wrappers around the same surface, and uses `OptimizationLazyColumn` for list entry view tracking. +- **Privacy and governance** — preview tooling and preview credentials are gated to debug or internal + builds, SDK consent is not used as the consent record, forwarded analytics payloads apply destination + consent and do not replay blocked events, and profile reset or consent withdrawal clears the + app-owned identifiers that policy requires. +- **Local validation path** — validate against the Android reference implementation or your app's own + targeted Compose instrumentation flow before relying on production telemetry. + +**Reference excerpt:** + +```bash +# These run against this repository's maintained Android reference implementation, not your app. +# SDK unit tests, from packages/android/ContentfulOptimization: +./gradlew testDebugUnitTest +# Targeted Compose Maestro suites, from the monorepo root: +pnpm implementation:run -- android-sdk test:e2e:compose -- --flow +``` ## Troubleshooting -| Symptom | Likely cause | Check | -| -------------------------------------- | ----------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------- | -| `No OptimizationClient provided` | A composable called SDK helpers outside `OptimizationRoot`. | Move the composable under the root that owns the SDK client. | -| Entries always render baseline content | The entry is not optimized, selected optimizations are missing, links are unresolved, or the CDA payload is all-locale. | Verify consent, screen or identify events, `include`, concrete `locale`, and `fields.nt_experiences`. | -| Tap handler does not run | `trackTaps = false` disabled the SDK tap wrapper, including the supplied `onTap`. | Remove the explicit `trackTaps = false` or handle the click outside `OptimizedEntry`. | -| View tracking is inconsistent in lists | The entry cannot read the active list viewport. | Use `OptimizationLazyColumn` for `LazyColumn` content or provide an app-owned tracking path. | -| Screen events are duplicated | `ScreenTrackingEffect` is mounted in repeated child composables. | Move the effect to the route or destination root and keep screen names stable. | -| Preview panel is missing | `PreviewPanelConfig` is not passed, `enabled` is `false`, or the build gate excludes it. | Verify the debug or internal-build condition and the `OptimizationRoot` preview config. | +| Symptom | Likely cause | Check | +| -------------------------------------- | ----------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------ | +| `No OptimizationClient provided` | A composable called SDK helpers outside `OptimizationRoot`. | Move the composable under the `OptimizationRoot` that owns the SDK client. | +| Entries always render baseline content | The entry is not optimized, selected optimizations are missing, links are unresolved, or the CDA payload is all-locale. | Verify consent, a `screen` or `identify` event, `include` depth, a concrete `locale`, and `fields.nt_experiences`. | +| Entry view or tap events are missing | Tracking was opted out, consent does not permit `trackView`/`trackClick`, or the list lacks a scroll context. | Confirm `trackViews`/`trackTaps`, consent, the dwell threshold, and `OptimizationLazyColumn` for lists. | +| Screen events duplicate or go missing | `ScreenTrackingEffect` is placed in repeated child composables, or more than one path tracks the route. | Place the effect at the route or destination root, keep names stable, and use one screen-tracking path. | +| Preview panel shows identifiers only | No `PreviewContentfulClient` was passed, so the panel cannot fetch definitions. | Pass a `PreviewContentfulClient` so the panel fetches `nt_audience`/`nt_experience` and shows names. | ## Reference implementations to compare against -- [Android reference implementation](../../implementations/android-sdk/README.md) - Demonstrates +- [Android reference implementation](../../implementations/android-sdk/README.md) — the maintained Compose and Android Views shells that exercise native Android bridge behavior, single-locale CDA fetching, entry resolution, interaction tracking, screen tracking, live updates, `getMergeTagValue(...)`, Custom Flags, event diagnostics, 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 index ff12bee2..14891199 100644 --- 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 @@ -1,21 +1,64 @@ # Integrating the Optimization Android SDK in an Android Views app -Use this guide when you want to add Optimization, Analytics, screen tracking, Custom Flags, MergeTag -rendering, and preview overrides to a native Android application built with XML layouts or Android -Views. Use the Compose guide when the screen is built with Jetpack Compose: -[Integrating the Optimization Android SDK in a Jetpack Compose app](./integrating-the-optimization-android-sdk-in-a-compose-app.md). +Use this guide to add Contentful personalization to a native Android app built with XML layouts and +Android Views. By the end of the quick start, the SDK is running from your application and one screen +event has passed the SDK's consent gate, with a visible label confirming it. + +**New to personalization?** Here is the whole idea in five points: + +- In Contentful you author **variants** of an entry and attach them to an **experience** — a rule + that decides which visitors see which variant. +- As the app runs, Contentful's **Experience API** looks at who the visitor is and picks the variant + for each experience. Swapping a fetched entry for its picked variant is called **resolving** the + entry. +- The Experience API also returns a **profile**: the anonymous, per-visitor identity and state used + to keep personalization consistent across requests or app launches. +- Your app hands a Contentful entry to the SDK at the point where that entry becomes output. The SDK + gives back the selected variant, or the original entry when no variant applies—the **baseline + fallback**. +- You render the returned entry with the same application components you already use. + +The Android SDK persists the profile in `SharedPreferences` across app launches when persistence +consent allows it. + +That is enough to start. The guide introduces policy and optional capabilities at the point you need +them. + +You will get there in two milestones: + +- **Milestone 1 — the SDK initialized from your application and one accepted screen event (the quick + start below).** Once your app also hands the SDK a fetched Contentful entry, that entry resolves to + a variant or the baseline through `OptimizedEntryView` and `resolveOptimizedEntry` (the + [Contentful fetching and entry resolution](#contentful-fetching-and-entry-resolution) section). + This is complete and shippable on its own. +- **Milestone 2 — the opt-in layers (later).** Consent handoff, interaction tracking, identity, + Custom Flags, live updates, the preview panel, and offline delivery, each introduced by the section + that needs it. Start with [Consent and privacy-policy handoff](#consent-and-privacy-policy-handoff). + +This guide uses `com.contentful.java:optimization-android`. Android Views apps drive the SDK through +an application-scoped `OptimizationManager` singleton: you initialize it once from your `Application`, +then read `OptimizationManager.client` from the activities and fragments that track events or resolve +entries. The SDK does not replace your Contentful client — your app still owns Contentful fetching, +link resolution, consent UX, identity policy, navigation, caching, and rendering. If your screens are +built with Jetpack Compose instead, use the +[Integrating the Optimization Android SDK in a Jetpack Compose app](./integrating-the-optimization-android-sdk-in-a-compose-app.md) +guide. ## Quick start -This path uses the Android/native default pre-consent allow-list, where `screen` can emit before an -explicit consent decision. If your policy requires strict opt-in before any Optimization event, set -`allowedEventTypes = emptyList()` and complete the consent handoff section before sending events or -expecting selected variants. +Most Android Views apps share one shape: an `Application` subclass runs process-wide setup, and an +`Activity` presents content. This quick start assumes that shape and proves the smallest result: **the +SDK initializes from your application and one screen event is accepted, and a visible label flips to +confirm it.** It initializes one manager in `Application.onCreate`, registers the subclass in the +manifest, and tracks the current screen from an activity's `onResume`. -This path initializes the SDK, emits one screen event for selection context, fetches one -single-locale Contentful entry, and renders the resolved entry in an Android View. +This quick start assumes your application policy permits Optimization to start with accepted consent +and renders no end-user consent UI, so it seeds `StorageDefaults(consent = true)` — the shorthand +that accepts both consent axes at once. If personalization must wait for a consent decision, keep this +structure and add the [Consent and privacy-policy handoff](#consent-and-privacy-policy-handoff) step +before you ship; it explains the two axes and the split form that sets them separately. -1. Add the Android SDK dependency to the application module. +1. Add the SDK dependency to your application module from Maven Central. **Copy this:** @@ -29,98 +72,152 @@ single-locale Contentful entry, and renders the resolved entry in an Android Vie } ``` -2. Initialize the process-wide Views client. +2. Initialize the SDK from your `Application` subclass. `OptimizationManager.initialize(...)` is a + normal (non-suspend) call that constructs the process-wide client and starts it in the background; + activities read `OptimizationManager.client` afterward. **Adapt this to your use case:** - ```kotlin - class MyApplication : Application() { - override fun onCreate() { - super.onCreate() - - val appLocale = "en-US" - - OptimizationManager.initialize( - context = this, - config = OptimizationConfig( - clientId = "your-client-id", - environment = "main", - locale = appLocale, - logLevel = if (BuildConfig.DEBUG) { - OptimizationLogLevel.debug - } else { - OptimizationLogLevel.error - }, - ), - ) - } - } + ```diff + +import com.contentful.optimization.core.OptimizationConfig + +import com.contentful.optimization.core.OptimizationLogLevel + +import com.contentful.optimization.core.StorageDefaults + +import com.contentful.optimization.views.OptimizationManager + import android.app.Application + + class MyApplication : Application() { + override fun onCreate() { + super.onCreate() + + + // Initialize once for the process, before any activity reads OptimizationManager.client. + + // StorageDefaults carries startup state; consent = true accepts both consent axes now. + + OptimizationManager.initialize( + + context = this, + + config = OptimizationConfig( + + clientId = "your-optimization-client-id", + + // environment defaults to "main"; pass it only when your setup differs. + + locale = "en-US", + + defaults = StorageDefaults(consent = true), + + logLevel = OptimizationLogLevel.debug, + + ), + + ) + } + } ``` -3. Emit one screen event, fetch a single-locale Contentful entry with linked optimization data, and - render it through `OptimizedEntryView`. + The unchanged lines above are illustrative context to match against your own `Application` + subclass, not a block to paste over it. If your app has no `Application` subclass yet, the whole + file is new. `StorageDefaults` is an SDK config type that carries the SDK's startup state, + including the two consent axes; `StorageDefaults(consent = true)` grants both at launch. - **Adapt this to your use case:** + Then register the subclass in `AndroidManifest.xml` with `android:name`. Without it, + `Application.onCreate` never runs and the SDK never initializes. - ```kotlin - class HomeActivity : AppCompatActivity() { - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) + **Adapt this to your use case:** - val heroSlot = OptimizedEntryView(this).apply { - accessibilityIdentifier = "content-entry-home-hero" - setContentRenderer { resolvedEntry -> - // Render the resolver output; it falls back to baseline content. - HeroBinder.create(context, resolvedEntry) - } - } - setContentView(heroSlot) + ```diff + + ``` - lifecycleScope.launch { - // Wait until the bridge can accept events and resolve optimized entries. - OptimizationManager.client.isInitialized.first { it } +3. Track the current screen from an activity and reflect the outcome in a label. `HomeActivity` below + is illustrative app shape — adapt it to a screen you already render, keeping the two stream + subscriptions and the `ScreenTracker.trackScreen` call in `onResume`. - // Emit screen context once before fetching content that can use selected variants. - OptimizationManager.client.screen("Home") + **Adapt this to your use case:** - // Your app owns Contentful fetching; request one concrete locale with resolved links. - val heroEntry = contentfulClient.fetchEntry( - id = "home-hero", - include = 10, - locale = "en-US", - ) + ```diff + +import androidx.lifecycle.Lifecycle + +import androidx.lifecycle.lifecycleScope + +import androidx.lifecycle.repeatOnLifecycle + +import com.contentful.optimization.views.OptimizationManager + +import com.contentful.optimization.views.ScreenTracker + +import kotlinx.coroutines.launch + import android.os.Bundle + import android.widget.TextView + import androidx.appcompat.app.AppCompatActivity + + class HomeActivity : AppCompatActivity() { + + private val statusLabel by lazy { findViewById(R.id.optimization_status) } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContentView(R.layout.activity_home) + + statusLabel.text = "Waiting for Optimization" + + + + // eventStream carries accepted events. It is a replay-buffered SharedFlow, so this + + // collector still receives the screen event even if it subscribes just after it fired. + + lifecycleScope.launch { + + repeatOnLifecycle(Lifecycle.State.STARTED) { + + OptimizationManager.client.eventStream.collect { event -> + + // Screen events carry type == "screen" (the value the SDK emits). + + if (event["type"] as? String == "screen") { + + statusLabel.text = "Optimization screen event accepted" + + } + + } + + } + + } + + // blockedEventStream carries events the consent or allow-list gate stopped. + + lifecycleScope.launch { + + repeatOnLifecycle(Lifecycle.State.STARTED) { + + OptimizationManager.client.blockedEventStream.collect { blocked -> + + statusLabel.text = "Optimization screen event blocked: ${blocked.reason}" + + } + + } + + } + } - heroSlot.setEntry(heroEntry) - } - } - } + + override fun onResume() { + + super.onResume() + + // ScreenTracker tracks the current screen and retries once the SDK is ready and consent + + // allows, so it is safe to call here without awaiting initialization yourself. + + ScreenTracker.trackScreen("Home") + + } + } ``` -4. Verify that the entry renders either its baseline content or selected variant content in the - Android View. + The surrounding activity code is illustrative context to match against your own screen, not a block + to paste over it. `statusLabel` is a reader-owned `TextView`; give it an id in the layout you + already use for this screen. + +4. Verify the first run. Build and run the application module on a device or emulator. The status + label reads `Optimization screen event accepted`. It flips when the `screen` event reaches + `eventStream`, which carries events that passed the SDK's local consent and allow-list gate. Here, + "accepted" means the SDK let the event through and queued it for delivery — it does not confirm + that Contentful received the event, only that the local gate let it through. Because + `StorageDefaults(consent = true)` grants consent and `screen` is on the SDK's default pre-consent + allow-list, the event is accepted. + + If the label reads `Optimization screen event blocked: `, the consent or allow-list gate + rejected the event and the reason names why. If the label stays on `Waiting for Optimization`, no + event reached either stream, which means the SDK never initialized. Filter logcat by the tag + `ContentfulOptimization`: a successful start logs `[init] SDK initialized successfully` (visible + because `logLevel = OptimizationLogLevel.debug`). If that line never appears, the most common + Android cause is the missing manifest registration — without `android:name=".MyApplication"` on + ``, `Application.onCreate` never runs and `OptimizationManager.initialize(...)` is + never called.
Table of Contents -- [Required setup](#required-setup) +- [Before you start](#before-you-start) - [Core integration](#core-integration) - [SDK installation and process-wide client](#sdk-installation-and-process-wide-client) - [Consent and privacy-policy handoff](#consent-and-privacy-policy-handoff) - - [Contentful fetching and entry shape](#contentful-fetching-and-entry-shape) - - [Entry rendering and fallback behavior](#entry-rendering-and-fallback-behavior) - - [Screen and custom event tracking](#screen-and-custom-event-tracking) + - [Contentful fetching and entry resolution](#contentful-fetching-and-entry-resolution) + - [Screen and navigation tracking](#screen-and-navigation-tracking) - [Entry interaction tracking](#entry-interaction-tracking) + - [Identity, profile continuity, and reset](#identity-profile-continuity-and-reset) - [Optional integrations](#optional-integrations) - - [Scrollable entry lists](#scrollable-entry-lists) - - [Identity and reset controls](#identity-and-reset-controls) - - [Runtime locale changes](#runtime-locale-changes) - - [Analytics diagnostics and forwarding](#analytics-diagnostics-and-forwarding) + - [Custom events and analytics diagnostics](#custom-events-and-analytics-diagnostics) - [Custom Flags and MergeTag rendering](#custom-flags-and-mergetag-rendering) - - [Live updates and variant locking](#live-updates-and-variant-locking) -- [Advanced integrations](#advanced-integrations) + - [Live updates and locked variants](#live-updates-and-locked-variants) - [Preview panel](#preview-panel) - - [Offline delivery and queue observability](#offline-delivery-and-queue-observability) +- [Advanced integrations](#advanced-integrations) + - [Offline delivery, queue observability, and app-owned caching](#offline-delivery-queue-observability-and-app-owned-caching) - [Production checks](#production-checks) - [Troubleshooting](#troubleshooting) - [Reference implementations to compare against](#reference-implementations-to-compare-against) @@ -128,34 +225,38 @@ single-locale Contentful entry, and renders the resolved entry in an Android Vie
-## Required setup - -Use this setup inventory for the full guide: - -| Setup item | Category | Required for quick start | Where to configure | -| -------------------------------------------------------------------- | ------------------------------ | ------------------------ | ---------------------------------------------------------------------------------------------------- | -| Android SDK dependency | Required for first integration | Yes | Application module Gradle dependency | -| Optimization client ID and environment | Required for first integration | Yes | `OptimizationConfig(clientId = ..., environment = ...)` | -| Experience API and Insights API endpoint overrides | Common but policy-dependent | No | `OptimizationApiConfig` for staging, mock, or non-default hosts | -| SDK Experience and event locale | Common but policy-dependent | Conditional | `OptimizationConfig(locale = appLocale)` | -| Consent and profile-continuity policy | Common but policy-dependent | Conditional | `StorageDefaults`, `allowedEventTypes`, `client.consent(...)`, and consent UI | -| Contentful Delivery or Preview API client, credentials, and API host | Required for first integration | Yes | Application-owned Contentful client configuration | -| Single-locale Contentful entries with linked optimization data | Required for first integration | Yes | Application-owned Contentful Delivery API or Content Preview API fetch code | -| Android process entry point | Required for first integration | Yes | `Application.onCreate` with `OptimizationManager.initialize(...)` | -| Activity, Fragment, or navigation lifecycle hooks | Required for first integration | Yes | `ScreenTracker.trackScreen(...)` or direct `client.screen(...)` calls | -| Android Views entry-rendering adapter | Required for first integration | Yes | `OptimizedEntryView` in XML layouts, Activity code, Fragment code, or adapters | -| Entry view and tap tracking defaults | Common but policy-dependent | No | `OptimizationManager.initialize(...)` and per-view `OptimizedEntryView` properties | -| Scrollable entry-list helper | Optional | No | `TrackingRecyclerView` for RecyclerView-based screens | -| Identity, profile, and reset controls | Optional | No | Login, account, logout, or reset handlers calling `client.identify(...)` or reset | -| Runtime locale changes | Optional | No | App locale state, `client.setLocale(...)`, and Contentful refetch logic | -| Analytics diagnostics or forwarding | Optional | No | `client.eventStream`, `client.blockedEventStream`, and app-owned destinations | -| Custom Flags and MergeTag rendering | Optional | No | Views, adapters, or binders that call `getFlag(...)`, `observeFlag(...)`, or `getMergeTagValue(...)` | -| Preview-panel UI and preview Contentful definitions | Advanced or production-only | No | Debug or internal Activity code with `PreviewPanelConfig` and `attachPreviewPanel` | -| Strict pre-consent event policy | Advanced or production-only | Conditional | `OptimizationConfig.allowedEventTypes`, usually `emptyList()` for strict opt-in | -| Offline queue bounds, retry policy, and delivery callbacks | Advanced or production-only | No | `QueuePolicy` in `OptimizationConfig` | - -The SDK does not replace your Contentful delivery client. Your application still owns Contentful -fetching, consent UX, identity policy, routing, caching, and rendering. +## Before you start + +The sections below walk the integration in order. First, gather the few things you can only get from +outside this guide: + +- **An Android Views app and its Gradle build**, able to add a Maven Central dependency and run a + build. The SDK requires `minSdk` 24 or later and Java 11 bytecode, and it publishes to Maven + Central. Package requirements and the published coordinate are in the + [Optimization Android SDK README](../../packages/android/README.md). +- **Contentful delivery credentials** — space ID, delivery token, environment, and one concrete + locale — read from your app's configuration layer, used by your own Contentful fetching. +- **At least one entry with a variant attached to an experience**, authored in Contentful. Without + an authored variant, the integration can still run correctly while returning the baseline, so you + cannot yet distinguish working personalization from a content-authoring gap. For the first + personalized-content test, target all visitors so the test request or visitor matches automatically. +- **Your Optimization project values** — client ID and environment, from your Optimization project + settings. The `environment` defaults to `main`, so pass it only when your setup differs. The + Experience API (which picks variants) and the Insights API (which receives event and interaction + delivery) each have a base URL that defaults correctly; you only set them for mocks or non-default + hosts (see [SDK installation and process-wide client](#sdk-installation-and-process-wide-client)). + +You do not need a setup inventory up front. Everything else — consent, entry resolution, screen +tracking, interaction tracking, identity, live updates, preview, and offline delivery — is introduced +by the section that needs it. + +> [!NOTE] +> +> Read the SDK client ID, Contentful credentials, and any base-URL overrides from your app's own +> configuration layer — `BuildConfig` fields, a Gradle build value, or a generated config type. This +> guide's examples use inline placeholder strings for clarity; the Android reference app centralizes +> these in a shared `AppConfig` because it runs against shared mock defaults. Use whatever +> configuration convention your app already uses and keep it consistent. ## Core integration @@ -163,10 +264,12 @@ fetching, consent UX, identity policy, routing, caching, and rendering. **Integration category:** Required for first integration -1. Confirm that the consuming Android app supports Android `minSdk` 24 or later, Java 11 bytecode, - Kotlin, and Maven Central. The package requirements and published coordinate are documented in - the [Optimization Android SDK README](../../packages/android/README.md). -2. Add the SDK dependency to the app module. +The Android SDK ships as one AAR on Maven Central and runs its shared optimization logic in a small +runtime embedded in the AAR (referred to as the bridge below). That runtime starts asynchronously, so +callers wait for readiness before making direct calls that depend on it. + +1. Confirm the consuming app supports `minSdk` 24 or later, Java 11 bytecode, Kotlin, and Maven + Central, then add the dependency to the application module. **Copy this:** @@ -180,19 +283,24 @@ fetching, consent UX, identity policy, routing, caching, and rendering. } ``` -3. Create one `OptimizationConfig` with the Optimization client ID, Contentful environment, and SDK - Experience or event locale. +2. Build one `OptimizationConfig`. Only `clientId` is required; the rest have working defaults. + 1. Pass `clientId` from your configuration layer. + 2. Pass `environment` only when it is not the Kotlin-side default `"main"`. + 3. Pass `locale` when Experience API requests and event context must use the same language as the + Contentful entries you render. + 4. Set `api = OptimizationApiConfig(...)` (`experienceBaseUrl`/`insightsBaseUrl`) only for mock, + staging, or other non-default endpoints — both default correctly otherwise. + 5. Keep `logLevel` at its default `OptimizationLogLevel.error` in production unless your + operational policy allows more verbose logging. **Adapt this to your use case:** ```kotlin - val appLocale = "en-US" - val optimizationConfig = OptimizationConfig( - clientId = "your-client-id", - environment = "main", - // Match this to the locale used by the app-owned Contentful fetch. - locale = appLocale, + clientId = "your-optimization-client-id", + // environment defaults to "main"; pass it only when your setup differs. + // Keep the SDK event and Experience locale aligned with the CDA entries you render. + locale = "en-US", logLevel = if (BuildConfig.DEBUG) { OptimizationLogLevel.debug } else { @@ -201,11 +309,11 @@ fetching, consent UX, identity policy, routing, caching, and rendering. ) ``` - Pass `api = OptimizationApiConfig(...)` only when the app uses staging, mock, or non-default - Experience API or Insights API hosts. Omit it for the default Contentful Optimization endpoints. - -4. Initialize `OptimizationManager` once for the app process. Production apps usually do this from - `Application.onCreate` before any Activity or Fragment reads `OptimizationManager.client`. +3. Initialize `OptimizationManager` once for the process, from `Application.onCreate`, and register + the `Application` subclass in the manifest (see the quick start). `initialize` is idempotent — the + first call constructs and starts the client; later calls only update the global tracking defaults + and preview client and do not recreate it. Reading `OptimizationManager.client` before `initialize` + throws. **Adapt this to your use case:** @@ -213,30 +321,26 @@ fetching, consent UX, identity policy, routing, caching, and rendering. class MyApplication : Application() { override fun onCreate() { super.onCreate() - - // Initialize once before Activities or Fragments read OptimizationManager.client. - OptimizationManager.initialize( - context = this, - config = optimizationConfig, - ) + // Initialize once before any activity or fragment reads OptimizationManager.client. + OptimizationManager.initialize(context = this, config = optimizationConfig) } } ``` -5. Treat `OptimizationManager.initialize(...)` as asynchronous. `OptimizationManager.client` is - available immediately after initialization is requested, but suspend APIs that depend on the - bridge require `client.isInitialized` to become `true`. +4. Await readiness for direct suspend calls. `OptimizationManager.client` is available immediately, + but the underlying `OptimizationClient.initialize(config)` is a `suspend` function that + `OptimizationManager` launches in the background, so suspend APIs that touch the bridge require + `client.isInitialized` to become `true` first. (Screen tracking through `ScreenTracker` and entry + rendering through `OptimizedEntryView` handle this readiness for you; the await matters when you + call the client directly.) **Copy this:** ```kotlin lifecycleScope.launch { - // Direct suspend APIs require the async bridge initialization to finish first. + // Direct suspend APIs require the background initialization to finish first. OptimizationManager.client.isInitialized.first { it } - OptimizationManager.client.track( - event = "App Ready", - properties = mapOf("surface" to "views"), - ) + OptimizationManager.client.track(event = "App Ready", properties = mapOf("surface" to "views")) } ``` @@ -247,689 +351,677 @@ For lifecycle and coroutine behavior, see **Integration category:** Common but policy-dependent -1. Decide whether the application can start SDK event emission and durable profile continuity before - rendering a consent UI. The SDK stores and applies consent state, but the application or CMP owns - notice text, jurisdiction logic, consent records, and withdrawal policy. -2. For a default-on accepted policy, seed accepted consent during initialization. +Consent policy belongs to your application. The SDK provides the runtime gate; your app or CMP owns +notice text, user choices, consent records, jurisdiction logic, and withdrawal behavior. Consent has +two independent axes: event consent (may the SDK personalize and emit events) and persistence consent +(may the SDK store profile continuity in `SharedPreferences`). + +1. Use `StorageDefaults(consent = true)` at startup only when application policy permits SDK activity + at launch. `StorageDefaults` values take precedence over persisted `SharedPreferences` values on + every launch, so a seeded value can replace a stored choice — apps that persist a user's decision + leave `defaults` unset and call `consent(...)` from resolved policy instead. **Copy this:** ```kotlin val optimizationConfig = OptimizationConfig( - clientId = "your-client-id", + clientId = "your-optimization-client-id", // Seed accepted consent only when your app policy permits event emission at startup. defaults = StorageDefaults(consent = true), ) ``` -3. For a user-choice policy, leave consent unset and call `client.consent(true)` or - `client.consent(false)` from application-owned UI after initialization completes. +2. Leave `defaults` unset when the app must collect a choice before gated events can emit, and call + `consent(...)` from an app-owned banner, CMP callback, or settings flow. `consent(...)` no-ops + before initialization, so wait for readiness before applying a choice. **Adapt this to your use case:** ```kotlin class ConsentActivity : AppCompatActivity() { + private val client get() = OptimizationManager.client + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) + setContentView(R.layout.activity_consent) - acceptButton.isEnabled = false - rejectButton.isEnabled = false - - lifecycleScope.launch { - OptimizationManager.client.isInitialized.first { it } - acceptButton.isEnabled = true - rejectButton.isEnabled = true - } - - acceptButton.setOnClickListener { - // Boolean consent applies to events and durable profile-continuity persistence. - applyConsent(true) - } - - rejectButton.setOnClickListener { - // Rejection blocks gated SDK events such as view, tap, and custom track calls. - applyConsent(false) - } - - lifecycleScope.launch { - repeatOnLifecycle(Lifecycle.State.STARTED) { - OptimizationManager.client.state.collect { state -> - consentBanner.isVisible = state.consent == null - } - } - } + // acceptButton and rejectButton are reader-owned views from your layout. + acceptButton.setOnClickListener { applyConsent(true) } + rejectButton.setOnClickListener { applyConsent(false) } } private fun applyConsent(accepted: Boolean) { lifecycleScope.launch { // consent(...) no-ops before initialization, so wait before applying the choice. - OptimizationManager.client.isInitialized.first { it } - OptimizationManager.client.consent(accepted) + client.isInitialized.first { it } + // Boolean consent sets both event emission and durable profile continuity. + client.consent(accepted) } } } ``` -4. Use object-form consent when event emission is allowed but durable profile-continuity storage - must stay session-only. +3. Use the split form when event emission is allowed but durable profile continuity must stay + session-only. `consent(events = false)` withdraws event consent and purges queues but leaves + persistence unless you also pass `persistence = false`; `consent(false)` clears both axes, purges + queues, and clears durable continuity while in-memory state stays usable until reset or teardown. **Adapt this to your use case:** ```kotlin lifecycleScope.launch { OptimizationManager.client.isInitialized.first { it } + // Emit events but keep profile continuity session-only. OptimizationManager.client.consent(events = true, persistence = false) } ``` -By default, Android/native `identify` and `screen` are allowed before consent so a mobile journey -can establish profile context and anonymous screen analytics. View, tap, custom `track(...)`, and -most other events are blocked until event consent is accepted. For strict opt-in before any -Optimization event, replace the default allow-list during initialization: +When `allowedEventTypes` is unset, the SDK's default pre-consent allow-list lets `identify` and +`screen` emit before event consent, so a mobile journey can establish profile context and anonymous +screen analytics. Entry views, entry taps, and custom `track` events are blocked until consent is +accepted. To require strict opt-in before any Optimization event, replace the default allow-list +during initialization. **Copy this:** ```kotlin val strictConfig = OptimizationConfig( - clientId = "your-client-id", - // Empty means no SDK events are allowed before explicit consent. + clientId = "your-optimization-client-id", + // Empty means no SDK event emits before explicit consent. allowedEventTypes = emptyList(), ) ``` -For cross-SDK policy guidance, see -[Consent management in the Optimization SDK Suite](../concepts/consent-management-in-the-optimization-sdk-suite.md). +For the consent responsibility model and blocked-event behavior, see +[Consent management in the Optimization SDK Suite](../concepts/consent-management-in-the-optimization-sdk-suite.md#event-allow-lists-and-blocked-events). -### Contentful fetching and entry shape +### Contentful fetching and entry resolution **Integration category:** Required for first integration -1. Keep Contentful entry fetching in the application layer. The Android SDK resolves entries that - the app provides; it does not fetch Contentful entries for your UI. -2. Fetch entries with one Contentful locale and enough include depth for linked optimization data. - Do not pass all-locale CDA responses or `locale=*` payloads into `OptimizedEntryView`. -3. Use the same application Contentful locale for the SDK `locale` when Experience API responses and - event context must match the rendered content language. - -**Follow this pattern:** - -```kotlin -suspend fun fetchHomeEntries( - contentfulClient: ContentfulDeliveryClient, - appLocale: String, -): List> { - return contentfulClient.getEntries( - contentType = "homePage", - // Resolve linked optimization entries before handing the payload to OptimizedEntryView. - include = 10, - // Use one concrete Contentful locale; do not pass locale=* responses to the SDK. - locale = appLocale, - ) -} -``` +The Android SDK has no fetch-by-ID path, so your app always owns the Contentful Delivery API fetch. +You fetch the entry, hand it to the SDK, and the SDK resolves it locally against the current +visitor's selected optimizations — the SDK's current set of picked variants, one per experience the +profile matched. + +`OptimizedEntryView` is the Views renderer: it detects an optimized entry by the SDK-fixed +`fields.nt_experiences` link field, observes the client's `selectedOptimizations` (plural — the +current set), resolves the entry, and renders the result through a renderer you supply. +`nt_experiences` links each experience's `nt_variants` and audience entries; these are SDK-owned +Optimization content-model names, not names you choose, so your fetch must `include` deeply enough to +pull them back in one payload. Each resolved result carries a single `selectedOptimization` +(singular) — the one selection applied to that entry. Note the one-letter difference: +`selectedOptimizations` is the set the view observes, while `selectedOptimization` is the one applied +to a given entry. + +1. Keep Contentful fetching in the application layer, with one concrete locale and enough include + depth for the linked optimization data. Do not pass all-locale CDA responses or `locale=*` payloads + to `OptimizedEntryView` — they fall back to baseline. +2. Add `OptimizedEntryView` from XML or create it in activity, fragment, or adapter code. -4. Pass direct single-locale entry maps to the Views adapter after the SDK is initialized or after - the app has enough profile state for its rendering policy. - -**Adapt this to your use case:** - -```kotlin -lifecycleScope.launch { - val appLocale = getAppLocale() - - // Wait until the SDK can resolve optimized entries against profile state. - OptimizationManager.client.isInitialized.first { it } - - val entries = contentfulClient.fetchHomeEntries(locale = appLocale) - renderEntries(entries) -} -``` + **Copy this:** -The resolver expects direct field values such as `fields.nt_experiences` and linked variant entries -such as `optimizationEntry.fields.nt_variants`. For the full locale model, see -[Locale handling in the Optimization SDK Suite](../concepts/locale-handling-in-the-optimization-sdk-suite.md). -For the Contentful entry contract and fallback rules, see -[Entry optimization and variant resolution](../concepts/entry-personalization-and-variant-resolution.md#single-locale-cda-entry-contract). + ```xml + + ``` -### Entry rendering and fallback behavior +3. Set a renderer that turns the resolved entry map into a child `View`, then call `setEntry(...)` + with the fetched baseline entry. -**Integration category:** Required for first integration + **Adapt this to your use case:** -1. Add `OptimizedEntryView` from XML or create it from Activity, Fragment, or adapter code. + ```kotlin + val heroSlot = findViewById(R.id.hero_slot) + // Optional, reader-chosen: OptimizedEntryView.accessibilityIdentifier sets the view's + // contentDescription so tests and accessibility tooling can find the slot. Omit it if you don't need it. + heroSlot.accessibilityIdentifier = "content-entry-home-hero" + heroSlot.setContentRenderer { resolvedEntry -> + // ContentEntryBinder is reader-owned: your code that turns a resolved entry map into a View. + // resolvedEntry is the variant when one applies, or the baseline entry otherwise. + ContentEntryBinder.create(context = heroSlot.context, entry = resolvedEntry) + } -**Copy this:** + lifecycleScope.launch { + // contentfulFetcher is reader-owned: your CDA fetch and link resolution. One concrete + // locale, include depth deep enough to resolve nt_experiences and nt_variants in one payload. + val heroEntry = contentfulFetcher.fetchEntry(id = "home-hero", include = 10, locale = "en-US") + // The view resolves the entry locally and re-resolves as profile state arrives. + heroSlot.setEntry(heroEntry) + } + ``` -```xml - -``` +4. Treat baseline fallback as expected behavior. `resolveOptimizedEntry` (which `OptimizedEntryView` + calls for you) is a `suspend`, fail-soft resolver. It returns a `ResolvedOptimizedEntry` — an + SDK-owned result wrapper carrying `entry` (the entry to render) and `selectedOptimization` (the + applied selection, or null). Its `entry` is the resolved variant when one applies and the baseline + entry unchanged otherwise — when the client is not initialized, when the entry is not optimized, + when no selected optimization matches, when linked optimization data is missing, on all-locale + payloads, or when the variant is not in the payload — so it never throws or breaks the UI. -2. Set `accessibilityIdentifier`, set a renderer that turns the resolved entry map into a child - `View`, then call `setEntry(...)` with the baseline Contentful entry. +For custom rendering surfaces, call `resolveOptimizedEntry(baseline, selectedOptimizations)` directly +instead of through the view. -**Adapt this to your use case:** +**Follow this pattern:** ```kotlin -val heroSlot = findViewById(R.id.hero_slot) - -heroSlot.accessibilityIdentifier = "content-entry-home-hero" -heroSlot.setContentRenderer { resolvedEntry -> - // Render the resolver output; it is the baseline fallback when no variant resolves. - HeroBinder.create(context = heroSlot.context, entry = resolvedEntry) -} -heroSlot.setEntry(heroEntry) +// Passing null uses the SDK's current selection state; pass an explicit snapshot to lock a screen. +val result = OptimizationManager.client.resolveOptimizedEntry(baseline = entry) +// Always render result.entry; result.selectedOptimization is the applied selection, or null. +render(result.entry) ``` -3. Render the map passed to the renderer. It can be the baseline entry, the selected variant entry, - or the baseline fallback when no variant resolves. -4. Treat baseline fallback as expected behavior. Missing optimization data, unresolved Contentful - links, all-locale payloads, absent selected optimizations, and out-of-range variants all render - the baseline entry instead of throwing. - -`OptimizedEntryView` mirrors the Compose `OptimizedEntry` behavior for non-optimized entries, -variant resolution, variant locking, live updates, view tracking, tap tracking, and -`accessibilityIdentifier` as `contentDescription`. For deeper resolution mechanics, see -[Entry optimization and variant resolution](../concepts/entry-personalization-and-variant-resolution.md#fallback-behavior). +If the app locale changes at runtime, call `client.setLocale(locale)` to update the SDK Experience +and event locale, then refetch Contentful entries in the new locale and re-render — `setLocale` +updates the SDK locale only and does not refetch entries; it throws before initialization or on an +invalid locale. For the entry contract and fallback rules, see +[Entry optimization and variant resolution](../concepts/entry-personalization-and-variant-resolution.md#single-locale-cda-entry-contract), +and for the locale boundary see +[Locale handling in the Optimization SDK Suite](../concepts/locale-handling-in-the-optimization-sdk-suite.md). -### Screen and custom event tracking +### Screen and navigation tracking **Integration category:** Required for first integration -1. Emit screen events when an Activity or Fragment becomes the active application screen. For simple - Activity screens, call `ScreenTracker.trackScreen(...)` from `onResume`. +The quick start tracked one screen from `onResume`. Real Android navigation repeats lifecycle +callbacks across activity, fragment, and in-activity transitions, so choose the call that matches the +event you want. -**Copy this:** +`ScreenTracker.trackScreen(name)` is the idiomatic Views API. It sets the current screen name, +observes the client's state, and calls `trackCurrentScreen` on each state emission — so it retries +once the SDK is ready and consent allows, and it swallows early-lifecycle failures. `trackCurrentScreen` +deduplicates the current route in the bridge by `routeKey` (which defaults to `name`), so a repeat of +the same current screen is skipped. Use plain `client.screen(name, properties)` only for intentional +one-off raw screen events, which carry no dedupe, or when a screen event needs properties. The suspend +emitters `screen` and `trackCurrentScreen` return an `EventEmissionResult` — an SDK result type with +an `accepted` flag (and optional `data`) that is `true` when the event passed the local consent and +allow-list gate. -```kotlin -override fun onResume() { - super.onResume() - // Track once per visible screen lifecycle instead of from repeated child view binding. - ScreenTracker.trackScreen("Home") -} -``` +1. Call `ScreenTracker.trackScreen(name)` from `Activity.onResume` (or `Fragment.onResume`) so it + fires once per visible screen lifecycle. -2. Emit a new screen event after an in-Activity navigation state change when multiple logical - screens share one Activity. + **Copy this:** -**Adapt this to your use case:** + ```kotlin + override fun onResume() { + super.onResume() + // Track once per visible screen lifecycle, not from repeated child-view binding. + ScreenTracker.trackScreen("Home") + } + ``` -```kotlin -private fun transitionTo(destination: Destination) { - renderDestination(destination) +2. Emit a new screen event after an in-activity navigation state change when several logical screens + share one activity. - // Emit the screen event after the app has committed its navigation state. - when (destination) { - Destination.HOME -> ScreenTracker.trackScreen("NavigationHome") - Destination.DETAIL -> ScreenTracker.trackScreen("NavigationDetail") - } -} -``` + **Adapt this to your use case:** + + ```kotlin + private fun transitionTo(destination: Destination) { + // renderDestination is reader-owned: your own view-state swap. + renderDestination(destination) + // Emit the screen event after the app has committed its navigation state. + when (destination) { + Destination.HOME -> ScreenTracker.trackScreen("NavigationHome") + Destination.DETAIL -> ScreenTracker.trackScreen("NavigationDetail") + } + } + ``` -3. Use direct client calls when a screen event needs properties or when a business interaction needs - a custom event. +3. Use a direct client call when a screen event needs properties. -**Adapt this to your use case:** + **Adapt this to your use case:** -```kotlin -lifecycleScope.launch { - // Wait for bridge initialization before calling suspend client APIs directly. - OptimizationManager.client.isInitialized.first { it } - - OptimizationManager.client.screen( - name = "BlogPostDetail", - properties = mapOf("postId" to postId), - ) - - OptimizationManager.client.track( - event = "Purchase Completed", - properties = mapOf("sku" to "sku-1"), - ) -} -``` + ```kotlin + lifecycleScope.launch { + // Wait for initialization before calling suspend client APIs directly. + OptimizationManager.client.isInitialized.first { it } + // postId is reader-owned: the value that identifies this destination. + OptimizationManager.client.screen(name = "BlogPostDetail", properties = mapOf("postId" to postId)) + } + ``` -`ScreenTracker` uses the same client as `OptimizationManager.client` and swallows initialization -failures so early lifecycle calls do not crash the host Activity. Wait for `isInitialized` before -calling suspend APIs directly. +For shared tracking mechanics and event delivery, see +[Android SDK runtime and interaction mechanics](../concepts/android-sdk-runtime-and-interaction-mechanics.md#tracking-mechanics). ### Entry interaction tracking **Integration category:** Common but policy-dependent -1. Leave global view and tap tracking defaults enabled when your Analytics and privacy policy - permits them. Pass `trackViews = false` or `trackTaps = false` globally when a surface must opt - out by default. +`OptimizedEntryView` wires entry view tracking and tap tracking for you: it installs a view-timing +controller for visibility-based view events and a click listener for taps, so you own only the +enablement policy, not the geometry or the payloads. Entry views deliver on the wire as `component` +events; entry taps as `component_click`. Both still respect the SDK consent gate. Your app decides +whether these events are allowed by its Analytics and privacy policy. -**Copy this:** +1. Leave the global `trackViews` and `trackTaps` defaults enabled when your policy permits them. Pass + `trackViews = false` or `trackTaps = false` to `initialize` when a surface must opt out by default. -```kotlin -OptimizationManager.initialize( - context = this, - config = optimizationConfig, - // Opt out globally only when this app must not emit tap analytics by default. - trackTaps = false, -) -``` - -2. Override tracking per entry when a component needs different behavior from the global default. - -**Adapt this to your use case:** - -```kotlin -OptimizedEntryView(context).apply { - // Disable SDK view tracking for entries tracked by a different application surface. - trackViews = false - setContentRenderer { resolvedEntry -> - HeroBinder.create(context, resolvedEntry) - } - setEntry(hero) -} - -OptimizedEntryView(context).apply { - setContentRenderer { resolvedEntry -> - CtaBinder.create(context, resolvedEntry) - } - setEntry(cta) -} - -OptimizedEntryView(context).apply { - onTap = { baselineEntry -> - navigateToEntry(baselineEntry) - } - // Direct onTap is a per-entry override; it keeps this entry tappable even - // when global trackTaps is false. - setContentRenderer { resolvedEntry -> - CtaBinder.create(context, resolvedEntry) - } - setEntry(cta) -} -``` + **Copy this:** -3. Set per-entry `trackTaps = false` only when that entry must opt out of the SDK tap handling path. - Do not combine it with `onTap`; if a component needs an app-owned tap handler without SDK tap - tracking, attach a normal Android click listener inside the rendered child view instead. -4. Tune view-tracking timing only when the default 2 second dwell time, 80% visible ratio, or 5 - second update interval does not match the component. + ```kotlin + OptimizationManager.initialize( + context = this, + config = optimizationConfig, + // Opt out globally only when this app must not emit tap analytics by default. + trackTaps = false, + ) + ``` -**Adapt this to your use case:** +2. Override tracking per entry when a component needs different behavior from the global default. A + non-null `onTap` keeps the entry tappable even when global `trackTaps` is on; setting per-entry + `trackViews = false` or `trackTaps = false` opts that one entry out. -```kotlin -OptimizedEntryView(context).apply { - // Changing timing changes when verification-visible view events are emitted. - minVisibleRatio = 0.75 - dwellTimeMs = 1500 - viewDurationUpdateIntervalMs = 5000 - setContentRenderer { resolvedEntry -> - PromoBinder.create(context, resolvedEntry) - } - setEntry(promo) -} -``` + **Adapt this to your use case:** -SDK view and tap event emission still respects the SDK consent gate. For interaction timing, -component event metadata, and offline delivery behavior, see -[Android SDK runtime and interaction mechanics](../concepts/android-sdk-runtime-and-interaction-mechanics.md#tracking-mechanics). + ```kotlin + OptimizedEntryView(context).apply { + // Disable SDK view tracking for entries tracked by a different application surface. + trackViews = false + setContentRenderer { resolvedEntry -> ContentEntryBinder.create(context, resolvedEntry) } + setEntry(hero) + } -## Optional integrations + OptimizedEntryView(context).apply { + // onTap is a per-entry override; navigateToEntry is reader-owned navigation. + onTap = { baselineEntry -> navigateToEntry(baselineEntry) } + setContentRenderer { resolvedEntry -> ContentEntryBinder.create(context, resolvedEntry) } + setEntry(cta) + } + ``` -### Scrollable entry lists +3. Tune view-tracking timing only when the default 2-second dwell time, 80% visible ratio, or + 5-second update interval does not match the component. -**Integration category:** Optional + **Adapt this to your use case:** -1. Use `TrackingRecyclerView` when a `RecyclerView` screen needs an extra scroll-frame signal for - descendant `OptimizedEntryView` visibility checks. + ```kotlin + OptimizedEntryView(context).apply { + // Changing timing changes when view events are emitted for this entry. + minVisibleRatio = 0.75 + dwellTimeMs = 1500 + viewDurationUpdateIntervalMs = 5000 + setContentRenderer { resolvedEntry -> ContentEntryBinder.create(context, resolvedEntry) } + setEntry(promo) + } + ``` -**Adapt this to your use case:** +4. For a `RecyclerView` screen, use the SDK's `TrackingRecyclerView` (a `RecyclerView` subclass) so + descendant `OptimizedEntryView` instances re-check visibility on each scroll frame. It is an + optional, redundant signal — each `OptimizedEntryView` also re-checks from its own layout callbacks + — so plain scroll containers work without it. Keep item views stable across rebinding so dwell + timers are not reset mid-view. -```kotlin -val recyclerView = TrackingRecyclerView(this).apply { - // RecyclerView scrolls can need an extra visibility signal for descendant entry views. - layoutManager = LinearLayoutManager(this@HomeActivity) - adapter = ContentEntryAdapter(entries) -} -``` + **Adapt this to your use case:** -2. In each adapter item, wrap the rendered Contentful entry with `OptimizedEntryView`. + ```kotlin + val recyclerView = TrackingRecyclerView(this).apply { + layoutManager = LinearLayoutManager(this@HomeActivity) + // ContentEntryAdapter is reader-owned; each item view holder wraps its entry in an + // OptimizedEntryView and calls setContentRenderer + setEntry on a stable instance. + adapter = ContentEntryAdapter(entries) + } + ``` -**Adapt this to your use case:** +For interaction timing, component event metadata, and duplicate prevention, see +[Android SDK runtime and interaction mechanics](../concepts/android-sdk-runtime-and-interaction-mechanics.md#tracking-mechanics). -```kotlin -class ContentEntryViewHolder( - private val parent: ViewGroup, -) : RecyclerView.ViewHolder( - OptimizedEntryView(parent.context), - ) { - private val optimizedEntryView = itemView as OptimizedEntryView - - fun bind(entry: Map) { - // Keep item views stable so dwell timers are not reset by repeated rebinding. - optimizedEntryView.setContentRenderer { resolvedEntry -> - ContentEntryBinder.create(parent.context, resolvedEntry) - } - optimizedEntryView.setEntry(entry) - } -} -``` +### Identity, profile continuity, and reset -`OptimizedEntryView` also rechecks visibility from layout callbacks, so plain `ScrollView` screens -can use normal child views unless a RecyclerView adapter needs the additional helper. +**Integration category:** Common but policy-dependent -### Identity and reset controls +Identity policy belongs to the application. The SDK can identify a visitor, update selected +optimizations and `changes` (the inline field and flag values the Experience API returned for the +visitor) from Experience API responses, persist profile-continuity state when allowed, and reset +SDK-managed profile state — but it does not decide when a user becomes known or how account data is +governed. -**Integration category:** Optional +1. Call `identify(userId, traits)` after sign-in or when the app has a stable application user ID. + Gate traits before sending sensitive or restricted data. -1. Call `identify(...)` after login, account selection, or another application-owned identity - decision. Gate traits before sending sensitive or restricted data. + **Adapt this to your use case:** -**Adapt this to your use case:** + ```kotlin + lifecycleScope.launch { + // Identify after the app has made its own login or account-selection decision. + OptimizationManager.client.isInitialized.first { it } + OptimizationManager.client.identify(userId = "user-123", traits = mapOf("plan" to "pro")) + } + ``` -```kotlin -lifecycleScope.launch { - // Identify after the app has made its own login or account-selection decision. - OptimizationManager.client.isInitialized.first { it } - OptimizationManager.client.identify( - userId = "user-123", - traits = mapOf("plan" to "pro"), - ) -} -``` +2. Call `reset()` on logout, account switch, or a privacy flow that must clear SDK-managed profile + state, then emit a fresh profile-producing event before expecting new selections. -2. Call `reset()` when the app must clear SDK-managed profile continuity, such as during logout or a - local test reset. + **Adapt this to your use case:** -**Adapt this to your use case:** + ```kotlin + lifecycleScope.launch { + OptimizationManager.client.isInitialized.first { it } + OptimizationManager.client.reset() + // Emit fresh profile-producing context after reset before expecting new variants. + ScreenTracker.trackScreen("Home") + } + ``` -```kotlin -lifecycleScope.launch { - OptimizationManager.client.isInitialized.first { it } - OptimizationManager.client.reset() - // Emit fresh profile-producing context after reset before expecting new variants. - ScreenTracker.trackScreen("Home") -} -``` +`reset()` clears profile continuity (profile, changes, selected optimizations, the anonymous ID, the +current-screen dedupe tracker, and sticky-view keys) but **preserves consent state**, and it no-ops +before initialization. When persistence consent is allowed, the SDK writes continuity to +`SharedPreferences` under the `com.contentful.optimization.` key prefix and publishes state from an +Experience response after that write settles; in tests and relaunch flows, wait for SDK-derived state +instead of adding storage delays. The SDK provides no built-in cross-platform identity handoff — store +account IDs, consent records, and cross-device identity state in application code. For the identifier +model, see +[Consent management in the Optimization SDK Suite](../concepts/consent-management-in-the-optimization-sdk-suite.md#revocation-and-profile-cleanup). -After reset, emit a fresh screen, identify, page, or other approved profile-producing event before -expecting new selected optimizations. Store account IDs, consent records, and cross-device identity -state outside the SDK. +## Optional integrations -### Runtime locale changes +### Custom events and analytics diagnostics **Integration category:** Optional -1. When the app locale changes, update the SDK Experience and event locale. -2. Refetch Contentful entries with the same app Contentful locale before rendering localized content - through `OptimizedEntryView`. -3. Invalidate application caches that were keyed by the previous locale. - -**Adapt this to your use case:** - -```kotlin -lifecycleScope.launch { - OptimizationManager.client.isInitialized.first { it } - - val nextLocale = getAppLocale() - // setLocale updates SDK Experience/event locale; the app still refetches Contentful entries. - OptimizationManager.client.setLocale(nextLocale) +Use custom events for business actions that are not tied to a Contentful entry swap, and the event +streams for local diagnostics or app-owned analytics forwarding. - val entries = contentfulClient.fetchHomeEntries(locale = nextLocale) - renderEntries(entries) -} -``` +1. Call `track(event, properties)` for a business event. It is a suspend emitter that returns an + `EventEmissionResult`. -`setLocale(...)` changes the SDK Experience/event locale. It does not refetch Contentful entries, -infer device language, or validate that the locale is enabled in the Contentful environment. For the -locale boundary, see -[Locale handling in the Optimization SDK Suite](../concepts/locale-handling-in-the-optimization-sdk-suite.md). + **Copy this:** -### Analytics diagnostics and forwarding + ```kotlin + lifecycleScope.launch { + OptimizationManager.client.isInitialized.first { it } + // A custom business event, not tied to a Contentful entry swap. + OptimizationManager.client.track(event = "Purchase Completed", properties = mapOf("sku" to "ABC-123")) + } + ``` -**Integration category:** Optional +2. Collect `eventStream` for accepted events and `blockedEventStream` for events stopped by consent or + the allow-list. Both are `SharedFlow`s with a replay buffer of 64, so a late subscriber still + receives up to the last 64 events — unlike the iOS passthrough streams, you do not have to subscribe + before the events fire. Deduplicate forwarded events by event semantics, not UI lifecycle, because + Android views can be recreated on configuration or navigation changes. -1. Use `eventStream` for debug surfaces, local tests, or application-owned analytics forwarding. -2. Use `blockedEventStream` and `onEventBlocked` to verify consent and `allowedEventTypes` blocks - for SDK calls that reach Core. -3. Deduplicate forwarded events by event semantics, not by UI lifecycle, because Android views can - be recreated during configuration or navigation changes. -4. Use `eventStream`, `EventEmissionResult`, queue callbacks, tracking timing checks, adapter state, - debug logs, or app-owned diagnostics for other suppressed behavior. + **Adapt this to your use case:** -**Adapt this to your use case:** + ```kotlin + lifecycleScope.launch { + repeatOnLifecycle(Lifecycle.State.STARTED) { + // analyticsDebugger is reader-owned: your debug display or forwarding sink. + OptimizationManager.client.eventStream.collect { event -> analyticsDebugger.render(event) } + } + } -```kotlin -lifecycleScope.launch { - repeatOnLifecycle(Lifecycle.State.STARTED) { - OptimizationManager.client.eventStream.collect { event -> - // Deduplicate before forwarding outside debug displays or test assertions. - analyticsDebugger.render(event) - } - } -} -``` + lifecycleScope.launch { + repeatOnLifecycle(Lifecycle.State.STARTED) { + // blockedEventStream (or the onEventBlocked config callback) is the diagnostic for a + // missing event during integration. + OptimizationManager.client.blockedEventStream.collect { blocked -> + Log.w("Optimization", "blocked ${blocked.method}: ${blocked.reason}") + } + } + } + ``` -For forwarding SDK context to other destinations, see +When forwarding SDK events to third-party destinations, apply the same app-owned consent policy, +deduplication, and data-minimization rules that govern the destination. For destination mapping, +consent, identity, dedupe, and governance guidance, see [Forwarding Optimization SDK context to analytics and tag management tools](./forwarding-optimization-sdk-context-to-analytics-and-tag-management-tools.md). ### Custom Flags and MergeTag rendering **Integration category:** Optional -Custom Flags and MergeTag helpers read profile-backed values from the initialized -`OptimizationClient`. Use them from Views, adapters, or binders that already wait for SDK -initialization. +Use Custom Flags when your Optimization data includes profile-backed feature values, and merge tags +when it includes profile-driven text substitutions in Rich Text. Both read from SDK state separately +from entry-variant resolution, and they wait for initialization before returning real values. -1. Use `client.getFlag(name)` for a one-time Custom Flag read. -2. Use `client.observeFlag(name)` when a View needs a `StateFlow` that updates with flag value - changes. -3. Use `client.getMergeTagValue(mergeTagEntry)` while rendering Contentful Rich Text that includes - resolved `nt_mergetag` entries. -4. Provide fallback text when a merge tag is unresolved, missing, or unavailable for the active - profile. +1. Read a flag once with `getFlag(name)` when a synchronous value is enough (returns `null` before + init or when unresolved). -**Adapt this to your use case:** + **Adapt this to your use case:** -```kotlin -lifecycleScope.launch { - OptimizationManager.client.isInitialized.first { it } + ```kotlin + lifecycleScope.launch { + OptimizationManager.client.isInitialized.first { it } + // headlineBadge is reader-owned UI; getFlag returns a JSONValue? read from SDK state. + val headlineFlag = OptimizationManager.client.getFlag("homepage-headline") + headlineBadge.text = headlineFlag?.stringValue ?: "default" + } + ``` - val headlineFlag = OptimizationManager.client.getFlag("homepage-headline") - headlineBadge.text = headlineFlag?.toString() ?: "default" -} -``` +2. Observe with `observeFlag(name)` when a view must update as flag values change. It returns a + `StateFlow` (the Android reactive idiom, where iOS uses a Combine publisher). + Subscribing to a flag observable emits a `component` flag-view event when consent and profile + allow, so treat a flag subscription as a tracked analytics exposure, not a free read, and govern it + like any other event. -**Adapt this to your use case:** + **Adapt this to your use case:** -```kotlin -lifecycleScope.launch { - OptimizationManager.client.isInitialized.first { it } - val ctaFlag = OptimizationManager.client.observeFlag("homepage-cta") + ```kotlin + lifecycleScope.launch { + OptimizationManager.client.isInitialized.first { it } + val ctaFlag = OptimizationManager.client.observeFlag("homepage-cta") + repeatOnLifecycle(Lifecycle.State.STARTED) { + ctaFlag.collect { flagValue -> ctaButton.text = flagValue?.stringValue ?: "Continue" } + } + } + ``` - repeatOnLifecycle(Lifecycle.State.STARTED) { - ctaFlag.collect { flagValue -> - ctaButton.text = flagValue?.toString() ?: "Continue" - } - } -} -``` +3. Resolve merge tags with `getMergeTagValue(mergeTagEntry)` while rendering Rich Text. `nt_mergetag` + is the SDK-fixed Optimization content type for a merge tag — a profile-driven text substitution + embedded inline in Rich Text; it is not a name you choose. Your app owns extracting the embedded + `nt_mergetag` entry from the Rich Text node before calling the SDK, which resolves the selector + against the current profile and returns the resolved string or `null`. -**Follow this pattern:** + **Follow this pattern:** -```kotlin -suspend fun resolveMergeTagText( - mergeTagEntry: Map, -): String { - OptimizationManager.client.isInitialized.first { it } - - // Keep fallback copy in the app so unresolved merge tags do not break Rich Text rendering. - return OptimizationManager.client.getMergeTagValue(mergeTagEntry) - ?: readFallbackValue(mergeTagEntry) - ?: "[Merge Tag]" -} -``` + ```kotlin + suspend fun resolveMergeTagText(mergeTagEntry: Map): String { + OptimizationManager.client.isInitialized.first { it } + // Keep fallback copy in the app so an unresolved merge tag does not break Rich Text rendering. + return OptimizationManager.client.getMergeTagValue(mergeTagEntry) + ?: readFallbackValue(mergeTagEntry) + ?: "[Merge Tag]" + } + ``` -### Live updates and variant locking +For the merge-tag data model, see +[Entry optimization and variant resolution](../concepts/entry-personalization-and-variant-resolution.md#merge-tags-and-localized-profile-values). + +### Live updates and locked variants **Integration category:** Optional -1. Keep live updates disabled for reading surfaces where content must not change while the visitor - is looking at it. This is the default global Views behavior. +Views apps choose whether optimized content updates live or locks to the first selected variant for +the screen. The global default is locked: with `liveUpdates = false` (the default), +`OptimizedEntryView` snapshots the first non-null selection it sees and resolves against that locked +value thereafter, so content does not change while the visitor is looking at it. + +1. Keep the default for reading surfaces where content must not shift mid-view. 2. Enable live updates globally when most rendered entries must react to profile or preview changes without remounting. -**Adapt this to your use case:** + **Adapt this to your use case:** -```kotlin -OptimizationManager.initialize( - context = this, - config = optimizationConfig, - // Live updates let mounted OptimizedEntryView instances react to profile changes. - liveUpdates = true, -) -``` + ```kotlin + OptimizationManager.initialize( + context = this, + config = optimizationConfig, + // Live updates let mounted OptimizedEntryView instances re-resolve as selections change. + liveUpdates = true, + ) + ``` 3. Override live updates per entry when only one component needs live behavior. -**Adapt this to your use case:** + **Adapt this to your use case:** -```kotlin -OptimizedEntryView(context).apply { - // Override the global locking behavior only for entries that must update while mounted. - liveUpdates = true - setContentRenderer { resolvedEntry -> - DashboardBinder.create(context, resolvedEntry) - } - setEntry(dashboardEntry) -} -``` + ```kotlin + OptimizedEntryView(context).apply { + liveUpdates = true + // ContentEntryBinder and dashboardEntry are reader-owned, as elsewhere in this guide. + setContentRenderer { resolvedEntry -> ContentEntryBinder.create(context, resolvedEntry) } + setEntry(dashboardEntry) + } + ``` -When the preview panel is open, `OptimizedEntryView` instances re-resolve live so audience and -variant overrides apply immediately. When the panel closes, only non-live optimized entries without -a caller-supplied `selectedOptimizations` override lock to the previewed selection. Live entries -continue following `client.selectedOptimizations`, and explicit overrides keep resolving from their -explicit value. For precedence rules, see +To drive your own optimization state, pass an explicit selections snapshot to +`setEntry(entry, selectedOptimizations)`: a non-null list resolves against exactly that snapshot, +while the default `null` observes the SDK's current selection state. An open preview panel forces live +updates in every `OptimizedEntryView` (overriding an explicit `liveUpdates = false`) so applied +overrides appear immediately; when the panel closes, non-live entries without a caller-supplied +selections override lock to the previewed selection. For the precedence rules, see [Android SDK runtime and interaction mechanics](../concepts/android-sdk-runtime-and-interaction-mechanics.md#live-updates-and-preview-behavior). -## Advanced integrations - ### Preview panel -**Integration category:** Advanced or production-only +**Integration category:** Optional -1. Gate the preview panel behind a debug, internal-build, or staff-only condition. In Android Views - integrations, the Activity controls visibility by deciding whether to call - `OptimizationManager.attachPreviewPanel(...)`. -2. Pass `PreviewPanelConfig(contentfulClient = previewContentfulClient)` during initialization when - the panel must display audience and experience names. Without a `PreviewContentfulClient`, the - panel falls back to identifiers. +The preview panel is a debug and authoring surface that lets an internal user force audience +qualification and variant selection on the local device. An audience is the rule an experience uses to +target a set of visitors. Gate the panel behind a debug or internal-build condition so production users +cannot open local overrides. -**Adapt this to your use case:** +1. Pass a `PreviewPanelConfig` to `initialize` under a debug gate. Supply a `PreviewContentfulClient` + (the built-in `ContentfulHTTPPreviewClient` fetches `nt_audience` and `nt_experience` definitions) + so the panel shows audience and experience names; without it the panel still opens but falls back + to raw identifiers. -```kotlin -OptimizationManager.initialize( - context = this, - config = optimizationConfig, - // Keep preview definitions behind a debug or internal-build gate. - previewPanel = if (BuildConfig.DEBUG) { - PreviewPanelConfig(contentfulClient = previewContentfulClient) - } else { - null - }, -) -``` + **Adapt this to your use case:** -3. Call `attachPreviewPanel(...)` after `setContentView(...)` in each Activity that displays the - floating entry point. + ```kotlin + OptimizationManager.initialize( + context = this, + config = optimizationConfig, + // Keep preview definitions behind a debug or internal-build gate. + previewPanel = if (BuildConfig.DEBUG) { + PreviewPanelConfig( + contentfulClient = ContentfulHTTPPreviewClient( + spaceId = "your-space-id", + accessToken = "your-cda-token", + environment = "main", + ), + ) + } else { + null + }, + ) + ``` -**Adapt this to your use case:** +2. Attach the floating entry point after `setContentView(...)` in each activity that should show it. + `attachPreviewPanel` uses the same initialized client the rest of the app uses, so overrides affect + the same resolver and event state. -```kotlin -class HomeActivity : AppCompatActivity() { - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - setContentView(R.layout.home) - - if (BuildConfig.DEBUG) { - // Attach after setContentView so the floating entry point mounts into this Activity. - OptimizationManager.attachPreviewPanel(this) - } - } -} -``` + **Adapt this to your use case:** -4. Do not expose preview controls in production traffic unless your organization has an explicit - internal-access policy. Preview overrides can force audience qualification and variant selection - for the local device. + ```kotlin + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContentView(R.layout.home) + if (BuildConfig.DEBUG) { + // Attach after setContentView so the floating entry point mounts into this activity. + OptimizationManager.attachPreviewPanel(this) + } + } + ``` + +Do not expose preview controls in production traffic unless your organization has an explicit +internal-access policy. -### Offline delivery and queue observability +## Advanced integrations + +### Offline delivery, queue observability, and app-owned caching **Integration category:** Advanced or production-only -1. Use the default queue behavior unless the app has production delivery budgets or observability - requirements that need custom bounds. -2. Configure `QueuePolicy` for offline event caps, retry behavior, and delivery callbacks. +The Android SDK monitors network reachability, queues events while offline, flushes when connectivity +returns, and flushes as the app moves to the background. No setup is required for the default offline +path. -**Adapt this to your use case:** +1. Add a `QueuePolicy` only when production telemetry needs queue bounds or delivery callbacks. The + offline Experience queue holds up to 100 events by default (tunable via + `QueuePolicy.offlineMaxEvents`); queues are in-memory only, with no durable outbox, and do not + survive process death. -```kotlin -val optimizationConfig = OptimizationConfig( - clientId = "your-client-id", - queuePolicy = QueuePolicy( - // Cap offline storage according to the app's production delivery budget. - offlineMaxEvents = 100, - onOfflineDrop = { event -> - Log.w("Optimization", "Dropped offline event: ${event.context}") - }, - ), -) -``` + **Adapt this to your use case:** + + ```kotlin + val optimizationConfig = OptimizationConfig( + clientId = "your-optimization-client-id", + queuePolicy = QueuePolicy( + // Cap offline storage to the app's production delivery budget. + offlineMaxEvents = 100, + onOfflineDrop = { event -> Log.w("Optimization", "Dropped offline event: ${event.context}") }, + ), + ) + ``` + +2. Use queue callbacks for operational diagnostics, not for resending blocked or dropped events. +3. Keep Contentful entry caching in the application layer — the SDK does not cache CDA responses for + Views rendering; you own content caching with locale-aware keys. +4. Call `flush()` only for deliberate release, test, or lifecycle flows; the SDK already flushes on + background and reconnect. -The Android SDK monitors network reachability, queues events while offline, flushes when -connectivity returns, and flushes when the app moves toward the background. For the runtime model, -see +For the runtime delivery model, see [Android SDK runtime and interaction mechanics](../concepts/android-sdk-runtime-and-interaction-mechanics.md#offline-and-app-lifecycle-delivery). ## Production checks -Before releasing an Android Views integration, verify these points: - -- **Credentials and runtime configuration** - The app uses the correct Maven coordinate, client ID, - Contentful environment, SDK locale, and any non-default Experience API or Insights API endpoints. -- **Consent behavior** - Default-on consent is seeded only when policy permits it, consent UI calls - `client.consent(...)` for every choice, withdrawal blocks later gated events, and suppressed - interactions are not replayed later. -- **Event delivery** - Screen, custom, view, and tap events are accepted in the expected consent - states, offline delivery flushes after reconnect, and debug event displays or forwarding code are - not exposed to unintended audiences. -- **Content fallback behavior** - Optimized entries are fetched with one Contentful locale and - resolved optimization links. Baseline fallback is accepted for missing selections, unresolved - links, out-of-range variants, blocked or non-allow-listed profile-producing events, and users who - do not qualify for a variant. -- **Duplicate tracking prevention** - RecyclerView adapters and state collectors do not recreate - `OptimizedEntryView` instances on every SDK state emission. Stable item rendering prevents dwell - timers and component event metadata from resetting mid-view. -- **Privacy and governance** - Identity traits, custom event properties, forwarded analytics events, - preview overrides, and persisted profile continuity follow the app's privacy policy and consent - record. -- **Local validation path** - For the reference implementation, run targeted Views Maestro flows - such as `pnpm implementation:run -- android-sdk test:e2e:views -- --flow tap-tracking`, - `pnpm implementation:run -- android-sdk test:e2e:views -- --flow screen-tracking`, and - `pnpm implementation:run -- android-sdk test:e2e:views -- --flow live-updates` when those - behaviors are relevant. +Before releasing an Android Views integration, verify these checks: + +- **Credentials and runtime configuration** — The app uses the intended Maven coordinate, client ID, + Contentful environment, SDK `locale`, and CDA locale. Non-default Experience or Insights API base + URLs and `OptimizationLogLevel.debug` logging are absent from production builds unless explicitly + approved. +- **Consent behavior** — Startup consent is seeded only when policy permits it, consent UI calls + `consent(...)` for every choice, withdrawal blocks later gated events, split event and persistence + consent behaves as intended, and `reset()` behavior matches legal and privacy requirements. +- **Event delivery** — Screen, custom, tap, view, identify, and flag-view events appear when allowed + and are blocked or omitted when policy denies them; offline delivery flushes after reconnect. +- **Content fallback behavior** — Baseline entries render when selected optimizations are missing, + Contentful links are unresolved, variants are out of range, all-locale payloads are fetched, or the + visitor does not qualify. +- **Duplicate tracking prevention** — Activity and fragment lifecycle hooks, RecyclerView adapters, + and state collectors do not recreate `OptimizedEntryView` instances or re-emit screen, tap, or view + events for one intended interaction or visibility cycle. +- **Privacy and governance** — Identity traits, custom event properties, forwarded analytics events, + preview-panel access, and persisted profile continuity follow the app's data-minimization and + retention policy. +- **Local validation path** — Compare your integration against the Android reference implementation. + The repository's maintainers validate Views behavior with Maestro flows driven from + `implementations/android-sdk/`; those runners are maintainer commands, not app commands. + + **Reference excerpt:** + + ```sh + # From the optimization monorepo root — a maintainer command that runs the Views Maestro suite. + pnpm implementation:run -- android-sdk test:e2e:views -- --flow screen-tracking + ``` ## Troubleshooting -| Symptom | Likely cause | Action | -| ---------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------- | -| `OptimizationManager.client` throws | The app read the client before calling `OptimizationManager.initialize(...)`. | Initialize once before Activity or Fragment code reads the client. | -| Suspend API calls fail or no-op | The bridge has not finished initializing. | Wait for `client.isInitialized.first { it }` before direct suspend calls. | -| Entries always render baseline content | Blocked or non-allow-listed profile-producing events, strict opt-in without accepted consent, missing selections, unresolved links, or all-locale Contentful payloads. | Accept consent or allow the needed screen or identity event, verify selections, fetch one locale, and include links. | -| View or tap events do not appear | Consent blocks tracking, tracking is opted out, dwell timing is not met, or views are recreated mid-dwell. | Check consent state, `trackViews`, `trackTaps`, visibility timing, and adapter stability. | -| Preview panel shows identifiers only | No preview `PreviewContentfulClient` was passed during initialization. | Pass `PreviewPanelConfig(contentfulClient = previewContentfulClient)` for rich definitions. | -| Preview panel appears in the wrong build | The Activity calls `attachPreviewPanel(...)` without an app-owned gate. | Wrap the attach call in a debug, internal-build, or staff-only condition. | +- **SDK never initializes or events never emit** — Confirm the `Application` subclass is registered + with `android:name` in `AndroidManifest.xml` (without it `onCreate` never runs), and that direct + suspend calls await `client.isInitialized.first { it }` before running. +- **Optimized entries always render the baseline** — Confirm the app fetched a single-locale entry + with enough `include` depth for `nt_experiences` and `nt_variants`, that consent or the allow-list + is not blocking profile-producing events, and that `client.selectedOptimizations` is non-empty for + the visitor. +- **Tap or view events do not appear** — Check consent, `allowedEventTypes`, per-entry `trackViews` + and `trackTaps`, whether the view reached the configured visibility threshold long enough to emit, + and whether views are recreated mid-dwell. +- **Screen events appear more than once** — Review `onResume` calls across activity, fragment, and + in-activity transitions, and prefer `ScreenTracker.trackScreen` (which dedupes the current route by + `routeKey`) over raw `screen` for lifecycle tracking. +- **Preview panel opens but shows identifiers** — Pass a `PreviewContentfulClient` that can fetch + `nt_audience` and `nt_experience` entries from the correct space and environment. ## Reference implementations to compare against -- [Android reference implementation](../../implementations/android-sdk/README.md) - Demonstrates the - same native SDK behavior in Compose and Android Views shells, including entry resolution, - interaction tracking, screen tracking, live updates, Custom Flags, `getMergeTagValue(...)`, - preview-panel overrides, and mock API flows. +- [Android reference implementation](../../implementations/android-sdk/README.md) — Maintained Compose + and Android Views shells that exercise the native Android bridge against the shared mock API: + accepted-consent startup, single-locale CDA fetching, entry resolution, screen and navigation + tracking, interaction tracking, Custom Flags and merge tags, live updates, offline queueing, and + preview-panel overrides. Use it as the comparison and validation target for Views integration + behavior. From ad2e2e41d2a8e7cd767c5d101a1c620361daf74a Mon Sep 17 00:00:00 2001 From: Tim Beyer Date: Mon, 20 Jul 2026 17:41:17 +0200 Subject: [PATCH 5/6] docs(docs): register Android blueprints and funnel back review rules Add the two Android blueprints to the authoring index and note the native Android family is now covered (with the Kotlin extern:-pointer exception, shared with iOS). Fold the durable rules the Android review surfaced into the authoring checklist: a required host-registration step (Android Application android:name) shown inline with its silent-failure mode; cross-SDK contrast asides bounded in a single-target guide; and a verify step that can distinguish success from failure (no unfalsifiable "renders baseline or variant" proof). Co-Authored-By: Claude Opus 4.8 (1M context) --- documentation/authoring/README.md | 15 +++++++++------ .../references/authoring-checklist.md | 17 +++++++++++++++++ 2 files changed, 26 insertions(+), 6 deletions(-) diff --git a/documentation/authoring/README.md b/documentation/authoring/README.md index ee783218..bfcbfeb8 100644 --- a/documentation/authoring/README.md +++ b/documentation/authoring/README.md @@ -222,12 +222,13 @@ comes from the scoped knowledge-author and technical-foundation reviews. Blueprints and KB files currently cover the six JS/TS integration guides — Node, Web, React Web, Next.js App Router, Next.js Pages Router, and React Native — plus the two native iOS guides (SwiftUI -and UIKit), which share one KB file (`native/ios.md`) and one per-UI blueprint each. Because the iOS -package is a Swift Package rather than a TypeScript one, `knowledge:check` cannot resolve Swift -`#symbol` pointers; iOS Swift facts use `extern:` pointers naming the exact file and symbol, while -behavior that executes in the shared JS bridge (`optimization-js-bridge`) or core uses resolvable -keys. The Android Kotlin guides, the decision guide, and supplemental guides remain outside the -regeneration guarantee until their KB and planning edges are bootstrapped. +and UIKit) and the two native Android guides (Jetpack Compose and XML Views). Each native family +shares one KB file (`native/ios.md`, `native/android.md`) with one per-UI blueprint each. Because the +native packages are a Swift Package and a Kotlin/Gradle library rather than TypeScript ones, +`knowledge:check` cannot resolve Swift or Kotlin `#symbol` pointers; native facts use `extern:` +pointers naming the exact file and symbol, while behavior that executes in the shared JS bridge +(`optimization-js-bridge`) or core uses resolvable keys. The decision guide and supplemental guides +remain outside the regeneration guarantee until their KB and planning edges are bootstrapped. ## Files @@ -246,6 +247,8 @@ blueprints/ react-native.md ios-swiftui.md ios-uikit.md + android-compose.md + android-views.md fragments/ personalization-explainer.md authored-variant-gotcha.md diff --git a/skills/optimization-guide-authoring/references/authoring-checklist.md b/skills/optimization-guide-authoring/references/authoring-checklist.md index f07e9187..70f1151b 100644 --- a/skills/optimization-guide-authoring/references/authoring-checklist.md +++ b/skills/optimization-guide-authoring/references/authoring-checklist.md @@ -50,6 +50,11 @@ add per-archetype checks. - [ ] No informal filler or hype that reads oddly in a reference doc ("this is the payoff", "the magic happens here", "boom", gratuitous "just"). Section openers state what the section does plainly. +- [ ] **Cross-SDK contrast asides are bounded in a single-target guide.** A guide for one SDK may note + once that its idiom differs from a sibling ("these are `StateFlow`s, not the iOS Combine + publishers") where that genuinely prevents a wrong assumption, but does not repeat the contrast on + every reactive/suspend/lifecycle surface — the target reader knows their own platform, not the + sibling's, so repeated "unlike iOS…" asides are jargon that adds noise. - [ ] Every fenced code block has exactly one bold intent label immediately before it (`**Copy this:**`, `**Adapt this to your use case:**`, `**Follow this pattern:**`, or `**Reference excerpt:**`). No former/other labels. @@ -145,6 +150,12 @@ add per-archetype checks. it observes — a client-side signal (a debug log, a returned `accepted` flag) proves the SDK emitted/allowed the event locally, not that the server received it, so the wording does not imply server receipt. +- [ ] **The verify step can distinguish success from failure.** A proof whose only observable outcome + is true under both success and failure is not a verification — e.g. "confirm the entry renders + its baseline or its variant," when the SDK renders baseline on _any_ failure, cannot tell working + personalization from a broken integration or an unauthored variant. Pair a render proof with the + `authored-variant-gotcha` prerequisite and a distinguishable signal, or prove a + state-changing/event outcome the reader can see succeed or fail. - [ ] **The quick start is grounded in a real app shape** — no invented fetch shapes (e.g. a hardcoded array of entry IDs). The most common real shape leads; other shapes are pointed to a feature section. @@ -160,6 +171,12 @@ add per-archetype checks. Android / React Native targets, the native install step (`pod install`, `npx expo prebuild`, or equivalent) that must run before the app launches appears in the quick start where the reader installs, not deferred to `## Before you start` — the reader needs it before the verify step. +- [ ] **A required host-registration step is shown inline, with its silent-failure mode named.** When + the SDK only runs because the host framework wires up a reader-owned entry point — most sharply, + an Android `Application` subclass that does nothing unless registered via `android:name` in + `AndroidManifest.xml` — the quick start shows that registration and states that omitting it makes + initialization silently never run. A create-the-class step whose registration is implied is a + quick start that cannot be performed. - [ ] The entry-wrap example shows any cast the render prop requires and states the fallback contract; the cast matches the matching reference implementation (verify against `implementations/`). From 4cdc8b8785ded155d7c5228ac471099896ca13bb Mon Sep 17 00:00:00 2001 From: Tim Beyer Date: Mon, 20 Jul 2026 18:03:24 +0200 Subject: [PATCH 6/6] fix(docs): restore SDK capabilities dropped in Android reconciliation A content-loss audit of the reconciled guides against their pre-pipeline versions found capabilities trimmed along with reader-owned scaffolding. Restore them: - Views: the tri-state consent-banner observation (state.consent == null), and the trackTaps=false + onTap conflict caveat plus the app-owned-click-listener alternative for a component that needs a tap handler without SDK tap analytics. - Compose: the OptimizationApiConfig field names (experienceBaseUrl / insightsBaseUrl) in the config example, and the on-leave final duration-update caveat for entry view tracking. All verified against Kotlin source. Passes pnpm guides:check. Co-Authored-By: Claude Opus 4.8 (1M context) --- ...timization-android-sdk-in-a-compose-app.md | 9 +++++++- ...optimization-android-sdk-in-a-views-app.md | 21 ++++++++++++++++--- 2 files changed, 26 insertions(+), 4 deletions(-) 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 index 29fb7e3e..8bd98e8d 100644 --- 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 @@ -250,6 +250,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.rememberCoroutineScope import com.contentful.optimization.compose.LocalOptimizationClient import com.contentful.optimization.compose.OptimizationRoot +import com.contentful.optimization.core.OptimizationApiConfig import com.contentful.optimization.core.OptimizationConfig import com.contentful.optimization.core.OptimizationLogLevel import kotlinx.coroutines.launch @@ -263,6 +264,11 @@ fun AppRoot() { // environment defaults to "main"; set it only when your Contentful environment differs. locale = "en-US", logLevel = OptimizationLogLevel.warn, + // Both base URLs default correctly; set api only for staging, mocks, or non-default hosts. + api = OptimizationApiConfig( + experienceBaseUrl = "https://experience.staging.example.com/", + insightsBaseUrl = "https://insights.staging.example.com/", + ), ), ) { AppNavGraph() @@ -562,7 +568,8 @@ uses the real scroll position; without an enclosing scroll context, tracking ass and uses the system display height as the viewport, which suits only non-scrolling or already-visible layouts. The default view threshold is 80% visibility (`minVisibleRatio` `0.8`) held for 2000 ms (`dwellTimeMs`); after the first view event, duration updates emit every 5000 ms -(`viewDurationUpdateIntervalMs`) while the entry stays visible. +(`viewDurationUpdateIntervalMs`) while the entry stays visible, and a final duration update emits when +the entry leaves the viewport once at least one view event has fired. A tap uses Compose's `clickable {}` on the `OptimizedEntry` wrapper: it emits the `component_click` event, then calls the optional `onTap` lambda. That lambda receives the **baseline** entry you passed 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 index 14891199..3eb23ff8 100644 --- 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 @@ -373,7 +373,9 @@ two independent axes: event consent (may the SDK personalize and emit events) an 2. Leave `defaults` unset when the app must collect a choice before gated events can emit, and call `consent(...)` from an app-owned banner, CMP callback, or settings flow. `consent(...)` no-ops - before initialization, so wait for readiness before applying a choice. + before initialization, so wait for readiness before applying a choice. `client.state` exposes the + current decision as a tri-state `consent: Boolean?` — `true`, `false`, or `null` when the visitor + has not decided yet — so you can observe `state.consent == null` to know when to show the banner. **Adapt this to your use case:** @@ -385,9 +387,18 @@ two independent axes: event consent (may the SDK personalize and emit events) an super.onCreate(savedInstanceState) setContentView(R.layout.activity_consent) - // acceptButton and rejectButton are reader-owned views from your layout. + // acceptButton, rejectButton, and consentBanner are reader-owned views from your layout. acceptButton.setOnClickListener { applyConsent(true) } rejectButton.setOnClickListener { applyConsent(false) } + + // Show the banner only while consent is undecided (null); hide it once the visitor chooses. + lifecycleScope.launch { + repeatOnLifecycle(Lifecycle.State.STARTED) { + client.state.collect { state -> + consentBanner.isVisible = state.consent == null + } + } + } } private fun applyConsent(accepted: Boolean) { @@ -611,7 +622,11 @@ whether these events are allowed by its Analytics and privacy policy. 2. Override tracking per entry when a component needs different behavior from the global default. A non-null `onTap` keeps the entry tappable even when global `trackTaps` is on; setting per-entry - `trackViews = false` or `trackTaps = false` opts that one entry out. + `trackViews = false` or `trackTaps = false` opts that one entry out. `onTap` runs through the same + tap path as SDK tap tracking, so `trackTaps = false` disables both the `component_click` event and + `onTap` — do not combine them. When a component needs an app-owned tap handler without SDK tap + analytics, set `trackTaps = false` and attach a normal Android click listener inside the child view + your renderer returns instead. **Adapt this to your use case:**