From f0a77fbc9f20c08b5556702d065a2fb025b05a9f Mon Sep 17 00:00:00 2001 From: Alex Freas Date: Sat, 30 May 2026 10:21:13 +0200 Subject: [PATCH 01/13] Extract preview-state transformation helpers from the JS bridge into previewStateHelpers --- .../optimization-js-bridge/src/index.ts | 243 ++++++++---------- .../src/previewStateHelpers.ts | 85 ++++++ 2 files changed, 198 insertions(+), 130 deletions(-) create mode 100644 packages/universal/optimization-js-bridge/src/previewStateHelpers.ts diff --git a/packages/universal/optimization-js-bridge/src/index.ts b/packages/universal/optimization-js-bridge/src/index.ts index 2ea75e07..557e5a58 100644 --- a/packages/universal/optimization-js-bridge/src/index.ts +++ b/packages/universal/optimization-js-bridge/src/index.ts @@ -10,14 +10,34 @@ import { type ContentfulEntry, type ExperienceDefinition, PreviewOverrideManager, - buildPreviewModel, createAudienceDefinitions, createExperienceDefinitions, createExperienceNameMap, } from '@contentful/optimization-core/preview-support' - -type ResolveOptimizedEntryParams = Parameters -type GetMergeTagValueParams = Parameters +import { computePreviewModel, transformOverrides } from './previewStateHelpers' + +type ResolveOptimizedEntryArgs = Parameters +type ResolveOptimizedEntryEntry = ResolveOptimizedEntryArgs[0] +type ResolveOptimizedEntrySelections = ResolveOptimizedEntryArgs[1] +type GetMergeTagValueEntry = Parameters[0] +type ScreenProperties = Parameters[0]['properties'] + +type ProfileValue = typeof signals.profile.value +type ChangesValue = typeof signals.changes.value +type SelectedOptimizationsValue = typeof signals.selectedOptimizations.value + +// Native runtimes (iOS JavaScriptCore, Android QuickJS) install these callbacks +// on the JS engine's globalThis before the bridge is loaded. The bridge calls +// them to push state/event updates back into the native layer. `window` is NOT +// defined in QuickJS or JSC — only `globalThis` is universal across both +// engines plus any browser-style WebView consumer. +interface NativeGlobal { + __nativeOnStateChange?: (json: string) => void + __nativeOnEventEmitted?: (json: string) => void + __nativeOnOverridesChanged?: (json: string) => void + __bridge?: Bridge +} +const nativeGlobal = globalThis as typeof globalThis & NativeGlobal interface BridgeConfig { clientId: string @@ -26,18 +46,18 @@ interface BridgeConfig { insightsBaseUrl?: string defaults?: { consent?: boolean - profile?: unknown - changes?: unknown - optimizations?: unknown + profile?: ProfileValue + changes?: ChangesValue + optimizations?: SelectedOptimizationsValue } } interface BridgeState { - profile: unknown + profile: ProfileValue | null consent: boolean | undefined canPersonalize: boolean - changes: unknown - selectedPersonalizations: unknown + changes: ChangesValue | null + selectedPersonalizations: SelectedOptimizationsValue | null } interface TrackViewPayload { @@ -56,59 +76,65 @@ interface TrackClickPayload { } interface Bridge { - initialize(config: BridgeConfig): void - identify( + initialize: (config: BridgeConfig) => void + identify: ( payload: { userId: string; traits?: Traits }, onSuccess: (json: string) => void, onError: (error: string) => void, - ): void - page( + ) => void + page: ( payload: Record, onSuccess: (json: string) => void, onError: (error: string) => void, - ): void - getProfile(): string | null - getState(): string - destroy(): void + ) => void + getProfile: () => string | null + getState: () => string + destroy: () => void // Async with callbacks - screen( - payload: { name: string; properties?: Record }, + screen: ( + payload: { name: string; properties?: ScreenProperties }, onSuccess: (json: string) => void, onError: (error: string) => void, - ): void - flush(onSuccess: (json: string) => void, onError: (error: string) => void): void - trackView( + ) => void + flush: (onSuccess: (json: string) => void, onError: (error: string) => void) => void + trackView: ( payload: TrackViewPayload, onSuccess: (json: string) => void, onError: (error: string) => void, - ): void - trackClick( + ) => void + trackClick: ( payload: TrackClickPayload, onSuccess: (json: string) => void, onError: (error: string) => void, - ): void + ) => void // Synchronous - consent(accept: boolean): void - reset(): void - personalizeEntry( - baseline: Record, - personalizations?: Array>, - ): string - getMergeTagValue(mergeTagEntry: Record): string | null - flag(name: string): void - setOnline(isOnline: boolean): void + consent: (accept: boolean) => void + reset: () => void + // Native code passes JSON-shaped objects; the bridge trusts the shape and + // forwards them straight to core. TypeScript types here document the + // expected payload, but no runtime narrowing is performed. + personalizeEntry: ( + baseline: ResolveOptimizedEntryEntry, + personalizations?: ResolveOptimizedEntrySelections, + ) => string + getMergeTagValue: (mergeTagEntry: GetMergeTagValueEntry) => string | null + flag: (name: string) => void + setOnline: (isOnline: boolean) => void // Preview panel - setPreviewPanelOpen(open: boolean): void - overrideAudience(audienceId: string, qualified: boolean, experienceIds: string[]): void - overrideVariant(experienceId: string, variantIndex: number): void - resetAudienceOverride(audienceId: string): void - resetVariantOverride(experienceId: string): void - resetAllOverrides(): void - loadDefinitions(audienceEntries: unknown[], experienceEntries: unknown[]): string - getPreviewState(): string + setPreviewPanelOpen: (open: boolean) => void + overrideAudience: (audienceId: string, qualified: boolean, experienceIds: string[]) => void + overrideVariant: (experienceId: string, variantIndex: number) => void + resetAudienceOverride: (audienceId: string) => void + resetVariantOverride: (experienceId: string) => void + resetAllOverrides: () => void + loadDefinitions: ( + audienceEntries: ContentfulEntry[], + experienceEntries: ContentfulEntry[], + ) => string + getPreviewState: () => string } let instance: CoreStateful | null = null @@ -144,19 +170,20 @@ const bridge: Bridge = { instance = new CoreStateful(coreConfig) // Apply stored defaults before any other operations - if (config.defaults) { - if (config.defaults.consent !== undefined) { - instance.consent(config.defaults.consent) + const { defaults } = config + if (defaults) { + const { consent, profile, changes, optimizations } = defaults + if (consent !== undefined) { + instance.consent(consent) } - if (config.defaults.profile !== undefined) { - signals.profile.value = config.defaults.profile as typeof signals.profile.value + if (profile !== undefined) { + signals.profile.value = profile } - if (config.defaults.changes !== undefined) { - signals.changes.value = config.defaults.changes as typeof signals.changes.value + if (changes !== undefined) { + signals.changes.value = changes } - if (config.defaults.optimizations !== undefined) { - signals.selectedOptimizations.value = config.defaults - .optimizations as typeof signals.selectedOptimizations.value + if (optimizations !== undefined) { + signals.selectedOptimizations.value = optimizations } } instance.consent(true) @@ -164,16 +191,12 @@ const bridge: Bridge = { // Create the override manager — registers a state interceptor that // preserves overrides across API refreshes and correctly appends // new experience entries when overriding audiences the user was never in. - const g = globalThis as Record - overrideManager = new PreviewOverrideManager({ selectedOptimizations: signals.selectedOptimizations, profile: signals.profile, stateInterceptors: instance.interceptors.state, onOverridesChanged: () => { - if (typeof g.__nativeOnOverridesChanged === 'function') { - ;(g.__nativeOnOverridesChanged as (json: string) => void)(bridge.getPreviewState()) - } + nativeGlobal.__nativeOnOverridesChanged?.(bridge.getPreviewState()) }, }) @@ -186,15 +209,15 @@ const bridge: Bridge = { selectedPersonalizations: signals.selectedOptimizations.value ?? null, } - if (typeof g.__nativeOnStateChange === 'function') { - ;(g.__nativeOnStateChange as (json: string) => void)(JSON.stringify(state)) - } + nativeGlobal.__nativeOnStateChange?.(JSON.stringify(state)) }) disposeEventEffect = effect(() => { - const evt = signals.event.value - if (evt && typeof g.__nativeOnEventEmitted === 'function') { - ;(g.__nativeOnEventEmitted as (json: string) => void)(JSON.stringify(evt)) + const { + event: { value }, + } = signals + if (value) { + nativeGlobal.__nativeOnEventEmitted?.(JSON.stringify(value)) } }) }, @@ -240,7 +263,7 @@ const bridge: Bridge = { instance .screen({ name: payload.name, - properties: (payload.properties ?? {}) as Record, + properties: payload.properties ?? {}, }) .then((data) => { onSuccess(JSON.stringify(data ?? null)) @@ -320,21 +343,15 @@ const bridge: Bridge = { flagSubscriptions.push(instance.states.flag(name).subscribe(() => undefined)) }, - personalizeEntry( - baseline: Record, - personalizations?: Array>, - ): string { + personalizeEntry(baseline, personalizations): string { if (!instance) return JSON.stringify({ entry: baseline }) - const result = instance.resolveOptimizedEntry( - baseline as unknown as ResolveOptimizedEntryParams[0], - personalizations as unknown as ResolveOptimizedEntryParams[1], - ) + const result = instance.resolveOptimizedEntry(baseline, personalizations) return JSON.stringify(result) }, - getMergeTagValue(mergeTagEntry: Record): string | null { + getMergeTagValue(mergeTagEntry): string | null { if (!instance) return null - const value = instance.getMergeTagValue(mergeTagEntry as unknown as GetMergeTagValueParams[0]) + const value = instance.getMergeTagValue(mergeTagEntry) return value ?? null }, @@ -368,17 +385,14 @@ const bridge: Bridge = { overrideManager?.resetAll() }, - loadDefinitions(audienceEntries: unknown[], experienceEntries: unknown[]): string { + loadDefinitions(audienceEntries, experienceEntries): string { try { - const audEntries = audienceEntries as ContentfulEntry[] - const expEntries = experienceEntries as ContentfulEntry[] - - audienceDefinitions = createAudienceDefinitions(audEntries) - experienceDefinitions = createExperienceDefinitions(expEntries) - experienceNameMap = createExperienceNameMap(expEntries) + audienceDefinitions = createAudienceDefinitions(audienceEntries) + experienceDefinitions = createExperienceDefinitions(experienceEntries) + experienceNameMap = createExperienceNameMap(experienceEntries) audienceNameMap = {} - for (const def of audienceDefinitions) { - audienceNameMap[def.id] = def.name + for (const { id, name } of audienceDefinitions) { + audienceNameMap[id] = name } return JSON.stringify({ @@ -401,51 +415,18 @@ const bridge: Bridge = { audiences: {}, selectedOptimizations: {}, } - const baselineOptimizations = overrideManager?.getBaselineSelectedOptimizations() - - // Transform audience overrides to the shape Swift expects: Record - const audienceOverrides: Record = {} - for (const [id, aud] of Object.entries(overrides.audiences)) { - audienceOverrides[id] = aud.isActive - } - - // Transform variant overrides to the shape Swift expects: Record - const variantOverrides: Record = {} - for (const [id, opt] of Object.entries(overrides.selectedOptimizations)) { - variantOverrides[id] = opt.variantIndex - } + const baselineOptimizations = overrideManager?.getBaselineSelectedOptimizations() ?? null - // Derive default variant indices from the baseline - const defaultVariantIndices: Record = {} - if (baselineOptimizations) { - for (const sel of baselineOptimizations) { - if (variantOverrides[sel.experienceId] !== undefined) { - defaultVariantIndices[sel.experienceId] = sel.variantIndex - } - } - } + const { audienceOverrides, variantOverrides, defaultVariantIndices } = transformOverrides( + overrides, + baselineOptimizations, + ) - // Compute the pre-baked UI model when definitions have been loaded by the host. - // Null when loadDefinitions() has not yet been called — iOS renders an empty state. - const previewModel = - audienceDefinitions && experienceDefinitions - ? { - ...buildPreviewModel({ - audienceDefinitions, - experienceDefinitions, - signals: { - profile: signals.profile.value, - selectedOptimizations: signals.selectedOptimizations.value, - consent: signals.consent.value, - isLoading: false, - }, - overrides, - baselineSelectedOptimizations: baselineOptimizations, - }), - audienceNameMap, - experienceNameMap, - } - : null + const previewModel = computePreviewModel( + { audienceDefinitions, experienceDefinitions, audienceNameMap, experienceNameMap }, + overrides, + baselineOptimizations, + ) return JSON.stringify({ profile: signals.profile.value ?? null, @@ -463,8 +444,10 @@ const bridge: Bridge = { }, getProfile(): string | null { - const p = signals.profile.value - return p ? JSON.stringify(p) : null + const { + profile: { value }, + } = signals + return value ? JSON.stringify(value) : null }, getState(): string { @@ -504,6 +487,6 @@ const bridge: Bridge = { }, } -;(globalThis as Record).__bridge = bridge +nativeGlobal.__bridge = bridge export default bridge diff --git a/packages/universal/optimization-js-bridge/src/previewStateHelpers.ts b/packages/universal/optimization-js-bridge/src/previewStateHelpers.ts new file mode 100644 index 00000000..5851b73e --- /dev/null +++ b/packages/universal/optimization-js-bridge/src/previewStateHelpers.ts @@ -0,0 +1,85 @@ +import { signals } from '@contentful/optimization-core' +import { + type AudienceDefinition, + type ExperienceDefinition, + type OverrideState, + type PreviewOverrideManager, + buildPreviewModel, +} from '@contentful/optimization-core/preview-support' + +export type BaselineSelections = ReturnType< + PreviewOverrideManager['getBaselineSelectedOptimizations'] +> + +export interface TransformedOverrides { + audienceOverrides: Record + variantOverrides: Record + defaultVariantIndices: Record +} + +export interface PreviewModelDeps { + audienceDefinitions: AudienceDefinition[] | null + experienceDefinitions: ExperienceDefinition[] | null + audienceNameMap: Record + experienceNameMap: Record +} + +// Transform internal override state into the flat Record +// shapes the native preview panel expects. +export function transformOverrides( + overrides: OverrideState, + baseline: BaselineSelections, +): TransformedOverrides { + const audienceOverrides: Record = {} + for (const [id, { isActive }] of Object.entries(overrides.audiences)) { + audienceOverrides[id] = isActive + } + + const variantOverrides: Record = {} + for (const [id, { variantIndex }] of Object.entries(overrides.selectedOptimizations)) { + variantOverrides[id] = variantIndex + } + + const defaultVariantIndices: Record = {} + if (baseline) { + for (const { experienceId, variantIndex } of baseline) { + if (variantOverrides[experienceId] !== undefined) { + defaultVariantIndices[experienceId] = variantIndex + } + } + } + + return { audienceOverrides, variantOverrides, defaultVariantIndices } +} + +export type PreviewModel = ReturnType & { + audienceNameMap: Record + experienceNameMap: Record +} + +// Build the pre-baked UI model when host has loaded definitions. Returns null +// when `loadDefinitions()` has not yet been called — iOS renders an empty state. +export function computePreviewModel( + deps: PreviewModelDeps, + overrides: OverrideState, + baseline: BaselineSelections, +): PreviewModel | null { + const { audienceDefinitions, experienceDefinitions, audienceNameMap, experienceNameMap } = deps + if (!audienceDefinitions || !experienceDefinitions) return null + return { + ...buildPreviewModel({ + audienceDefinitions, + experienceDefinitions, + signals: { + profile: signals.profile.value, + selectedOptimizations: signals.selectedOptimizations.value, + consent: signals.consent.value, + isLoading: false, + }, + overrides, + baselineSelectedOptimizations: baseline, + }), + audienceNameMap, + experienceNameMap, + } +} From b8e5dfcde4510106eb87c440f85c6f948fe7ff74 Mon Sep 17 00:00:00 2001 From: Alex Freas Date: Sat, 30 May 2026 10:25:10 +0200 Subject: [PATCH 02/13] Add the com.contentful.optimization.views SDK adapter, view tracking, and in-activity preview panel for XML apps --- packages/android/AGENTS.md | 12 + .../ContentfulOptimization/build.gradle.kts | 18 + .../optimization/core/OptimizationClient.kt | 8 +- .../preview/PreviewPanelActivity.kt | 132 ++++++-- .../preview/PreviewPanelContent.kt | 35 +- .../tracking/ViewTrackingController.kt | 79 ++++- .../optimization/views/OptimizationManager.kt | 127 +++++++ .../optimization/views/OptimizedEntryView.kt | 315 ++++++++++++++++++ .../optimization/views/ScreenTracker.kt | 36 ++ .../views/TrackingRecyclerView.kt | 60 ++++ .../tracking/ViewTrackingControllerTest.kt | 284 ++++++++++++++++ 11 files changed, 1064 insertions(+), 42 deletions(-) create mode 100644 packages/android/ContentfulOptimization/src/main/kotlin/com/contentful/optimization/views/OptimizationManager.kt create mode 100644 packages/android/ContentfulOptimization/src/main/kotlin/com/contentful/optimization/views/OptimizedEntryView.kt create mode 100644 packages/android/ContentfulOptimization/src/main/kotlin/com/contentful/optimization/views/ScreenTracker.kt create mode 100644 packages/android/ContentfulOptimization/src/main/kotlin/com/contentful/optimization/views/TrackingRecyclerView.kt create mode 100644 packages/android/ContentfulOptimization/src/test/kotlin/com/contentful/optimization/tracking/ViewTrackingControllerTest.kt diff --git a/packages/android/AGENTS.md b/packages/android/AGENTS.md index 513f28c0..42421da9 100644 --- a/packages/android/AGENTS.md +++ b/packages/android/AGENTS.md @@ -25,6 +25,18 @@ This directory owns native Android package work: the Kotlin Android library modu in `packages/universal/core-sdk`. - Keep Android preview-panel behavior aligned with iOS and React Native preview-panel behavior when changing shared preview contracts. +- The SDK ships **two** public UI adapter packages over the same `core` API: + - `com.contentful.optimization.compose` — Jetpack Compose adapter (`OptimizationRoot`, + `OptimizedEntry`, `ScreenTrackingEffect`, `OptimizationLazyColumn`, view/tap-tracking + Modifiers). + - `com.contentful.optimization.views` — XML Views adapter (`OptimizationManager`, + `OptimizedEntryView`, `ScreenTracker`, `TrackingRecyclerView`). Static-singleton client lookup + extends the previously-documented `PreviewPanelActivity.addFloatingButton` pattern; consumers + call `OptimizationManager.initialize` from `Application.onCreate` and read + `OptimizationManager.client` from any `Activity`/`Fragment`. + - Behavior parity between the two adapters is validated end-to-end by the Android matrix CI leg + (`e2e-android-sdk` runs the same UI Automator suite against both reference impls). A change in + one adapter that drifts from the other will fail one matrix leg. ## Cross-boundary validation diff --git a/packages/android/ContentfulOptimization/build.gradle.kts b/packages/android/ContentfulOptimization/build.gradle.kts index 483494ad..0840aa88 100644 --- a/packages/android/ContentfulOptimization/build.gradle.kts +++ b/packages/android/ContentfulOptimization/build.gradle.kts @@ -29,6 +29,22 @@ android { buildFeatures { compose = true } + + testOptions { + unitTests { + // ViewTrackingController tests are pure JVM (TestScope + TestLifecycleOwner + + // fake collaborators) and don't touch Android resources, so skip the Robolectric-style + // resource bundling that AGP would otherwise add to the unit-test classpath. Keeps + // `./gradlew :ContentfulOptimization:testDebugUnitTest` under a second. + isIncludeAndroidResources = false + // ViewTrackingController emits diagnostic `Log.d/Log.i/Log.w` calls under the + // "ViewTracking" tag. Without `isReturnDefaultValues = true` the JVM test runtime + // throws `RuntimeException: Method d in android.util.Log not mocked` on the first + // call; with it, the Android framework stubs return defaults (0/null/false) so the + // logging is a no-op in unit tests while still firing normally in production. + isReturnDefaultValues = true + } + } } kotlin { @@ -58,6 +74,7 @@ dependencies { implementation("io.github.dokar3:quickjs-kt:1.0.5") implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.1") implementation("androidx.lifecycle:lifecycle-process:2.8.7") + implementation("androidx.recyclerview:recyclerview:1.3.2") implementation("com.squareup.okhttp3:okhttp:4.12.0") implementation("org.json:json:20240303") @@ -71,4 +88,5 @@ dependencies { testImplementation("junit:junit:4.13.2") testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.8.1") + testImplementation("androidx.lifecycle:lifecycle-runtime-testing:2.8.7") } diff --git a/packages/android/ContentfulOptimization/src/main/kotlin/com/contentful/optimization/core/OptimizationClient.kt b/packages/android/ContentfulOptimization/src/main/kotlin/com/contentful/optimization/core/OptimizationClient.kt index 70e0126f..42681f63 100644 --- a/packages/android/ContentfulOptimization/src/main/kotlin/com/contentful/optimization/core/OptimizationClient.kt +++ b/packages/android/ContentfulOptimization/src/main/kotlin/com/contentful/optimization/core/OptimizationClient.kt @@ -1,6 +1,7 @@ package com.contentful.optimization.core import android.content.Context +import android.util.Log import com.contentful.optimization.bridge.QuickJsContextManager import com.contentful.optimization.handlers.AppLifecycleHandler import com.contentful.optimization.handlers.NetworkMonitor @@ -53,7 +54,12 @@ class OptimizationClient(private val applicationContext: Context) { init { bridge.onStateChange = { dict -> handleStateUpdate(dict) } - bridge.onEvent = { dict -> _events.tryEmit(dict) } + bridge.onEvent = { dict -> + if (dict["type"] == "component") { + Log.i("EventTrace", "bridge.onEvent component cid=${dict["componentId"]}") + } + _events.tryEmit(dict) + } bridge.onOverridesChanged = { state -> _previewState.value = state } } diff --git a/packages/android/ContentfulOptimization/src/main/kotlin/com/contentful/optimization/preview/PreviewPanelActivity.kt b/packages/android/ContentfulOptimization/src/main/kotlin/com/contentful/optimization/preview/PreviewPanelActivity.kt index becbea4f..9d1c5e98 100644 --- a/packages/android/ContentfulOptimization/src/main/kotlin/com/contentful/optimization/preview/PreviewPanelActivity.kt +++ b/packages/android/ContentfulOptimization/src/main/kotlin/com/contentful/optimization/preview/PreviewPanelActivity.kt @@ -2,16 +2,50 @@ package com.contentful.optimization.preview import android.app.Activity import android.os.Bundle -import android.view.Gravity import android.view.ViewGroup import android.widget.FrameLayout -import android.widget.ImageButton import androidx.activity.ComponentActivity import androidx.activity.compose.setContent +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Settings +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FloatingActionButton +import androidx.compose.material3.Icon +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.shadow +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.unit.dp import com.contentful.optimization.compose.LocalOptimizationClient import com.contentful.optimization.core.OptimizationClient +/** + * Compose-only host for the preview panel UI. Retained for backwards compatibility with consumers + * that historically launched this Activity directly; the SDK no longer routes + * [addFloatingButton]'s in-activity overlay through it. New callers should rely on the + * in-activity ComposeView overlay instead, which keeps the FAB and the panel within the + * embedding activity's accessibility tree — UiAutomator and other accessibility-tree consumers + * cannot reliably resolve nodes when the panel sits in a separate activity that takes window + * focus mid-transition. + */ class PreviewPanelActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { @@ -43,44 +77,90 @@ class PreviewPanelActivity : ComponentActivity() { private var sharedClient: OptimizationClient? = null private var sharedContentfulClient: PreviewContentfulClient? = null + /** + * Attach an in-activity preview-panel overlay to [activity]. The FAB and the panel both + * live in [activity]'s window (the FAB is rendered inside a ComposeView added to the + * activity's decorView; opening the panel raises a Compose `ModalBottomSheet` in the + * same window). The previous design launched [PreviewPanelActivity] as a separate + * activity on FAB tap, but that left UiAutomator unable to resolve panel content while + * the consuming activity still held focus during the window transition — every shared + * UI test that opened the panel timed out on `By.text("Preview Panel")` despite the + * activity displaying correctly. + * + * Returns the host [ComposeView] (or `null` if [com.contentful.optimization.views.OptimizationManager.initialize] + * has not been called yet). The return type stays an Android [android.view.View] for + * symmetry with the prior `ImageButton` return; callers historically only used it for + * lifecycle/teardown which is now driven by the host activity automatically. + */ fun addFloatingButton( activity: Activity, client: OptimizationClient, contentfulClient: PreviewContentfulClient? = null, - ): ImageButton { + ): android.view.View { sharedClient = client sharedContentfulClient = contentfulClient - val button = ImageButton(activity).apply { - setBackgroundColor(0xFFEADDFF.toInt()) - contentDescription = "preview-panel-fab" - setPadding(16, 16, 16, 16) - elevation = 8f - - val params = FrameLayout.LayoutParams( - dpToPx(activity, 56), - dpToPx(activity, 56), - ).apply { - gravity = Gravity.BOTTOM or Gravity.END - marginEnd = dpToPx(activity, 24) - bottomMargin = dpToPx(activity, 24) - } - layoutParams = params - - setOnClickListener { - val intent = android.content.Intent(activity, PreviewPanelActivity::class.java) - activity.startActivity(intent) + val host = ComposeView(activity).apply { + layoutParams = FrameLayout.LayoutParams( + FrameLayout.LayoutParams.MATCH_PARENT, + FrameLayout.LayoutParams.MATCH_PARENT, + ) + // The ComposeView only renders the FAB and a transient ModalBottomSheet, + // both of which paint over the underlying view layout. Without this the + // ComposeView's background swallows everything the consuming activity drew. + setContent { + CompositionLocalProvider(LocalOptimizationClient provides client) { + InActivityPreviewPanelOverlay(contentfulClient = contentfulClient) + } } } val decorView = activity.window.decorView as? ViewGroup - decorView?.addView(button) + decorView?.addView(host) + + return host + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun InActivityPreviewPanelOverlay(contentfulClient: PreviewContentfulClient?) { + val client = LocalOptimizationClient.current + var isOpen by remember { mutableStateOf(false) } + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) - return button + LaunchedEffect(isOpen) { + client.setPreviewPanelOpen(isOpen) + } + + Box(modifier = Modifier.fillMaxSize().background(Color.Transparent)) { + FloatingActionButton( + onClick = { isOpen = true }, + shape = CircleShape, + containerColor = PreviewTheme.Colors.FAB.background, + modifier = Modifier + .align(Alignment.BottomEnd) + .padding(PreviewTheme.Spacing.xxl) + .size(PreviewTheme.FABSize.diameter) + .shadow(8.dp, CircleShape) + .semantics { contentDescription = "preview-panel-fab" }, + ) { + Icon( + imageVector = Icons.Default.Settings, + contentDescription = "Open preview panel", + tint = PreviewTheme.Colors.FAB.icon, + ) } + } - private fun dpToPx(activity: Activity, dp: Int): Int { - return (dp * activity.resources.displayMetrics.density).toInt() + if (isOpen) { + ModalBottomSheet( + onDismissRequest = { isOpen = false }, + sheetState = sheetState, + containerColor = PreviewTheme.Colors.Background.secondary, + ) { + PreviewPanelContent(contentfulClient = contentfulClient) } } } diff --git a/packages/android/ContentfulOptimization/src/main/kotlin/com/contentful/optimization/preview/PreviewPanelContent.kt b/packages/android/ContentfulOptimization/src/main/kotlin/com/contentful/optimization/preview/PreviewPanelContent.kt index 86789c29..4e8b1944 100644 --- a/packages/android/ContentfulOptimization/src/main/kotlin/com/contentful/optimization/preview/PreviewPanelContent.kt +++ b/packages/android/ContentfulOptimization/src/main/kotlin/com/contentful/optimization/preview/PreviewPanelContent.kt @@ -201,10 +201,37 @@ private fun PreviewPanelMain(viewModel: PreviewViewModel) { TextButton(onClick = { showResetAlert = false }) { Text("Cancel") } }, confirmButton = { - TextButton(onClick = { - viewModel.resetAllOverrides() - showResetAlert = false - }) { Text("Reset") } + // Stand the confirm button up as a clickable Text rather than a + // Material3 TextButton so the `contentDescription` lives on the + // exact node that owns the click action — mirroring the proven + // pattern used by the panel's per-row reset controls in + // PreviewActionButton. Wrapping a TextButton in a non-merging + // semantics modifier instead produces a separate semantic node + // above the click handler, which UI Automator's + // accessibility-click then routes to the nearest clickable + // ancestor (the AlertDialog root) and ends up dismissing the + // dialog without invoking the reset. By.text("Reset") on its + // own can't disambiguate either, because the panel's + // reset-variant-* / reset-audience-* rows below the dialog + // also render a "Reset" label. + Text( + text = "Reset", + modifier = Modifier + .clickable { + viewModel.resetAllOverrides() + showResetAlert = false + } + .padding( + horizontal = PreviewTheme.Spacing.md, + vertical = PreviewTheme.Spacing.sm, + ) + .semantics { contentDescription = "reset-all-confirm" }, + style = TextStyle( + fontSize = PreviewTheme.FontSize.sm, + fontWeight = FontWeight.Medium, + color = PreviewTheme.Colors.Action.reset, + ), + ) }, ) } diff --git a/packages/android/ContentfulOptimization/src/main/kotlin/com/contentful/optimization/tracking/ViewTrackingController.kt b/packages/android/ContentfulOptimization/src/main/kotlin/com/contentful/optimization/tracking/ViewTrackingController.kt index b2281382..80742f3e 100644 --- a/packages/android/ContentfulOptimization/src/main/kotlin/com/contentful/optimization/tracking/ViewTrackingController.kt +++ b/packages/android/ContentfulOptimization/src/main/kotlin/com/contentful/optimization/tracking/ViewTrackingController.kt @@ -1,5 +1,6 @@ package com.contentful.optimization.tracking +import android.util.Log import androidx.lifecycle.DefaultLifecycleObserver import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.ProcessLifecycleOwner @@ -12,20 +13,55 @@ import kotlinx.coroutines.delay import kotlinx.coroutines.launch import java.util.UUID -class ViewTrackingController( - private val client: OptimizationClient, +/** + * The primary constructor is `internal` so JVM unit tests can supply a controlled + * [CoroutineScope] (typically a `kotlinx-coroutines-test` `TestScope`), a fake [LifecycleOwner] + * (typically `TestLifecycleOwner`), a recording [onTrackView] sink, and a virtual [clock] without + * dragging the full [OptimizationClient] (which requires a real `Context` for SharedPreferences + * and a JNI-loaded QuickJS bridge) into the test target. The public secondary constructor below + * preserves the prior `client: OptimizationClient` call shape used by [OptimizedEntryView]'s + * `attachController` and the Compose `Modifier.trackViews` so production call sites are + * unchanged. The dwell state machine (visibility threshold, 2s-then-5s timer cadence, attempts + * counter, resetCycle on becoming-invisible-before-first-emit) lives entirely in this class + * and is covered by `ViewTrackingControllerTest` in `src/test/`. + */ +class ViewTrackingController internal constructor( entry: Map, personalization: Map?, + private val onTrackView: suspend (TrackViewPayload) -> Unit, private val threshold: Double = 0.8, private val viewTimeMs: Int = 2000, private val viewDurationUpdateIntervalMs: Int = 5000, + private val scope: CoroutineScope = CoroutineScope(Dispatchers.Main), + private val lifecycleOwner: LifecycleOwner = ProcessLifecycleOwner.get(), + private val clock: () -> Long = { System.currentTimeMillis() }, ) : DefaultLifecycleObserver { + /** + * Production constructor used by `OptimizedEntryView` and `Modifier.trackViews`. Adapts the + * concrete [OptimizationClient]'s `trackView` to the suspending sink the controller calls + * through, and uses `Dispatchers.Main` + `ProcessLifecycleOwner` for the runtime defaults. + */ + constructor( + client: OptimizationClient, + entry: Map, + personalization: Map?, + threshold: Double = 0.8, + viewTimeMs: Int = 2000, + viewDurationUpdateIntervalMs: Int = 5000, + ) : this( + entry = entry, + personalization = personalization, + onTrackView = { payload -> client.trackView(payload) }, + threshold = threshold, + viewTimeMs = viewTimeMs, + viewDurationUpdateIntervalMs = viewDurationUpdateIntervalMs, + ) + var isVisible: Boolean = false private set private val metadata = TrackingMetadata(entry, personalization) - private val scope = CoroutineScope(Dispatchers.Main) private var viewId: String? = null private var visibleSinceMs: Long? = null @@ -40,7 +76,7 @@ class ViewTrackingController( private var lastViewportHeight: Float = 0f init { - ProcessLifecycleOwner.get().lifecycle.addObserver(this) + lifecycleOwner.lifecycle.addObserver(this) } fun updateVisibility( @@ -64,8 +100,16 @@ class ViewTrackingController( val nowVisible = visibilityRatio >= threshold if (nowVisible && !isVisible) { + trackingLog { + "componentId=${metadata.componentId} BECAME_VISIBLE " + + "ratio=${"%.2f".format(visibilityRatio)} h=$elementHeight vh=$visibleHeight" + } onBecameVisible() } else if (!nowVisible && isVisible) { + trackingLog { + "componentId=${metadata.componentId} BECAME_INVISIBLE " + + "ratio=${"%.2f".format(visibilityRatio)} h=$elementHeight vh=$visibleHeight attempts=$attempts" + } onBecameInvisible() } } @@ -79,7 +123,7 @@ class ViewTrackingController( fun destroy() { timerJob?.cancel() timerJob = null - ProcessLifecycleOwner.get().lifecycle.removeObserver(this) + lifecycleOwner.lifecycle.removeObserver(this) } override fun onStop(owner: LifecycleOwner) { @@ -112,7 +156,7 @@ class ViewTrackingController( private fun onBecameVisible() { isVisible = true viewId = UUID.randomUUID().toString() - visibleSinceMs = System.currentTimeMillis() + visibleSinceMs = clock() accumulatedMs = 0.0 attempts = 0 scheduleNextFire() @@ -131,13 +175,13 @@ class ViewTrackingController( private fun flushAccumulatedTime() { val since = visibleSinceMs ?: return - accumulatedMs += (System.currentTimeMillis() - since).toDouble() - visibleSinceMs = System.currentTimeMillis() + accumulatedMs += (clock() - since).toDouble() + visibleSinceMs = clock() } private fun pauseAccumulation() { val since = visibleSinceMs ?: return - accumulatedMs += (System.currentTimeMillis() - since).toDouble() + accumulatedMs += (clock() - since).toDouble() visibleSinceMs = null } @@ -170,10 +214,17 @@ class ViewTrackingController( viewDurationMs = accumulatedMs.toInt(), sticky = metadata.sticky, ) + trackingLog { + "EMIT componentId=${metadata.componentId} duration=${accumulatedMs.toInt()}ms attempt=$attempts" + } scope.launch { try { - client.trackView(payload) - } catch (_: Exception) { + onTrackView(payload) + } catch (e: Exception) { + Log.w( + "ViewTracking", + "trackView failed componentId=${metadata.componentId}: ${e.javaClass.simpleName}: ${e.message}", + ) } } } @@ -185,3 +236,9 @@ class ViewTrackingController( attempts = 0 } } + +private inline fun trackingLog(message: () -> String) { + if (Log.isLoggable("ViewTracking", Log.DEBUG)) { + Log.d("ViewTracking", message()) + } +} diff --git a/packages/android/ContentfulOptimization/src/main/kotlin/com/contentful/optimization/views/OptimizationManager.kt b/packages/android/ContentfulOptimization/src/main/kotlin/com/contentful/optimization/views/OptimizationManager.kt new file mode 100644 index 00000000..0aaa5112 --- /dev/null +++ b/packages/android/ContentfulOptimization/src/main/kotlin/com/contentful/optimization/views/OptimizationManager.kt @@ -0,0 +1,127 @@ +package com.contentful.optimization.views + +import android.app.Activity +import android.content.Context +import android.view.View +import com.contentful.optimization.core.OptimizationClient +import com.contentful.optimization.core.OptimizationConfig +import com.contentful.optimization.preview.PreviewContentfulClient +import com.contentful.optimization.preview.PreviewPanelActivity +import com.contentful.optimization.preview.PreviewPanelConfig +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancelChildren +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import java.util.concurrent.atomic.AtomicReference + +/** + * View-based counterpart of [com.contentful.optimization.compose.OptimizationRoot]. + * + * Initializes a process-wide [OptimizationClient] for XML/Views-based apps and exposes it via + * [client]. Reference implementations and consumer apps should call [initialize] from + * `Application.onCreate`, then read [client] from any [Activity] or `Fragment`. + * + * Mirrors the static-client pattern already documented in `packages/android/AGENTS.md` for + * [com.contentful.optimization.preview.PreviewPanelActivity], extended to cover the whole SDK + * rather than just the preview panel. + */ +object OptimizationManager { + + private val clientRef = AtomicReference(null) + private val previewClientRef = AtomicReference(null) + + /** Default applied to [OptimizedEntryView.trackViews] when the per-view value is null. */ + @Volatile + var trackViews: Boolean = true + private set + + /** Default applied to [OptimizedEntryView.trackTaps] when the per-view value is null. */ + @Volatile + var trackTaps: Boolean = false + private set + + /** Default applied to [OptimizedEntryView.liveUpdates] when the per-view value is null. */ + @Volatile + var liveUpdates: Boolean = false + private set + + private val initScope = CoroutineScope(SupervisorJob() + Dispatchers.Main) + + /** + * The live [OptimizationClient]. Call [initialize] before reading this, or it throws. + * + * The same error message is used as `LocalOptimizationClient`'s default value on the Compose + * side so cross-implementation diagnostics line up. + */ + val client: OptimizationClient + get() = clientRef.get() + ?: error( + "No OptimizationClient provided. Call OptimizationManager.initialize() in your " + + "Application.onCreate before reading OptimizationManager.client.", + ) + + /** + * Construct and initialize the [OptimizationClient]. Idempotent — subsequent calls update + * the global tracking defaults and preview-panel client but do not recreate the underlying + * client. + */ + fun initialize( + context: Context, + config: OptimizationConfig, + trackViews: Boolean = true, + trackTaps: Boolean = false, + liveUpdates: Boolean = false, + previewPanel: PreviewPanelConfig? = null, + ) { + this.trackViews = trackViews + this.trackTaps = trackTaps + this.liveUpdates = liveUpdates + previewClientRef.set(previewPanel?.contentfulClient) + + if (clientRef.get() != null) return + + val newClient = OptimizationClient(context.applicationContext) + clientRef.set(newClient) + initScope.launch { + try { + newClient.initialize(config) + } catch (_: Exception) { + // Initialization failures surface through `client.isInitialized` staying false; + // callers that observe state handle this the same way as the Compose path. + } + } + } + + /** + * Attach the preview-panel floating action button to [activity]. Returns null when the + * preview panel is disabled in the active [PreviewPanelConfig] or when [initialize] has not + * been called. Mirrors [com.contentful.optimization.compose.OptimizationRoot]'s + * `previewPanel` parameter, which inserts the same overlay on the Compose side. + */ + fun attachPreviewPanel(activity: Activity): View? { + val c = clientRef.get() ?: return null + return PreviewPanelActivity.addFloatingButton( + activity = activity, + client = c, + contentfulClient = previewClientRef.get(), + ) + } + + /** + * Tear down for testing or hot-reloads. Production apps don't normally call this. + */ + fun resetForTesting() { + clientRef.getAndSet(null)?.let { c -> + runBlocking(Dispatchers.Default) { + try { + c.destroy() + } catch (_: Exception) { + } + } + } + initScope.coroutineContext.cancelChildren() + previewClientRef.set(null) + } +} diff --git a/packages/android/ContentfulOptimization/src/main/kotlin/com/contentful/optimization/views/OptimizedEntryView.kt b/packages/android/ContentfulOptimization/src/main/kotlin/com/contentful/optimization/views/OptimizedEntryView.kt new file mode 100644 index 00000000..049287e4 --- /dev/null +++ b/packages/android/ContentfulOptimization/src/main/kotlin/com/contentful/optimization/views/OptimizedEntryView.kt @@ -0,0 +1,315 @@ +package com.contentful.optimization.views + +import android.content.Context +import android.graphics.Rect +import android.util.AttributeSet +import android.view.View +import android.view.ViewTreeObserver +import android.widget.FrameLayout +import com.contentful.optimization.core.PersonalizedResult +import com.contentful.optimization.core.TrackClickPayload +import com.contentful.optimization.tracking.TrackingMetadata +import com.contentful.optimization.tracking.ViewTrackingController +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.launch + +/** + * View-based counterpart of [com.contentful.optimization.compose.OptimizedEntry]. + * + * Wraps a single Contentful entry and renders the resolved (personalized) entry through a + * caller-supplied [contentRenderer]. Owns a [ViewTrackingController] for visibility-based view + * tracking and forwards click events to [com.contentful.optimization.core.OptimizationClient.trackClick]. + * + * Live-updates and locking semantics mirror `OptimizedEntry` so the same Contentful entry + * behaves identically whether rendered through Compose or XML Views. + * + * Typical use: + * ```kotlin + * val view = OptimizedEntryView(context) + * view.setContentRenderer { resolvedEntry -> + * TextView(context).apply { + * text = (resolvedEntry["fields"] as? Map<*, *>)?.get("text") as? String + * } + * } + * view.accessibilityIdentifier = "content-entry-$id" + * view.setEntry(rawEntry) + * ``` + */ +class OptimizedEntryView @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = 0, +) : FrameLayout(context, attrs, defStyleAttr) { + + // Configuration — public so consumers can tune per-instance without subclassing. + var viewTimeMs: Int = 2000 + var threshold: Double = 0.8 + var viewDurationUpdateIntervalMs: Int = 5000 + var liveUpdates: Boolean? = null + var trackViews: Boolean? = null + var trackTaps: Boolean? = null + var onTap: ((Map) -> Unit)? = null + + /** + * Scopes the entry's accessibility identifier. Mirrors `OptimizedEntry`'s + * `accessibilityIdentifier` parameter, which is the SDK's `contentDescription` contract. + */ + var accessibilityIdentifier: String? = null + set(value) { + field = value + contentDescription = value + } + + private var entry: Map? = null + private var personalizationsOverride: List>? = null + private var contentRenderer: ((Map) -> View)? = null + + private var trackingScope: CoroutineScope? = null + private var personalizationJob: Job? = null + private var previewJob: Job? = null + + private var controller: ViewTrackingController? = null + private var lockedPersonalizations: List>? = null + private var isLocked: Boolean = false + private var lastResult: PersonalizedResult? = null + // Track the (entry, personalization) tuple the current controller was built for so we + // don't tear it down on every personalization re-emission — that would reset the dwell + // timer and prevent component events from ever firing. Mirrors Compose's + // `remember(entry, personalization)` semantics. + private var controllerEntry: Map? = null + private var controllerPersonalization: Map? = null + + private val preDrawListener = ViewTreeObserver.OnPreDrawListener { + updateVisibility() + true + } + + /** + * Set the renderer that turns a resolved entry map into a child View. Called on every + * personalization update; the returned View replaces the previous content. + */ + fun setContentRenderer(renderer: (Map) -> View) { + this.contentRenderer = renderer + lastResult?.let { renderContent(it.entry) } + } + + /** + * Set the entry to personalize. Optional [personalizations] forces a specific set instead + * of observing the live personalizations stream (used by tests or callers driving their own + * personalization state). + */ + fun setEntry( + entry: Map, + personalizations: List>? = null, + ) { + this.entry = entry + this.personalizationsOverride = personalizations + this.lockedPersonalizations = null + this.isLocked = false + restartObservation() + } + + /** Force a visibility re-check from outside (e.g., from [TrackingRecyclerView] on scroll). */ + fun requestVisibilityCheck() { + updateVisibility() + } + + override fun onAttachedToWindow() { + super.onAttachedToWindow() + trackingScope = CoroutineScope(SupervisorJob() + Dispatchers.Main) + viewTreeObserver.addOnPreDrawListener(preDrawListener) + setOnClickListener { fireTrackClick() } + restartObservation() + } + + override fun onDetachedFromWindow() { + super.onDetachedFromWindow() + viewTreeObserver.removeOnPreDrawListener(preDrawListener) + controller?.let { + it.onDisappear() + it.destroy() + } + controller = null + trackingScope?.cancel() + trackingScope = null + personalizationJob = null + previewJob = null + } + + private fun restartObservation() { + val scope = trackingScope ?: return + val entry = entry ?: return + val client = OptimizationManager.client + + controller?.let { + it.onDisappear() + it.destroy() + } + controller = null + controllerEntry = null + controllerPersonalization = null + personalizationJob?.cancel() + previewJob?.cancel() + + @Suppress("UNCHECKED_CAST") + val fields = entry["fields"] as? Map + val isPersonalized = fields?.containsKey("nt_experiences") == true + val explicitOverride = personalizationsOverride + + personalizationJob = scope.launch { + if (!isPersonalized || explicitOverride != null) { + // Plain entry — or caller-supplied personalizations. No live observation. + publishResult( + client.personalizeEntry(baseline = entry, personalizations = explicitOverride), + ) + return@launch + } + // Mirrors the OptimizedEntry composable: collect each personalization emission + // rather than snapshotting, so the rapid sequence triggered by identify() is not + // coalesced. + client.selectedPersonalizations.collect { newValue -> + val previewOpen = client.isPreviewPanelOpen.value + val shouldLive = + if (previewOpen) true else liveUpdates ?: OptimizationManager.liveUpdates + if (!shouldLive && !isLocked && newValue != null) { + lockedPersonalizations = newValue + isLocked = true + } + val effective = if (shouldLive) newValue else lockedPersonalizations + publishResult( + client.personalizeEntry(baseline = entry, personalizations = effective), + ) + } + } + + previewJob = scope.launch { + client.isPreviewPanelOpen.collect { open -> + if (!open && isPersonalized && isLocked) { + lockedPersonalizations = client.selectedPersonalizations.value + publishResult( + client.personalizeEntry( + baseline = entry, + personalizations = lockedPersonalizations, + ), + ) + } + } + } + } + + private fun publishResult(result: PersonalizedResult) { + lastResult = result + renderContent(result.entry) + attachController(result) + } + + private fun renderContent(entry: Map) { + val renderer = contentRenderer ?: return + removeAllViews() + addView(renderer(entry)) + } + + private fun attachController(result: PersonalizedResult) { + if (!resolveTrackViews()) return + val entry = entry ?: return + val newPersonalization = result.personalization + @Suppress("UNCHECKED_CAST") + val entryId = (entry["sys"] as? Map)?.get("id") as? String ?: "" + + // If the controller is already wired up for the same (entry, personalization) tuple, + // keep it — rebuilding would reset the dwell timer mid-cycle. + if (controller != null && + controllerEntry === entry && + controllerPersonalization == newPersonalization + ) { + trackingLog { "attachController KEEP componentId=$entryId" } + updateVisibility() + return + } + + if (controller != null) { + trackingLog { "attachController REBUILD componentId=$entryId (was different tuple)" } + } else { + trackingLog { "attachController CREATE componentId=$entryId" } + } + + controller?.let { + it.onDisappear() + it.destroy() + } + controller = ViewTrackingController( + client = OptimizationManager.client, + entry = entry, + personalization = newPersonalization, + threshold = threshold, + viewTimeMs = viewTimeMs, + viewDurationUpdateIntervalMs = viewDurationUpdateIntervalMs, + ) + controllerEntry = entry + controllerPersonalization = newPersonalization + updateVisibility() + } + + private fun updateVisibility() { + val controller = controller ?: return + if (!isAttachedToWindow || height == 0) return + + // getGlobalVisibleRect intersects with every ancestor's clip rect plus the window's + // visible area, so rect.height() is the truly-visible portion of this view. + val rect = Rect() + val visible = getGlobalVisibleRect(rect) + val elementHeight = height.toFloat() + val visibleHeight = if (visible) rect.height().toFloat() else 0f + + // Encode the visibility geometry as (elementY=0, scrollY=0, viewportHeight=visibleHeight) + // so ViewTrackingController computes ratio = visibleHeight / elementHeight, matching the + // ratio the Compose `Modifier.trackViews` derives from its scroll-aware geometry. + controller.updateVisibility( + elementY = 0f, + elementHeight = elementHeight, + scrollY = 0f, + viewportHeight = visibleHeight, + ) + } + + private fun fireTrackClick() { + if (!resolveTrackTaps()) return + val entry = entry ?: return + val scope = trackingScope ?: return + val metadata = TrackingMetadata(entry, lastResult?.personalization) + scope.launch { + try { + OptimizationManager.client.trackClick( + TrackClickPayload( + componentId = metadata.componentId, + experienceId = metadata.experienceId, + variantIndex = metadata.variantIndex, + ), + ) + } catch (_: Exception) { + } + } + onTap?.invoke(entry) + } + + private fun resolveTrackViews(): Boolean = trackViews ?: OptimizationManager.trackViews + + private fun resolveTrackTaps(): Boolean { + val explicit = trackTaps + return when { + explicit == false -> false + explicit != null || onTap != null -> true + else -> OptimizationManager.trackTaps + } + } +} + +private inline fun trackingLog(message: () -> String) { + if (android.util.Log.isLoggable("ViewTracking", android.util.Log.DEBUG)) { + android.util.Log.d("ViewTracking", message()) + } +} diff --git a/packages/android/ContentfulOptimization/src/main/kotlin/com/contentful/optimization/views/ScreenTracker.kt b/packages/android/ContentfulOptimization/src/main/kotlin/com/contentful/optimization/views/ScreenTracker.kt new file mode 100644 index 00000000..2b8a5713 --- /dev/null +++ b/packages/android/ContentfulOptimization/src/main/kotlin/com/contentful/optimization/views/ScreenTracker.kt @@ -0,0 +1,36 @@ +package com.contentful.optimization.views + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.launch + +/** + * View-based counterpart of [com.contentful.optimization.compose.ScreenTrackingEffect]. + * + * Call from `Activity.onResume` or `Fragment.onResume` to emit a `screen` event with the given + * name through the active [com.contentful.optimization.core.OptimizationClient]: + * + * ```kotlin + * override fun onResume() { + * super.onResume() + * ScreenTracker.trackScreen("MainScreen") + * } + * ``` + * + * Failures (including the client not yet being initialized) are swallowed — same contract as + * the Compose `ScreenTrackingEffect`. + */ +object ScreenTracker { + + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main) + + fun trackScreen(name: String) { + scope.launch { + try { + OptimizationManager.client.screen(name = name) + } catch (_: Exception) { + } + } + } +} diff --git a/packages/android/ContentfulOptimization/src/main/kotlin/com/contentful/optimization/views/TrackingRecyclerView.kt b/packages/android/ContentfulOptimization/src/main/kotlin/com/contentful/optimization/views/TrackingRecyclerView.kt new file mode 100644 index 00000000..a121b5ae --- /dev/null +++ b/packages/android/ContentfulOptimization/src/main/kotlin/com/contentful/optimization/views/TrackingRecyclerView.kt @@ -0,0 +1,60 @@ +package com.contentful.optimization.views + +import android.content.Context +import android.util.AttributeSet +import android.view.View +import android.view.ViewGroup +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView + +/** + * View-based counterpart of [com.contentful.optimization.compose.OptimizationLazyColumn]. + * + * A drop-in [RecyclerView] that nudges descendant [OptimizedEntryView] instances to re-evaluate + * their visibility on every scroll frame. Optional: [OptimizedEntryView] inside any scroll + * container will also re-check via its own + * [android.view.ViewTreeObserver.OnPreDrawListener], so this class is a redundant signal path + * for scroll containers whose layout passes do not naturally redraw children. + */ +class TrackingRecyclerView @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = 0, +) : RecyclerView(context, attrs, defStyleAttr) { + + init { + addOnScrollListener( + object : OnScrollListener() { + override fun onScrolled(rv: RecyclerView, dx: Int, dy: Int) { + val lm = layoutManager as? LinearLayoutManager + if (lm != null) { + val first = lm.findFirstVisibleItemPosition() + val last = lm.findLastVisibleItemPosition() + if (first == RecyclerView.NO_POSITION || last == RecyclerView.NO_POSITION) { + return + } + for (i in first..last) { + val itemView = findViewHolderForAdapterPosition(i)?.itemView ?: continue + forEachOptimizedEntry(itemView) { + it.requestVisibilityCheck() + } + } + } else { + forEachOptimizedEntry(this@TrackingRecyclerView) { + it.requestVisibilityCheck() + } + } + } + }, + ) + } + + private fun forEachOptimizedEntry(root: View, action: (OptimizedEntryView) -> Unit) { + if (root is OptimizedEntryView) action(root) + if (root is ViewGroup) { + for (i in 0 until root.childCount) { + forEachOptimizedEntry(root.getChildAt(i), action) + } + } + } +} diff --git a/packages/android/ContentfulOptimization/src/test/kotlin/com/contentful/optimization/tracking/ViewTrackingControllerTest.kt b/packages/android/ContentfulOptimization/src/test/kotlin/com/contentful/optimization/tracking/ViewTrackingControllerTest.kt new file mode 100644 index 00000000..c93bb1c3 --- /dev/null +++ b/packages/android/ContentfulOptimization/src/test/kotlin/com/contentful/optimization/tracking/ViewTrackingControllerTest.kt @@ -0,0 +1,284 @@ +package com.contentful.optimization.tracking + +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.testing.TestLifecycleOwner +import com.contentful.optimization.core.TrackViewPayload +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.cancelChildren +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * Unit tests for [ViewTrackingController]'s dwell state machine. Drives the controller with + * synthetic visibility geometry and a virtual clock via `kotlinx-coroutines-test`'s `TestScope`, + * so timing is fully deterministic — no emulator, no scroll animation, no JS bridge. + * + * Note on test driving primitives: the controller's `scheduleNextFire` re-arms a new timer + * coroutine after each emit, so `advanceUntilIdle` would never reach "idle" while the entry is + * visible. These tests use `advanceTimeBy(N)` followed by `runCurrent()` to step the virtual + * clock by a known interval and drain only the tasks that became eligible at the new time. + * + * This is the right layer for the dwell contract. The E2E `@Test` methods that previously + * asserted on `component-stats-` resource ids after a real swipe were conflating three + * independent concerns (scrolling, demo-app analytics rendering, and tracker firing) and were + * intolerant of the real layout/swipe timing on the x86_64 CI emulator. They have been deleted + * — see `implementations/android-sdk/uitests/README.md` for the catalogue and rationale. + */ +@OptIn(ExperimentalCoroutinesApi::class) +class ViewTrackingControllerTest { + + @Test + fun `becoming visible at ratio above threshold and dwelling viewTimeMs fires trackView once with the entry's componentId`() = runTest { + val recorded = mutableListOf() + val controller = makeController( + scope = this, + onTrackView = { recorded.add(it) }, + clock = { testScheduler.currentTime }, + ) + + // Entry fully visible: h=100, vh=200 -> ratio = 1.0 (>= threshold) + controller.updateVisibility(elementY = 0f, elementHeight = 100f, scrollY = 0f, viewportHeight = 200f) + // Step the clock by viewTimeMs (default 2000) and drain. + advanceTimeBy(2_001L) + runCurrent() + + assertEquals("expected exactly one trackView call", 1, recorded.size) + assertEquals(TEST_ENTRY_ID, recorded.single().componentId) + assertEquals(0, recorded.single().variantIndex) + + cleanup(controller) + } + + @Test + fun `becoming invisible BEFORE viewTimeMs with attempts equals zero emits NO event and resets the cycle`() = runTest { + val recorded = mutableListOf() + val controller = makeController( + scope = this, + onTrackView = { recorded.add(it) }, + clock = { testScheduler.currentTime }, + ) + + // Become visible at ratio = 1.0 + controller.updateVisibility(0f, 100f, 0f, 100f) + // 1s of dwell — still under viewTimeMs (2000) + advanceTimeBy(1_000L) + runCurrent() + // Ratio drops below threshold (mimic the views CI swipe-during-dwell race) + controller.updateVisibility(0f, 100f, 60f, 100f) // visible portion 40 -> ratio 0.4 + runCurrent() + // Advance past where the original dwell would have fired, to prove the cycle was reset. + advanceTimeBy(2_001L) + runCurrent() + + assertTrue( + "expected no trackView calls when dwell was interrupted at attempts=0, got $recorded", + recorded.isEmpty(), + ) + assertEquals(false, controller.isVisible) + + cleanup(controller) + } + + @Test + fun `becoming invisible AFTER first emit (attempts greater than zero) fires a final event with the accumulated duration`() = runTest { + val recorded = mutableListOf() + val controller = makeController( + scope = this, + onTrackView = { recorded.add(it) }, + clock = { testScheduler.currentTime }, + ) + + // Fully visible -> first emit at viewTimeMs (2000) + controller.updateVisibility(0f, 100f, 0f, 200f) + advanceTimeBy(2_001L) + runCurrent() + assertEquals("first attempt emit", 1, recorded.size) + + // Dwell another 1000ms while still visible + advanceTimeBy(1_000L) + runCurrent() + // Now become invisible — attempts > 0 path fires a final event + controller.updateVisibility(0f, 100f, 80f, 200f) // ratio 0.2 + runCurrent() + + assertEquals("expected initial emit + final emit", 2, recorded.size) + val finalDuration = recorded.last().viewDurationMs + assertTrue( + "expected final viewDurationMs to include the 3s of visibility, got $finalDuration", + finalDuration >= 3_000, + ) + + cleanup(controller) + } + + @Test + fun `subsequent timer firings follow the viewTimeMs then viewDurationUpdateIntervalMs cadence`() = runTest { + val recorded = mutableListOf() + val controller = makeController( + scope = this, + onTrackView = { recorded.add(it) }, + clock = { testScheduler.currentTime }, + viewTimeMs = 2_000, + viewDurationUpdateIntervalMs = 5_000, + ) + + controller.updateVisibility(0f, 100f, 0f, 200f) + + // First emit at viewTimeMs (2_000) + advanceTimeBy(2_001L) + runCurrent() + assertEquals(1, recorded.size) + + // Second emit at viewTimeMs + viewDurationUpdateIntervalMs (7_000) + advanceTimeBy(5_000L) + runCurrent() + assertEquals(2, recorded.size) + + // Third emit at viewTimeMs + 2 * viewDurationUpdateIntervalMs (12_000) + advanceTimeBy(5_000L) + runCurrent() + assertEquals(3, recorded.size) + + recorded.forEach { payload -> + assertEquals("all emits should share the same componentId", TEST_ENTRY_ID, payload.componentId) + } + val viewIds = recorded.map { it.viewId }.distinct() + assertEquals("all emits in a single visibility cycle share the same viewId", 1, viewIds.size) + + cleanup(controller) + } + + @Test + fun `onStop pauses accumulation and onStart re-evaluates from last known geometry`() = runTest { + val recorded = mutableListOf() + val testLifecycleOwner = TestLifecycleOwner( + initialState = Lifecycle.State.STARTED, + coroutineDispatcher = UnconfinedTestDispatcher(testScheduler), + ) + val controller = makeController( + scope = this, + onTrackView = { recorded.add(it) }, + clock = { testScheduler.currentTime }, + lifecycleOwner = testLifecycleOwner, + ) + + controller.updateVisibility(0f, 100f, 0f, 200f) + advanceTimeBy(1_000L) + runCurrent() + + // App backgrounded -> pause should be called via DefaultLifecycleObserver.onStop + testLifecycleOwner.currentState = Lifecycle.State.CREATED + runCurrent() + assertEquals(false, controller.isVisible) + assertTrue("no emit yet (attempts was 0)", recorded.isEmpty()) + + // App foregrounded -> resume re-evaluates last known geometry (still ratio=1.0) + testLifecycleOwner.currentState = Lifecycle.State.STARTED + runCurrent() + assertEquals(true, controller.isVisible) + + // After full viewTimeMs of post-resume dwell, emit fires + advanceTimeBy(2_001L) + runCurrent() + assertEquals(1, recorded.size) + + cleanup(controller) + } + + /** + * Regression test pinning the current 0.8/0.8 symmetric threshold behavior. This was the + * shape of the failure on the views CI x86_64 emulator: at t≈+1s the test's + * `scrollToElement` swipe clipped the merge-tag entry to ratio≈0.54 (above 0.4, below 0.8), + * `onBecameInvisible` fired with `attempts=0`, `resetCycle()` wiped the dwell, and the + * 2s timer never reached `emitEvent`. If we later add hysteresis (e.g., enter at 0.8, exit + * at 0.4 — see plan `out of scope` section), this assertion will flip and the test must be + * updated to reflect the new contract. + */ + @Test + fun `regression - ratio dip from 1_00 to 0_54 before viewTimeMs resets the cycle and prevents emit`() = runTest { + val recorded = mutableListOf() + val controller = makeController( + scope = this, + onTrackView = { recorded.add(it) }, + clock = { testScheduler.currentTime }, + ) + + // Initial state matches CI artifact: BECAME_VISIBLE ratio=1.00 h=164 vh=164 + controller.updateVisibility(elementY = 0f, elementHeight = 164f, scrollY = 0f, viewportHeight = 164f) + assertEquals(true, controller.isVisible) + + // ~1s of dwell elapses + advanceTimeBy(1_000L) + runCurrent() + assertTrue("no emit while dwell in progress", recorded.isEmpty()) + + // Layout race: rich text resolves -> h grows from 164 to 207; mid-swipe -> vh shrinks to 112. + // Computed ratio: visibleHeight=112 / elementHeight=207 = 0.541 (matches CI log). + controller.updateVisibility(elementY = 0f, elementHeight = 207f, scrollY = 95f, viewportHeight = 207f) + runCurrent() + // visibleTop=max(0,95)=95, visibleBottom=min(207,302)=207, visibleHeight=112, ratio=0.541 + assertEquals("ratio below 0.8 with current symmetric threshold transitions to invisible", + false, controller.isVisible) + + // Step past where the original 2s timer would have fired, to prove the cycle stayed reset. + advanceTimeBy(2_001L) + runCurrent() + assertTrue( + "expected no emit because the cycle was reset at attempts=0, got $recorded", + recorded.isEmpty(), + ) + + cleanup(controller) + } + + // --- helpers --- + + /** + * Tear down the controller and any pending coroutines it launched into the TestScope + * (the next-attempt timer or in-flight emit). Without this, `runTest`'s end-of-test + * "did the test body complete?" check trips because the controller's `scheduleNextFire` + * unconditionally re-arms a fresh timer after every emit. + */ + private fun TestScope.cleanup(controller: ViewTrackingController) { + controller.destroy() + coroutineContext.cancelChildren() + runCurrent() + } + + private fun TestScope.makeController( + scope: CoroutineScope, + onTrackView: suspend (TrackViewPayload) -> Unit, + clock: () -> Long, + entry: Map = mapOf("sys" to mapOf("id" to TEST_ENTRY_ID)), + personalization: Map? = null, + threshold: Double = 0.8, + viewTimeMs: Int = 2_000, + viewDurationUpdateIntervalMs: Int = 5_000, + lifecycleOwner: LifecycleOwner = TestLifecycleOwner( + initialState = Lifecycle.State.STARTED, + coroutineDispatcher = UnconfinedTestDispatcher(testScheduler), + ), + ): ViewTrackingController = ViewTrackingController( + entry = entry, + personalization = personalization, + onTrackView = onTrackView, + threshold = threshold, + viewTimeMs = viewTimeMs, + viewDurationUpdateIntervalMs = viewDurationUpdateIntervalMs, + scope = scope, + lifecycleOwner = lifecycleOwner, + clock = clock, + ) + + companion object { + private const val TEST_ENTRY_ID = "1MwiFl4z7gkwqGYdvCmr8c" + } +} From 3ef3447d884899fc42c193ac7f009a8c01ad64e8 Mon Sep 17 00:00:00 2001 From: Alex Freas Date: Sat, 30 May 2026 10:25:13 +0200 Subject: [PATCH 03/13] Fix Compose OptimizedEntry losing reset-variant re-resolution by keying its collector only on entry --- .../optimization/compose/OptimizedEntry.kt | 28 ++++++++++++------- 1 file changed, 18 insertions(+), 10 deletions(-) diff --git a/packages/android/ContentfulOptimization/src/main/kotlin/com/contentful/optimization/compose/OptimizedEntry.kt b/packages/android/ContentfulOptimization/src/main/kotlin/com/contentful/optimization/compose/OptimizedEntry.kt index 81f3ce7e..17ee36e1 100644 --- a/packages/android/ContentfulOptimization/src/main/kotlin/com/contentful/optimization/compose/OptimizedEntry.kt +++ b/packages/android/ContentfulOptimization/src/main/kotlin/com/contentful/optimization/compose/OptimizedEntry.kt @@ -40,11 +40,6 @@ fun OptimizedEntry( fields?.containsKey("nt_experiences") == true } - // An open preview panel always forces live updates, overriding an explicit - // `liveUpdates = false`. The global toggle is only the default when no - // explicit per-component value is set. Mirrors the iOS `shouldLiveUpdate`. - val shouldLiveUpdate = if (isPreviewPanelOpen) true else liveUpdates ?: trackingConfig.liveUpdates - val viewsEnabled = trackViews ?: trackingConfig.trackViews val tapsEnabled = when { trackTaps == false -> false @@ -57,20 +52,33 @@ fun OptimizedEntry( } // Re-resolve by collecting the personalizations StateFlow directly rather - // than keying `produceState` on a `collectAsState()` snapshot. Compose - // snapshot conflation can coalesce the rapid emission sequence that - // `identify()` produces, which left a long-mounted live entry stuck on its - // first resolution. Collecting the flow observes every emission. + // than keying on a `collectAsState()` snapshot. Compose snapshot conflation + // can coalesce the rapid emission sequence that `identify()` produces, which + // left a long-mounted live entry stuck on its first resolution. Collecting + // the flow observes every emission. + // + // IMPORTANT: key this collector ONLY on `entry`, and read the current + // preview-panel state INSIDE the collect via `isPreviewPanelOpen.value` — + // never key the effect on a derived `shouldLiveUpdate`. Keying on the panel + // state cancels and restarts the collector every time the panel opens or + // closes, which can drop the `selectedPersonalizations` emission produced by + // a preview override change (e.g. reset-variant) and leave the entry + // resolved against a stale personalization set (observed as the baseline + // sticking after reset-variant). The View-based `OptimizedEntryView` uses + // this same long-lived collector; keeping them identical is what makes + // Compose and Views behave the same. // // Live entries follow the latest personalizations on every emission. Locked // entries freeze on the first non-null value — mirrors the iOS `onReceive` // lock — and ignore later updates until the component is remounted. - LaunchedEffect(entry, shouldLiveUpdate) { + LaunchedEffect(entry) { if (!isPersonalized) { result = PersonalizedResult(entry = entry, personalization = null) return@LaunchedEffect } client.selectedPersonalizations.collect { newValue -> + val shouldLiveUpdate = + if (client.isPreviewPanelOpen.value) true else liveUpdates ?: trackingConfig.liveUpdates if (!shouldLiveUpdate && !isLocked && newValue != null) { lockedPersonalizations = newValue isLocked = true From 7924581f709e7a3fdb95f101a111af294d7c2480 Mon Sep 17 00:00:00 2001 From: Alex Freas Date: Sat, 30 May 2026 10:28:47 +0200 Subject: [PATCH 04/13] Restructure the Android reference impl into :shared, :compose, and :views modules --- .../contentful/optimization/app/AppConfig.kt | 22 -- .../{app => compose}/build.gradle.kts | 1 + .../src/main/AndroidManifest.xml | 0 .../optimization/app/MainActivity.kt | 6 +- .../app/components/AnalyticsEventDisplay.kt | 2 +- .../app/components/ContentEntryView.kt | 1 + .../app/components/NestedContentEntryView.kt | 1 + .../optimization/app/components/RichText.kt | 0 .../app/screens/LiveUpdatesTestScreen.kt | 2 +- .../optimization/app/screens/MainScreen.kt | 11 +- .../app/screens/NavigationTestScreen.kt | 0 .../src/main/res/values/themes.xml | 0 implementations/android-sdk/package.json | 12 +- .../android-sdk/settings.gradle.kts | 4 +- .../android-sdk/shared/build.gradle.kts | 34 +++ .../shared/src/main/AndroidManifest.xml | 2 + .../optimization/shared/AppConfig.kt | 34 +++ .../optimization/shared}/ContentfulFetcher.kt | 56 +++- .../optimization/shared}/EventStore.kt | 2 +- .../shared}/MockPreviewContentfulClient.kt | 2 +- .../optimization/shared/RichText.kt | 81 ++++++ .../android-sdk/views/build.gradle.kts | 51 ++++ .../views/src/main/AndroidManifest.xml | 25 ++ .../app/views/LiveUpdatesTestActivity.kt | 250 ++++++++++++++++++ .../optimization/app/views/MainActivity.kt | 248 +++++++++++++++++ .../app/views/NavigationTestActivity.kt | 133 ++++++++++ .../app/views/ViewsApplication.kt | 14 + .../components/AnalyticsEventDisplayBinder.kt | 137 ++++++++++ .../components/ContentEntryViewBinder.kt | 131 +++++++++ .../NestedContentEntryViewBinder.kt | 60 +++++ .../app/views/support/TestTagging.kt | 48 ++++ .../res/layout/activity_live_updates_test.xml | 153 +++++++++++ .../src/main/res/layout/activity_main.xml | 63 +++++ .../res/layout/activity_navigation_test.xml | 80 ++++++ .../views/src/main/res/values/strings.xml | 4 + .../views/src/main/res/values/themes.xml | 6 + 36 files changed, 1628 insertions(+), 48 deletions(-) delete mode 100644 implementations/android-sdk/app/src/main/kotlin/com/contentful/optimization/app/AppConfig.kt rename implementations/android-sdk/{app => compose}/build.gradle.kts (97%) rename implementations/android-sdk/{app => compose}/src/main/AndroidManifest.xml (100%) rename implementations/android-sdk/{app => compose}/src/main/kotlin/com/contentful/optimization/app/MainActivity.kt (93%) rename implementations/android-sdk/{app => compose}/src/main/kotlin/com/contentful/optimization/app/components/AnalyticsEventDisplay.kt (98%) rename implementations/android-sdk/{app => compose}/src/main/kotlin/com/contentful/optimization/app/components/ContentEntryView.kt (97%) rename implementations/android-sdk/{app => compose}/src/main/kotlin/com/contentful/optimization/app/components/NestedContentEntryView.kt (98%) rename implementations/android-sdk/{app => compose}/src/main/kotlin/com/contentful/optimization/app/components/RichText.kt (100%) rename implementations/android-sdk/{app => compose}/src/main/kotlin/com/contentful/optimization/app/screens/LiveUpdatesTestScreen.kt (99%) rename implementations/android-sdk/{app => compose}/src/main/kotlin/com/contentful/optimization/app/screens/MainScreen.kt (95%) rename implementations/android-sdk/{app => compose}/src/main/kotlin/com/contentful/optimization/app/screens/NavigationTestScreen.kt (100%) rename implementations/android-sdk/{app => compose}/src/main/res/values/themes.xml (100%) create mode 100644 implementations/android-sdk/shared/build.gradle.kts create mode 100644 implementations/android-sdk/shared/src/main/AndroidManifest.xml create mode 100644 implementations/android-sdk/shared/src/main/kotlin/com/contentful/optimization/shared/AppConfig.kt rename implementations/android-sdk/{app/src/main/kotlin/com/contentful/optimization/app => shared/src/main/kotlin/com/contentful/optimization/shared}/ContentfulFetcher.kt (64%) rename implementations/android-sdk/{app/src/main/kotlin/com/contentful/optimization/app => shared/src/main/kotlin/com/contentful/optimization/shared}/EventStore.kt (98%) rename implementations/android-sdk/{app/src/main/kotlin/com/contentful/optimization/app => shared/src/main/kotlin/com/contentful/optimization/shared}/MockPreviewContentfulClient.kt (98%) create mode 100644 implementations/android-sdk/shared/src/main/kotlin/com/contentful/optimization/shared/RichText.kt create mode 100644 implementations/android-sdk/views/build.gradle.kts create mode 100644 implementations/android-sdk/views/src/main/AndroidManifest.xml create mode 100644 implementations/android-sdk/views/src/main/kotlin/com/contentful/optimization/app/views/LiveUpdatesTestActivity.kt create mode 100644 implementations/android-sdk/views/src/main/kotlin/com/contentful/optimization/app/views/MainActivity.kt create mode 100644 implementations/android-sdk/views/src/main/kotlin/com/contentful/optimization/app/views/NavigationTestActivity.kt create mode 100644 implementations/android-sdk/views/src/main/kotlin/com/contentful/optimization/app/views/ViewsApplication.kt create mode 100644 implementations/android-sdk/views/src/main/kotlin/com/contentful/optimization/app/views/components/AnalyticsEventDisplayBinder.kt create mode 100644 implementations/android-sdk/views/src/main/kotlin/com/contentful/optimization/app/views/components/ContentEntryViewBinder.kt create mode 100644 implementations/android-sdk/views/src/main/kotlin/com/contentful/optimization/app/views/components/NestedContentEntryViewBinder.kt create mode 100644 implementations/android-sdk/views/src/main/kotlin/com/contentful/optimization/app/views/support/TestTagging.kt create mode 100644 implementations/android-sdk/views/src/main/res/layout/activity_live_updates_test.xml create mode 100644 implementations/android-sdk/views/src/main/res/layout/activity_main.xml create mode 100644 implementations/android-sdk/views/src/main/res/layout/activity_navigation_test.xml create mode 100644 implementations/android-sdk/views/src/main/res/values/strings.xml create mode 100644 implementations/android-sdk/views/src/main/res/values/themes.xml diff --git a/implementations/android-sdk/app/src/main/kotlin/com/contentful/optimization/app/AppConfig.kt b/implementations/android-sdk/app/src/main/kotlin/com/contentful/optimization/app/AppConfig.kt deleted file mode 100644 index 9ebc00af..00000000 --- a/implementations/android-sdk/app/src/main/kotlin/com/contentful/optimization/app/AppConfig.kt +++ /dev/null @@ -1,22 +0,0 @@ -package com.contentful.optimization.app - -object AppConfig { - const val clientId = "mock-client-id" - const val environment = "master" - const val experienceBaseUrl = "http://localhost:8000/experience/" - const val insightsBaseUrl = "http://localhost:8000/insights/" - - const val contentfulBaseUrl = "http://localhost:8000/contentful/" - const val contentfulSpaceId = "mock-space-id" - - val entryIds = listOf( - "1MwiFl4z7gkwqGYdvCmr8c", - "4ib0hsHWoSOnCVdDkizE8d", - "xFwgG3oNaOcjzWiGe4vXo", - "2Z2WLOx07InSewC3LUB3eX", - "5XHssysWUDECHzKLzoIsg1", - "6zqoWXyiSrf0ja7I2WGtYj", - "7pa5bOx8Z9NmNcr7mISvD", - "1JAU028vQ7v6nB2swl3NBo", - ) -} diff --git a/implementations/android-sdk/app/build.gradle.kts b/implementations/android-sdk/compose/build.gradle.kts similarity index 97% rename from implementations/android-sdk/app/build.gradle.kts rename to implementations/android-sdk/compose/build.gradle.kts index 83624004..da3b0a51 100644 --- a/implementations/android-sdk/app/build.gradle.kts +++ b/implementations/android-sdk/compose/build.gradle.kts @@ -40,6 +40,7 @@ kotlin { dependencies { implementation(project(":ContentfulOptimization")) + implementation(project(":shared")) implementation(platform("androidx.compose:compose-bom:2024.12.01")) implementation("androidx.compose.ui:ui") diff --git a/implementations/android-sdk/app/src/main/AndroidManifest.xml b/implementations/android-sdk/compose/src/main/AndroidManifest.xml similarity index 100% rename from implementations/android-sdk/app/src/main/AndroidManifest.xml rename to implementations/android-sdk/compose/src/main/AndroidManifest.xml diff --git a/implementations/android-sdk/app/src/main/kotlin/com/contentful/optimization/app/MainActivity.kt b/implementations/android-sdk/compose/src/main/kotlin/com/contentful/optimization/app/MainActivity.kt similarity index 93% rename from implementations/android-sdk/app/src/main/kotlin/com/contentful/optimization/app/MainActivity.kt rename to implementations/android-sdk/compose/src/main/kotlin/com/contentful/optimization/app/MainActivity.kt index c181261c..796be99d 100644 --- a/implementations/android-sdk/app/src/main/kotlin/com/contentful/optimization/app/MainActivity.kt +++ b/implementations/android-sdk/compose/src/main/kotlin/com/contentful/optimization/app/MainActivity.kt @@ -17,6 +17,8 @@ import com.contentful.optimization.app.screens.MainScreen import com.contentful.optimization.compose.OptimizationRoot import com.contentful.optimization.core.OptimizationConfig import com.contentful.optimization.preview.PreviewPanelConfig +import com.contentful.optimization.shared.AppConfig +import com.contentful.optimization.shared.MockPreviewContentfulClient class MainActivity : ComponentActivity() { @@ -31,8 +33,6 @@ class MainActivity : ComponentActivity() { .apply() } - val simulateOffline = intent.getBooleanExtra("simulate_offline", false) - setContent { Surface( modifier = Modifier @@ -54,7 +54,7 @@ class MainActivity : ComponentActivity() { contentfulClient = MockPreviewContentfulClient(), ), ) { - MainScreen(simulateOffline = simulateOffline) + MainScreen() } } } diff --git a/implementations/android-sdk/app/src/main/kotlin/com/contentful/optimization/app/components/AnalyticsEventDisplay.kt b/implementations/android-sdk/compose/src/main/kotlin/com/contentful/optimization/app/components/AnalyticsEventDisplay.kt similarity index 98% rename from implementations/android-sdk/app/src/main/kotlin/com/contentful/optimization/app/components/AnalyticsEventDisplay.kt rename to implementations/android-sdk/compose/src/main/kotlin/com/contentful/optimization/app/components/AnalyticsEventDisplay.kt index 900cc873..d7944a44 100644 --- a/implementations/android-sdk/app/src/main/kotlin/com/contentful/optimization/app/components/AnalyticsEventDisplay.kt +++ b/implementations/android-sdk/compose/src/main/kotlin/com/contentful/optimization/app/components/AnalyticsEventDisplay.kt @@ -12,7 +12,7 @@ import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp -import com.contentful.optimization.app.EventStore +import com.contentful.optimization.shared.EventStore @Composable fun AnalyticsEventDisplay() { diff --git a/implementations/android-sdk/app/src/main/kotlin/com/contentful/optimization/app/components/ContentEntryView.kt b/implementations/android-sdk/compose/src/main/kotlin/com/contentful/optimization/app/components/ContentEntryView.kt similarity index 97% rename from implementations/android-sdk/app/src/main/kotlin/com/contentful/optimization/app/components/ContentEntryView.kt rename to implementations/android-sdk/compose/src/main/kotlin/com/contentful/optimization/app/components/ContentEntryView.kt index adc3ce42..ea647e93 100644 --- a/implementations/android-sdk/app/src/main/kotlin/com/contentful/optimization/app/components/ContentEntryView.kt +++ b/implementations/android-sdk/compose/src/main/kotlin/com/contentful/optimization/app/components/ContentEntryView.kt @@ -16,6 +16,7 @@ import androidx.compose.ui.semantics.semantics import androidx.compose.ui.unit.dp import com.contentful.optimization.compose.LocalOptimizationClient import com.contentful.optimization.compose.OptimizedEntry +import com.contentful.optimization.shared.RichText @Composable fun ContentEntryView(entry: Map) { diff --git a/implementations/android-sdk/app/src/main/kotlin/com/contentful/optimization/app/components/NestedContentEntryView.kt b/implementations/android-sdk/compose/src/main/kotlin/com/contentful/optimization/app/components/NestedContentEntryView.kt similarity index 98% rename from implementations/android-sdk/app/src/main/kotlin/com/contentful/optimization/app/components/NestedContentEntryView.kt rename to implementations/android-sdk/compose/src/main/kotlin/com/contentful/optimization/app/components/NestedContentEntryView.kt index f97451f5..6cf1d657 100644 --- a/implementations/android-sdk/app/src/main/kotlin/com/contentful/optimization/app/components/NestedContentEntryView.kt +++ b/implementations/android-sdk/compose/src/main/kotlin/com/contentful/optimization/app/components/NestedContentEntryView.kt @@ -16,6 +16,7 @@ import androidx.compose.ui.semantics.semantics import androidx.compose.ui.unit.dp import com.contentful.optimization.compose.LocalOptimizationClient import com.contentful.optimization.compose.OptimizedEntry +import com.contentful.optimization.shared.RichText @Composable fun NestedContentEntryView(entry: Map) { diff --git a/implementations/android-sdk/app/src/main/kotlin/com/contentful/optimization/app/components/RichText.kt b/implementations/android-sdk/compose/src/main/kotlin/com/contentful/optimization/app/components/RichText.kt similarity index 100% rename from implementations/android-sdk/app/src/main/kotlin/com/contentful/optimization/app/components/RichText.kt rename to implementations/android-sdk/compose/src/main/kotlin/com/contentful/optimization/app/components/RichText.kt diff --git a/implementations/android-sdk/app/src/main/kotlin/com/contentful/optimization/app/screens/LiveUpdatesTestScreen.kt b/implementations/android-sdk/compose/src/main/kotlin/com/contentful/optimization/app/screens/LiveUpdatesTestScreen.kt similarity index 99% rename from implementations/android-sdk/app/src/main/kotlin/com/contentful/optimization/app/screens/LiveUpdatesTestScreen.kt rename to implementations/android-sdk/compose/src/main/kotlin/com/contentful/optimization/app/screens/LiveUpdatesTestScreen.kt index 8701780c..8cd27861 100644 --- a/implementations/android-sdk/app/src/main/kotlin/com/contentful/optimization/app/screens/LiveUpdatesTestScreen.kt +++ b/implementations/android-sdk/compose/src/main/kotlin/com/contentful/optimization/app/screens/LiveUpdatesTestScreen.kt @@ -26,7 +26,7 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import com.contentful.optimization.app.ContentfulFetcher +import com.contentful.optimization.shared.ContentfulFetcher import com.contentful.optimization.compose.LocalOptimizationClient import com.contentful.optimization.compose.LocalTrackingConfig import com.contentful.optimization.compose.OptimizationLazyColumn diff --git a/implementations/android-sdk/app/src/main/kotlin/com/contentful/optimization/app/screens/MainScreen.kt b/implementations/android-sdk/compose/src/main/kotlin/com/contentful/optimization/app/screens/MainScreen.kt similarity index 95% rename from implementations/android-sdk/app/src/main/kotlin/com/contentful/optimization/app/screens/MainScreen.kt rename to implementations/android-sdk/compose/src/main/kotlin/com/contentful/optimization/app/screens/MainScreen.kt index cf4ce4cd..24085716 100644 --- a/implementations/android-sdk/app/src/main/kotlin/com/contentful/optimization/app/screens/MainScreen.kt +++ b/implementations/android-sdk/compose/src/main/kotlin/com/contentful/optimization/app/screens/MainScreen.kt @@ -22,9 +22,9 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.platform.testTag import androidx.compose.ui.unit.dp -import com.contentful.optimization.app.AppConfig -import com.contentful.optimization.app.ContentfulFetcher -import com.contentful.optimization.app.EventStore +import com.contentful.optimization.shared.AppConfig +import com.contentful.optimization.shared.ContentfulFetcher +import com.contentful.optimization.shared.EventStore import com.contentful.optimization.app.components.AnalyticsEventDisplay import com.contentful.optimization.app.components.ContentEntryView import com.contentful.optimization.app.components.NestedContentEntryView @@ -36,7 +36,7 @@ import kotlinx.coroutines.launch import org.json.JSONObject @Composable -fun MainScreen(simulateOffline: Boolean = false) { +fun MainScreen() { val client = LocalOptimizationClient.current val state by client.state.collectAsState() val scope = rememberCoroutineScope() @@ -51,9 +51,6 @@ fun MainScreen(simulateOffline: Boolean = false) { EventStore.subscribe(client.events, scope) client.consent(true) try { client.page(mapOf("url" to "app")) } catch (_: Exception) {} - if (simulateOffline) { - client.setOnline(false) - } } val profileKey = remember(state.profile) { diff --git a/implementations/android-sdk/app/src/main/kotlin/com/contentful/optimization/app/screens/NavigationTestScreen.kt b/implementations/android-sdk/compose/src/main/kotlin/com/contentful/optimization/app/screens/NavigationTestScreen.kt similarity index 100% rename from implementations/android-sdk/app/src/main/kotlin/com/contentful/optimization/app/screens/NavigationTestScreen.kt rename to implementations/android-sdk/compose/src/main/kotlin/com/contentful/optimization/app/screens/NavigationTestScreen.kt diff --git a/implementations/android-sdk/app/src/main/res/values/themes.xml b/implementations/android-sdk/compose/src/main/res/values/themes.xml similarity index 100% rename from implementations/android-sdk/app/src/main/res/values/themes.xml rename to implementations/android-sdk/compose/src/main/res/values/themes.xml diff --git a/implementations/android-sdk/package.json b/implementations/android-sdk/package.json index 3871cf4c..e008df52 100644 --- a/implementations/android-sdk/package.json +++ b/implementations/android-sdk/package.json @@ -3,10 +3,14 @@ "version": "0.0.0", "private": true, "scripts": { - "build": "./gradlew :app:assembleDebug", - "build:release": "./gradlew :app:assembleRelease", + "build": "./gradlew :compose:assembleDebug", + "build:compose": "./gradlew :compose:assembleDebug", + "build:views": "./gradlew :views:assembleDebug", + "build:apks": "./gradlew :compose:assembleDebug :views:assembleDebug", + "build:release": "./gradlew :compose:assembleRelease", "bootstrap": "./scripts/bootstrap.sh", - "test:ui": "./gradlew :uitests:connectedAndroidTest", - "test:e2e": "./scripts/run-e2e.sh" + "test:e2e": "./scripts/run-e2e.sh", + "test:e2e:compose": "APP_PACKAGE=com.contentful.optimization.app ./scripts/run-e2e.sh", + "test:e2e:views": "APP_PACKAGE=com.contentful.optimization.app.views ./scripts/run-e2e.sh" } } diff --git a/implementations/android-sdk/settings.gradle.kts b/implementations/android-sdk/settings.gradle.kts index 825cd9ae..fd04f872 100644 --- a/implementations/android-sdk/settings.gradle.kts +++ b/implementations/android-sdk/settings.gradle.kts @@ -16,7 +16,9 @@ dependencyResolutionManagement { rootProject.name = "OptimizationAndroidApp" -include(":app") +include(":compose") +include(":views") +include(":shared") include(":uitests") include(":ContentfulOptimization") project(":ContentfulOptimization").projectDir = diff --git a/implementations/android-sdk/shared/build.gradle.kts b/implementations/android-sdk/shared/build.gradle.kts new file mode 100644 index 00000000..5052ea94 --- /dev/null +++ b/implementations/android-sdk/shared/build.gradle.kts @@ -0,0 +1,34 @@ +plugins { + id("com.android.library") + id("org.jetbrains.kotlin.android") +} + +android { + namespace = "com.contentful.optimization.shared" + compileSdk = 36 + + defaultConfig { + minSdk = 24 + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_11 + targetCompatibility = JavaVersion.VERSION_11 + } +} + +kotlin { + compilerOptions { + jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_11) + } +} + +dependencies { + // Pulls in the OptimizationClient core API used by RichText.resolveText() and the + // preview-contentful interfaces consumed by MockPreviewContentfulClient. + api(project(":ContentfulOptimization")) + + implementation("com.squareup.okhttp3:okhttp:4.12.0") + implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.1") + implementation("org.json:json:20240303") +} diff --git a/implementations/android-sdk/shared/src/main/AndroidManifest.xml b/implementations/android-sdk/shared/src/main/AndroidManifest.xml new file mode 100644 index 00000000..b2d3ea12 --- /dev/null +++ b/implementations/android-sdk/shared/src/main/AndroidManifest.xml @@ -0,0 +1,2 @@ + + diff --git a/implementations/android-sdk/shared/src/main/kotlin/com/contentful/optimization/shared/AppConfig.kt b/implementations/android-sdk/shared/src/main/kotlin/com/contentful/optimization/shared/AppConfig.kt new file mode 100644 index 00000000..f2175d21 --- /dev/null +++ b/implementations/android-sdk/shared/src/main/kotlin/com/contentful/optimization/shared/AppConfig.kt @@ -0,0 +1,34 @@ +package com.contentful.optimization.shared + +object AppConfig { + const val clientId = "mock-client-id" + const val environment = "master" + + // The mock API server runs on the HOST machine. From inside an Android + // emulator, `localhost`/`127.0.0.1` is the emulator's OWN loopback, not the + // host — reaching the host that way requires an `adb reverse tcp:8000` + // forward. That forward is NOT persistent: any `adbd`/adb-server restart + // (common on loaded CI emulators, and observed mid-run on the Namespace + // x86_64 runner) silently drops it, after which every host call fails with + // "Connection refused". Navigation flows survived; everything that resolves + // content over the network did not. `10.0.2.2` is the emulator's stable, + // built-in alias for the host loopback and needs no adb forward, so it is + // immune to that churn on every architecture (arm64 locally, x86_64 in CI). + const val mockHost = "http://10.0.2.2:8000" + const val experienceBaseUrl = "$mockHost/experience/" + const val insightsBaseUrl = "$mockHost/insights/" + + const val contentfulBaseUrl = "$mockHost/contentful/" + const val contentfulSpaceId = "mock-space-id" + + val entryIds = listOf( + "1MwiFl4z7gkwqGYdvCmr8c", + "4ib0hsHWoSOnCVdDkizE8d", + "xFwgG3oNaOcjzWiGe4vXo", + "2Z2WLOx07InSewC3LUB3eX", + "5XHssysWUDECHzKLzoIsg1", + "6zqoWXyiSrf0ja7I2WGtYj", + "7pa5bOx8Z9NmNcr7mISvD", + "1JAU028vQ7v6nB2swl3NBo", + ) +} diff --git a/implementations/android-sdk/app/src/main/kotlin/com/contentful/optimization/app/ContentfulFetcher.kt b/implementations/android-sdk/shared/src/main/kotlin/com/contentful/optimization/shared/ContentfulFetcher.kt similarity index 64% rename from implementations/android-sdk/app/src/main/kotlin/com/contentful/optimization/app/ContentfulFetcher.kt rename to implementations/android-sdk/shared/src/main/kotlin/com/contentful/optimization/shared/ContentfulFetcher.kt index d4af05c9..c0b5d484 100644 --- a/implementations/android-sdk/app/src/main/kotlin/com/contentful/optimization/app/ContentfulFetcher.kt +++ b/implementations/android-sdk/shared/src/main/kotlin/com/contentful/optimization/shared/ContentfulFetcher.kt @@ -1,25 +1,58 @@ -package com.contentful.optimization.app +package com.contentful.optimization.shared +import android.util.Log import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay import kotlinx.coroutines.withContext import okhttp3.OkHttpClient import okhttp3.Request import org.json.JSONArray import org.json.JSONObject +import java.util.concurrent.TimeUnit object ContentfulFetcher { - private val httpClient = OkHttpClient() + private const val TAG = "ContentfulFetcher" + private const val MAX_ATTEMPTS = 3 + private const val RETRY_BACKOFF_MS = 250L + + // Generous timeouts plus a small retry loop keep this fetch path deterministic on a + // loaded CI emulator, where the first fetch after activity launch could otherwise time + // out under OkHttp's 10s defaults and silently return null — leaving AppConfig entries + // unrendered. The mock is reached via the emulator host alias `10.0.2.2` (see + // AppConfig.mockHost), so this no longer rides the fragile `adb reverse` tunnel. + private val httpClient = OkHttpClient.Builder() + .connectTimeout(30, TimeUnit.SECONDS) + .readTimeout(30, TimeUnit.SECONDS) + .writeTimeout(30, TimeUnit.SECONDS) + .callTimeout(45, TimeUnit.SECONDS) + .build() suspend fun fetchEntries(ids: List): List> { val entries = mutableListOf>() for (id in ids) { - fetchEntry(id)?.let { entries.add(it) } + val entry = fetchEntry(id) + if (entry != null) { + entries.add(entry) + } else { + Log.w(TAG, "fetchEntries: dropped entry id=$id (all attempts returned null)") + } } return entries } private suspend fun fetchEntry(id: String): Map? { + repeat(MAX_ATTEMPTS) { attempt -> + val result = fetchEntryOnce(id, attempt) + if (result != null) return result + if (attempt < MAX_ATTEMPTS - 1) { + delay(RETRY_BACKOFF_MS * (attempt + 1)) + } + } + return null + } + + private suspend fun fetchEntryOnce(id: String, attempt: Int): Map? { val url = "${AppConfig.contentfulBaseUrl}spaces/${AppConfig.contentfulSpaceId}" + "/environments/${AppConfig.environment}/entries?sys.id=$id&include=10" @@ -27,16 +60,25 @@ object ContentfulFetcher { try { val request = Request.Builder().url(url).build() val response = httpClient.newCall(request).execute() - val body = response.body?.string() ?: return@withContext null + val code = response.code + val body = response.body?.string() + if (body == null) { + Log.w(TAG, "fetchEntry[$id] attempt=$attempt: empty body (status=$code)") + return@withContext null + } val json = JSONObject(body) - val items = json.optJSONArray("items") ?: return@withContext null - if (items.length() == 0) return@withContext null + val items = json.optJSONArray("items") + if (items == null || items.length() == 0) { + Log.w(TAG, "fetchEntry[$id] attempt=$attempt: no items (status=$code, body length=${body.length})") + return@withContext null + } val entry = jsonObjectToMap(items.getJSONObject(0)) val includes = json.optJSONObject("includes")?.let { jsonObjectToMap(it) } resolveLinks(entry, includes) - } catch (_: Exception) { + } catch (e: Exception) { + Log.w(TAG, "fetchEntry[$id] attempt=$attempt: ${e.javaClass.simpleName}: ${e.message}") null } } diff --git a/implementations/android-sdk/app/src/main/kotlin/com/contentful/optimization/app/EventStore.kt b/implementations/android-sdk/shared/src/main/kotlin/com/contentful/optimization/shared/EventStore.kt similarity index 98% rename from implementations/android-sdk/app/src/main/kotlin/com/contentful/optimization/app/EventStore.kt rename to implementations/android-sdk/shared/src/main/kotlin/com/contentful/optimization/shared/EventStore.kt index 91de000d..4e2626a4 100644 --- a/implementations/android-sdk/app/src/main/kotlin/com/contentful/optimization/app/EventStore.kt +++ b/implementations/android-sdk/shared/src/main/kotlin/com/contentful/optimization/shared/EventStore.kt @@ -1,4 +1,4 @@ -package com.contentful.optimization.app +package com.contentful.optimization.shared import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job diff --git a/implementations/android-sdk/app/src/main/kotlin/com/contentful/optimization/app/MockPreviewContentfulClient.kt b/implementations/android-sdk/shared/src/main/kotlin/com/contentful/optimization/shared/MockPreviewContentfulClient.kt similarity index 98% rename from implementations/android-sdk/app/src/main/kotlin/com/contentful/optimization/app/MockPreviewContentfulClient.kt rename to implementations/android-sdk/shared/src/main/kotlin/com/contentful/optimization/shared/MockPreviewContentfulClient.kt index 79b44fa7..5f4a4566 100644 --- a/implementations/android-sdk/app/src/main/kotlin/com/contentful/optimization/app/MockPreviewContentfulClient.kt +++ b/implementations/android-sdk/shared/src/main/kotlin/com/contentful/optimization/shared/MockPreviewContentfulClient.kt @@ -1,4 +1,4 @@ -package com.contentful.optimization.app +package com.contentful.optimization.shared import com.contentful.optimization.preview.ContentfulEntriesResult import com.contentful.optimization.preview.ContentfulIncludes diff --git a/implementations/android-sdk/shared/src/main/kotlin/com/contentful/optimization/shared/RichText.kt b/implementations/android-sdk/shared/src/main/kotlin/com/contentful/optimization/shared/RichText.kt new file mode 100644 index 00000000..10f13d4e --- /dev/null +++ b/implementations/android-sdk/shared/src/main/kotlin/com/contentful/optimization/shared/RichText.kt @@ -0,0 +1,81 @@ +package com.contentful.optimization.shared + +import com.contentful.optimization.core.OptimizationClient + +/** + * Flattens a Contentful Rich Text document into a plain display string, + * resolving inline merge-tag entries against the current profile. + * + * Mirrors the iOS app's `RichText` so the flattened text matches byte for byte: + * top-level nodes are joined with a single space, a node's children with the + * empty string. + */ +@Suppress("UNCHECKED_CAST") +object RichText { + + /** True when [field] is a Rich Text document node rather than a plain string. */ + fun isRichTextDocument(field: Any?): Boolean { + val dict = field as? Map<*, *> ?: return false + return dict["nodeType"] == "document" && dict["content"] is List<*> + } + + /** + * Resolve an entry's `text` field to a display string: flatten a Rich Text + * document (resolving merge tags), pass a plain string through, otherwise + * fall back to `"No content"`. + */ + suspend fun resolveText(field: Any?, client: OptimizationClient): String { + if (isRichTextDocument(field)) { + return flatten(field as Map, client) + } + return field as? String ?: "No content" + } + + private suspend fun flatten(document: Map, client: OptimizationClient): String { + val content = document["content"] as? List<*> ?: return "" + val parts = mutableListOf() + for (node in content.mapNotNull { it as? Map }) { + parts.add(extractText(node, client)) + } + return parts.joinToString(" ") + } + + private suspend fun extractText(node: Map, client: OptimizationClient): String { + return when (node["nodeType"]) { + "text" -> node["value"] as? String ?: "" + "embedded-entry-inline" -> resolveEmbeddedEntry(node, client) + else -> { + val content = node["content"] as? List<*> ?: return "" + val parts = mutableListOf() + for (child in content.mapNotNull { it as? Map }) { + parts.add(extractText(child, client)) + } + parts.joinToString("") + } + } + } + + private suspend fun resolveEmbeddedEntry( + node: Map, + client: OptimizationClient, + ): String { + val data = node["data"] as? Map ?: return "[Merge Tag]" + val target = data["target"] as? Map ?: return "[Merge Tag]" + val sys = target["sys"] as? Map ?: return "[Merge Tag]" + + // A still-unresolved Link means the fetcher did not inline the entry; + // there is nothing to resolve against. + if (sys["type"] == "Link") return "[Merge Tag]" + + val contentTypeSys = + (sys["contentType"] as? Map)?.get("sys") as? Map + if (contentTypeSys?.get("id") != "nt_mergetag") return "[Merge Tag]" + + val resolved = client.getMergeTagValue(target) + if (!resolved.isNullOrEmpty()) return resolved + + // Fall back to the merge tag's configured fallback value. + val fields = target["fields"] as? Map + return fields?.get("nt_fallback") as? String ?: "[Merge Tag]" + } +} diff --git a/implementations/android-sdk/views/build.gradle.kts b/implementations/android-sdk/views/build.gradle.kts new file mode 100644 index 00000000..0cf4d0a3 --- /dev/null +++ b/implementations/android-sdk/views/build.gradle.kts @@ -0,0 +1,51 @@ +plugins { + id("com.android.application") + id("org.jetbrains.kotlin.android") +} + +android { + namespace = "com.contentful.optimization.app.views" + compileSdk = 36 + + defaultConfig { + // Distinct applicationId from the Compose impl so UI Automator can target each independently + // by package name (per the APP_PACKAGE instrumentation argument set up in Step 5). + applicationId = "com.contentful.optimization.app.views" + minSdk = 24 + targetSdk = 35 + versionCode = 1 + versionName = "1.0" + } + + buildTypes { + release { + isMinifyEnabled = false + } + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_11 + targetCompatibility = JavaVersion.VERSION_11 + } +} + +kotlin { + compilerOptions { + jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_11) + } +} + +dependencies { + implementation(project(":ContentfulOptimization")) + implementation(project(":shared")) + + implementation("androidx.appcompat:appcompat:1.7.0") + implementation("androidx.core:core-ktx:1.13.1") + implementation("androidx.activity:activity-ktx:1.9.3") + implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.8.7") + implementation("androidx.constraintlayout:constraintlayout:2.1.4") + implementation("androidx.recyclerview:recyclerview:1.3.2") + implementation("com.google.android.material:material:1.12.0") + + implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.1") +} diff --git a/implementations/android-sdk/views/src/main/AndroidManifest.xml b/implementations/android-sdk/views/src/main/AndroidManifest.xml new file mode 100644 index 00000000..e5c19bbf --- /dev/null +++ b/implementations/android-sdk/views/src/main/AndroidManifest.xml @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + diff --git a/implementations/android-sdk/views/src/main/kotlin/com/contentful/optimization/app/views/LiveUpdatesTestActivity.kt b/implementations/android-sdk/views/src/main/kotlin/com/contentful/optimization/app/views/LiveUpdatesTestActivity.kt new file mode 100644 index 00000000..fce1ee14 --- /dev/null +++ b/implementations/android-sdk/views/src/main/kotlin/com/contentful/optimization/app/views/LiveUpdatesTestActivity.kt @@ -0,0 +1,250 @@ +package com.contentful.optimization.app.views + +import android.os.Bundle +import android.view.View +import android.widget.Button +import android.widget.LinearLayout +import android.widget.TextView +import androidx.appcompat.app.AppCompatActivity +import androidx.lifecycle.lifecycleScope +import com.contentful.optimization.app.views.support.setTestTag +import com.contentful.optimization.shared.ContentfulFetcher +import com.contentful.optimization.views.OptimizationManager +import com.contentful.optimization.views.OptimizedEntryView +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch + +/** + * View-based counterpart of `LiveUpdatesTestScreen`. + * + * Holds three [OptimizedEntryView] slots that exercise the three live-update modes + * (default / live / locked), plus the toggle controls and status labels the existing + * `LiveUpdatesTests` UI Automator suite asserts against. + */ +class LiveUpdatesTestActivity : AppCompatActivity() { + + private val client get() = OptimizationManager.client + + private lateinit var closeButton: Button + private lateinit var identifyButton: Button + private lateinit var resetButton: Button + private lateinit var toggleGlobalButton: Button + private lateinit var simulatePreviewButton: Button + + private lateinit var identifiedStatus: TextView + private lateinit var globalStatus: TextView + private lateinit var previewPanelStatus: TextView + + private lateinit var defaultSlot: OptimizedEntryView + private lateinit var liveSlot: OptimizedEntryView + private lateinit var lockedSlot: OptimizedEntryView + + private var globalLiveUpdates = false + private var isPreviewPanelSimulated = false + private var isIdentified = false + private var loadedEntry: Map? = null + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContentView(R.layout.activity_live_updates_test) + + bindViews() + applyTestTags() + wireButtons() + + // Compose's LiveUpdatesTestScreen is mounted/unmounted within a single Activity, so the + // bridge's preview-panel flag and the locked state of OptimizedEntry composables reset + // naturally between visits via Compose's `key(..., isPreviewPanelSimulated)` block. The + // Views impl is a separate Activity reused across UI Automator test cases, so we + // explicitly close the SDK preview panel here to mirror that fresh-start contract. + // Without this, a prior test that toggled the panel can leave it open in the bridge, + // which forces shouldLive=true for the locked slots in the next test and breaks the + // "locked must not update after identify" assertions. + OptimizationManager.client.setPreviewPanelOpen(false) + + loadEntry() + } + + private fun bindViews() { + closeButton = findViewById(R.id.close_live_updates_test_button) + identifyButton = findViewById(R.id.live_updates_identify_button) + resetButton = findViewById(R.id.live_updates_reset_button) + toggleGlobalButton = findViewById(R.id.toggle_global_live_updates_button) + simulatePreviewButton = findViewById(R.id.simulate_preview_panel_button) + + identifiedStatus = findViewById(R.id.identified_status) + globalStatus = findViewById(R.id.global_live_updates_status) + previewPanelStatus = findViewById(R.id.preview_panel_status) + + defaultSlot = findViewById(R.id.default_slot) + liveSlot = findViewById(R.id.live_slot) + lockedSlot = findViewById(R.id.locked_slot) + } + + private fun applyTestTags() { + findViewById(R.id.live_updates_scroll_view).setTestTag("live-updates-scroll-view") + closeButton.setTestTag("close-live-updates-test-button") + identifyButton.setTestTag("live-updates-identify-button") + resetButton.setTestTag("live-updates-reset-button") + toggleGlobalButton.setTestTag("toggle-global-live-updates-button") + simulatePreviewButton.setTestTag("simulate-preview-panel-button") + + identifiedStatus.setTestTag("identified-status") + globalStatus.setTestTag("global-live-updates-status") + previewPanelStatus.setTestTag("preview-panel-status") + + defaultSlot.accessibilityIdentifier = "default-personalization" + liveSlot.accessibilityIdentifier = "live-personalization" + lockedSlot.accessibilityIdentifier = "locked-personalization" + } + + private fun wireButtons() { + closeButton.setOnClickListener { finish() } + identifyButton.setOnClickListener { handleIdentify() } + resetButton.setOnClickListener { handleReset() } + toggleGlobalButton.setOnClickListener { toggleGlobalLiveUpdates() } + simulatePreviewButton.setOnClickListener { togglePreviewPanelSimulation() } + } + + private fun loadEntry() { + lifecycleScope.launch { + // Wait for the SDK to populate selectedPersonalizations before mounting any + // OptimizedEntryView. Compose renders LiveUpdatesTestScreen inside the same + // Activity as MainScreen, so by the time the user taps the test button, the + // bridge has already emitted a non-null personalizations value and the screen's + // `liveUpdates = false` slots lock onto a variant on their first collect. The + // Views impl uses a separate Activity, and without this gate the slots see the + // initial `null` emission first and publish baseline content, then lock onto the + // variant on the second emission — the test then sees the entry id change after + // identify even though the slot is supposedly locked. + OptimizationManager.client.selectedPersonalizations.first { it != null } + val entries = ContentfulFetcher.fetchEntries(listOf("2Z2WLOx07InSewC3LUB3eX")) + loadedEntry = entries.firstOrNull() ?: return@launch + attachSlotRenderers() + renderSlots() + } + } + + private fun attachSlotRenderers() { + defaultSlot.setContentRenderer { resolvedEntry -> + renderEntryDisplay(resolvedEntry, prefix = "default") + } + liveSlot.setContentRenderer { resolvedEntry -> + renderEntryDisplay(resolvedEntry, prefix = "live") + } + lockedSlot.setContentRenderer { resolvedEntry -> + renderEntryDisplay(resolvedEntry, prefix = "locked") + } + } + + private fun renderSlots() { + val entry = loadedEntry ?: return + + // Match the Compose semantics: default slot inherits the global setting, the live and + // locked slots pin explicitly. Passing `liveUpdates = null` to the default slot leaves + // it free to fall back to OptimizationManager.liveUpdates — but the global toggle here + // is a per-screen value, not a global SDK default. So we feed the screen's + // globalLiveUpdates into the default slot explicitly. + defaultSlot.liveUpdates = globalLiveUpdates + defaultSlot.setEntry(entry) + + liveSlot.liveUpdates = true + liveSlot.setEntry(entry) + + lockedSlot.liveUpdates = false + lockedSlot.setEntry(entry) + } + + private fun renderEntryDisplay(entry: Map, prefix: String): View { + @Suppress("UNCHECKED_CAST") + val fields = entry["fields"] as? Map + val text = fields?.get("text") as? String ?: "No content" + + @Suppress("UNCHECKED_CAST") + val sys = entry["sys"] as? Map + val entryId = sys?.get("id") as? String ?: "" + + val column = LinearLayout(this).apply { + orientation = LinearLayout.VERTICAL + setTestTag("$prefix-container") + } + + column.addView( + TextView(this).apply { + this.text = text + contentDescription = text + setTestTag("$prefix-text") + }, + ) + val entryLabel = "Entry: $entryId" + column.addView( + TextView(this).apply { + this.text = entryLabel + contentDescription = entryLabel + setTestTag("$prefix-entry-id") + }, + ) + return column + } + + private fun handleIdentify() { + lifecycleScope.launch { + try { + client.identify( + userId = "charles", + traits = mapOf("identified" to true), + ) + } catch (_: Exception) { + } + isIdentified = true + applyIdentifiedUI() + } + } + + private fun handleReset() { + client.reset() + lifecycleScope.launch { + try { + client.page(mapOf("url" to "live-updates-test")) + } catch (_: Exception) { + } + isIdentified = false + applyIdentifiedUI() + } + } + + private fun applyIdentifiedUI() { + identifyButton.visibility = if (isIdentified) View.GONE else View.VISIBLE + resetButton.visibility = if (isIdentified) View.VISIBLE else View.GONE + val label = if (isIdentified) "Yes" else "No" + identifiedStatus.text = label + identifiedStatus.contentDescription = label + } + + private fun toggleGlobalLiveUpdates() { + globalLiveUpdates = !globalLiveUpdates + toggleGlobalButton.text = if (globalLiveUpdates) "Global: ON" else "Global: OFF" + val label = if (globalLiveUpdates) "ON" else "OFF" + globalStatus.text = label + globalStatus.contentDescription = label + // Restart the default slot so the new global setting takes effect mid-screen, mirroring + // the Compose `key(globalLiveUpdates, ...)` recomposition. + loadedEntry?.let { + defaultSlot.liveUpdates = globalLiveUpdates + defaultSlot.setEntry(it) + } + } + + private fun togglePreviewPanelSimulation() { + isPreviewPanelSimulated = !isPreviewPanelSimulated + simulatePreviewButton.text = + if (isPreviewPanelSimulated) "Close Preview Panel" else "Simulate Preview Panel" + val label = if (isPreviewPanelSimulated) "Open" else "Closed" + previewPanelStatus.text = label + previewPanelStatus.contentDescription = label + // Drive the SDK preview-panel flag so the OptimizedEntryView observation loop switches + // every slot — including the locked one — into live-update mode while open. Matches + // the Compose path's `client.setPreviewPanelOpen(...)` call. + client.setPreviewPanelOpen(isPreviewPanelSimulated) + } +} diff --git a/implementations/android-sdk/views/src/main/kotlin/com/contentful/optimization/app/views/MainActivity.kt b/implementations/android-sdk/views/src/main/kotlin/com/contentful/optimization/app/views/MainActivity.kt new file mode 100644 index 00000000..20f15605 --- /dev/null +++ b/implementations/android-sdk/views/src/main/kotlin/com/contentful/optimization/app/views/MainActivity.kt @@ -0,0 +1,248 @@ +package com.contentful.optimization.app.views + +import android.content.Context +import android.content.Intent +import android.os.Bundle +import android.view.View +import android.widget.Button +import android.widget.LinearLayout +import android.widget.TextView +import androidx.appcompat.app.AppCompatActivity +import androidx.lifecycle.lifecycleScope +import com.contentful.optimization.app.views.components.AnalyticsEventDisplayBinder +import com.contentful.optimization.app.views.components.ContentEntryViewBinder +import com.contentful.optimization.app.views.components.NestedContentEntryViewBinder +import com.contentful.optimization.app.views.components.isNestedContent +import com.contentful.optimization.app.views.support.setTestTag +import com.contentful.optimization.core.OptimizationConfig +import com.contentful.optimization.preview.PreviewPanelConfig +import com.contentful.optimization.shared.AppConfig +import com.contentful.optimization.shared.ContentfulFetcher +import com.contentful.optimization.shared.EventStore +import com.contentful.optimization.shared.MockPreviewContentfulClient +import com.contentful.optimization.shared.RichText +import com.contentful.optimization.views.OptimizationManager +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch + +/** + * View-based counterpart of the Compose `MainScreen` + `MainActivity` pairing. + * + * Hosts the entry list, the action row (Identify/Reset, Navigation Test, Live Updates Test), and + * the analytics-events display. Mirrors the Compose path so the existing UI Automator tests + * (which look up `By.res("identify-button")`, `By.res("main-scroll-view")`, etc.) work unchanged. + */ +class MainActivity : AppCompatActivity() { + + private val client get() = OptimizationManager.client + + private lateinit var identifyButton: Button + private lateinit var resetButton: Button + private lateinit var navigationTestButton: Button + private lateinit var liveUpdatesTestButton: Button + private lateinit var scrollView: View + private lateinit var entriesContainer: LinearLayout + private lateinit var loadingIndicator: TextView + + private var analyticsDisplayBinder: AnalyticsEventDisplayBinder? = null + private var isIdentified: Boolean = false + private var entriesLoaded: Boolean = false + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + if (intent.getBooleanExtra("reset", false)) { + getSharedPreferences("com.contentful.optimization", Context.MODE_PRIVATE) + .edit() + .clear() + .apply() + } + + // Initialize the SDK after the reset check so a `--ez reset true` cold start clears the + // persisted profile before the bridge reads it. OptimizationManager.initialize is + // idempotent across activities, so launching NavigationTestActivity / LiveUpdatesTestActivity + // before MainActivity's onCreate has finished is still safe. + OptimizationManager.initialize( + context = this, + config = OptimizationConfig( + clientId = AppConfig.clientId, + environment = AppConfig.environment, + experienceBaseUrl = AppConfig.experienceBaseUrl, + insightsBaseUrl = AppConfig.insightsBaseUrl, + debug = true, + ), + trackViews = true, + trackTaps = true, + previewPanel = PreviewPanelConfig( + contentfulClient = MockPreviewContentfulClient(), + ), + ) + + setContentView(R.layout.activity_main) + + // Attach the preview-panel floating button synchronously so it's visible on the same + // frame as the action row. testFABIsVisible uses a no-wait findObject(By.desc(...)) so + // the FAB has to be in the accessibility tree by the time the @Before setUp's wait for + // the identify button returns. The Compose impl gets this for free because + // OptimizationRoot/PreviewPanelOverlay synthesizes the FAB inside the initial + // composition. OptimizationManager.attachPreviewPanel only needs the OptimizationClient + // reference (already non-null after initialize() returns), not the bridge having + // finished its async initialize() — the FAB tap won't open PreviewPanelActivity until + // the user actually taps it, by which time init has settled. + OptimizationManager.attachPreviewPanel(this) + + identifyButton = findViewById(R.id.identify_button) + resetButton = findViewById(R.id.reset_button) + navigationTestButton = findViewById(R.id.navigation_test_button) + liveUpdatesTestButton = findViewById(R.id.live_updates_test_button) + scrollView = findViewById(R.id.main_scroll_view) + entriesContainer = findViewById(R.id.entries_container) + loadingIndicator = findViewById(R.id.loading_indicator) + + // testTags must match the Compose `Modifier.testTag(...)` strings byte-for-byte so the + // shared UI Automator suite resolves them identically across both apps. + identifyButton.setTestTag("identify-button") + resetButton.setTestTag("reset-button") + navigationTestButton.setTestTag("navigation-test-button") + liveUpdatesTestButton.setTestTag("live-updates-test-button") + scrollView.setTestTag("main-scroll-view") + + identifyButton.setOnClickListener { handleIdentify() } + resetButton.setOnClickListener { handleReset() } + navigationTestButton.setOnClickListener { + startActivity(Intent(this, NavigationTestActivity::class.java)) + } + liveUpdatesTestButton.setOnClickListener { + startActivity(Intent(this, LiveUpdatesTestActivity::class.java)) + } + + // Mirrors MainScreen.LaunchedEffect(Unit): subscribe events, consent, page, optional + // offline. The Compose impl gates rendering on `client.isInitialized` via + // OptimizationRoot, so its content's LaunchedEffects always see an initialized client. + // The Views impl renders immediately, so we wait for init here before driving the SDK — + // otherwise consent/page silently no-op and the profile state flow never advances. + EventStore.subscribe(client.events, lifecycleScope) + lifecycleScope.launch { + client.isInitialized.first { it } + client.consent(true) + try { + client.page(mapOf("url" to "app")) + } catch (_: Exception) { + } + } + + observeProfileForEntries() + } + + private fun observeProfileForEntries() { + lifecycleScope.launch { + client.state.collect { state -> + val profile = state.profile + val identified = + @Suppress("UNCHECKED_CAST") + (profile?.get("traits") as? Map)?.get("identified") == true + updateIdentifiedUI(identified) + + if (profile == null) return@collect + + // Fetch + render entries exactly once per Activity lifetime. Subsequent profile + // emissions (consent updates, identify, etc.) flow through the SDK and update + // personalizations on existing OptimizedEntryView instances; recreating the + // entry list here would tear down view-tracking controllers mid-dwell and miss + // component events, which doesn't happen on the Compose side because Compose's + // diffing keeps existing nodes when the data is identical. + if (entriesLoaded) return@collect + entriesLoaded = true + client.subscribeToFlag("boolean") + val entries = ContentfulFetcher.fetchEntries(AppConfig.entryIds) + // Pre-resolve each entry's rich text on the (suspending) coroutine + // path BEFORE handing the entry to the synchronous view binder. This + // mirrors the iOS pattern where `RichText.resolveText` is a synchronous + // function (no `await`), so UILabel.text is set at construction time + // and the view's measured height is stable from the first layout pass. + // The Views path's previous behavior — async resolution inside an + // `onViewAttachedToWindow` listener — caused the merge-tag entry's + // TextView to grow from a "No content" placeholder to its resolved text + // height (164 → 207 px observed) up to ~1 s after `OptimizedEntryView` + // had already fired `ViewTrackingController.onBecameVisible` at the + // smaller height, and that height growth combined with the test's + // scroll-to-stats swipe dropped the visible ratio below the 0.8 + // threshold, hitting `onBecameInvisible` while `attempts == 0` and + // resetting the 2s dwell cycle — so the entry's `trackView` event + // never fired and `component-stats-1MwiFl4z…` never appeared. + val resolvedBaselineTexts = entries.map { entry -> + @Suppress("UNCHECKED_CAST") + val fields = entry["fields"] as? Map + RichText.resolveText(fields?.get("text"), client) + } + renderEntries(entries, resolvedBaselineTexts) + } + } + } + + private fun updateIdentifiedUI(identified: Boolean) { + if (identified == isIdentified) return + isIdentified = identified + identifyButton.visibility = if (identified) View.GONE else View.VISIBLE + resetButton.visibility = if (identified) View.VISIBLE else View.GONE + } + + private fun handleIdentify() { + // Activity render is not gated on isInitialized, so the user can tap Identify before + // the bridge finishes booting. client.identify requires an initialized client and would + // otherwise throw + get caught silently here, leaving the UI stuck on the Identify state. + lifecycleScope.launch { + client.isInitialized.first { it } + try { + client.identify( + userId = "charles", + traits = mapOf("identified" to true), + ) + } catch (_: Exception) { + } + } + } + + private fun handleReset() { + lifecycleScope.launch { + client.isInitialized.first { it } + client.reset() + try { + client.page(mapOf("url" to "app")) + } catch (_: Exception) { + } + } + } + + private fun renderEntries( + entries: List>, + resolvedBaselineTexts: List = emptyList(), + ) { + if (entries.isEmpty()) { + loadingIndicator.visibility = View.VISIBLE + return + } + loadingIndicator.visibility = View.GONE + entriesContainer.removeAllViews() + + entries.forEachIndexed { index, entry -> + val resolvedText = resolvedBaselineTexts.getOrNull(index) + val child = if (isNestedContent(entry)) { + NestedContentEntryViewBinder.create(this, entry) + } else { + ContentEntryViewBinder.create(this, entry, resolvedText) + } + entriesContainer.addView(child) + } + + // Analytics events display lives at the end of the scrollable content, matching the + // Compose Column layout that places AnalyticsEventDisplay after the entries. + val analyticsContainer = LinearLayout(this).apply { + orientation = LinearLayout.VERTICAL + } + entriesContainer.addView(analyticsContainer) + val binder = AnalyticsEventDisplayBinder(this, analyticsContainer) + binder.attach(lifecycleScope) + analyticsDisplayBinder = binder + } +} diff --git a/implementations/android-sdk/views/src/main/kotlin/com/contentful/optimization/app/views/NavigationTestActivity.kt b/implementations/android-sdk/views/src/main/kotlin/com/contentful/optimization/app/views/NavigationTestActivity.kt new file mode 100644 index 00000000..0d6eb1b2 --- /dev/null +++ b/implementations/android-sdk/views/src/main/kotlin/com/contentful/optimization/app/views/NavigationTestActivity.kt @@ -0,0 +1,133 @@ +package com.contentful.optimization.app.views + +import android.os.Bundle +import android.view.View +import android.widget.Button +import android.widget.TextView +import androidx.activity.OnBackPressedCallback +import androidx.appcompat.app.AppCompatActivity +import androidx.lifecycle.lifecycleScope +import com.contentful.optimization.app.views.support.setTestTag +import com.contentful.optimization.views.OptimizationManager +import com.contentful.optimization.views.ScreenTracker +import kotlinx.coroutines.launch + +/** + * View-based counterpart of `NavigationTestScreen`. Owns three view states (Home, ViewOne, + * ViewTwo) and emits the same `screen` events the Compose `ScreenTrackingEffect` calls do, so + * the existing screen-tracking UI Automator tests resolve identically against both impls. + */ +class NavigationTestActivity : AppCompatActivity() { + + private lateinit var closeButton: Button + private lateinit var homePane: View + private lateinit var viewOnePane: View + private lateinit var viewTwoPane: View + private lateinit var goToViewOneButton: Button + private lateinit var goToViewTwoButton: Button + + private lateinit var homeScreenEventLog: TextView + private lateinit var viewOneLastScreenEvent: TextView + private lateinit var viewOneScreenEventLog: TextView + private lateinit var viewTwoLastScreenEvent: TextView + private lateinit var viewTwoScreenEventLog: TextView + + private val screenLog = mutableListOf() + + private enum class State { HOME, VIEW_ONE, VIEW_TWO } + private var state: State = State.HOME + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContentView(R.layout.activity_navigation_test) + + closeButton = findViewById(R.id.close_navigation_test_button) + homePane = findViewById(R.id.navigation_home) + viewOnePane = findViewById(R.id.navigation_view_one) + viewTwoPane = findViewById(R.id.navigation_view_two) + goToViewOneButton = findViewById(R.id.go_to_view_one_button) + goToViewTwoButton = findViewById(R.id.go_to_view_two_button) + homeScreenEventLog = findViewById(R.id.home_screen_event_log) + viewOneLastScreenEvent = findViewById(R.id.view_one_last_screen_event) + viewOneScreenEventLog = findViewById(R.id.view_one_screen_event_log) + viewTwoLastScreenEvent = findViewById(R.id.view_two_last_screen_event) + viewTwoScreenEventLog = findViewById(R.id.view_two_screen_event_log) + + closeButton.setTestTag("close-navigation-test-button") + goToViewOneButton.setTestTag("go-to-view-one-button") + goToViewTwoButton.setTestTag("go-to-view-two-button") + homePane.setTestTag("navigation-home") + viewOnePane.setTestTag("navigation-view-test-one") + viewTwoPane.setTestTag("navigation-view-test-two") + homeScreenEventLog.setTestTag("screen-event-log") + viewOneLastScreenEvent.setTestTag("last-screen-event") + viewOneScreenEventLog.setTestTag("screen-event-log") + viewTwoLastScreenEvent.setTestTag("last-screen-event") + viewTwoScreenEventLog.setTestTag("screen-event-log") + + closeButton.setOnClickListener { finish() } + goToViewOneButton.setOnClickListener { transitionTo(State.VIEW_ONE) } + goToViewTwoButton.setOnClickListener { transitionTo(State.VIEW_TWO) } + + onBackPressedDispatcher.addCallback( + this, + object : OnBackPressedCallback(true) { + override fun handleOnBackPressed() { + when (state) { + State.VIEW_TWO -> transitionTo(State.VIEW_ONE) + State.VIEW_ONE -> transitionTo(State.HOME) + State.HOME -> finish() + } + } + }, + ) + + observeScreenEvents() + renderPanes() + // Initial screen event matches `ScreenTrackingEffect("NavigationHome")` on the home destination. + ScreenTracker.trackScreen("NavigationHome") + } + + private fun observeScreenEvents() { + lifecycleScope.launch { + OptimizationManager.client.events.collect { event -> + val type = event["type"] as? String ?: return@collect + if (type != "screen" && type != "screenViewEvent") return@collect + val name = event["name"] as? String ?: return@collect + screenLog.add(name) + updateLogTextViews() + } + } + } + + private fun transitionTo(target: State) { + state = target + renderPanes() + when (target) { + State.HOME -> ScreenTracker.trackScreen("NavigationHome") + State.VIEW_ONE -> ScreenTracker.trackScreen("NavigationViewOne") + State.VIEW_TWO -> ScreenTracker.trackScreen("NavigationViewTwo") + } + } + + private fun renderPanes() { + homePane.visibility = if (state == State.HOME) View.VISIBLE else View.GONE + viewOnePane.visibility = if (state == State.VIEW_ONE) View.VISIBLE else View.GONE + viewTwoPane.visibility = if (state == State.VIEW_TWO) View.VISIBLE else View.GONE + } + + private fun updateLogTextViews() { + val joined = screenLog.joinToString(",") + homeScreenEventLog.text = joined + homeScreenEventLog.contentDescription = joined + viewOneScreenEventLog.text = joined + viewOneScreenEventLog.contentDescription = joined + viewTwoScreenEventLog.text = joined + viewTwoScreenEventLog.contentDescription = joined + val last = screenLog.lastOrNull() ?: "" + viewOneLastScreenEvent.text = last + viewOneLastScreenEvent.contentDescription = last + viewTwoLastScreenEvent.text = last + viewTwoLastScreenEvent.contentDescription = last + } +} diff --git a/implementations/android-sdk/views/src/main/kotlin/com/contentful/optimization/app/views/ViewsApplication.kt b/implementations/android-sdk/views/src/main/kotlin/com/contentful/optimization/app/views/ViewsApplication.kt new file mode 100644 index 00000000..4acb8fee --- /dev/null +++ b/implementations/android-sdk/views/src/main/kotlin/com/contentful/optimization/app/views/ViewsApplication.kt @@ -0,0 +1,14 @@ +package com.contentful.optimization.app.views + +import android.app.Application + +/** + * Application class for the Views reference impl. The SDK itself is initialized lazily by + * [MainActivity] so the `--ez reset true` launch flag has a chance to clear the SDK's + * SharedPreferences BEFORE `OptimizationManager.initialize` reads them via the bridge. + * + * Compose handles this implicitly because `OptimizationRoot` constructs the client inside the + * Compose tree, after the activity's `onCreate` has run — preserving the same ordering here + * keeps `clearProfileState`/`relaunchClean` working identically across both reference impls. + */ +class ViewsApplication : Application() diff --git a/implementations/android-sdk/views/src/main/kotlin/com/contentful/optimization/app/views/components/AnalyticsEventDisplayBinder.kt b/implementations/android-sdk/views/src/main/kotlin/com/contentful/optimization/app/views/components/AnalyticsEventDisplayBinder.kt new file mode 100644 index 00000000..e9032525 --- /dev/null +++ b/implementations/android-sdk/views/src/main/kotlin/com/contentful/optimization/app/views/components/AnalyticsEventDisplayBinder.kt @@ -0,0 +1,137 @@ +package com.contentful.optimization.app.views.components + +import android.content.Context +import android.graphics.Typeface +import android.view.View +import android.widget.LinearLayout +import android.widget.TextView +import com.contentful.optimization.app.views.support.setTestTag +import com.contentful.optimization.shared.EventStore +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.launch + +/** + * View-based counterpart of `AnalyticsEventDisplay` from the Compose reference impl. + * + * Renders the analytics events list and per-component statistics into a [LinearLayout]. The + * subscriptions to [EventStore.events] and [EventStore.componentStats] survive for the lifetime + * of the supplied [CoroutineScope] passed to [attach]. + */ +class AnalyticsEventDisplayBinder( + private val context: Context, + private val container: LinearLayout, +) { + private val headerLabel = TextView(context).apply { + text = "Analytics Events" + setTypeface(typeface, Typeface.BOLD) + } + private val eventsCount = TextView(context).apply { + setTestTag("events-count") + } + private val emptyMessage = TextView(context).apply { + text = "No events tracked yet" + setTestTag("no-events-message") + contentDescription = "No events tracked yet" + } + private val eventsList = LinearLayout(context).apply { + orientation = LinearLayout.VERTICAL + } + private val statsList = LinearLayout(context).apply { + orientation = LinearLayout.VERTICAL + } + + init { + val padding = context.dp(16) + container.apply { + orientation = LinearLayout.VERTICAL + setPadding(padding, padding, padding, padding) + setTestTag("analytics-events-container") + } + container.addView(headerLabel) + container.addView(eventsCount) + container.addView(emptyMessage) + container.addView(eventsList) + container.addView(statsList) + } + + fun attach(scope: CoroutineScope) { + scope.launch { + // Combine the two flows so the UI only updates once per state change tuple, matching + // Compose's `collectAsState` semantics where both `events` and `componentStats` + // recompose the same composable. + EventStore.events.combine(EventStore.componentStats) { events, stats -> events to stats } + .collect { (events, stats) -> render(events, stats) } + } + } + + private fun render( + events: List, + stats: Map, + ) { + val countText = "Events: ${events.size}" + eventsCount.text = countText + eventsCount.contentDescription = countText + + emptyMessage.visibility = if (events.isEmpty()) View.VISIBLE else View.GONE + + eventsList.removeAllViews() + val nonComponent = events.filter { it.type != "component" } + nonComponent.forEachIndexed { index, event -> + val testId = if (event.componentId != null) { + "event-${event.type}-${event.componentId}" + } else { + "event-${event.type}-$index" + } + val desc = buildString { + append(event.type) + event.componentId?.let { append(" - Component: $it") } + event.viewDurationMs?.let { append(" - ${it}ms") } + } + val row = TextView(context).apply { + text = desc + contentDescription = desc + setTestTag(testId) + } + eventsList.addView(row) + } + + statsList.removeAllViews() + stats.keys.sorted().forEach { cid -> + val s = stats[cid] ?: return@forEach + val block = LinearLayout(context).apply { + orientation = LinearLayout.VERTICAL + setTestTag("component-stats-$cid") + } + + val countLine = "Count: ${s.count}" + block.addView( + TextView(context).apply { + text = countLine + contentDescription = countLine + setTestTag("event-count-$cid") + }, + ) + + val durationLine = "Duration: ${s.latestViewDurationMs?.toString() ?: "N/A"}" + block.addView( + TextView(context).apply { + text = durationLine + contentDescription = durationLine + setTestTag("event-duration-$cid") + }, + ) + + val viewIdLine = "ViewId: ${s.latestViewId ?: "N/A"}" + block.addView( + TextView(context).apply { + text = viewIdLine + contentDescription = viewIdLine + setTestTag("event-view-id-$cid") + }, + ) + + statsList.addView(block) + } + } +} diff --git a/implementations/android-sdk/views/src/main/kotlin/com/contentful/optimization/app/views/components/ContentEntryViewBinder.kt b/implementations/android-sdk/views/src/main/kotlin/com/contentful/optimization/app/views/components/ContentEntryViewBinder.kt new file mode 100644 index 00000000..6a835ec1 --- /dev/null +++ b/implementations/android-sdk/views/src/main/kotlin/com/contentful/optimization/app/views/components/ContentEntryViewBinder.kt @@ -0,0 +1,131 @@ +package com.contentful.optimization.app.views.components + +import android.content.Context +import android.util.TypedValue +import android.view.View +import android.widget.LinearLayout +import android.widget.TextView +import androidx.lifecycle.findViewTreeLifecycleOwner +import androidx.lifecycle.lifecycleScope +import com.contentful.optimization.app.views.support.setTestTag +import com.contentful.optimization.shared.RichText +import com.contentful.optimization.views.OptimizationManager +import com.contentful.optimization.views.OptimizedEntryView +import kotlinx.coroutines.launch + +/** + * Builds an [OptimizedEntryView] wrapping a single entry, mirroring `ContentEntryView` from the + * Compose reference impl: outer wrapper carries `content-entry-$entryId` as its accessibility + * identifier, inner column carries `entry-text-$entryId` as a test tag and a content description + * combining the resolved text with `[Entry: $entryId]` so the existing UI Automator helpers + * (which match `By.descContains("[Entry: $entryId]")`) work unchanged. + */ +object ContentEntryViewBinder { + + /** + * @param resolvedBaselineText Pre-resolved rich text for the BASE entry, computed on the + * caller's suspending path. When supplied and the SDK's content renderer is invoked with the + * original (non-personalized) resolved entry, this text is used as the initial TextView value + * so the column's measured height is stable from the first layout pass — mirroring iOS's + * synchronous `RichText.resolveText`. When `null` (or when a personalized variant with a + * different sys.id is handed in), [renderEntryColumn] falls back to the previous async + * `onViewAttachedToWindow` resolution path. + */ + fun create( + context: Context, + entry: Map, + resolvedBaselineText: String? = null, + ): View { + val entryId = entryId(entry) + + val view = OptimizedEntryView(context).apply { + accessibilityIdentifier = "content-entry-$entryId" + trackTaps = true + } + view.setContentRenderer { resolvedEntry -> + val text = if (resolvedBaselineText != null && entryId(resolvedEntry) == entryId) { + resolvedBaselineText + } else { + null + } + renderEntryColumn(context, resolvedEntry, entryId, text) + } + view.setEntry(entry) + return view + } + + internal fun renderEntryColumn( + context: Context, + resolvedEntry: Map, + entryId: String, + preResolvedText: String? = null, + ): View { + @Suppress("UNCHECKED_CAST") + val fields = resolvedEntry["fields"] as? Map + // 16dp matches the Compose ContentEntryView's `.padding(16.dp)` — but the Compose Column + // uses Material3 typography with tighter line height than the default platform TextView, + // which makes the analytics block sit just below the viewport on identical content. Trim + // a few dp off horizontally and use a tighter line spacing so the entry list fits in the + // same vertical budget as the Compose impl. + val padding = context.dp(12) + + val initialText = preResolvedText ?: "No content" + val textView = TextView(context).apply { + text = initialText + setLineSpacing(0f, 1.0f) + } + val idLabel = TextView(context).apply { + text = "[Entry: $entryId]" + setLineSpacing(0f, 1.0f) + } + + val column = LinearLayout(context).apply { + orientation = LinearLayout.VERTICAL + setPadding(padding, padding, padding, padding) + addView(textView) + addView(idLabel) + setTestTag("entry-text-$entryId") + contentDescription = "$initialText [Entry: $entryId]" + } + + // Only fall back to async resolution when the caller did NOT pre-resolve the text. With + // pre-resolved text the TextView is rendered at its final size from the first layout + // pass, so OptimizedEntryView's `ViewTrackingController` sees a stable height and its 2s + // dwell cycle is not reset by a layout-driven visibility transition. + if (preResolvedText == null) { + column.addOnAttachStateChangeListener( + object : View.OnAttachStateChangeListener { + override fun onViewAttachedToWindow(v: View) { + val owner = v.findViewTreeLifecycleOwner() ?: return + owner.lifecycleScope.launch { + val resolved = RichText.resolveText( + fields?.get("text"), + OptimizationManager.client, + ) + textView.text = resolved + column.contentDescription = "$resolved [Entry: $entryId]" + } + } + + override fun onViewDetachedFromWindow(v: View) { + } + }, + ) + } + + return column + } +} + +internal fun Context.dp(value: Int): Int = + TypedValue.applyDimension( + TypedValue.COMPLEX_UNIT_DIP, + value.toFloat(), + resources.displayMetrics, + ).toInt() + +@Suppress("UNCHECKED_CAST") +internal fun entryId(entry: Map): String { + val sys = entry["sys"] as? Map + return sys?.get("id") as? String ?: "" +} diff --git a/implementations/android-sdk/views/src/main/kotlin/com/contentful/optimization/app/views/components/NestedContentEntryViewBinder.kt b/implementations/android-sdk/views/src/main/kotlin/com/contentful/optimization/app/views/components/NestedContentEntryViewBinder.kt new file mode 100644 index 00000000..dfd431dd --- /dev/null +++ b/implementations/android-sdk/views/src/main/kotlin/com/contentful/optimization/app/views/components/NestedContentEntryViewBinder.kt @@ -0,0 +1,60 @@ +package com.contentful.optimization.app.views.components + +import android.content.Context +import android.view.View +import android.widget.LinearLayout +import com.contentful.optimization.views.OptimizedEntryView + +/** + * Renders a `nestedContent` entry tree: an outer wrapper plus a recursive list of nested entries + * underneath it. Mirrors `NestedContentEntryView` from the Compose reference impl. + */ +object NestedContentEntryViewBinder { + + fun create(context: Context, entry: Map): View { + val entryId = entryId(entry) + val column = LinearLayout(context).apply { + orientation = LinearLayout.VERTICAL + } + + val wrapper = OptimizedEntryView(context).apply { + accessibilityIdentifier = "content-entry-$entryId" + } + wrapper.setContentRenderer { resolvedEntry -> + // Compose's NestedEntryText derives the test tag id from the RESOLVED entry, so the + // personalization variant's sys.id becomes the test tag (e.g. + // `entry-text-2KIWllNZJT205BwOSkMINg` for the nested return-visitor variant). The + // outer OptimizedEntryView's accessibilityIdentifier stays on the BASE id to match + // the non-nested path. + val resolvedId = entryId(resolvedEntry) + ContentEntryViewBinder.renderEntryColumn(context, resolvedEntry, resolvedId) + } + wrapper.setEntry(entry) + column.addView(wrapper) + + @Suppress("UNCHECKED_CAST") + val fields = entry["fields"] as? Map + val nested = (fields?.get("nested") as? List<*>).orEmpty() + @Suppress("UNCHECKED_CAST") + nested + .filterIsInstance>() + .filter { + val sys = it["sys"] as? Map + sys?.get("id") != null + } + .forEach { nestedEntry -> + column.addView(create(context, nestedEntry)) + } + + return column + } +} + +@Suppress("UNCHECKED_CAST") +internal fun isNestedContent(entry: Map): Boolean { + val sys = entry["sys"] as? Map ?: return false + val contentType = sys["contentType"] as? Map ?: return false + val innerSys = contentType["sys"] as? Map ?: return false + val id = innerSys["id"] as? String ?: return false + return id == "nestedContent" +} diff --git a/implementations/android-sdk/views/src/main/kotlin/com/contentful/optimization/app/views/support/TestTagging.kt b/implementations/android-sdk/views/src/main/kotlin/com/contentful/optimization/app/views/support/TestTagging.kt new file mode 100644 index 00000000..29e2dffa --- /dev/null +++ b/implementations/android-sdk/views/src/main/kotlin/com/contentful/optimization/app/views/support/TestTagging.kt @@ -0,0 +1,48 @@ +package com.contentful.optimization.app.views.support + +import android.view.View +import androidx.core.view.AccessibilityDelegateCompat +import androidx.core.view.ViewCompat +import androidx.core.view.accessibility.AccessibilityNodeInfoCompat + +/** + * Expose [testTag] as this View's `viewIdResourceName` for UI Automator. Matches the Compose + * reference impl's `Modifier.testTag("foo-bar")` + `testTagsAsResourceId = true` setup, so a + * single test selector (`By.res("foo-bar")`) finds the matching element in both apps. + * + * Android `android:id` resource names cannot contain hyphens, so we cannot reuse the kebab-case + * test-tag strings as XML ids. The standard accessibility plumbing — [AccessibilityNodeInfoCompat.setViewIdResourceName] — + * lets us still report any string as the view-id resource name to accessibility consumers, + * which is what UI Automator queries through `By.res`. + */ +fun View.setTestTag(testTag: String) { + importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_YES + // Belt-and-suspenders: surface the test tag through contentDescription too. The view also + // gets the test tag as its `viewIdResourceName` via the AccessibilityDelegate below; this + // covers the `By.desc` fallback for any caller (or environment) that doesn't see the + // overridden resource-id name. This overwrites any existing `contentDescription`, which is + // acceptable because this helper is only called in test builds of the views reference impl, + // where the test tag is the canonical accessibility identifier. + contentDescription = testTag + // Drop the framework-assigned resource id (set by android:id in XML). View.onInitializeAccessibilityNodeInfoInternal + // populates AccessibilityNodeInfo#viewIdResourceName from `Resources.getResourceName(mID)` + // every time the node info is built — even after our delegate's super call returns — and on + // some platform builds (notably the API 35 x86_64 emulator image used in CI) that framework- + // populated name appears to clobber our delegate override before UiAutomator's `By.res` + // reads it. Setting `id = View.NO_ID` removes the framework's source value so the delegate's + // `setViewIdResourceName(testTag)` is the only source the framework can use. + // Side effect: `findViewById` will no longer resolve this view — acceptable in the views reference impl because tag lookup runs through UIAutomator, not Android view lookups. + id = View.NO_ID + ViewCompat.setAccessibilityDelegate( + this, + object : AccessibilityDelegateCompat() { + override fun onInitializeAccessibilityNodeInfo( + host: View, + info: AccessibilityNodeInfoCompat, + ) { + super.onInitializeAccessibilityNodeInfo(host, info) + info.setViewIdResourceName(testTag) + } + }, + ) +} diff --git a/implementations/android-sdk/views/src/main/res/layout/activity_live_updates_test.xml b/implementations/android-sdk/views/src/main/res/layout/activity_live_updates_test.xml new file mode 100644 index 00000000..06206578 --- /dev/null +++ b/implementations/android-sdk/views/src/main/res/layout/activity_live_updates_test.xml @@ -0,0 +1,153 @@ + + + + + + + + + +