Use this guide when you want to add Optimization, Analytics, screen tracking, entry interaction tracking, Custom Flags, MergeTag rendering, and preview overrides to a native Android application built with Jetpack Compose.
The Compose integration uses OptimizationRoot, OptimizedEntry, OptimizationLazyColumn, and
ScreenTrackingEffect. Your application still owns Contentful entry fetching, consent policy,
identity policy, navigation, app-owned caching, and final rendering. Use the Android Views guide
instead when your app is View-based:
Integrating the Optimization Android SDK in an Android Views app.
Use this path when your application policy permits Optimization to start with accepted consent. If your policy requires an end-user choice first, complete the consent handoff section before sending an accepted SDK event.
Add the Android SDK from Maven Central to your Android application module.
Copy this:
repositories {
mavenCentral()
}
dependencies {
implementation("com.contentful.java:optimization-android:<version>")
}
Configure the SDK with accepted startup consent, mount OptimizationRoot, and emit one screen
event from the first Compose route.
Adapt this to your use case:
import androidx.compose.runtime.Composable
import com.contentful.optimization.compose.OptimizationRoot
import com.contentful.optimization.compose.ScreenTrackingEffect
import com.contentful.optimization.core.OptimizationConfig
import com.contentful.optimization.core.OptimizationLogLevel
import com.contentful.optimization.core.StorageDefaults
val optimizationConfig = OptimizationConfig(
clientId = "your-optimization-client-id",
environment = "main",
// Use this default only when your app policy permits accepted consent at startup.
defaults = StorageDefaults(consent = true),
logLevel = if (BuildConfig.DEBUG) OptimizationLogLevel.debug else OptimizationLogLevel.error,
)
@Composable
fun AppRoot() {
OptimizationRoot(
config = optimizationConfig,
// Own one SDK client for the Compose tree that emits Optimization events.
) {
HomeScreen()
}
}
@Composable
fun HomeScreen() {
// Emit one screen event from the route root, not from repeated child composables.
ScreenTrackingEffect(screenName = "Home")
HomeContent()
}
Verify the first run. Confirm one screen event for Home is accepted with consent marked as
accepted in your SDK diagnostics, mock server, or network logs.
Use this setup inventory before you move beyond the quick start:
| Setup item | Category | Required for quick start | Where to configure |
|---|---|---|---|
Android app module using Jetpack Compose, Android minSdk 24 or later, and Java 11 bytecode |
Required for first integration | Yes | Android application Gradle configuration |
Maven Central repository and com.contentful.java:optimization-android dependency |
Required for first integration | Yes | Application Gradle repositories and dependencies |
| Optimization client ID and environment | Required for first integration | Yes | OptimizationConfig, usually from app runtime configuration |
| Experience API and Insights API endpoint overrides | Common but policy-dependent | No | OptimizationApiConfig for staging, mock, or non-default hosts |
| Contentful Delivery API client, space, environment, access token, and CDA host | Required for first integration | No | Application-owned Contentful fetching layer |
Optimized Contentful entries with linked nt_experiences and variant data |
Required for first integration | No | Contentful content model, entries, and CDA include depth |
| Single Contentful CDA locale and SDK Experience/event locale | Required for first integration | No | App locale policy, Contentful requests, and OptimizationConfig.locale |
OptimizationRoot mounted around the Compose tree that uses SDK helpers |
Required for first integration | Yes | Compose app root, navigation root, or feature root |
| Screen, route, or lifecycle tracking hook | Required for first integration | Yes | ScreenTrackingEffect or app-owned client calls in Compose screen lifecycle |
| Accepted consent startup policy or user-choice handoff | Common but policy-dependent | Yes | StorageDefaults, allowedEventTypes, and application consent UI or CMP callbacks |
| Entry view and tap tracking policy | Common but policy-dependent | No | OptimizationRoot tracking defaults and per-entry OptimizedEntry options |
| User identity, profile continuity, and reset policy | Common but policy-dependent | No | Account, session, or settings screens that call identify(...) and reset() |
| Custom business events and analytics diagnostics | Optional | No | track(...), eventStream, blockedEventStream, and app-owned forwarding code |
| Custom Flags and MergeTag rendering | Optional | No | Components that call getFlag(...), observeFlag(...), or getMergeTagValue(...) |
| Live updates for mounted entries | Optional | No | OptimizationRoot.liveUpdates and per-entry OptimizedEntry.liveUpdates |
| Preview panel and preview-definition Contentful client | Optional | No | PreviewPanelConfig, PreviewContentfulClient, and debug or internal-build gates |
| Strict pre-consent allow-list, queue policy, and blocked-event diagnostics | Advanced or production-only | No | OptimizationConfig.allowedEventTypes, queuePolicy, and onEventBlocked |
| Offline, lifecycle, and local reference-app validation path | Advanced or production-only | No | Release checks, Android reference implementation, and targeted Maestro suites |
The Android SDK does not fetch Contentful entries for your application UI. Fetch entries in the
application layer, then pass single-locale entry maps to OptimizedEntry or
client.resolveOptimizedEntry(...).
OptimizationRootIntegration category: Required for first integration
OptimizationRoot is the normal Compose entry point. It creates an OptimizationClient, calls
initialize(config), provides the initialized client through LocalOptimizationClient, applies
tracking defaults to descendant OptimizedEntry components, and renders a loading indicator until
the client is ready. For package status and installation details, see the
Optimization Android SDK README.
com.contentful.java:optimization-android to the application module.OptimizationConfig with the Optimization client ID, environment, and SDK
Experience/event locale.OptimizationRoot.LocalOptimizationClient.current inside descendant composables that call SDK methods
directly.Copy this:
dependencies {
implementation("com.contentful.java:optimization-android:<version>")
}
Adapt this to your use case:
import androidx.compose.runtime.Composable
import com.contentful.optimization.compose.OptimizationRoot
import com.contentful.optimization.core.OptimizationApiConfig
import com.contentful.optimization.core.OptimizationConfig
val config = OptimizationConfig(
clientId = "your-optimization-client-id",
environment = "main",
api = OptimizationApiConfig(
experienceBaseUrl = "https://experience.ninetailed.co/",
insightsBaseUrl = "https://ingest.insights.ninetailed.co/",
),
locale = "en-US",
)
@Composable
fun AppRoot() {
// Mount once around the Compose subtree that shares this SDK client.
OptimizationRoot(
config = config,
) {
AppNavGraph()
}
}
OptimizationClient exposes async work as suspend functions and state as Kotlin flows. Call
suspending methods from Compose effects, event-handler coroutine scopes, lifecycle-aware coroutines,
or another app-owned coroutine scope. For lifecycle details, see
Android SDK runtime and interaction mechanics.
Integration category: Common but policy-dependent
Consent policy remains application-owned. Use the default accepted startup path only when application policy permits Optimization by default and no end-user consent UI is rendered. Otherwise, leave consent unset and connect your CMP, account preference, or in-app banner to the SDK.
StorageDefaults(consent = true) when policy permits default-on
Optimization.client.consent(true) after the visitor accepts and client.consent(false) after the
visitor rejects.client.state when consent UI needs to reflect SDK state across app launches.Copy this:
val defaultOnConfig = OptimizationConfig(
clientId = "your-optimization-client-id",
// Use default accepted consent only when your app does not need a prior user choice.
defaults = StorageDefaults(consent = true),
)
Adapt this to your use case:
@Composable
fun ConsentControls(content: @Composable () -> Unit) {
val client = LocalOptimizationClient.current
val state by client.state.collectAsState()
if (state.consent == null) {
Row {
// Wire these calls to your CMP or privacy UI, not to SDK-owned policy.
Button(onClick = { client.consent(true) }) {
Text("Accept")
}
Button(onClick = { client.consent(false) }) {
Text("Reject")
}
}
} else {
content()
}
}
By default, mobile identify and screen events can emit before event consent is accepted. Entry
views, entry taps, page, and custom track events are blocked until consent is accepted or their
event types are allow-listed. Boolean consent calls control both event emission and durable profile
continuity. Use this form when events can emit but profile, selected optimizations, changes, and
anonymous identity must stay session-only:
Copy this:
client.consent(events = true, persistence = false)
For cross-SDK policy details, see Consent management in the Optimization SDK Suite.
Integration category: Required for first integration
Your app owns Contentful fetching. The SDK resolver expects standard single-locale Contentful CDA entry maps with direct field values and linked optimization entries already resolved in the payload.
OptimizationConfig.locale when Experience API responses and event
context need to stay aligned with rendered Contentful content.locale=*.fields.nt_experiences, optimization config, and linked
variant entries.Map<String, Any> entry objects to SDK helpers.Adapt this to your use case:
@Composable
fun HomeScreen(contentfulClient: ContentfulDeliveryClient) {
val appLocale = getAppLocale()
var entries by remember { mutableStateOf<List<Map<String, Any>>>(emptyList()) }
LaunchedEffect(appLocale) {
// Fetch one CDA locale and enough linked entries for nt_experiences and variants.
entries = contentfulClient.fetchEntries(
ids = listOf("hero-entry-id", "cta-entry-id"),
include = 10,
locale = appLocale,
)
}
HomeContent(entries = entries)
}
The SDK top-level locale does not modify your Contentful client or refetch content after locale
changes. For the full locale model, see
Locale handling in the Optimization SDK Suite.
For the single-locale CDA shape and fallback rules, see
Entry optimization and variant resolution.
Integration category: Required for first integration
OptimizedEntry resolves a Contentful entry against the selected variants for the visitor, passes
non-optimized entries through unchanged, and falls back to the baseline entry when optimization data
is missing, unresolved, out of range, or not selected for the visitor. The render lambda receives
the entry map your app must convert into its own view model or Compose hierarchy.
OptimizedEntry.accessibilityIdentifier when tests or accessibility tooling need a stable wrapper
identifier.client.resolveOptimizedEntry(...) directly only when a custom UI abstraction needs the same
local resolver without the Compose wrapper.Adapt this to your use case:
@Composable
fun HeroSection(entry: Map<String, Any>) {
OptimizedEntry(
entry = entry,
accessibilityIdentifier = "home-hero-optimization",
) { resolvedEntry ->
// Always render the resolved entry; the SDK returns the baseline when no variant is usable.
HeroCard(entry = resolvedEntry)
}
}
Follow this pattern:
LaunchedEffect(entry) {
// Direct resolution is for custom abstractions that cannot use the Compose wrapper.
client.selectedOptimizations.collect { selectedOptimizations ->
val result = client.resolveOptimizedEntry(
baseline = entry,
selectedOptimizations = selectedOptimizations,
)
resolvedEntry = result.entry
}
}
Collect selectedOptimizations inside the effect when custom UI must re-resolve after profile,
preview, or consent-driven selection changes. Reading client.selectedOptimizations.value only
captures the current snapshot.
The resolver does not fetch Contentful entries, evaluate audiences, call the Experience API, or mutate state. It joins the current selected optimization metadata with linked entries already present in the Contentful payload. For deeper mechanics, see Entry optimization and variant resolution.
Integration category: Required for first integration
Compose apps track native screens from composable lifecycle. ScreenTrackingEffect emits the
current screen through the SDK and rechecks consent state, so an active screen can emit after
tracking becomes allowed.
ScreenTrackingEffect once from the root composable for each route or screen destination.client.trackCurrentScreen(...) from a
LaunchedEffect.Copy this:
@Composable
fun HomeScreen() {
// Put screen tracking at the destination root to prevent duplicate screen events.
ScreenTrackingEffect(screenName = "Home")
HomeContent()
}
Adapt this to your use case:
@Composable
fun DetailsScreen(postId: String) {
val client = LocalOptimizationClient.current
LaunchedEffect(postId) {
// Key dynamic screens by route identity so navigation changes emit in sequence.
client.trackCurrentScreen(
name = "BlogPostDetail",
properties = mapOf("postId" to postId),
routeKey = "blog-post-$postId",
)
}
DetailsContent(postId = postId)
}
The Android reference implementation exercises screen tracking with Compose Navigation and asserts the visited sequence through the SDK event stream.
Integration category: Common but policy-dependent
OptimizationRoot defines global tracking defaults. OptimizedEntry can override those defaults
per entry. View and tap tracking default to on; pass trackViews = false or trackTaps = false
globally or per entry when a surface must opt out.
OptimizationLazyColumn for LazyColumn content so entry visibility uses the list viewport.minVisibleRatio, dwellTimeMs, or viewDurationUpdateIntervalMs per entry only when the
default timing does not match the component.Copy this:
OptimizationRoot(
config = config,
// Opt out globally only when this screen must not emit tap analytics.
trackTaps = false,
) {
AppNavGraph()
}
Adapt this to your use case:
OptimizationLazyColumn {
items(entries) { entry ->
OptimizedEntry(
entry = entry,
// Avoid adding another SDK tap call around this same clickable surface.
onTap = { resolvedEntry -> navigateToEntry(resolvedEntry) },
) { resolvedEntry ->
CtaCard(entry = resolvedEntry)
}
}
}
Entry view tracking emits after 2 seconds at 80% visibility, sends duration updates every 5 seconds while visible, and sends a final duration update when the entry leaves view after a view event has already fired. For timing and event-delivery details, see Android SDK runtime and interaction mechanics.
Integration category: Common but policy-dependent
Identity policy belongs to your application. Use SDK identity methods only after your product,
privacy, and account logic decides which user ID and traits can be sent. Android persists consent
and profile-continuity state in SharedPreferences when persistence consent permits it.
identify(...) from an authenticated account flow or another approved identity moment.client.state, selectedOptimizations, and optimizationPossible from Compose state when
UI needs to reflect SDK state.reset() when the active SDK profile must be cleared from the current session.Adapt this to your use case:
@Composable
fun AccountControls() {
val client = LocalOptimizationClient.current
val state by client.state.collectAsState()
val scope = rememberCoroutineScope()
Column {
Text("Consent: ${state.consent}")
Button(
onClick = {
scope.launch {
// Send identity only after your app's account and privacy policy approves it.
client.identify(
userId = "user-123",
traits = mapOf("plan" to "pro"),
)
}
},
) {
Text("Identify")
}
Button(onClick = { client.reset() }) {
Text("Reset")
}
}
}
When durable profile continuity is allowed, the SDK stores profile, selected optimizations, changes, and anonymous ID before it publishes the corresponding state update. Tests can wait for SDK state instead of adding storage-delay sleeps before relaunching the app.
Integration category: Optional
Use track(...) for app-owned business events. Use eventStream and blockedEventStream for debug
surfaces, tests, and application-owned analytics forwarding. The SDK does not configure third-party
analytics destinations for you.
track(...) from the application event handler that owns the business action after event
consent is accepted or an approved allowedEventTypes policy permits track.eventStream in debug surfaces or test-only views that need to inspect emitted
events.blockedEventStream or pass onEventBlocked when validating consent gates.Adapt this to your use case:
@Composable
fun PurchaseButton() {
val client = LocalOptimizationClient.current
val state by client.state.collectAsState()
val scope = rememberCoroutineScope()
// Replace this gate with your app-owned policy if `track` is explicitly allow-listed.
val canTrackPurchase = state.consent == true
Button(
enabled = canTrackPurchase,
onClick = {
scope.launch {
client.track(
// Custom events are blocked until event consent is accepted unless allow-listed.
event = "Purchase Completed",
properties = mapOf("sku" to "sku-1"),
)
}
},
) {
Text("Purchase")
}
}
Adapt this to your use case:
@Composable
fun AnalyticsDebugPanel() {
val client = LocalOptimizationClient.current
var latestEvent by remember { mutableStateOf<Map<String, Any>?>(null) }
LaunchedEffect(client) {
// Use this stream for diagnostics or app-owned forwarding after downstream consent checks.
client.eventStream.collect { event ->
latestEvent = event
}
}
val latestType = latestEvent?.get("type") ?: "none"
Text("Most recent SDK event: $latestType")
}
For destination mapping patterns, 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 same initialized
OptimizationClient. Use them inside components that already run under OptimizationRoot.
client.getFlag(name) for a one-time flag read.client.observeFlag(name) when a component 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:
@Composable
fun BooleanFlagBadge() {
val client = LocalOptimizationClient.current
// Observed flags emit flag-view events for delivered values.
val flagValue = remember { client.observeFlag("boolean") }.collectAsState()
Text("Flag value: ${flagValue.value}")
}
Follow this pattern:
suspend fun resolveMergeTagText(
client: OptimizationClient,
mergeTagEntry: Map<String, Any>,
): String {
// Keep fallback copy in the app so unresolved merge tags do not break rendering.
return client.getMergeTagValue(mergeTagEntry)
?: readFallbackValue(mergeTagEntry)
?: "[Merge Tag]"
}
The Android reference implementation resolves Rich Text merge tags with getMergeTagValue(...) and
asserts the rendered text in the shared Maestro variant flows.
Integration category: Optional
By default, OptimizedEntry locks to the first variant it resolves so content does not change while
a visitor is reading it. Enable live updates when a screen needs mounted entries to react to profile
changes or preview overrides without a reload.
OptimizationRoot(liveUpdates = true) when mounted entries inherit live updates by default.OptimizedEntry(liveUpdates = true) for an entry that must update even when the global
default is locked.OptimizedEntry(liveUpdates = false) for an entry that must stay locked even when the global
default is live.Copy this:
OptimizationRoot(
config = config,
// Mounted entries inherit live updates unless they set their own liveUpdates value.
liveUpdates = true,
) {
AppNavGraph()
}
Adapt this to your use case:
OptimizedEntry(
entry = dashboardEntry,
// Use per-entry live updates for content that must follow profile or preview changes.
liveUpdates = true,
) { resolvedEntry ->
Dashboard(entry = resolvedEntry)
}
When the preview panel closes, locked entries keep the previewed selected optimization as their locked value. For precedence rules, see Android SDK runtime and interaction mechanics.
Integration category: Optional
The preview panel is a developer and internal-review tool. Gate it behind debug or internal-build configuration, and keep preview credentials out of public release builds.
PreviewPanelConfig to OptimizationRoot only for builds that can expose preview tooling.PreviewContentfulClient when the panel needs audience and experience names.contentfulClient when identifier-only preview data is enough.Adapt this to your use case:
val previewContentfulClient = ContentfulHTTPPreviewClient(
spaceId = "your-space-id",
accessToken = "your-contentful-delivery-token",
environment = "main",
)
OptimizationRoot(
config = config,
previewPanel = if (BuildConfig.DEBUG) {
// Keep preview tooling and credentials out of public release builds.
PreviewPanelConfig(contentfulClient = previewContentfulClient)
} else {
null
},
) {
AppNavGraph()
}
The panel's floating action button and sheet live in the Compose tree created by OptimizationRoot.
The Android reference implementation uses PreviewPanelConfig(contentfulClient = ...) and runs
shared Maestro flows for panel visibility, profile data, refresh behavior, and audience or variant
overrides.
Integration category: Advanced or production-only
Use strict event policy controls only after privacy review defines which events can emit before consent and how blocked or queued events are observed.
allowedEventTypes = emptyList() when no Optimization events can emit before consent.onEventBlocked or blockedEventStream to verify consent or allowedEventTypes blocks
during development.QueuePolicy only when the default queue behavior needs production-specific limits or
diagnostics.Use these allowedEventTypes selectors exactly when allow-listing Android events:
| Selector | Allows |
|---|---|
identify |
Identity Experience events |
page |
Page Experience events from client.page(...) |
screen |
Screen Experience events |
track |
Custom business events from track(...) |
component |
Entry view events and flag-view payloads |
component_click |
Entry tap events |
flag |
Custom Flag view tracking without all views |
Android does not expose hover tracking. component_hover applies to SDKs that support hover, such
as Web and Node.
For the cross-SDK selector list and consent behavior, see Consent management in the Optimization SDK Suite.
Adapt this to your use case:
val strictConfig = OptimizationConfig(
clientId = "your-optimization-client-id",
// Empty means no SDK events are allowed before explicit consent.
allowedEventTypes = emptyList(),
queuePolicy = QueuePolicy(
offlineMaxEvents = 100,
onOfflineDrop = { event ->
logDroppedOptimizationEvent(event)
},
),
// Blocked events are for verification; they are not replayed after later consent.
onEventBlocked = { blockedEvent ->
logBlockedOptimizationEvent(blockedEvent)
},
)
Blocked events are dropped at the SDK boundary and are not replayed after consent(true). Keep any
application-owned retry or forwarding behavior aligned with your consent policy.
Integration category: Advanced or production-only
The Android SDK monitors network reachability and process lifecycle after initialization. Events queue in memory while the device is offline, flush when connectivity returns, and flush when the app moves toward the background.
OptimizationRoot initializes the
client.flush() only from explicit app-owned delivery checkpoints or tests.setOnline(...) only for deterministic test or diagnostic flows, not as a replacement for
the SDK network monitor.Follow this pattern:
LaunchedEffect(Unit) {
// Use deterministic network controls only in tests or diagnostics.
// Accept event consent before queueing a custom track event in a test flow.
client.consent(true)
client.setOnline(false)
client.track(event = "Queued Event")
client.setOnline(true)
client.flush()
}
For runtime details, see Android SDK runtime and interaction mechanics.
Integration category: Advanced or production-only
Native Compose apps do not use the Node or browser hybrid-continuity model. The Android SDK stores SDK consent and profile-continuity state, but it does not own app Contentful response caches, server cookies, or SSR-to-browser anonymous ID handoff.
StorageDefaults as a replacement for server-side profile persistence or
cross-device account identity.For server and browser continuity patterns, use the web and Node guides instead of this native Compose guide.
Before releasing a Compose integration, verify these points:
minSdk, Java bytecode level, and Maven
artifact version for the release build.OptimizationRoot for the active SDK tree,
calls screen tracking at route roots, avoids nesting multiple SDK tap wrappers around the same
surface, and uses a scroll-aware helper for list entry view tracking../gradlew testDebugUnitTest from
packages/android/ContentfulOptimization for changed SDK behavior. Run targeted Compose Maestro
suites with pnpm implementation:run -- android-sdk test:e2e:compose -- --flow <suite> from the
repository root for user-visible tracking, preview, navigation, or live-update behavior.| Symptom | Likely cause | Check |
|---|---|---|
No OptimizationClient provided |
A composable called SDK helpers outside OptimizationRoot. |
Move the composable under the root that owns the SDK client. |
| Entries always render baseline content | The entry is not optimized, selected optimizations are missing, links are unresolved, or the CDA payload is all-locale. | Verify consent, screen or identify events, include, concrete locale, and fields.nt_experiences. |
| Tap handler does not run | trackTaps = false disabled the SDK tap wrapper, including the supplied onTap. |
Remove the explicit trackTaps = false or handle the click outside OptimizedEntry. |
| View tracking is inconsistent in lists | The entry cannot read the active list viewport. | Use OptimizationLazyColumn for LazyColumn content or provide an app-owned tracking path. |
| Screen events are duplicated | ScreenTrackingEffect is mounted in repeated child composables. |
Move the effect to the route or destination root and keep screen names stable. |
| Preview panel is missing | PreviewPanelConfig is not passed, enabled is false, or the build gate excludes it. |
Verify the debug or internal-build condition and the OptimizationRoot preview config. |
getMergeTagValue(...), Custom Flags, event diagnostics, and preview-panel overrides against the
same mock API.