Integrate the Optimization Android SDK in a Jetpack Compose app
Overview
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 — 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
and Entry resolution and fallback rendering sections
then add entries resolving through
OptimizedEntryonce 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
instead.
Quick start
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
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 step before you ship, which
explains the two axes and the split form that sets them separately.
-
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.
Adapt this to your use case:
-
Wrap your app UI in
OptimizationRoot, pass your Optimization client ID, setlogLevel = OptimizationLogLevel.debugso the SDK logs its activity, and addScreenTrackingEffectto one screen you already render.Adapt this to your use case:
The
MainActivityandHomeScreenscaffolding above is illustrative context to match against your own app, not a file to paste over yours. Wrap your existingsetContent { }tree inOptimizationRootand addScreenTrackingEffectto a screen you already render — keep the rest of your composables as they are. -
Verify the first run. Launch the app on a device or emulator. Because
logLevel = OptimizationLogLevel.debugis set, the SDK logs its activity to logcat under theContentfulOptimizationtag.ScreenTrackingEffectsends the screen event throughtrackCurrentScreen, so filter logcat to theContentfulOptimizationtag and look for the pair of bridge lines it logs —[bridge] Calling trackCurrentScreen asyncfollowed by[bridge] trackCurrentScreen succeeded, whose result payload contains"accepted":true. Thatsucceededline withacceptedtrue is the proof the event passed the consent gate. If you see"accepted":falseinstead — or configureonEventBlockedand 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. The Custom events and analytics diagnostics section adds a programmaticeventStreamobserver for asserting on events in code rather than reading logs.
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
minSdk24 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 toOptimizedEntryorclient.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.
environmenthas 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 throughOptimizationApiConfigonly for mocks or non-default hosts (see Install and initializeOptimizationRoot).
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.
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
Install and initialize OptimizationRoot
Integration category: Required for first integration
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.
- Add
com.contentful.java:optimization-androidfrom Maven Central and build the app. - Create one
OptimizationConfigwith the Optimization client ID.environmentdefaults to"main", so pass it only when your Contentful environment differs. - Pass
localewhen Experience API responses and event context must use the same app locale as your Contentful entry fetches. - Pass an
api(OptimizationApiConfig) override only for staging, mocks, or non-default hosts; both base URLs default correctly otherwise, so most apps omitapi. - Read the initialized client with
LocalOptimizationClient.currentinside descendant composables that call SDK methods directly.
Adapt this to your use case:
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.
For package status and installation details, see
the Optimization Android SDK README.
Consent and privacy-policy handoff
Integration category: Common but policy-dependent
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.
- Seed accepted consent with
StorageDefaults(consent = true)only when policy permits default-on Optimization and no consent UI is shown. - Otherwise leave consent unset and call
client.consent(true)after the visitor accepts,client.consent(false)after they reject. - Use the split form when events are allowed but durable profile continuity must stay session-only.
- Read
client.statewhen consent UI must reflect SDK state across app launches.
Adapt this to your use case:
Copy this:
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. For the cross-SDK
consent model, see
Consent management in the Optimization SDK Suite.
Contentful entry fetching and locale shape
Integration category: Required for first integration
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<String, Any>) 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.
- Choose the application Contentful locale in your app’s navigation, i18n, or account layer.
- Pass the same locale to
OptimizationConfig(locale = ...)when Experience responses and event context must align with rendered content. - Fetch entries with a concrete locale and enough include depth for
nt_experiences→nt_experience→nt_variants/nt_audience. - 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:
For the full data shape and locale boundary, see Entry optimization and variant resolution and Locale handling in the Optimization SDK Suite.
Entry resolution and fallback rendering
Integration category: Required for first integration
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.
- Pass the baseline Contentful entry map to
OptimizedEntryand read fields from the resolved entry in the render lambda. - Keep field parsing and view rendering in your own composables; the resolved entry keeps the baseline field shape.
- Provide your own loading treatment while the app-owned fetch is pending —
OptimizedEntryneeds an entry to render, so gate it on your fetched state. - Use
client.resolveOptimizedEntry(...)directly only when a component must separate resolution from rendering.
Adapt this to your use case:
Follow this pattern:
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.
Screen and navigation tracking
Integration category: Required for first integration
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.
- Place
ScreenTrackingEffectonce at the route or destination root of each screen that maps to an analytics screen. - Use stable names for navigation destinations so downstream reporting can group events.
- For dynamic names or an explicit route key, call
client.trackCurrentScreen(...)from aLaunchedEffectonce the data is available. - Use one screen-tracking path per route to avoid duplicate events from repeated child composition.
Follow this pattern:
Adapt this to your use case:
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
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.
- Leave view and tap tracking enabled for entries that need exposure and interaction analytics.
- Set
trackViews = falseortrackTaps = falseonOptimizationRootfor a tree-wide opt-out, or on an individualOptimizedEntryfor one surface. - Wrap scrollable entry lists in
OptimizationLazyColumnfor accurate viewport timing. - Tune
dwellTimeMs,minVisibleRatio, andviewDurationUpdateIntervalMsper entry only when analytics requirements differ from the defaults. - Drive navigation from the resolved entry in the render lambda, and use
onTaponly for side effects the SDK tap event should also trigger.
Adapt this to your use case:
Adapt this to your use case:
Adapt this to your use case:
For timing thresholds, scroll context, and delivery behavior, see Android SDK runtime and interaction mechanics.
Identity, profile continuity, and reset
Integration category: Common but policy-dependent
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<Boolean> that is true when the current consent and
allow-list configuration can produce optimizations at all) are also StateFlows. 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.
- Call
identify(...)from the authenticated flow or account state change that owns identity. - Read
client.state(andselectedOptimizations/optimizationPossible) from Compose state when UI must reflect SDK state. - Call
client.reset()on sign-out or a privacy reset that must clear profile continuity. - Keep application-owned account IDs, server cookies, and third-party destination identifiers in the systems that own them.
Adapt this to your use case:
Optional integrations
Custom events and analytics diagnostics
Integration category: Optional
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 SharedFlows
(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.
- Call
client.track(...)from the composable event handler that owns the business action, after event consent is accepted or an approvedallowedEventTypespolicy permitstrack. - Collect
client.eventStreamin debug surfaces or test-only composables that inspect emitted events. - Collect
client.blockedEventStreamor passonEventBlockedwhen validating consent gates. - Apply your destination consent policy before forwarding SDK context to another analytics tool.
Adapt this to your use case:
Adapt this to your use case:
For cross-SDK forwarding patterns, see Forwarding Optimization SDK context to analytics and tag management tools.
Custom Flags and MergeTag rendering
Integration category: Optional
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<JSONValue?> (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.
- Use
client.getFlag(name)for a one-time flag read after the SDK is initialized. - Use
client.observeFlag(name)when Compose state must follow flag changes. - Resolve Rich Text
nt_mergetagentries withclient.getMergeTagValue(mergeTagEntry)after your fetcher has inlined the target entry. - Provide app-owned fallback rendering when a flag or merge-tag value is missing.
Adapt this to your use case:
Follow this pattern:
The Android reference implementation resolves Rich Text merge tags with getMergeTagValue(...) and
asserts the rendered text in the shared Maestro variant flows.
Live updates
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
changes or preview overrides without a reload.
- Set
liveUpdates = trueonOptimizationRootwhen most mounted entries in the tree must update as SDK state changes. - Set
liveUpdates = trueon an individualOptimizedEntryfor a localized live section. - Set
liveUpdates = falseon an individualOptimizedEntryto keep it locked even under a live global default. - Expect the preview panel to force live updates while it is open so overrides apply immediately.
Adapt this to your use case:
Adapt this to your use case:
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.
Preview panel
Integration category: Optional
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.
- Pass
PreviewPanelConfigtoOptimizationRootonly for builds that can expose preview tooling. - Pass a
PreviewContentfulClientwhen the panel needs audience and experience names. - Use
ContentfulHTTPPreviewClientfor a direct CDA-backed panel, or implementPreviewContentfulClientaround your existing Contentful client. - Verify the floating action button is absent from production release variants.
Adapt this to your use case:
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.
Advanced integrations
Strict event policy and queue controls
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.
- Set
allowedEventTypes = emptyList()when no Optimization events can emit before consent. - Configure a narrow allow-list only for event types approved by your product and privacy policy.
- Use
onEventBlockedor collectblockedEventStreamto verify consent orallowedEventTypesblocks during development. - Use
QueuePolicyonly when the default queue behavior needs production-specific limits or diagnostics.
Use these allowedEventTypes selectors when allow-listing Android events:
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.
Adapt this to your use case:
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
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.
- Let the SDK manage normal network and lifecycle handling after
OptimizationRootinitializes the client. - Call
client.flush()only from explicit app-owned delivery checkpoints or tests. - Use
client.setOnline(...)only for deterministic test or diagnostic flows, not as a replacement for the SDK network monitor. - Validate offline-sensitive release behavior with deterministic SDK controls or platform tests instead of relying on unstable emulator network toggles.
Follow this pattern:
For runtime details, see Android SDK runtime and interaction mechanics.
Content caching and hybrid continuity boundaries
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 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.
- Keep Contentful response caching in the application fetching layer.
- Include the application Contentful locale and entry IDs in cache keys when localized content can differ.
- Invalidate or refetch cached Contentful entries when the app locale changes.
- Do not treat Android
StorageDefaultsas 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.
Production checks
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
OptimizationRootfor 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 usesOptimizationLazyColumnfor 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:
Troubleshooting
Reference implementations to compare against
- Android reference implementation — 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.