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:
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:
OptimizedEntryView and resolveOptimizedEntry (the
Contentful fetching and entry resolution section).
This is complete and shippable on its own.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
guide.
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:
repositories {
mavenCentral()
}
dependencies {
implementation("com.contentful.java:optimization-android:<version>")
}
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:
+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,
+ ),
+ )
}
}
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.
Then register the subclass in AndroidManifest.xml with android:name. Without it,
Application.onCreate never 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. 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.
Adapt this to your use case:
+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}"
+ }
+ }
+ }
}
+ 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")
+ }
}
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.
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: <reason>, 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>, Application.onCreate never runs and OptimizationManager.initialize(...) is
never called.
The sections below walk the integration in order. First, gather the few things you can only get from outside this guide:
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.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).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.
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 minSdk 24 or later, Java 11 bytecode, Kotlin, and Maven
Central, then add the dependency to the application module.
Copy this:
repositories {
mavenCentral()
}
dependencies {
implementation("com.contentful.java:optimization-android:<version>")
}
Build one OptimizationConfig. Only clientId is required; the rest have working defaults.
clientId from your configuration layer.environment only when it is not the Kotlin-side default "main".locale when Experience API requests and event context must use the same language as the
Contentful entries you render.api = OptimizationApiConfig(...) (experienceBaseUrl/insightsBaseUrl) only for mock,
staging, or other non-default endpoints — both default correctly otherwise.logLevel at its default OptimizationLogLevel.error in production unless your
operational policy allows more verbose logging.Adapt this to your use case:
val optimizationConfig = OptimizationConfig(
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 {
OptimizationLogLevel.error
},
)
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:
class MyApplication : Application() {
override fun onCreate() {
super.onCreate()
// Initialize once before any activity or fragment reads OptimizationManager.client.
OptimizationManager.initialize(context = this, config = optimizationConfig)
}
}
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:
lifecycleScope.launch {
// 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"))
}
For lifecycle and coroutine behavior, see Android SDK runtime and interaction mechanics.
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. 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:
val optimizationConfig = OptimizationConfig(
clientId = "your-optimization-client-id",
// Seed accepted consent only when your app policy permits event emission at startup.
defaults = StorageDefaults(consent = true),
)
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:
class ConsentActivity : AppCompatActivity() {
private val client get() = OptimizationManager.client
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_consent)
// acceptButton, rejectButton, and consentBanner are reader-owned views from your layout.
acceptButton.setOnClickListener { applyConsent(true) }
rejectButton.setOnClickListener { applyConsent(false) }
// Show the banner only while consent is undecided (null); hide it once the visitor chooses.
lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED) {
client.state.collect { state ->
consentBanner.isVisible = state.consent == null
}
}
}
}
private fun applyConsent(accepted: Boolean) {
lifecycleScope.launch {
// consent(...) no-ops before initialization, so wait before applying the choice.
client.isInitialized.first { it }
// Boolean consent sets both event emission and durable profile continuity.
client.consent(accepted)
}
}
}
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:
lifecycleScope.launch {
OptimizationManager.client.isInitialized.first { it }
// Emit events but keep profile continuity session-only.
OptimizationManager.client.consent(events = true, persistence = false)
}
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:
val strictConfig = OptimizationConfig(
clientId = "your-optimization-client-id",
// Empty means no SDK event emits before explicit consent.
allowedEventTypes = emptyList(),
)
For the consent responsibility model and blocked-event behavior, see Consent management in the Optimization SDK Suite.
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
to OptimizedEntryView — they fall back to baseline.
Add OptimizedEntryView from XML or create it in activity, fragment, or adapter code.
Copy this:
<com.contentful.optimization.views.OptimizedEntryView
android:id="@+id/hero_slot"
android:layout_width="match_parent"
android:layout_height="wrap_content" />
Set a renderer that turns the resolved entry map into a child View, then call setEntry(...)
with the fetched baseline entry.
Adapt this to your use case:
val heroSlot = findViewById<OptimizedEntryView>(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)
}
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)
}
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.
For custom rendering surfaces, call resolveOptimizedEntry(baseline, selectedOptimizations) directly
instead of through the view.
Follow this pattern:
// 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)
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.
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) from Activity.onResume (or Fragment.onResume) so it
fires once per visible screen lifecycle.
Copy this:
override fun onResume() {
super.onResume()
// Track once per visible screen lifecycle, not from repeated child-view binding.
ScreenTracker.trackScreen("Home")
}
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:
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")
}
}
Use a direct client call when a screen event needs properties.
Adapt this to your use case:
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))
}
For shared tracking mechanics and event delivery, see Android SDK runtime and interaction mechanics.
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 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.
Copy this:
OptimizationManager.initialize(
context = this,
config = optimizationConfig,
// Opt out globally only when this app must not emit tap analytics by default.
trackTaps = false,
)
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.
Adapt this to your use case:
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)
}
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)
}
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:
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)
}
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.
Adapt this to your use case:
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)
}
For interaction timing, component event metadata, and duplicate prevention, see Android SDK runtime and interaction mechanics.
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:
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"))
}
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:
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.
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 an
EventEmissionResult.
Copy this:
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"))
}
Collect eventStream for accepted events and blockedEventStream for events stopped by consent or
the allow-list. Both are SharedFlows 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:
lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED) {
// analyticsDebugger is reader-owned: your debug display or forwarding sink.
OptimizationManager.client.eventStream.collect { event -> 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}")
}
}
}
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.
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 (returns null before
init or when unresolved).
Adapt this to your use case:
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"
}
Observe with observeFlag(name) when a view must update as flag values change. It returns a
StateFlow<JSONValue?> (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:
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" }
}
}
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:
suspend fun resolveMergeTagText(mergeTagEntry: Map<String, Any>): 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.
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:
OptimizationManager.initialize(
context = this,
config = optimizationConfig,
// Live updates let mounted OptimizedEntryView instances re-resolve as selections change.
liveUpdates = true,
)
Override live updates per entry when only one component needs live behavior.
Adapt this to your use case:
OptimizedEntryView(context).apply {
liveUpdates = true
// ContentEntryBinder and dashboardEntry are reader-owned, as elsewhere in this guide.
setContentRenderer { resolvedEntry -> ContentEntryBinder.create(context, resolvedEntry) }
setEntry(dashboardEntry)
}
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.
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 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.
Adapt this to your use case:
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
},
)
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.
Adapt this to your use case:
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.
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 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.
Adapt this to your use case:
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}") },
),
)
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.
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:
# 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
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.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.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.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.PreviewContentfulClient that can fetch
nt_audience and nt_experience entries from the correct space and environment.