Integrate the Optimization Android SDK in an Android Views app
Overview
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
OptimizedEntryViewandresolveOptimizedEntry(the 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.
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
Integrate the Optimization Android SDK in a Jetpack Compose app
guide.
Quick start
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 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 step
before you ship; it explains the two axes and the split form that sets them separately.
-
Add the SDK dependency to your application module from Maven Central.
Copy this:
-
Initialize the SDK from your
Applicationsubclass.OptimizationManager.initialize(...)is a normal (non-suspend) call that constructs the process-wide client and starts it in the background; activities readOptimizationManager.clientafterward.Adapt this to your use case:
The unchanged lines above are illustrative context to match against your own
Applicationsubclass, not a block to paste over it. If your app has noApplicationsubclass yet, the whole file is new.StorageDefaultsis an SDK config type that carries the SDK’s startup state, including the two consent axes;StorageDefaults(consent = true)grants both at launch.Then register the subclass in
AndroidManifest.xmlwithandroid:name. Without it,Application.onCreatenever runs and the SDK never initializes.Adapt this to your use case:
-
Track the current screen from an activity and reflect the outcome in a label.
HomeActivitybelow is illustrative app shape — adapt it to a screen you already render, keeping the two stream subscriptions and theScreenTracker.trackScreencall inonResume.Adapt this to your use case:
The surrounding activity code is illustrative context to match against your own screen, not a block to paste over it.
statusLabelis a reader-ownedTextView; give it an id in the layout you already use for this screen. -
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 thescreenevent reacheseventStream, 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. BecauseStorageDefaults(consent = true)grants consent andscreenis on the SDK’s default pre-consent allow-list, the event is accepted.If the label reads
Optimization screen event blocked: <reason>, the consent or allow-list gate rejected the event and the reason names why. If the label stays onWaiting for Optimization, no event reached either stream, which means the SDK never initialized. Filter logcat by the tagContentfulOptimization: a successful start logs[init] SDK initialized successfully(visible becauselogLevel = OptimizationLogLevel.debug). If that line never appears, the most common Android cause is the missing manifest registration — withoutandroid:name=".MyApplication"on<application>,Application.onCreatenever runs andOptimizationManager.initialize(...)is never called.
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
minSdk24 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. - 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
environmentdefaults tomain, 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).
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.
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
SDK installation and process-wide client
Integration category: Required for first integration
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.
-
Confirm the consuming app supports
minSdk24 or later, Java 11 bytecode, Kotlin, and Maven Central, then add the dependency to the application module.Copy this:
-
Build one
OptimizationConfig. OnlyclientIdis required; the rest have working defaults.- Pass
clientIdfrom your configuration layer. - Pass
environmentonly when it is not the Kotlin-side default"main". - Pass
localewhen Experience API requests and event context must use the same language as the Contentful entries you render. - Set
api = OptimizationApiConfig(...)(experienceBaseUrl/insightsBaseUrl) only for mock, staging, or other non-default endpoints — both default correctly otherwise. - Keep
logLevelat its defaultOptimizationLogLevel.errorin production unless your operational policy allows more verbose logging.
Adapt this to your use case:
- Pass
-
Initialize
OptimizationManageronce for the process, fromApplication.onCreate, and register theApplicationsubclass in the manifest (see the quick start).initializeis 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. ReadingOptimizationManager.clientbeforeinitializethrows.Adapt this to your use case:
-
Await readiness for direct suspend calls.
OptimizationManager.clientis available immediately, but the underlyingOptimizationClient.initialize(config)is asuspendfunction thatOptimizationManagerlaunches in the background, so suspend APIs that touch the bridge requireclient.isInitializedto becometruefirst. (Screen tracking throughScreenTrackerand entry rendering throughOptimizedEntryViewhandle this readiness for you; the await matters when you call the client directly.)Copy this:
For lifecycle and coroutine behavior, see Android SDK runtime and interaction mechanics.
Consent and privacy-policy handoff
Integration category: Common but policy-dependent
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).
-
Use
StorageDefaults(consent = true)at startup only when application policy permits SDK activity at launch.StorageDefaultsvalues take precedence over persistedSharedPreferencesvalues on every launch, so a seeded value can replace a stored choice — apps that persist a user’s decision leavedefaultsunset and callconsent(...)from resolved policy instead.Copy this:
-
Leave
defaultsunset when the app must collect a choice before gated events can emit, and callconsent(...)from an app-owned banner, CMP callback, or settings flow.consent(...)no-ops before initialization, so wait for readiness before applying a choice.client.stateexposes the current decision as a tri-stateconsent: Boolean?—true,false, ornullwhen the visitor has not decided yet — so you can observestate.consent == nullto know when to show the banner.Adapt this to your use case:
-
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 passpersistence = 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:
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:
For the consent responsibility model and blocked-event behavior, see Consent management in the Optimization SDK Suite.
Contentful fetching and entry resolution
Integration category: Required for first integration
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.
-
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 toOptimizedEntryView— they fall back to baseline. -
Add
OptimizedEntryViewfrom XML or create it in activity, fragment, or adapter code.Copy this:
-
Set a renderer that turns the resolved entry map into a child
View, then callsetEntry(...)with the fetched baseline entry.Adapt this to your use case:
-
Treat baseline fallback as expected behavior.
resolveOptimizedEntry(whichOptimizedEntryViewcalls for you) is asuspend, fail-soft resolver. It returns aResolvedOptimizedEntry— an SDK-owned result wrapper carryingentry(the entry to render) andselectedOptimization(the applied selection, or null). Itsentryis 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.
For custom rendering surfaces, call resolveOptimizedEntry(baseline, selectedOptimizations) directly
instead of through the view.
Follow this pattern:
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,
and for the locale boundary see
Locale handling in the Optimization SDK Suite.
Screen and navigation tracking
Integration category: Required for first integration
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.
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.
-
Call
ScreenTracker.trackScreen(name)fromActivity.onResume(orFragment.onResume) so it fires once per visible screen lifecycle.Copy this:
-
Emit a new screen event after an in-activity navigation state change when several logical screens share one activity.
Adapt this to your use case:
-
Use a direct client call when a screen event needs properties.
Adapt this to your use case:
For shared tracking mechanics and event delivery, see Android SDK runtime and interaction mechanics.
Entry interaction tracking
Integration category: Common but policy-dependent
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.
-
Leave the global
trackViewsandtrackTapsdefaults enabled when your policy permits them. PasstrackViews = falseortrackTaps = falsetoinitializewhen a surface must opt out by default.Copy this:
-
Override tracking per entry when a component needs different behavior from the global default. A non-null
onTapkeeps the entry tappable even when globaltrackTapsis on; setting per-entrytrackViews = falseortrackTaps = falseopts that one entry out.onTapruns through the same tap path as SDK tap tracking, sotrackTaps = falsedisables both thecomponent_clickevent andonTap— do not combine them. When a component needs an app-owned tap handler without SDK tap analytics, settrackTaps = falseand attach a normal Android click listener inside the child view your renderer returns instead.Adapt this to your use case:
-
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.
Adapt this to your use case:
-
For a
RecyclerViewscreen, use the SDK’sTrackingRecyclerView(aRecyclerViewsubclass) so descendantOptimizedEntryViewinstances re-check visibility on each scroll frame. It is an optional, redundant signal — eachOptimizedEntryViewalso 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.Adapt this to your use case:
For interaction timing, component event metadata, and duplicate prevention, see Android SDK runtime and interaction mechanics.
Identity, profile continuity, and reset
Integration category: Common but policy-dependent
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.
-
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.Adapt this to your use case:
-
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.Adapt this to your use case:
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.
Optional integrations
Custom events and analytics diagnostics
Integration category: Optional
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.
-
Call
track(event, properties)for a business event. It is a suspend emitter that returns anEventEmissionResult.Copy this:
-
Collect
eventStreamfor accepted events andblockedEventStreamfor events stopped by consent or the allow-list. Both areSharedFlows 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.Adapt this to your use case:
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.
Custom Flags and MergeTag rendering
Integration category: Optional
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.
-
Read a flag once with
getFlag(name)when a synchronous value is enough (returnsnullbefore init or when unresolved).Adapt this to your use case:
-
Observe with
observeFlag(name)when a view must update as flag values change. It returns aStateFlow<JSONValue?>(the Android reactive idiom, where iOS uses a Combine publisher). Subscribing to a flag observable emits acomponentflag-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:
-
Resolve merge tags with
getMergeTagValue(mergeTagEntry)while rendering Rich Text.nt_mergetagis 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 embeddednt_mergetagentry from the Rich Text node before calling the SDK, which resolves the selector against the current profile and returns the resolved string ornull.Follow this pattern:
For the merge-tag data model, see Entry optimization and variant resolution.
Live updates and locked variants
Integration category: Optional
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.
-
Keep the default for reading surfaces where content must not shift mid-view.
-
Enable live updates globally when most rendered entries must react to profile or preview changes without remounting.
Adapt this to your use case:
-
Override live updates per entry when only one component needs live behavior.
Adapt this to your use case:
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.
Preview panel
Integration category: Optional
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.
-
Pass a
PreviewPanelConfigtoinitializeunder a debug gate. Supply aPreviewContentfulClient(the built-inContentfulHTTPPreviewClientfetchesnt_audienceandnt_experiencedefinitions) so the panel shows audience and experience names; without it the panel still opens but falls back to raw identifiers.Adapt this to your use case:
-
Attach the floating entry point after
setContentView(...)in each activity that should show it.attachPreviewPaneluses the same initialized client the rest of the app uses, so overrides affect the same resolver and event state.Adapt this to your use case:
Do not expose preview controls in production traffic unless your organization has an explicit internal-access policy.
Advanced integrations
Offline delivery, queue observability, and app-owned caching
Integration category: Advanced or production-only
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.
-
Add a
QueuePolicyonly when production telemetry needs queue bounds or delivery callbacks. The offline Experience queue holds up to 100 events by default (tunable viaQueuePolicy.offlineMaxEvents); queues are in-memory only, with no durable outbox, and do not survive process death.Adapt this to your use case:
-
Use queue callbacks for operational diagnostics, not for resending blocked or dropped events.
-
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.
-
Call
flush()only for deliberate release, test, or lifecycle flows; the SDK already flushes on background and reconnect.
For the runtime delivery model, see Android SDK runtime and interaction mechanics.
Production checks
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 andOptimizationLogLevel.debuglogging 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, andreset()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
OptimizedEntryViewinstances 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:
Troubleshooting
- SDK never initializes or events never emit — Confirm the
Applicationsubclass is registered withandroid:nameinAndroidManifest.xml(without itonCreatenever runs), and that direct suspend calls awaitclient.isInitialized.first { it }before running. - Optimized entries always render the baseline — Confirm the app fetched a single-locale entry
with enough
includedepth fornt_experiencesandnt_variants, that consent or the allow-list is not blocking profile-producing events, and thatclient.selectedOptimizationsis non-empty for the visitor. - Tap or view events do not appear — Check consent,
allowedEventTypes, per-entrytrackViewsandtrackTaps, 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
onResumecalls across activity, fragment, and in-activity transitions, and preferScreenTracker.trackScreen(which dedupes the current route byrouteKey) over rawscreenfor lifecycle tracking. - Preview panel opens but shows identifiers — Pass a
PreviewContentfulClientthat can fetchnt_audienceandnt_experienceentries from the correct space and environment.
Reference implementations to compare against
- Android reference implementation — 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.