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/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). 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..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 @@ -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,112 @@ 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, + // 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() } } + +@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 +332,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 +341,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 +459,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 +468,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 +536,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 +548,50 @@ 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, 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 +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 +601,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 +613,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 +670,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 +720,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 +743,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 +790,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 +804,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 +818,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 +850,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 +896,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 +905,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 +927,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 +975,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 +991,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 +1003,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..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 @@ -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,53 +351,50 @@ 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. `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:** ```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) - } + // 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) { - OptimizationManager.client.state.collect { state -> + client.state.collect { state -> consentBanner.isVisible = state.consent == null } } @@ -303,633 +404,639 @@ For lifecycle and coroutine behavior, see 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, - ) -} -``` - -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:** +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. -```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:** -3. Use direct client calls when a screen event needs properties or when a business interaction needs - a custom event. + ```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") + } + } + ``` -**Adapt this to your use case:** +3. Use a direct client call when a screen event needs properties. -```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"), - ) -} -``` + **Adapt this to your use case:** + + ```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. - -**Copy this:** - -```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:** +`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. -```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) -} +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. -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. `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. -```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. +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. -**Adapt this to your use case:** +1. Call `track(event, properties)` for a business event. It is a suspend emitter that returns an + `EventEmissionResult`. -```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) - - val entries = contentfulClient.fetchHomeEntries(locale = nextLocale) - renderEntries(entries) -} -``` - -`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]" + } + ``` + +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 variant locking +### 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. diff --git a/documentation/internal/sdk-knowledge/native/android.md b/documentation/internal/sdk-knowledge/native/android.md new file mode 100644 index 00000000..41df49ff --- /dev/null +++ b/documentation/internal/sdk-knowledge/native/android.md @@ -0,0 +1,361 @@ +# 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 +- `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` + 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 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/`).