Use this guide when you want to add Optimization, Analytics, screen tracking, Custom Flags, MergeTag rendering, and preview overrides to a native Android application built with XML layouts or Android Views. Use the Compose guide when the screen is built with Jetpack Compose: Integrating the Optimization Android SDK in a Jetpack Compose app.
This path uses the Android/native default pre-consent allow-list, where screen can emit before an
explicit consent decision. If your policy requires strict opt-in before any Optimization event, set
allowedEventTypes = emptyList() and complete the consent handoff section before sending events or
expecting selected variants.
This path initializes the SDK, emits one screen event for selection context, fetches one single-locale Contentful entry, and renders the resolved entry in an Android View.
Add the Android SDK dependency to the application module.
Copy this:
repositories {
mavenCentral()
}
dependencies {
implementation("com.contentful.java:optimization-android:<version>")
}
Initialize the process-wide Views client.
Adapt this to your use case:
class MyApplication : Application() {
override fun onCreate() {
super.onCreate()
val appLocale = "en-US"
OptimizationManager.initialize(
context = this,
config = OptimizationConfig(
clientId = "your-client-id",
environment = "main",
locale = appLocale,
logLevel = if (BuildConfig.DEBUG) {
OptimizationLogLevel.debug
} else {
OptimizationLogLevel.error
},
),
)
}
}
Emit one screen event, fetch a single-locale Contentful entry with linked optimization data, and
render it through OptimizedEntryView.
Adapt this to your use case:
class HomeActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val heroSlot = OptimizedEntryView(this).apply {
accessibilityIdentifier = "content-entry-home-hero"
setContentRenderer { resolvedEntry ->
// Render the resolver output; it falls back to baseline content.
HeroBinder.create(context, resolvedEntry)
}
}
setContentView(heroSlot)
lifecycleScope.launch {
// Wait until the bridge can accept events and resolve optimized entries.
OptimizationManager.client.isInitialized.first { it }
// Emit screen context once before fetching content that can use selected variants.
OptimizationManager.client.screen("Home")
// Your app owns Contentful fetching; request one concrete locale with resolved links.
val heroEntry = contentfulClient.fetchEntry(
id = "home-hero",
include = 10,
locale = "en-US",
)
heroSlot.setEntry(heroEntry)
}
}
}
Verify that the entry renders either its baseline content or selected variant content in the Android View.
Use this setup inventory for the full guide:
| Setup item | Category | Required for quick start | Where to configure |
|---|---|---|---|
| Android SDK dependency | Required for first integration | Yes | Application module Gradle dependency |
| Optimization client ID and environment | Required for first integration | Yes | OptimizationConfig(clientId = ..., environment = ...) |
| Experience API and Insights API endpoint overrides | Common but policy-dependent | No | OptimizationApiConfig for staging, mock, or non-default hosts |
| SDK Experience and event locale | Common but policy-dependent | Conditional | OptimizationConfig(locale = appLocale) |
| Consent and profile-continuity policy | Common but policy-dependent | Conditional | StorageDefaults, allowedEventTypes, client.consent(...), and consent UI |
| Contentful Delivery or Preview API client, credentials, and API host | Required for first integration | Yes | Application-owned Contentful client configuration |
| Single-locale Contentful entries with linked optimization data | Required for first integration | Yes | Application-owned Contentful Delivery API or Content Preview API fetch code |
| Android process entry point | Required for first integration | Yes | Application.onCreate with OptimizationManager.initialize(...) |
| Activity, Fragment, or navigation lifecycle hooks | Required for first integration | Yes | ScreenTracker.trackScreen(...) or direct client.screen(...) calls |
| Android Views entry-rendering adapter | Required for first integration | Yes | OptimizedEntryView in XML layouts, Activity code, Fragment code, or adapters |
| Entry view and tap tracking defaults | Common but policy-dependent | No | OptimizationManager.initialize(...) and per-view OptimizedEntryView properties |
| Scrollable entry-list helper | Optional | No | TrackingRecyclerView for RecyclerView-based screens |
| Identity, profile, and reset controls | Optional | No | Login, account, logout, or reset handlers calling client.identify(...) or reset |
| Runtime locale changes | Optional | No | App locale state, client.setLocale(...), and Contentful refetch logic |
| Analytics diagnostics or forwarding | Optional | No | client.eventStream, client.blockedEventStream, and app-owned destinations |
| Custom Flags and MergeTag rendering | Optional | No | Views, adapters, or binders that call getFlag(...), observeFlag(...), or getMergeTagValue(...) |
| Preview-panel UI and preview Contentful definitions | Advanced or production-only | No | Debug or internal Activity code with PreviewPanelConfig and attachPreviewPanel |
| Strict pre-consent event policy | Advanced or production-only | Conditional | OptimizationConfig.allowedEventTypes, usually emptyList() for strict opt-in |
| Offline queue bounds, retry policy, and delivery callbacks | Advanced or production-only | No | QueuePolicy in OptimizationConfig |
The SDK does not replace your Contentful delivery client. Your application still owns Contentful fetching, consent UX, identity policy, routing, caching, and rendering.
Integration category: Required for first integration
Confirm that the consuming Android app supports Android minSdk 24 or later, Java 11 bytecode,
Kotlin, and Maven Central. The package requirements and published coordinate are documented in
the Optimization Android SDK README.
Add the SDK dependency to the app module.
Copy this:
repositories {
mavenCentral()
}
dependencies {
implementation("com.contentful.java:optimization-android:<version>")
}
Create one OptimizationConfig with the Optimization client ID, Contentful environment, and SDK
Experience or event locale.
Adapt this to your use case:
val appLocale = "en-US"
val optimizationConfig = OptimizationConfig(
clientId = "your-client-id",
environment = "main",
// Match this to the locale used by the app-owned Contentful fetch.
locale = appLocale,
logLevel = if (BuildConfig.DEBUG) {
OptimizationLogLevel.debug
} else {
OptimizationLogLevel.error
},
)
Pass api = OptimizationApiConfig(...) only when the app uses staging, mock, or non-default
Experience API or Insights API hosts. Omit it for the default Contentful Optimization endpoints.
Initialize OptimizationManager once for the app process. Production apps usually do this from
Application.onCreate before any Activity or Fragment reads OptimizationManager.client.
Adapt this to your use case:
class MyApplication : Application() {
override fun onCreate() {
super.onCreate()
// Initialize once before Activities or Fragments read OptimizationManager.client.
OptimizationManager.initialize(
context = this,
config = optimizationConfig,
)
}
}
Treat OptimizationManager.initialize(...) as asynchronous. OptimizationManager.client is
available immediately after initialization is requested, but suspend APIs that depend on the
bridge require client.isInitialized to become true.
Copy this:
lifecycleScope.launch {
// Direct suspend APIs require the async bridge 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
Decide whether the application can start SDK event emission and durable profile continuity before rendering a consent UI. The SDK stores and applies consent state, but the application or CMP owns notice text, jurisdiction logic, consent records, and withdrawal policy.
For a default-on accepted policy, seed accepted consent during initialization.
Copy this:
val optimizationConfig = OptimizationConfig(
clientId = "your-client-id",
// Seed accepted consent only when your app policy permits event emission at startup.
defaults = StorageDefaults(consent = true),
)
For a user-choice policy, leave consent unset and call client.consent(true) or
client.consent(false) from application-owned UI after initialization completes.
Adapt this to your use case:
class ConsentActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
acceptButton.isEnabled = false
rejectButton.isEnabled = false
lifecycleScope.launch {
OptimizationManager.client.isInitialized.first { it }
acceptButton.isEnabled = true
rejectButton.isEnabled = true
}
acceptButton.setOnClickListener {
// Boolean consent applies to events and durable profile-continuity persistence.
applyConsent(true)
}
rejectButton.setOnClickListener {
// Rejection blocks gated SDK events such as view, tap, and custom track calls.
applyConsent(false)
}
lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED) {
OptimizationManager.client.state.collect { state ->
consentBanner.isVisible = state.consent == null
}
}
}
}
private fun applyConsent(accepted: Boolean) {
lifecycleScope.launch {
// consent(...) no-ops before initialization, so wait before applying the choice.
OptimizationManager.client.isInitialized.first { it }
OptimizationManager.client.consent(accepted)
}
}
}
Use object-form consent when event emission is allowed but durable profile-continuity storage must stay session-only.
Adapt this to your use case:
lifecycleScope.launch {
OptimizationManager.client.isInitialized.first { it }
OptimizationManager.client.consent(events = true, persistence = false)
}
By default, Android/native identify and screen are allowed before consent so a mobile journey
can establish profile context and anonymous screen analytics. View, tap, custom track(...), and
most other events are blocked until event consent is accepted. For strict opt-in before any
Optimization event, replace the default allow-list during initialization:
Copy this:
val strictConfig = OptimizationConfig(
clientId = "your-client-id",
// Empty means no SDK events are allowed before explicit consent.
allowedEventTypes = emptyList(),
)
For cross-SDK policy guidance, see Consent management in the Optimization SDK Suite.
Integration category: Required for first integration
locale=* payloads into OptimizedEntryView.locale when Experience API responses and
event context must match the rendered content language.Follow this pattern:
suspend fun fetchHomeEntries(
contentfulClient: ContentfulDeliveryClient,
appLocale: String,
): List<Map<String, Any>> {
return contentfulClient.getEntries(
contentType = "homePage",
// Resolve linked optimization entries before handing the payload to OptimizedEntryView.
include = 10,
// Use one concrete Contentful locale; do not pass locale=* responses to the SDK.
locale = appLocale,
)
}
Adapt this to your use case:
lifecycleScope.launch {
val appLocale = getAppLocale()
// Wait until the SDK can resolve optimized entries against profile state.
OptimizationManager.client.isInitialized.first { it }
val entries = contentfulClient.fetchHomeEntries(locale = appLocale)
renderEntries(entries)
}
The resolver expects direct field values such as fields.nt_experiences and linked variant entries
such as optimizationEntry.fields.nt_variants. For the full locale model, see
Locale handling in the Optimization SDK Suite.
For the Contentful entry contract and fallback rules, see
Entry optimization and variant resolution.
Integration category: Required for first integration
OptimizedEntryView from XML or create it from Activity, Fragment, or adapter code.Copy this:
accessibilityIdentifier, set a renderer that turns the resolved entry map into a child
View, then call setEntry(...) with the baseline Contentful entry.Adapt this to your use case:
val heroSlot = findViewById<OptimizedEntryView>(R.id.hero_slot)
heroSlot.accessibilityIdentifier = "content-entry-home-hero"
heroSlot.setContentRenderer { resolvedEntry ->
// Render the resolver output; it is the baseline fallback when no variant resolves.
HeroBinder.create(context = heroSlot.context, entry = resolvedEntry)
}
heroSlot.setEntry(heroEntry)
OptimizedEntryView mirrors the Compose OptimizedEntry behavior for non-optimized entries,
variant resolution, variant locking, live updates, view tracking, tap tracking, and
accessibilityIdentifier as contentDescription. For deeper resolution mechanics, see
Entry optimization and variant resolution.
Integration category: Required for first integration
ScreenTracker.trackScreen(...) from onResume.Copy this:
override fun onResume() {
super.onResume()
// Track once per visible screen lifecycle instead of from repeated child view binding.
ScreenTracker.trackScreen("Home")
}
Adapt this to your use case:
private fun transitionTo(destination: Destination) {
renderDestination(destination)
// Emit the screen event after the app has committed its navigation state.
when (destination) {
Destination.HOME -> ScreenTracker.trackScreen("NavigationHome")
Destination.DETAIL -> ScreenTracker.trackScreen("NavigationDetail")
}
}
Adapt this to your use case:
lifecycleScope.launch {
// Wait for bridge initialization before calling suspend client APIs directly.
OptimizationManager.client.isInitialized.first { it }
OptimizationManager.client.screen(
name = "BlogPostDetail",
properties = mapOf("postId" to postId),
)
OptimizationManager.client.track(
event = "Purchase Completed",
properties = mapOf("sku" to "sku-1"),
)
}
ScreenTracker uses the same client as OptimizationManager.client and swallows initialization
failures so early lifecycle calls do not crash the host Activity. Wait for isInitialized before
calling suspend APIs directly.
Integration category: Common but policy-dependent
trackViews = false or trackTaps = false globally 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,
)
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 ->
HeroBinder.create(context, resolvedEntry)
}
setEntry(hero)
}
OptimizedEntryView(context).apply {
setContentRenderer { resolvedEntry ->
CtaBinder.create(context, resolvedEntry)
}
setEntry(cta)
}
OptimizedEntryView(context).apply {
onTap = { baselineEntry ->
navigateToEntry(baselineEntry)
}
// Direct onTap is a per-entry override; it keeps this entry tappable even
// when global trackTaps is false.
setContentRenderer { resolvedEntry ->
CtaBinder.create(context, resolvedEntry)
}
setEntry(cta)
}
trackTaps = false only when that entry must opt out of the SDK tap handling path.
Do not combine it with onTap; if a component needs an app-owned tap handler without SDK tap
tracking, attach a normal Android click listener inside the rendered child view instead.Adapt this to your use case:
OptimizedEntryView(context).apply {
// Changing timing changes when verification-visible view events are emitted.
minVisibleRatio = 0.75
dwellTimeMs = 1500
viewDurationUpdateIntervalMs = 5000
setContentRenderer { resolvedEntry ->
PromoBinder.create(context, resolvedEntry)
}
setEntry(promo)
}
SDK view and tap event emission still respects the SDK consent gate. For interaction timing, component event metadata, and offline delivery behavior, see Android SDK runtime and interaction mechanics.
Integration category: Optional
TrackingRecyclerView when a RecyclerView screen needs an extra scroll-frame signal for
descendant OptimizedEntryView visibility checks.Adapt this to your use case:
val recyclerView = TrackingRecyclerView(this).apply {
// RecyclerView scrolls can need an extra visibility signal for descendant entry views.
layoutManager = LinearLayoutManager(this@HomeActivity)
adapter = ContentEntryAdapter(entries)
}
OptimizedEntryView.Adapt this to your use case:
class ContentEntryViewHolder(
private val parent: ViewGroup,
) : RecyclerView.ViewHolder(
OptimizedEntryView(parent.context),
) {
private val optimizedEntryView = itemView as OptimizedEntryView
fun bind(entry: Map<String, Any>) {
// Keep item views stable so dwell timers are not reset by repeated rebinding.
optimizedEntryView.setContentRenderer { resolvedEntry ->
ContentEntryBinder.create(parent.context, resolvedEntry)
}
optimizedEntryView.setEntry(entry)
}
}
OptimizedEntryView also rechecks visibility from layout callbacks, so plain ScrollView screens
can use normal child views unless a RecyclerView adapter needs the additional helper.
Integration category: Optional
identify(...) after login, account selection, or another application-owned identity
decision. Gate traits before sending sensitive or restricted data.Adapt this to your use case:
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"),
)
}
reset() when the app must clear SDK-managed profile continuity, such as during logout or a
local test reset.Adapt this to your use case:
lifecycleScope.launch {
OptimizationManager.client.isInitialized.first { it }
OptimizationManager.client.reset()
// Emit fresh profile-producing context after reset before expecting new variants.
ScreenTracker.trackScreen("Home")
}
After reset, emit a fresh screen, identify, page, or other approved profile-producing event before expecting new selected optimizations. Store account IDs, consent records, and cross-device identity state outside the SDK.
Integration category: Optional
OptimizedEntryView.Adapt this to your use case:
lifecycleScope.launch {
OptimizationManager.client.isInitialized.first { it }
val nextLocale = getAppLocale()
// setLocale updates SDK Experience/event locale; the app still refetches Contentful entries.
OptimizationManager.client.setLocale(nextLocale)
val entries = contentfulClient.fetchHomeEntries(locale = nextLocale)
renderEntries(entries)
}
setLocale(...) changes the SDK Experience/event locale. It does not refetch Contentful entries,
infer device language, or validate that the locale is enabled in the Contentful environment. For the
locale boundary, see
Locale handling in the Optimization SDK Suite.
Integration category: Optional
eventStream for debug surfaces, local tests, or application-owned analytics forwarding.blockedEventStream and onEventBlocked to verify consent and allowedEventTypes blocks
for SDK calls that reach Core.eventStream, EventEmissionResult, queue callbacks, tracking timing checks, adapter state,
debug logs, or app-owned diagnostics for other suppressed behavior.Adapt this to your use case:
lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED) {
OptimizationManager.client.eventStream.collect { event ->
// Deduplicate before forwarding outside debug displays or test assertions.
analyticsDebugger.render(event)
}
}
}
For forwarding SDK context to other destinations, see Forwarding Optimization SDK context to analytics and tag management tools.
Integration category: Optional
Custom Flags and MergeTag helpers read profile-backed values from the initialized
OptimizationClient. Use them from Views, adapters, or binders that already wait for SDK
initialization.
client.getFlag(name) for a one-time Custom Flag read.client.observeFlag(name) when a View needs a StateFlow that updates with flag value
changes.client.getMergeTagValue(mergeTagEntry) while rendering Contentful Rich Text that includes
resolved nt_mergetag entries.Adapt this to your use case:
lifecycleScope.launch {
OptimizationManager.client.isInitialized.first { it }
val headlineFlag = OptimizationManager.client.getFlag("homepage-headline")
headlineBadge.text = headlineFlag?.toString() ?: "default"
}
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?.toString() ?: "Continue"
}
}
}
Follow this pattern:
suspend fun resolveMergeTagText(
mergeTagEntry: Map<String, Any>,
): String {
OptimizationManager.client.isInitialized.first { it }
// Keep fallback copy in the app so unresolved merge tags do not break Rich Text rendering.
return OptimizationManager.client.getMergeTagValue(mergeTagEntry)
?: readFallbackValue(mergeTagEntry)
?: "[Merge Tag]"
}
Integration category: Optional
Adapt this to your use case:
OptimizationManager.initialize(
context = this,
config = optimizationConfig,
// Live updates let mounted OptimizedEntryView instances react to profile changes.
liveUpdates = true,
)
Adapt this to your use case:
OptimizedEntryView(context).apply {
// Override the global locking behavior only for entries that must update while mounted.
liveUpdates = true
setContentRenderer { resolvedEntry ->
DashboardBinder.create(context, resolvedEntry)
}
setEntry(dashboardEntry)
}
When the preview panel is open, OptimizedEntryView instances re-resolve live so audience and
variant overrides apply immediately. When the panel closes, only non-live optimized entries without
a caller-supplied selectedOptimizations override lock to the previewed selection. Live entries
continue following client.selectedOptimizations, and explicit overrides keep resolving from their
explicit value. For precedence rules, see
Android SDK runtime and interaction mechanics.
Integration category: Advanced or production-only
OptimizationManager.attachPreviewPanel(...).PreviewPanelConfig(contentfulClient = previewContentfulClient) during initialization when
the panel must display audience and experience names. Without a PreviewContentfulClient, the
panel falls back to 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 = previewContentfulClient)
} else {
null
},
)
attachPreviewPanel(...) after setContentView(...) in each Activity that displays the
floating entry point.Adapt this to your use case:
class HomeActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.home)
if (BuildConfig.DEBUG) {
// Attach after setContentView so the floating entry point mounts into this Activity.
OptimizationManager.attachPreviewPanel(this)
}
}
}
Integration category: Advanced or production-only
QueuePolicy for offline event caps, retry behavior, and delivery callbacks.Adapt this to your use case:
val optimizationConfig = OptimizationConfig(
clientId = "your-client-id",
queuePolicy = QueuePolicy(
// Cap offline storage according to the app's production delivery budget.
offlineMaxEvents = 100,
onOfflineDrop = { event ->
Log.w("Optimization", "Dropped offline event: ${event.context}")
},
),
)
The Android SDK monitors network reachability, queues events while offline, flushes when connectivity returns, and flushes when the app moves toward the background. For the runtime model, see Android SDK runtime and interaction mechanics.
Before releasing an Android Views integration, verify these points:
client.consent(...) for every choice, withdrawal blocks later gated events, and suppressed
interactions are not replayed later.OptimizedEntryView instances on every SDK state emission. Stable item rendering prevents dwell
timers and component event metadata from resetting mid-view.pnpm implementation:run -- android-sdk test:e2e:views -- --flow tap-tracking,
pnpm implementation:run -- android-sdk test:e2e:views -- --flow screen-tracking, and
pnpm implementation:run -- android-sdk test:e2e:views -- --flow live-updates when those
behaviors are relevant.| Symptom | Likely cause | Action |
|---|---|---|
OptimizationManager.client throws |
The app read the client before calling OptimizationManager.initialize(...). |
Initialize once before Activity or Fragment code reads the client. |
| Suspend API calls fail or no-op | The bridge has not finished initializing. | Wait for client.isInitialized.first { it } before direct suspend calls. |
| Entries always render baseline content | Blocked or non-allow-listed profile-producing events, strict opt-in without accepted consent, missing selections, unresolved links, or all-locale Contentful payloads. | Accept consent or allow the needed screen or identity event, verify selections, fetch one locale, and include links. |
| View or tap events do not appear | Consent blocks tracking, tracking is opted out, dwell timing is not met, or views are recreated mid-dwell. | Check consent state, trackViews, trackTaps, visibility timing, and adapter stability. |
| Preview panel shows identifiers only | No preview PreviewContentfulClient was passed during initialization. |
Pass PreviewPanelConfig(contentfulClient = previewContentfulClient) for rich definitions. |
| Preview panel appears in the wrong build | The Activity calls attachPreviewPanel(...) without an app-owned gate. |
Wrap the attach call in a debug, internal-build, or staff-only condition. |
getMergeTagValue(...),
preview-panel overrides, and mock API flows.