Use this guide when you want to add Personalization, Analytics, screen tracking, and preview overrides to a native Android application built with XML layouts or Android Views.
For shared runtime behavior, consent gates, tracking thresholds, live-update precedence, and offline delivery, see Android SDK runtime and interaction mechanics. Use the Compose guide instead if your app is Compose-based: Integrating the Optimization Android SDK in a Jetpack Compose app.
The XML Views integration uses the SDK's View-based adapter surface:
OptimizationManager initializes one process-wide OptimizationClient and stores global tracking
and live-update defaults.OptimizedEntryView resolves a personalized Contentful entry and can attach view and tap
tracking.TrackingRecyclerView nudges descendant OptimizedEntryView instances to re-check visibility
while lists scroll.ScreenTracker emits screen events from Activity or Fragment lifecycle methods.OptimizationManager.attachPreviewPanel(...) mounts the developer-only preview panel entry point.The SDK does not replace your Contentful delivery client. Your application still owns Contentful fetching, consent UX, identity policy, navigation, and rendering.
Most XML Views integrations follow this sequence:
OptimizationConfig.OptimizationManager from Application.onCreate.OptimizationManager.client from activities or fragments that render Contentful content.OptimizedEntryView.Optional additions include live updates when entries need to react to optimization state changes after initial render, and the preview panel when authors or engineers need local audience and variant overrides.
The Android reference implementation in this repository demonstrates the same SDK behavior in Compose and XML Views shells:
Add the Android SDK from Maven Central as described in the Optimization Android SDK README. In an Android application module, the dependency looks like this:
dependencies {
implementation("com.contentful.java:optimization-android:<version>")
}
Then create an OptimizationConfig with the Optimization client ID and the Contentful locale
information your app uses when fetching entries:
val optimizationConfig = OptimizationConfig(
clientId = "your-client-id",
environment = "master",
contentfulLocales = ContentfulLocales(default = "en-US"),
locale = "en-US",
debug = BuildConfig.DEBUG,
)
Only clientId is required. If application policy permits Optimization by default and no end-user
consent UI is rendered, set defaults = StorageDefaults(consent = true). Otherwise, leave defaults
unset and connect client.consent(true) and client.consent(false) to the app's consent UI.
Use contentfulLocales and locale when the same screen renders localized Contentful entries. For
the full locale model, see
Locale handling in the Optimization SDK Suite.
Initialize the SDK once from Application.onCreate. OptimizationManager owns the process-wide
client used by View-based activities and fragments.
class MyApplication : Application() {
override fun onCreate() {
super.onCreate()
OptimizationManager.initialize(
context = this,
config = optimizationConfig,
trackViews = true,
trackTaps = false,
liveUpdates = false,
previewPanel = PreviewPanelConfig(
contentfulClient = previewContentfulClient,
),
)
}
}
Read the client from any Activity or Fragment after initialization:
class HomeActivity : AppCompatActivity() {
private val client: OptimizationClient
get() = OptimizationManager.client
}
OptimizationManager.initialize(...) starts SDK initialization asynchronously. Observe
client.isInitialized before running work that depends on the bridge being ready. For lifecycle
details, see
Android SDK runtime and interaction mechanics.
If your application policy permits Optimization by default, seed accepted consent in
OptimizationConfig and omit consent controls:
val optimizationConfig = OptimizationConfig(
clientId = "your-client-id",
defaults = StorageDefaults(consent = true),
)
That starts all gated SDK events immediately and permits durable profile-continuity storage for profile, selected optimizations, changes, and the anonymous ID.
When application policy depends on user choice, leave consent unset and call
client.consent(true | false) from an application-owned consent UI.
class ConsentActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
acceptButton.setOnClickListener {
OptimizationManager.client.consent(true)
}
rejectButton.setOnClickListener {
OptimizationManager.client.consent(false)
}
lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED) {
OptimizationManager.client.state.collect { state ->
consentBanner.isVisible = state.consent == null
}
}
}
}
}
identify and screen remain allowed before consent so a mobile journey can establish profile
context and anonymous screen analytics. For cross-SDK consent policy guidance, see
Consent management in the Optimization SDK Suite.
The consent value is persisted and restored on later launches. Profile-continuity state persists only when persistence consent allows it. Use the app's consent policy to decide whether a stored value remains valid.
Use OptimizationManager.client.consent(events = true, persistence = false) when events are allowed
but durable profile continuity must stay session-only.
OptimizedEntryView is the View-based component for rendering Contentful entries through the
Optimization resolver. It passes non-personalized entries through unchanged, resolves personalized
entries against the selected variants for the visitor, and can attach view and tap tracking.
Fetch entries from Contentful as single-locale JSON-shaped maps and include linked optimization
references in the payload. Pass those maps to OptimizedEntryView.
Use the resolved client.locale value for app-owned Contentful Delivery API requests that feed SDK
entry resolution:
lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED) {
OptimizationManager.client.isInitialized.collect { isInitialized ->
if (isInitialized) {
val locale = OptimizationManager.client.locale ?: "en-US"
val entries = contentfulClient.fetchHomeEntries(locale = locale)
renderEntries(entries)
}
}
}
}
The resolver expects the same single-locale CDA entry contract used by the other SDK runtimes. For details, see Entry personalization and variant resolution.
fun createHeroView(entry: Map<String, Any>): View {
return OptimizedEntryView(this).apply {
trackTaps = true
accessibilityIdentifier = "home-hero-personalization"
setContentRenderer { resolvedEntry ->
HeroBinder.create(context, resolvedEntry)
}
setEntry(entry)
}
}
The renderer receives the resolved entry map. The application owns converting fields from that map into the view model or View hierarchy it wants to render.
OptimizedEntryView checks visibility from its own layout callbacks. Use TrackingRecyclerView
when a scrolling list needs an additional signal on every scroll frame.
val recyclerView = TrackingRecyclerView(this).apply {
layoutManager = LinearLayoutManager(this@HomeActivity)
adapter = ContentEntryAdapter(entries)
}
In each item view, wrap the rendered Contentful entry with OptimizedEntryView.
OptimizationManager.initialize(...) defines defaults for every OptimizedEntryView:
OptimizationManager.initialize(
context = this,
config = optimizationConfig,
trackViews = true,
trackTaps = false,
)
View tracking defaults to on. Tap tracking defaults to off because taps are usually tied to application-specific navigation or business actions.
OptimizedEntryView(context).apply {
trackViews = false
setContentRenderer { resolvedEntry -> HeroBinder.create(context, resolvedEntry) }
setEntry(hero)
}
OptimizedEntryView(context).apply {
trackTaps = true
setContentRenderer { resolvedEntry -> CtaBinder.create(context, resolvedEntry) }
setEntry(cta)
}
OptimizedEntryView(context).apply {
onTap = { resolvedEntry -> navigateToEntry(resolvedEntry) }
setContentRenderer { resolvedEntry -> CtaBinder.create(context, resolvedEntry) }
setEntry(cta)
}
Setting trackTaps = false disables tap tracking even when onTap is present. For timing
thresholds and event delivery behavior, see
Android SDK runtime and interaction mechanics.
Call ScreenTracker.trackScreen(...) from Activity.onResume or Fragment.onResume:
override fun onResume() {
super.onResume()
ScreenTracker.trackScreen("Home")
}
For dynamic names or tracking after data loads, call the client directly:
lifecycleScope.launch {
OptimizationManager.client.screen(
name = "BlogPostDetail",
properties = mapOf("postId" to postId),
)
}
By default, OptimizedEntryView 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 to react to profile or
preview changes without a reload:
OptimizationManager.initialize(
context = this,
config = optimizationConfig,
liveUpdates = true,
)
OptimizedEntryView(context).apply {
liveUpdates = true
setContentRenderer { resolvedEntry -> DashboardBinder.create(context, resolvedEntry) }
setEntry(dashboard)
}
The preview panel forces live updates while it is open. For precedence rules, see Android SDK runtime and interaction mechanics.
Gate the preview panel behind a debug or internal-build flag. In XML Views apps, call
OptimizationManager.attachPreviewPanel(...) from each Activity that displays the floating entry
point.
class HomeActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.home)
if (BuildConfig.DEBUG) {
OptimizationManager.attachPreviewPanel(this)
}
}
}
Pass PreviewPanelConfig(contentfulClient = previewContentfulClient) to
OptimizationManager.initialize(...) when the panel needs to display audience and experience names.
Without a PreviewContentfulClient, the panel displays identifiers.
This example combines application-level initialization, preview-panel gating, screen tracking, entry rendering, and tap tracking:
class MyApplication : Application() {
override fun onCreate() {
super.onCreate()
OptimizationManager.initialize(
context = this,
config = optimizationConfig,
trackViews = true,
trackTaps = false,
previewPanel = PreviewPanelConfig(
contentfulClient = previewContentfulClient,
),
)
}
}
class HomeActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val list = TrackingRecyclerView(this).apply {
layoutManager = LinearLayoutManager(this@HomeActivity)
}
setContentView(list)
if (BuildConfig.DEBUG) {
OptimizationManager.attachPreviewPanel(this)
}
lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED) {
OptimizationManager.client.isInitialized.collect { isInitialized ->
if (isInitialized) {
val locale = OptimizationManager.client.locale ?: "en-US"
val entries = fetchHomeEntries(locale = locale)
list.adapter = ContentEntryAdapter(entries)
}
}
}
}
}
override fun onResume() {
super.onResume()
ScreenTracker.trackScreen("Home")
}
}
fun bindEntry(parent: ViewGroup, entry: Map<String, Any>) {
parent.addView(
OptimizedEntryView(parent.context).apply {
trackTaps = true
setContentRenderer { resolvedEntry ->
ContentEntryBinder.create(context, resolvedEntry)
}
setEntry(entry)
},
)
}