diff --git a/.github/workflows/main-pipeline.yaml b/.github/workflows/main-pipeline.yaml
index 9b0db815..9a4a9fd8 100644
--- a/.github/workflows/main-pipeline.yaml
+++ b/.github/workflows/main-pipeline.yaml
@@ -26,6 +26,7 @@ jobs:
e2e_web_sdk_react: ${{ steps.filter.outputs.e2e_web_sdk_react }}
e2e_react_web_sdk: ${{ steps.filter.outputs.e2e_react_web_sdk }}
e2e_react_native_android: ${{ steps.filter.outputs.e2e_react_native_android }}
+ e2e_android: ${{ steps.filter.outputs.e2e_android }}
e2e_ios: ${{ steps.filter.outputs.e2e_ios }}
steps:
- uses: namespacelabs/nscloud-checkout-action@938f5d2d403d6224d9a0c0dc559b1dae09c2ede4 # v8.1.1
@@ -126,6 +127,14 @@ jobs:
- 'package.json'
- 'pnpm-lock.yaml'
- '.github/workflows/main-pipeline.yaml'
+ # Android native implementation E2E coverage scope.
+ e2e_android:
+ - 'implementations/android-sdk/**'
+ - 'lib/mocks/**'
+ - 'packages/android/**'
+ - 'package.json'
+ - 'pnpm-lock.yaml'
+ - '.github/workflows/main-pipeline.yaml'
# iOS native implementation E2E coverage scope.
e2e_ios:
- 'implementations/ios-sdk/**'
@@ -659,6 +668,156 @@ jobs:
if-no-files-found: error
retention-days: 1
+ e2e-android-sdk:
+ name: ๐ค E2E Android Native
+ runs-on: namespace-profile-linux-16-vcpu-32-gb-ram-optimal
+ timeout-minutes: 45
+ needs: [setup, changes]
+ if: needs.changes.outputs.e2e_android == 'true'
+ env:
+ CI: 'true'
+ GRADLE_OPTS: >-
+ -Dorg.gradle.daemon=false -Dorg.gradle.parallel=true -Dorg.gradle.jvmargs=-Xmx4g
+ -Dkotlin.daemon.jvm.options=-Xmx2g
+ steps:
+ - uses: namespacelabs/nscloud-checkout-action@938f5d2d403d6224d9a0c0dc559b1dae09c2ede4 # v8.1.1
+
+ - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
+ with:
+ node-version-file: '.nvmrc'
+ package-manager-cache: false
+
+ - uses: pnpm/action-setup@903f9c1a6ebcba6cf41d87230be49611ac97822e # v6.0.3
+
+ - name: Set Android SDK environment variables
+ run: |
+ echo "ANDROID_SDK_ROOT=$HOME/.android/sdk" >> "$GITHUB_ENV"
+ echo "ANDROID_HOME=$HOME/.android/sdk" >> "$GITHUB_ENV"
+
+ - name: Prepare cache directories
+ run: |
+ mkdir -p "$HOME/.android/sdk" "$HOME/.android/avd" "$HOME/.android/cache"
+
+ - name: Set up caches (Namespace)
+ uses: namespacelabs/nscloud-cache-action@15799a6b54e5765f85b2aac25b3f0df43ed571c0 # v1.4.3
+ with:
+ cache: |
+ pnpm
+ gradle
+ path: |
+ ~/.android/sdk
+ ~/.android/avd
+ ~/.android/cache
+
+ - name: Install system dependencies (Android emulator)
+ run: |
+ sudo apt-get update
+ sudo apt-get install -y --no-install-recommends \
+ ca-certificates curl unzip zip git \
+ netcat-openbsd cpu-checker \
+ libgl1 libnss3 libx11-6 libx11-xcb1 libxcomposite1 libxdamage1 libxrandr2 libxtst6 \
+ libxi6 libxrender1 libxkbcommon0 libgbm1 libdbus-1-3 libdrm2 libpulse0
+ sudo apt-get install -y --no-install-recommends libasound2 || sudo apt-get install -y --no-install-recommends libasound2t64
+
+ - name: Verify KVM is available
+ run: |
+ if [ ! -e /dev/kvm ]; then
+ echo "/dev/kvm not found; Android hardware acceleration will not work." >&2
+ exit 1
+ fi
+ ls -l /dev/kvm
+ sudo kvm-ok || true
+
+ - name: Setup Java
+ uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
+ with:
+ distribution: 'temurin'
+ java-version: '17'
+
+ - name: Setup Android SDK
+ uses: android-actions/setup-android@40fd30fb8d7440372e1316f5d1809ec01dcd3699 # v4.0.1
+
+ - name: Install JS dependencies
+ run: pnpm install --prefer-offline --frozen-lockfile
+
+ - name: Build Android bridge JS bundle
+ run: pnpm --filter @contentful/optimization-android-bridge build
+
+ - name: Build app and test APKs
+ working-directory: implementations/android-sdk
+ run: ./gradlew :app:assembleDebug :uitests:assembleDebug
+
+ - name: Start Mock Server
+ run: |
+ pnpm --dir lib/mocks serve > /tmp/mock-server.log 2>&1 &
+ echo $! > /tmp/mock-server.pid
+ for i in {1..60}; do
+ if nc -z localhost 8000 2>/dev/null; then
+ echo "Mock server is ready"
+ break
+ fi
+ echo "Waiting for mock server... ($i/60)"
+ sleep 1
+ done
+ if ! nc -z localhost 8000 2>/dev/null; then
+ echo "Mock server failed to start:"
+ cat /tmp/mock-server.log
+ exit 1
+ fi
+
+ - name: Run Android E2E Tests (emulator)
+ uses: reactivecircus/android-emulator-runner@e89f39f1abbbd05b1113a29cf4db69e7540cae5a # v2.37.0
+ with:
+ api-level: 35
+ arch: x86_64
+ target: google_apis
+ profile: pixel_7
+ avd-name: test
+ force-avd-creation: true
+ emulator-boot-timeout: 600
+ cores: 6
+ ram-size: 4096M
+ disk-size: 8G
+ disable-animations: true
+ emulator-options: -no-window -no-audio -no-boot-anim -gpu swiftshader_indirect
+ script: |
+ echo "Disabling animations..."
+ adb shell settings put global window_animation_scale 0
+ adb shell settings put global transition_animation_scale 0
+ adb shell settings put global animator_duration_scale 0
+ echo "Installing APKs..."
+ adb install -r implementations/android-sdk/app/build/outputs/apk/debug/app-debug.apk
+ adb install -r implementations/android-sdk/uitests/build/outputs/apk/debug/uitests-debug.apk
+ echo "Setting up adb reverse port forwarding..."
+ adb reverse tcp:8000 tcp:8000
+ sleep 3
+ adb shell "for i in 1 2 3 4 5 6 7 8 9 10; do nc -z localhost 8000 2>/dev/null && echo 'Mock server tunnel verified' && exit 0; sleep 1; done; echo 'WARNING: tunnel verification timed out'"
+ echo "Running UI Automator 2 E2E tests..."
+ adb shell am instrument -w com.contentful.optimization.uitests/androidx.test.runner.AndroidJUnitRunner 2>&1 | tee /tmp/test-output.log
+ grep -q "FAILURES" /tmp/test-output.log && { echo "::error::Android UI tests failed"; exit 1; } || true
+ grep -q "Process crashed" /tmp/test-output.log && { echo "::error::Test process crashed"; exit 1; } || true
+
+ - name: Upload logs on failure
+ if: failure()
+ run: |
+ echo "=== Mock Server Logs ==="
+ cat /tmp/mock-server.log || echo "No mock server logs found"
+
+ - name: Stop Mock Server
+ if: always()
+ run: |
+ kill $(cat /tmp/mock-server.pid) 2>/dev/null || true
+
+ - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
+ if: always()
+ with:
+ name: ci-results-android-sdk
+ path: |
+ implementations/android-sdk/logs/
+ /tmp/mock-server.log
+ /tmp/test-output.log
+ retention-days: 7
+
e2e-ios-sdk-build:
name: ๐ Build iOS UI Test Bundles
runs-on: namespace-profile-macos-apple-silicon-arm64-6-cpu-14-gb
diff --git a/implementations/android-sdk/.gitignore b/implementations/android-sdk/.gitignore
new file mode 100644
index 00000000..fc1112e2
--- /dev/null
+++ b/implementations/android-sdk/.gitignore
@@ -0,0 +1,5 @@
+.gradle/
+app/build/
+uitests/build/
+local.properties
+logs/
diff --git a/implementations/android-sdk/AGENTS.md b/implementations/android-sdk/AGENTS.md
new file mode 100644
index 00000000..fc14d470
--- /dev/null
+++ b/implementations/android-sdk/AGENTS.md
@@ -0,0 +1,69 @@
+# AGENTS.md
+
+Read the repository root `AGENTS.md`, then `implementations/AGENTS.md`, before this file.
+
+## Scope
+
+This is the native Android reference implementation for bridge and preview-panel validation work. It
+uses a Jetpack Compose app shell with the Android SDK library module included via Gradle composite
+build.
+
+## Key paths
+
+- `app/src/main/kotlin/com/contentful/optimization/app/` โ App source
+- `app/src/main/kotlin/com/contentful/optimization/app/screens/` โ Screen composables
+- `app/src/main/kotlin/com/contentful/optimization/app/components/` โ Reusable UI components
+- `uitests/` โ UI Automator 2 E2E test module (`com.android.test`)
+- `uitests/src/main/kotlin/.../uitests/tests/` โ Test files (1:1 mirror of iOS XCUITest suite)
+- `uitests/src/main/kotlin/.../uitests/support/` โ Shared test helpers, app launcher, device
+ extensions
+- `scripts/` โ Build and run scripts
+- `build.gradle.kts` โ Root build config (plugin versions)
+- `settings.gradle.kts` โ Project structure (includes SDK module + uitests via project.dir)
+- `app/build.gradle.kts` โ App module build config and dependencies
+
+## Local rules
+
+- Keep this app focused on validating native Android integration behavior. Reusable SDK behavior
+ belongs in `packages/android/ContentfulOptimization`, and TypeScript bridge behavior belongs in
+ `packages/android/android-zipline-bridge`.
+- The mock server must be running at `http://localhost:8000` before running the app. Use
+ `adb reverse tcp:8000 tcp:8000` to forward the port to the emulator.
+- The app references the SDK via Gradle `include` + `project.dir` in `settings.gradle.kts`. After
+ SDK source changes, rebuild via `./gradlew :app:assembleDebug` from this directory.
+- Keep accessibility identifiers (testTags) aligned with the iOS SwiftUI implementation and
+ `implementations/PREVIEW_PANEL_SCENARIOS.md`.
+- Use `Modifier.testTag()` for app-level test identifiers. The root composable sets
+ `testTagsAsResourceId = true` so UI Automator 2 can discover them as `resource-id`.
+- The SDK uses `Modifier.semantics { contentDescription = ... }` for its own identifiers (e.g.,
+ `OptimizedEntry`'s `accessibilityIdentifier` parameter).
+- Test launch arguments use intent extras: `--ez reset true` clears SDK SharedPreferences,
+ `--ez simulate_offline true` sets the client offline.
+
+## Commands
+
+- `pnpm serve:mocks` (from monorepo root)
+- From `implementations/android-sdk/`: `./gradlew :app:assembleDebug`
+- From `implementations/android-sdk/`: `./scripts/bootstrap.sh`
+- Build bridge first: `pnpm --filter @contentful/optimization-android-bridge build`
+- Build UI test APK: `./gradlew :uitests:assembleDebug`
+- Run all UI tests: `./gradlew :uitests:connectedAndroidTest`
+- Run single test class:
+ `./gradlew :uitests:connectedAndroidTest -Pandroid.testInstrumentationRunnerArguments.class=com.contentful.optimization.uitests.tests.AnalyticsTests`
+
+## UI tests
+
+- The `uitests/` module is a `com.android.test` Gradle module โ fully decoupled from app internals.
+- Tests interact with the app purely through UI Automator 2's accessibility layer.
+- Element discovery: `By.res("testTag")` for app `testTag` values, `By.desc("id")` for SDK
+ `contentDescription` elements (e.g., `content-entry-{id}`).
+- Test names and accessibility identifiers match the iOS XCUITest suite at
+ `implementations/ios-sdk/uitests/Tests/` for cross-platform test parity.
+- The mock server must be running and port-forwarded before running tests.
+
+## Usually validate
+
+- Run the app on emulator after changes to verify UI renders correctly.
+- Verify accessibility identifiers match iOS counterparts when changing UI structure.
+- Rebuild `@contentful/optimization-android-bridge` before testing when bridge source changed.
+- After UI structure changes, run `./gradlew :uitests:assembleDebug` to verify test APK compiles.
diff --git a/implementations/android-sdk/README.md b/implementations/android-sdk/README.md
new file mode 100644
index 00000000..4d9ff1f8
--- /dev/null
+++ b/implementations/android-sdk/README.md
@@ -0,0 +1,90 @@
+
+
+
+
+
+
+
+### Contentful Personalization & Analytics
+
+
Android SDK Reference Implementation
+
+[Readme](./README.md) ยท [Guides](https://contentful.github.io/optimization/documents/Guides.html) ยท
+[Reference](https://contentful.github.io/optimization/) ยท [Contributing](../../CONTRIBUTING.md)
+
+
+
+---
+
+> [!CAUTION] Pre-release. API surface is not yet stable.
+
+This is the native Android reference implementation for the
+[Contentful Optimization Android SDK](../../packages/android/README.md). It demonstrates the minimal
+integration pattern using Jetpack Compose and serves as a test target for UI Automator 2 E2E tests.
+
+## What this demonstrates
+
+- `OptimizationRoot` initialization with mock server configuration
+- `OptimizedEntry` personalization with view and click tracking
+- Nested entry resolution and recursive rendering
+- Navigation with screen tracking via `ScreenTrackingEffect`
+- Live updates behavior: default (global), explicit live, and locked variants
+- `PreviewPanelOverlay` with audience/variant override controls
+- Analytics event display for debugging tracked events
+- All accessibility identifiers aligned with the iOS SwiftUI implementation for cross-platform E2E
+ parity
+
+## Prerequisites
+
+- Android SDK with `ANDROID_HOME` set
+- Android emulator or connected device
+- `adb` in PATH
+- pnpm dependencies installed at monorepo root (`pnpm install`)
+- Android bridge built: `pnpm --filter @contentful/optimization-android-bridge build`
+
+## Setup
+
+From the monorepo root:
+
+```sh
+pnpm install
+pnpm --filter @contentful/optimization-android-bridge build
+```
+
+## Running locally
+
+The bootstrap script starts the mock server, builds the app, and launches it on an emulator:
+
+```sh
+cd implementations/android-sdk
+./scripts/bootstrap.sh
+```
+
+Or manually:
+
+```sh
+# Terminal 1: Start mock server
+pnpm serve:mocks
+
+# Terminal 2: Build and install
+cd implementations/android-sdk
+adb reverse tcp:8000 tcp:8000
+./gradlew :app:assembleDebug
+adb install -r app/build/outputs/apk/debug/app-debug.apk
+adb shell am start -n com.contentful.optimization.app/.MainActivity
+```
+
+To launch with test arguments (clear state or simulate offline):
+
+```sh
+adb shell am start -n com.contentful.optimization.app/.MainActivity --ez reset true
+adb shell am start -n com.contentful.optimization.app/.MainActivity --ez simulate_offline true
+```
+
+## Related
+
+- [Android SDK](../../packages/android/README.md)
+- [iOS SDK Reference Implementation](../ios-sdk/README.md)
+- [React Native Reference Implementation](../react-native-sdk/README.md)
+- [Preview Panel Scenarios](../PREVIEW_PANEL_SCENARIOS.md)
+- [Mock Server](../../lib/mocks/README.md)
diff --git a/implementations/android-sdk/app/build.gradle.kts b/implementations/android-sdk/app/build.gradle.kts
new file mode 100644
index 00000000..83624004
--- /dev/null
+++ b/implementations/android-sdk/app/build.gradle.kts
@@ -0,0 +1,54 @@
+plugins {
+ id("com.android.application")
+ id("org.jetbrains.kotlin.android")
+ id("org.jetbrains.kotlin.plugin.compose")
+}
+
+android {
+ namespace = "com.contentful.optimization.app"
+ compileSdk = 36
+
+ defaultConfig {
+ applicationId = "com.contentful.optimization.app"
+ minSdk = 24
+ targetSdk = 35
+ versionCode = 1
+ versionName = "1.0"
+ }
+
+ buildTypes {
+ release {
+ isMinifyEnabled = false
+ }
+ }
+
+ compileOptions {
+ sourceCompatibility = JavaVersion.VERSION_11
+ targetCompatibility = JavaVersion.VERSION_11
+ }
+
+ buildFeatures {
+ compose = true
+ }
+}
+
+kotlin {
+ compilerOptions {
+ jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_11)
+ }
+}
+
+dependencies {
+ implementation(project(":ContentfulOptimization"))
+
+ implementation(platform("androidx.compose:compose-bom:2024.12.01"))
+ implementation("androidx.compose.ui:ui")
+ implementation("androidx.compose.foundation:foundation")
+ implementation("androidx.compose.material3:material3")
+ implementation("androidx.lifecycle:lifecycle-runtime-compose:2.8.7")
+ implementation("androidx.activity:activity-compose:1.9.3")
+ implementation("androidx.navigation:navigation-compose:2.8.5")
+
+ implementation("com.squareup.okhttp3:okhttp:4.12.0")
+ implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.1")
+}
diff --git a/implementations/android-sdk/app/src/main/AndroidManifest.xml b/implementations/android-sdk/app/src/main/AndroidManifest.xml
new file mode 100644
index 00000000..494ad1f0
--- /dev/null
+++ b/implementations/android-sdk/app/src/main/AndroidManifest.xml
@@ -0,0 +1,18 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
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
new file mode 100644
index 00000000..9ebc00af
--- /dev/null
+++ b/implementations/android-sdk/app/src/main/kotlin/com/contentful/optimization/app/AppConfig.kt
@@ -0,0 +1,22 @@
+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/src/main/kotlin/com/contentful/optimization/app/ContentfulFetcher.kt b/implementations/android-sdk/app/src/main/kotlin/com/contentful/optimization/app/ContentfulFetcher.kt
new file mode 100644
index 00000000..4db53a4e
--- /dev/null
+++ b/implementations/android-sdk/app/src/main/kotlin/com/contentful/optimization/app/ContentfulFetcher.kt
@@ -0,0 +1,125 @@
+package com.contentful.optimization.app
+
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.withContext
+import okhttp3.OkHttpClient
+import okhttp3.Request
+import org.json.JSONArray
+import org.json.JSONObject
+
+object ContentfulFetcher {
+
+ private val httpClient = OkHttpClient()
+
+ suspend fun fetchEntries(ids: List): List