diff --git a/.github/workflows/main-pipeline.yaml b/.github/workflows/main-pipeline.yaml index 92e8c1f5..8e4bf20a 100644 --- a/.github/workflows/main-pipeline.yaml +++ b/.github/workflows/main-pipeline.yaml @@ -670,10 +670,10 @@ jobs: if-no-files-found: error retention-days: 1 - e2e-android-sdk: - name: 🤖 E2E Android Native + e2e-android-sdk-build: + name: 🤖 Build Android APKs runs-on: namespace-profile-linux-16-vcpu-32-gb-ram-optimal - timeout-minutes: 45 + timeout-minutes: 30 needs: [setup, changes] if: needs.changes.outputs.e2e_android == 'true' env: @@ -697,8 +697,7 @@ jobs: echo "ANDROID_HOME=$HOME/.android/sdk" >> "$GITHUB_ENV" - name: Prepare cache directories - run: | - mkdir -p "$HOME/.android/sdk" "$HOME/.android/avd" "$HOME/.android/cache" + run: mkdir -p "$HOME/.android/sdk" "$HOME/.android/cache" - name: Set up caches (Namespace) uses: namespacelabs/nscloud-cache-action@15799a6b54e5765f85b2aac25b3f0df43ed571c0 # v1.4.3 @@ -706,6 +705,88 @@ jobs: cache: | pnpm gradle + path: | + ~/.android/sdk + ~/.android/cache + + - 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 the JS bridge bundles + run: pnpm --filter @contentful/optimization-js-bridge build + + - name: Build Compose, Views, and UI test APKs + working-directory: implementations/android-sdk + run: ./gradlew :compose:assembleDebug :views:assembleDebug :uitests:assembleDebug + + - name: Collect APK artifacts at stable paths + run: | + # Use a workspace-relative directory because actions/upload-artifact@v7 keys the + # in-artifact paths off the least-common-ancestor of the inputs — an absolute + # /tmp path produces /tmp/... inside the zip, which then unpacks under the download + # target instead of into it. + mkdir -p android-apks + cp implementations/android-sdk/compose/build/outputs/apk/debug/compose-debug.apk android-apks/ + cp implementations/android-sdk/views/build/outputs/apk/debug/views-debug.apk android-apks/ + cp implementations/android-sdk/uitests/build/outputs/apk/debug/uitests-debug.apk android-apks/ + ls -la android-apks/ + + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: android-apks + path: android-apks/ + if-no-files-found: error + retention-days: 1 + + e2e-android-sdk: + name: 🤖 E2E Android (${{ matrix.app }}) + runs-on: namespace-profile-linux-16-vcpu-32-gb-ram-optimal + timeout-minutes: 45 + needs: [setup, changes, e2e-android-sdk-build] + if: needs.changes.outputs.e2e_android == 'true' + strategy: + fail-fast: false + matrix: + include: + - app: compose + package: com.contentful.optimization.app + - app: views + package: com.contentful.optimization.app.views + env: + CI: 'true' + APP_PACKAGE: ${{ matrix.package }} + APP_APK: ${{ matrix.app }}-debug.apk + 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 path: | ~/.android/sdk ~/.android/avd @@ -742,12 +823,10 @@ jobs: - name: Install JS dependencies run: pnpm install --prefer-offline --frozen-lockfile - - name: Build the JS bridge bundles - run: pnpm --filter @contentful/optimization-js-bridge build - - - name: Build app and test APKs - working-directory: implementations/android-sdk - run: ./gradlew :app:assembleDebug :uitests:assembleDebug + - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: android-apks + path: android-apks - name: Start Mock Server run: | @@ -787,28 +866,53 @@ jobs: # `set -o pipefail` would error out with "Illegal option -o pipefail" and terminate # the script before any test ran. The grep-on-test-output checks below already # detect instrumentation failures regardless of pipe-status propagation. - 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 + # The action's `disable-animations: true` already disables animation scales — no + # need to run the manual adb shell settings put lines here. + echo "Installing APKs (target app: $APP_PACKAGE)..." + adb install -r "android-apks/$APP_APK" + adb install -r android-apks/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..." + echo "Resetting /sdcard/test-failures (per-test failure context dump target)..." + adb shell "rm -rf /sdcard/test-failures && mkdir -p /sdcard/test-failures" + echo "Clearing logcat ring buffer (will be dumped post-run by the workflow)..." + adb logcat -c + echo "Running UI Automator 2 E2E tests against $APP_PACKAGE..." # Fail-fast: stream am instrument output through awk; on the first - # "Error in test" or "Process crashed" line, force-stop the test - # process to abort the remaining suite. AndroidJUnitRunner has no - # built-in early-exit, so without this every subsequent @Before - # waits its full 20-30s waitForElement timeout and one root-cause - # failure in an early class burns the whole 45-minute job budget. - # force-stop on the .uitests process kills the instrumentation; - # the remote `am instrument -w` exits and the local pipeline - # collapses. fflush keeps the GitHub Actions log live so we see - # the failure when it happens, not at the end. + # per-test error (INSTRUMENTATION_STATUS_CODE -1/-2) or device-side + # process crash, force-stop the test process to abort the remaining + # suite. AndroidJUnitRunner has no built-in early-exit, so without + # this every subsequent @Before waits its full 20-30s + # waitForElement timeout and one root-cause failure in an early + # class burns the whole 45-minute job budget. force-stop on the + # .uitests process kills the instrumentation; the remote + # `am instrument -w` exits and the local pipeline collapses. + # fflush keeps the GitHub Actions log live so we see the failure + # when it happens, not at the end. + # + # `-r` (raw mode) is REQUIRED for streaming: without it + # `am instrument` buffers the entire human-readable summary + # (dots, `OK (N tests)` / `FAILURES!!!`) until end-of-suite, so + # the awk fail-fast never sees per-test progress and a single + # 20-min hang produces zero output until the outer `timeout 1200` + # kills the pipeline (observed on run 26534799046 views matrix + # entry). With `-r`, AndroidJUnitRunner emits + # `INSTRUMENTATION_STATUS_CODE: ` after each test (1=started, + # 0=passed, -1=assumption-failed, -2=errored/failed) and the awk + # regex matches the failure codes in real time. + # + # Per-test wedges are caught by the JUnit `Timeout` rule in + # PerTestRule.kt (60s/test), which surfaces as a `stack=...` + # block followed by `INSTRUMENTATION_STATUS_CODE: -2`. The outer + # `timeout 1200` here is a belt-and-suspenders safety net: it + # caps total wall time at 20 min (normal happy path ~7 min) so a + # deadlock that prevents JUnit's worker-thread from firing — or + # a wedged `adb` — can't burn the full 45-min job budget. On + # `timeout` kill, the post-grep for `INSTRUMENTATION_CODE: ` (the + # final overall-run code emitted only when am instrument + # completes cleanly) is missing and the step fails. # # IMPORTANT: this MUST be a single physical YAML line. The # reactivecircus/android-emulator-runner action's parseScript() @@ -819,16 +923,20 @@ jobs: # are dropped as no-op syntax errors. Verified in the action's # src/script-parser.ts: # rawScript.trim().split(/\r\n|\n|\r/) - adb shell am instrument -w com.contentful.optimization.uitests/androidx.test.runner.AndroidJUnitRunner 2>&1 | tee /tmp/test-output.log | awk 'BEGIN{aborted=0} /Error in test|Process crashed/{if(!aborted){aborted=1;print;print "::error::Test failure detected — aborting remaining suite";fflush();system("adb shell am force-stop com.contentful.optimization.uitests >/dev/null 2>&1");exit 1} next} {print;fflush()}' - grep -q "FAILURES\|Error in test" /tmp/test-output.log && { echo "::error::Android UI tests failed"; exit 1; } || true + timeout --kill-after=30 1200 adb shell am instrument -w -r -e APP_PACKAGE "$APP_PACKAGE" com.contentful.optimization.uitests/androidx.test.runner.AndroidJUnitRunner 2>&1 | tee /tmp/test-output.log | awk 'BEGIN{aborted=0} /^INSTRUMENTATION_STATUS_CODE:[[:space:]]+-[12]$|Process crashed/{if(!aborted){aborted=1;print;print "::error::Test failure detected — aborting remaining suite";fflush();system("adb shell am force-stop com.contentful.optimization.uitests >/dev/null 2>&1");exit 1} next} {print;fflush()}'; e=$?; echo "--- capturing post-run debug context (logcat + /sdcard/test-failures) ---"; adb logcat -d > /tmp/logcat.log 2>&1 || true; rm -rf /tmp/test-failures; adb pull /sdcard/test-failures /tmp/test-failures 2>&1 || true; exit $e + grep -qE "^INSTRUMENTATION_STATUS_CODE:[[:space:]]+-[12]$" /tmp/test-output.log && { echo "::error::Android UI tests failed (per-test failure detected)"; exit 1; } || true grep -q "Process crashed" /tmp/test-output.log && { echo "::error::Test process crashed"; exit 1; } || true - grep -q "OK (" /tmp/test-output.log || { echo "::error::Android UI tests did not complete successfully (missing OK status)"; exit 1; } + grep -qE "^INSTRUMENTATION_CODE:[[:space:]]+-?[0-9]+$" /tmp/test-output.log || { echo "::error::Android UI tests did not complete cleanly (missing terminal INSTRUMENTATION_CODE — likely timeout, hang, or am instrument abort)"; exit 1; } - - name: Upload logs on failure + - name: Print debug context on failure if: failure() run: | echo "=== Mock Server Logs ===" cat /tmp/mock-server.log || echo "No mock server logs found" + echo "=== Per-test failure captures (/tmp/test-failures) ===" + ls -la /tmp/test-failures 2>/dev/null || echo "No per-test failure captures pulled" + echo "=== Logcat tail (/tmp/logcat.log, last 500 lines) ===" + tail -n 500 /tmp/logcat.log 2>/dev/null || echo "No logcat dump found" - name: Stop Mock Server if: always() @@ -838,11 +946,13 @@ jobs: - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 if: always() with: - name: ci-results-android-sdk + name: ci-results-android-sdk-${{ matrix.app }} path: | implementations/android-sdk/logs/ /tmp/mock-server.log /tmp/test-output.log + /tmp/logcat.log + /tmp/test-failures/ retention-days: 7 e2e-ios-sdk-build: diff --git a/implementations/android-sdk/AGENTS.md b/implementations/android-sdk/AGENTS.md index 0f384342..98a378c7 100644 --- a/implementations/android-sdk/AGENTS.md +++ b/implementations/android-sdk/AGENTS.md @@ -4,66 +4,106 @@ Read the repository root `AGENTS.md`, then `implementations/AGENTS.md`, before t ## 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. +This directory hosts **two** native Android reference implementations that integrate the Android SDK +from `packages/android/ContentfulOptimization` — one Jetpack Compose, one legacy XML Views. Both +apps demonstrate the same SDK capabilities, expose the same test IDs, and are driven by the **same** +UI Automator 2 suite from `uitests/`. This mirrors the iOS `swiftui/` + `uikit/` pair at +`implementations/ios-sdk/`. ## 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 +- `compose/src/main/kotlin/com/contentful/optimization/app/` — Jetpack Compose reference impl + - `screens/` — Screen composables + - `components/` — Reusable UI components + - applicationId: `com.contentful.optimization.app` +- `views/src/main/kotlin/com/contentful/optimization/app/views/` — XML Views reference impl + - `screens/` is folded into per-screen Activities (`MainActivity`, `NavigationTestActivity`, + `LiveUpdatesTestActivity`) + - `components/` — `*Binder` objects that construct the equivalent View trees + - `support/TestTagging.kt` — `View.setTestTag(testTag)` extension that exposes a kebab-case string + as the view's `viewIdResourceName` via [AccessibilityNodeInfoCompat.setViewIdResourceName], + matching Compose's `testTagsAsResourceId = true` behavior so the same UI Automator + `By.res("testTag")` selector resolves in both apps + - applicationId: `com.contentful.optimization.app.views` +- `shared/src/main/kotlin/com/contentful/optimization/shared/` — Platform-agnostic Kotlin helpers + (`AppConfig`, `ContentfulFetcher`, `EventStore`, `MockPreviewContentfulClient`, `RichText`) + consumed by both `:compose` and `:views` - `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 + - `uitests/src/main/kotlin/.../uitests/tests/` — Test files (1:1 mirror of iOS XCUITest suite) + - `uitests/src/main/kotlin/.../uitests/support/` — `AppLauncher` (reads `APP_PACKAGE` from the + instrumentation arguments), `TestHelpers`, `DeviceExtensions` +- `scripts/` — Build and run scripts; `run-e2e.sh` selects the target gradle module + APK from the + `APP_PACKAGE` env var - `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 +- `settings.gradle.kts` — Project structure (includes `:compose`, `:views`, `:shared`, `:uitests`, + and the SDK module via `project.dir`) ## 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/universal/optimization-js-bridge`. -- The mock server must be running at `http://localhost:8000` before running the app. Use +- **Both apps must be kept in lock-step.** Any new screen, component, button, or test-id must land + in `compose/` and `views/` in the same change. The shared `uitests/` suite asserts the same + contract against both, so a one-sided change breaks one matrix leg in CI. +- Reusable SDK behavior belongs in `packages/android/ContentfulOptimization`; TypeScript bridge + behavior belongs in `packages/universal/optimization-js-bridge`. Platform-agnostic helpers shared + between `:compose` and `:views` belong in `:shared`. Compose-only or Views-only glue stays in its + own app module. +- The mock server must be running at `http://localhost:8000` before running either 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. +- After SDK source changes, rebuild both apps via + `./gradlew :compose:assembleDebug :views: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-ID contract + +- **Compose impl:** `Modifier.testTag("foo-bar")` on the composable; the root composable sets + `testTagsAsResourceId = true` so UI Automator resolves the tag through `By.res("foo-bar")`. +- **Views impl:** `view.setTestTag("foo-bar")` (the extension in `views/.../support/TestTagging.kt`) + installs an `AccessibilityDelegateCompat` whose `onInitializeAccessibilityNodeInfo` reports the + same string as `viewIdResourceName`, so the same `By.res("foo-bar")` selector resolves the + matching `View`. Android XML `android:id` values must be valid Java identifiers and cannot carry + kebab-case test tags, which is why the resource-id name is set programmatically. +- **SDK-side accessibility identifiers** (e.g. `OptimizedEntry`/`OptimizedEntryView`'s + `accessibilityIdentifier` parameter) are surfaced through `contentDescription` and matched by + tests with `By.desc("content-entry-${id}")`. This applies to both impls; the SDK adapter is + responsible for setting the `contentDescription` so the same selector resolves. - 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-js-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` +- Build one app: `./gradlew :compose:assembleDebug` or `./gradlew :views:assembleDebug` +- Build everything for the matrix: `pnpm build:apks` +- Bootstrap Compose impl on emulator: `./scripts/bootstrap.sh` +- Run UI tests against Compose app: `pnpm test:e2e:compose` +- Run UI tests against Views app: `pnpm test:e2e:views` +- Default `pnpm test:e2e` targets the Compose app +- Run a single test class against either app: `pnpm test:e2e:compose -- --test-class 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}`). +- The instrumentation runner argument `APP_PACKAGE` selects the app: `AppLauncher` reads it via + `InstrumentationRegistry.getArguments()` and defaults to `com.contentful.optimization.app` when + unset (so plain IDE runs hit the Compose impl). +- Element discovery: `By.res("testTag")` for app-level test identifiers (works for both apps thanks + to the test-id contract above), `By.desc("id")` for SDK-level `accessibilityIdentifier` 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. +- Build and assemble both APKs after Kotlin changes: + `./gradlew :compose:assembleDebug :views:assembleDebug :uitests:assembleDebug`. +- After UI structure changes, run the UI test suite against both apps locally before pushing — + `pnpm test:e2e:compose && pnpm test:e2e:views`. A regression that only shows up against one app + points to a test-id or behavior divergence between the two reference impls. +- Rebuild `@contentful/optimization-js-bridge` before testing when bridge source changed. - Verify accessibility identifiers match iOS counterparts when changing UI structure. - Rebuild `@contentful/optimization-js-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 index ac6b29c3..ac782cee 100644 --- a/implementations/android-sdk/README.md +++ b/implementations/android-sdk/README.md @@ -69,8 +69,8 @@ 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 +./gradlew :compose:assembleDebug +adb install -r compose/build/outputs/apk/debug/compose-debug.apk adb shell am start -n com.contentful.optimization.app/.MainActivity ``` 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 95% 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..7e101445 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() { 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 97% 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..a9db6177 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 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..ca28871b 100644 --- a/implementations/android-sdk/package.json +++ b/implementations/android-sdk/package.json @@ -3,10 +3,15 @@ "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 :uitests: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/scripts/bootstrap.sh b/implementations/android-sdk/scripts/bootstrap.sh index 25d2a6e2..fc1c3e94 100755 --- a/implementations/android-sdk/scripts/bootstrap.sh +++ b/implementations/android-sdk/scripts/bootstrap.sh @@ -192,12 +192,12 @@ build_app() { log_info "Building Android app..." cd "$APP_DIR" - ./gradlew :app:assembleDebug + ./gradlew :compose:assembleDebug log_info "Build complete" } install_and_launch() { - local apk="$APP_DIR/app/build/outputs/apk/debug/app-debug.apk" + local apk="$APP_DIR/compose/build/outputs/apk/debug/compose-debug.apk" if [[ ! -f "$apk" ]]; then log_error "APK not found at $apk. Did the build succeed?" diff --git a/implementations/android-sdk/scripts/run-e2e.sh b/implementations/android-sdk/scripts/run-e2e.sh index d3e9b3ab..fefd6bb2 100755 --- a/implementations/android-sdk/scripts/run-e2e.sh +++ b/implementations/android-sdk/scripts/run-e2e.sh @@ -74,7 +74,16 @@ TEST_CLASS="" TEST_METHOD="" UITEST_PACKAGE="com.contentful.optimization.uitests.tests" -APP_PACKAGE="com.contentful.optimization.app" +# APP_PACKAGE selects which reference implementation to drive. When unset (or set to +# "all"/"both"), the script runs the suite twice: once against the Compose impl and once +# against the XML Views impl, mirroring the CI matrix in main-pipeline.yaml. Override via env +# var to a single package value to drive only one impl. The Gradle module name + APK file +# name are derived from the package below in `resolve_app_module`. +APP_PACKAGE="${APP_PACKAGE:-all}" +APP_MODULE="" + +COMPOSE_PACKAGE="com.contentful.optimization.app" +VIEWS_PACKAGE="com.contentful.optimization.app.views" RED='\033[0;31m' GREEN='\033[0;32m' @@ -159,6 +168,21 @@ parse_args() { fi } +resolve_app_module() { + case "$APP_PACKAGE" in + "$COMPOSE_PACKAGE") + APP_MODULE="compose" + ;; + "$VIEWS_PACKAGE") + APP_MODULE="views" + ;; + *) + echo "[ERROR] Unknown APP_PACKAGE: $APP_PACKAGE. Expected $COMPOSE_PACKAGE or $VIEWS_PACKAGE." >&2 + exit 1 + ;; + esac +} + log_info() { echo -e "${GREEN}[INFO]${NC} $1"; } log_warn() { echo -e "${YELLOW}[WARN]${NC} $1"; } log_error() { echo -e "${RED}[ERROR]${NC} $1"; } @@ -456,6 +480,16 @@ start_mock_server() { fi } +force_stop_other_apps() { + # Local convenience: a previous run against the OTHER reference impl leaves its process + # cached by Android, and its stale focused window confuses UiAutomator window discovery + # in this run. CI never hits this because each matrix leg has its own emulator with only + # one app installed; this is purely a local-state hygiene step. + for pkg in com.contentful.optimization.app com.contentful.optimization.app.views; do + adb shell am force-stop "$pkg" 2>/dev/null || true + done +} + setup_adb() { log_info "Setting up adb reverse port forwarding..." adb reverse tcp:${MOCK_SERVER_PORT} tcp:${MOCK_SERVER_PORT} @@ -489,14 +523,18 @@ build_apks() { return 0 fi - log_info "Building app APK and test APK..." + # Build both implementation APKs and the test APK in a single Gradle invocation. Even + # when only one impl is requested, the cost of an extra :assembleDebug is negligible + # against the Gradle daemon startup and configures the cache for the "all" run path + # without a separate Gradle invocation per impl. + log_info "Building compose, views, and uitests APKs..." cd "$APP_DIR" - ./gradlew :app:assembleDebug :uitests:assembleDebug + ./gradlew :compose:assembleDebug :views:assembleDebug :uitests:assembleDebug log_info "Build complete" } install_apks() { - local app_apk="$APP_DIR/app/build/outputs/apk/debug/app-debug.apk" + local app_apk="$APP_DIR/$APP_MODULE/build/outputs/apk/debug/${APP_MODULE}-debug.apk" local test_apk="$APP_DIR/uitests/build/outputs/apk/debug/uitests-debug.apk" if [[ ! -f "$app_apk" ]]; then @@ -523,7 +561,8 @@ run_tests() { mkdir -p "$LOG_DIR" - local am_args="-w" + # Forward the target package to AppLauncher so the in-process tests know which app to drive. + local am_args="-w -e APP_PACKAGE ${APP_PACKAGE}" if [[ -n "$TEST_CLASS" ]]; then local full_class="${UITEST_PACKAGE}.${TEST_CLASS}" @@ -596,12 +635,39 @@ run_tests() { fi } +run_for_app() { + local package="$1" + APP_PACKAGE="$package" + resolve_app_module + # Per-app test log so a follow-on run does not clobber the previous app's log. + TEST_LOG="${LOG_DIR}/test-results-${APP_MODULE}.log" + + log_info "--- Running E2E suite against $APP_PACKAGE (module :$APP_MODULE) ---" + + force_stop_other_apps + install_apks + run_tests +} + main() { parse_args "$@" + local apps_to_run=() + case "$APP_PACKAGE" in + all|both|"") + apps_to_run=("$COMPOSE_PACKAGE" "$VIEWS_PACKAGE") + ;; + *) + # Validate single-app selection up front. + APP_PACKAGE="$APP_PACKAGE" resolve_app_module + apps_to_run=("$APP_PACKAGE") + ;; + esac + log_info "=== Android UI Automator 2 E2E Test Runner ===" log_info "Root directory: $ROOT_DIR" log_info "App directory: $APP_DIR" + log_info "Target apps: ${apps_to_run[*]}" log_info "CI mode: $CI" [[ -n "$TEST_CLASS" ]] && log_info "Test class: $TEST_CLASS" [[ -n "$TEST_METHOD" ]] && log_info "Test method: $TEST_METHOD" @@ -611,8 +677,10 @@ main() { setup_adb build_bridge build_apks - install_apks - run_tests + + for package in "${apps_to_run[@]}"; do + run_for_app "$package" + done log_info "=== All tests completed successfully ===" } 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/app/src/main/kotlin/com/contentful/optimization/app/AppConfig.kt b/implementations/android-sdk/shared/src/main/kotlin/com/contentful/optimization/shared/AppConfig.kt similarity index 93% rename from implementations/android-sdk/app/src/main/kotlin/com/contentful/optimization/app/AppConfig.kt rename to implementations/android-sdk/shared/src/main/kotlin/com/contentful/optimization/shared/AppConfig.kt index 9ebc00af..83724f26 100644 --- a/implementations/android-sdk/app/src/main/kotlin/com/contentful/optimization/app/AppConfig.kt +++ b/implementations/android-sdk/shared/src/main/kotlin/com/contentful/optimization/shared/AppConfig.kt @@ -1,4 +1,4 @@ -package com.contentful.optimization.app +package com.contentful.optimization.shared object AppConfig { const val clientId = "mock-client-id" 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 99% 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..f32ae852 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,4 +1,4 @@ -package com.contentful.optimization.app +package com.contentful.optimization.shared import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext 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/uitests/build.gradle.kts b/implementations/android-sdk/uitests/build.gradle.kts index 0dfb5c1b..20b95c1d 100644 --- a/implementations/android-sdk/uitests/build.gradle.kts +++ b/implementations/android-sdk/uitests/build.gradle.kts @@ -23,7 +23,11 @@ android { targetCompatibility = JavaVersion.VERSION_11 } - targetProjectPath = ":app" + // The compose reference impl is the default target. Step 5 reads APP_PACKAGE from the + // instrumentation arguments to switch targets at runtime, but Gradle still needs a single + // compile-time link. Keeping the link pointed at the Compose app preserves the existing + // CI surface; the matrix CI leg installs the views APK separately before running. + targetProjectPath = ":compose" } kotlin { diff --git a/implementations/android-sdk/uitests/src/main/kotlin/com/contentful/optimization/uitests/support/AppLauncher.kt b/implementations/android-sdk/uitests/src/main/kotlin/com/contentful/optimization/uitests/support/AppLauncher.kt index bd2b3945..2d39a526 100644 --- a/implementations/android-sdk/uitests/src/main/kotlin/com/contentful/optimization/uitests/support/AppLauncher.kt +++ b/implementations/android-sdk/uitests/src/main/kotlin/com/contentful/optimization/uitests/support/AppLauncher.kt @@ -8,8 +8,14 @@ import androidx.test.uiautomator.UiDevice import androidx.test.uiautomator.Until object AppLauncher { - const val APP_PACKAGE = "com.contentful.optimization.app" - private const val MAIN_ACTIVITY = "$APP_PACKAGE.MainActivity" + // Read the target app package from the instrumentation runner arguments so the same test APK + // can drive both the Compose and the XML Views reference impls. Default is the Compose impl + // so local IDE runs (`./gradlew :uitests:connectedAndroidTest`) keep working without extra + // flags. The Android CI matrix and `scripts/run-e2e.sh` set `-e APP_PACKAGE `. + val APP_PACKAGE: String = + InstrumentationRegistry.getArguments().getString("APP_PACKAGE") + ?: "com.contentful.optimization.app" + private val MAIN_ACTIVITY: String = "$APP_PACKAGE.MainActivity" fun launchApp(device: UiDevice, extras: Map = emptyMap()) { val context = InstrumentationRegistry.getInstrumentation().context diff --git a/implementations/android-sdk/uitests/src/main/kotlin/com/contentful/optimization/uitests/support/PerTestRule.kt b/implementations/android-sdk/uitests/src/main/kotlin/com/contentful/optimization/uitests/support/PerTestRule.kt new file mode 100644 index 00000000..4f629236 --- /dev/null +++ b/implementations/android-sdk/uitests/src/main/kotlin/com/contentful/optimization/uitests/support/PerTestRule.kt @@ -0,0 +1,50 @@ +package com.contentful.optimization.uitests.support + +import android.util.Log +import androidx.test.platform.app.InstrumentationRegistry +import androidx.test.uiautomator.UiDevice +import org.junit.rules.RuleChain +import org.junit.rules.TestRule +import org.junit.rules.TestWatcher +import org.junit.rules.Timeout +import org.junit.runner.Description +import java.io.File +import java.util.concurrent.TimeUnit + +object PerTestRule { + private const val TAG = "PerTestRule" + + // 60s is generous: average test runs in ~7s; the slowest legitimate tests chain + // two EXTENDED_TIMEOUT (30s) waits. Anything longer is a hang we want to abort. + private const val PER_TEST_TIMEOUT_SECONDS = 60L + + // adb pull target. Cleared at suite start by the workflow. + private val FAILURE_DIR = File("/sdcard/test-failures") + + fun create(): TestRule = RuleChain + .outerRule(failureContextWatcher()) + .around( + Timeout.builder() + .withTimeout(PER_TEST_TIMEOUT_SECONDS, TimeUnit.SECONDS) + // If the test thread is stuck in native code and can't be interrupted, + // dump its stack so we know where it wedged instead of just timing out. + .withLookingForStuckThread(true) + .build() + ) + + private fun failureContextWatcher(): TestWatcher = object : TestWatcher() { + override fun failed(e: Throwable, description: Description) { + val tag = "${description.className}#${description.methodName}" + Log.e(TAG, "FAILED $tag: ${e.javaClass.simpleName}: ${e.message}") + runCatching { + FAILURE_DIR.mkdirs() + val device = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation()) + val hierarchy = File(FAILURE_DIR, "$tag.uix") + val screenshot = File(FAILURE_DIR, "$tag.png") + device.dumpWindowHierarchy(hierarchy) + device.takeScreenshot(screenshot) + Log.e(TAG, "Wrote failure context: ${hierarchy.absolutePath}, ${screenshot.absolutePath}") + }.onFailure { Log.e(TAG, "Failed to capture failure context for $tag", it) } + } + } +} diff --git a/implementations/android-sdk/uitests/src/main/kotlin/com/contentful/optimization/uitests/support/TestHelpers.kt b/implementations/android-sdk/uitests/src/main/kotlin/com/contentful/optimization/uitests/support/TestHelpers.kt index cbb8a74a..f0b261e7 100644 --- a/implementations/android-sdk/uitests/src/main/kotlin/com/contentful/optimization/uitests/support/TestHelpers.kt +++ b/implementations/android-sdk/uitests/src/main/kotlin/com/contentful/optimization/uitests/support/TestHelpers.kt @@ -32,8 +32,25 @@ object TestHelpers { } fun tapElement(device: UiDevice, element: UiObject2, singleClick: Boolean = false) { - performAccessibilityClick(element) - if (singleClick) return + val accessibilityClickFired = performAccessibilityClick(element) + // The device.click() is a fallback for when the accessibility action + // failed to dispatch (e.g., uiAutomation returned no root, or the node + // wasn't found in the live tree). When the accessibility click did + // fire, doing a second tap 100ms later at the original bounds is + // actively harmful: if the first tap morphed the button in place + // (e.g., Identify → Reset on the live-updates test screen), the + // physical click lands on the morphed button and undoes the action. + if (singleClick || accessibilityClickFired) { + // performAction(ACTION_CLICK) only enqueues the action — the + // caller's next step (e.g., the test's follow-up pressBack) + // races against the UI thread processing the click. Sleep so the + // click is dispatched, then wait for an idle frame so the + // resulting state change (dialog dismissal, button morph, etc.) + // is committed before we return. + Thread.sleep(100) + device.waitForIdle(1500L) + return + } Thread.sleep(100) try { val bounds = element.visibleBounds diff --git a/implementations/android-sdk/uitests/src/main/kotlin/com/contentful/optimization/uitests/tests/AnalyticsTests.kt b/implementations/android-sdk/uitests/src/main/kotlin/com/contentful/optimization/uitests/tests/AnalyticsTests.kt index cd0dfa43..48340fee 100644 --- a/implementations/android-sdk/uitests/src/main/kotlin/com/contentful/optimization/uitests/tests/AnalyticsTests.kt +++ b/implementations/android-sdk/uitests/src/main/kotlin/com/contentful/optimization/uitests/tests/AnalyticsTests.kt @@ -5,14 +5,20 @@ import androidx.test.platform.app.InstrumentationRegistry import androidx.test.uiautomator.By import androidx.test.uiautomator.UiDevice import com.contentful.optimization.uitests.support.AppLauncher +import com.contentful.optimization.uitests.support.PerTestRule import com.contentful.optimization.uitests.support.TestHelpers import com.contentful.optimization.uitests.support.clearProfileState import org.junit.Before +import org.junit.Rule import org.junit.Test +import org.junit.rules.TestRule import org.junit.runner.RunWith @RunWith(AndroidJUnit4::class) class AnalyticsTests { + @get:Rule + val rule: TestRule = PerTestRule.create() + private lateinit var device: UiDevice @Before diff --git a/implementations/android-sdk/uitests/src/main/kotlin/com/contentful/optimization/uitests/tests/FlagViewTrackingTests.kt b/implementations/android-sdk/uitests/src/main/kotlin/com/contentful/optimization/uitests/tests/FlagViewTrackingTests.kt index cdad0014..85cd4d16 100644 --- a/implementations/android-sdk/uitests/src/main/kotlin/com/contentful/optimization/uitests/tests/FlagViewTrackingTests.kt +++ b/implementations/android-sdk/uitests/src/main/kotlin/com/contentful/optimization/uitests/tests/FlagViewTrackingTests.kt @@ -4,10 +4,13 @@ import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.platform.app.InstrumentationRegistry import androidx.test.uiautomator.By import androidx.test.uiautomator.UiDevice +import com.contentful.optimization.uitests.support.PerTestRule import com.contentful.optimization.uitests.support.TestHelpers import com.contentful.optimization.uitests.support.clearProfileState import org.junit.Before +import org.junit.Rule import org.junit.Test +import org.junit.rules.TestRule import org.junit.runner.RunWith /** @@ -17,6 +20,9 @@ import org.junit.runner.RunWith */ @RunWith(AndroidJUnit4::class) class FlagViewTrackingTests { + @get:Rule + val rule: TestRule = PerTestRule.create() + private lateinit var device: UiDevice @Before diff --git a/implementations/android-sdk/uitests/src/main/kotlin/com/contentful/optimization/uitests/tests/IdentifiedVariantsTests.kt b/implementations/android-sdk/uitests/src/main/kotlin/com/contentful/optimization/uitests/tests/IdentifiedVariantsTests.kt index 9cb87f00..95c8b3ac 100644 --- a/implementations/android-sdk/uitests/src/main/kotlin/com/contentful/optimization/uitests/tests/IdentifiedVariantsTests.kt +++ b/implementations/android-sdk/uitests/src/main/kotlin/com/contentful/optimization/uitests/tests/IdentifiedVariantsTests.kt @@ -7,16 +7,22 @@ import androidx.test.uiautomator.UiDevice import androidx.test.uiautomator.UiScrollable import androidx.test.uiautomator.UiSelector import com.contentful.optimization.uitests.support.AppLauncher +import com.contentful.optimization.uitests.support.PerTestRule import com.contentful.optimization.uitests.support.TestHelpers import com.contentful.optimization.uitests.support.clearProfileState import org.junit.Assert import org.junit.Before import org.junit.BeforeClass +import org.junit.Rule import org.junit.Test +import org.junit.rules.TestRule import org.junit.runner.RunWith @RunWith(AndroidJUnit4::class) class IdentifiedVariantsTests { + @get:Rule + val rule: TestRule = PerTestRule.create() + private lateinit var device: UiDevice companion object { diff --git a/implementations/android-sdk/uitests/src/main/kotlin/com/contentful/optimization/uitests/tests/LiveUpdatesTests.kt b/implementations/android-sdk/uitests/src/main/kotlin/com/contentful/optimization/uitests/tests/LiveUpdatesTests.kt index ec7399d0..be76d721 100644 --- a/implementations/android-sdk/uitests/src/main/kotlin/com/contentful/optimization/uitests/tests/LiveUpdatesTests.kt +++ b/implementations/android-sdk/uitests/src/main/kotlin/com/contentful/optimization/uitests/tests/LiveUpdatesTests.kt @@ -5,12 +5,15 @@ import androidx.test.platform.app.InstrumentationRegistry import androidx.test.uiautomator.By import androidx.test.uiautomator.UiDevice import com.contentful.optimization.uitests.support.AppLauncher +import com.contentful.optimization.uitests.support.PerTestRule import com.contentful.optimization.uitests.support.TestHelpers import com.contentful.optimization.uitests.support.clearProfileState import org.junit.After import org.junit.Assert import org.junit.Before +import org.junit.Rule import org.junit.Test +import org.junit.rules.TestRule import org.junit.runner.RunWith /** @@ -21,6 +24,9 @@ private val ENTRY_ID_TEXT_PATTERN = Regex("""^Entry: [a-zA-Z0-9]+$""") @RunWith(AndroidJUnit4::class) class LiveUpdatesTests { + @get:Rule + val rule: TestRule = PerTestRule.create() + private lateinit var device: UiDevice @Before diff --git a/implementations/android-sdk/uitests/src/main/kotlin/com/contentful/optimization/uitests/tests/OfflineBehaviorTests.kt b/implementations/android-sdk/uitests/src/main/kotlin/com/contentful/optimization/uitests/tests/OfflineBehaviorTests.kt index ec2a62df..aa456035 100644 --- a/implementations/android-sdk/uitests/src/main/kotlin/com/contentful/optimization/uitests/tests/OfflineBehaviorTests.kt +++ b/implementations/android-sdk/uitests/src/main/kotlin/com/contentful/optimization/uitests/tests/OfflineBehaviorTests.kt @@ -5,16 +5,22 @@ import androidx.test.platform.app.InstrumentationRegistry import androidx.test.uiautomator.By import androidx.test.uiautomator.UiDevice import com.contentful.optimization.uitests.support.AppLauncher +import com.contentful.optimization.uitests.support.PerTestRule import com.contentful.optimization.uitests.support.TestHelpers import com.contentful.optimization.uitests.support.clearProfileState import org.junit.After import org.junit.Assert import org.junit.Before +import org.junit.Rule import org.junit.Test +import org.junit.rules.TestRule import org.junit.runner.RunWith @RunWith(AndroidJUnit4::class) class OfflineBehaviorTests { + @get:Rule + val rule: TestRule = PerTestRule.create() + private lateinit var device: UiDevice // Time allowed after reconnecting for the SDK online signal to flip and the diff --git a/implementations/android-sdk/uitests/src/main/kotlin/com/contentful/optimization/uitests/tests/PreviewPanelOverridesTests.kt b/implementations/android-sdk/uitests/src/main/kotlin/com/contentful/optimization/uitests/tests/PreviewPanelOverridesTests.kt index 6d16ba9c..0f6b394c 100644 --- a/implementations/android-sdk/uitests/src/main/kotlin/com/contentful/optimization/uitests/tests/PreviewPanelOverridesTests.kt +++ b/implementations/android-sdk/uitests/src/main/kotlin/com/contentful/optimization/uitests/tests/PreviewPanelOverridesTests.kt @@ -8,15 +8,21 @@ import androidx.test.uiautomator.UiScrollable import androidx.test.uiautomator.UiSelector import androidx.test.uiautomator.Until import com.contentful.optimization.uitests.support.AppLauncher +import com.contentful.optimization.uitests.support.PerTestRule import com.contentful.optimization.uitests.support.TestHelpers import com.contentful.optimization.uitests.support.clearProfileState import org.junit.Assert import org.junit.Before +import org.junit.Rule import org.junit.Test +import org.junit.rules.TestRule import org.junit.runner.RunWith @RunWith(AndroidJUnit4::class) class PreviewPanelOverridesTests { + @get:Rule + val rule: TestRule = PerTestRule.create() + private lateinit var device: UiDevice companion object { @@ -352,8 +358,27 @@ class PreviewPanelOverridesTests { scrollPanelToElement("reset-all-overrides") TestHelpers.waitAndTap(device, By.desc("reset-all-overrides")) - // Confirm the native AlertDialog. - TestHelpers.waitAndTap(device, By.text("Reset")) + // Confirm the native AlertDialog. Use the dialog confirm button's + // testTag rather than By.text("Reset") because the panel beneath the + // dialog also has per-row "Reset" labels (reset-variant-* / + // reset-audience-*) and a text-based selector would non- + // deterministically match one of those first. + TestHelpers.waitAndTap(device, By.desc("reset-all-confirm")) + // Wait until the dialog body text is gone before pressing back to + // close the panel. Without this gate, closePanel's pressBack can race + // the dialog's dismissal: the back can be consumed by the still- + // attached dialog window instead of the bottom sheet, leaving the + // panel open when assertEntryVisible runs and rendering the variant + // entry unreachable (the modal sheet excludes the activity's entries + // from the accessibility tree). The dialog title and the panel footer + // share the "Reset to Actual State" text, so we key off the dialog + // body copy — which only exists while the dialog is open. + val dialogBodyPrefix = "This will clear all manual overrides" + val dialogGoneDeadline = System.currentTimeMillis() + TestHelpers.ELEMENT_TIMEOUT + while (System.currentTimeMillis() < dialogGoneDeadline) { + if (device.findObject(By.textStartsWith(dialogBodyPrefix)) == null) break + Thread.sleep(100) + } closePanel() diff --git a/implementations/android-sdk/uitests/src/main/kotlin/com/contentful/optimization/uitests/tests/PreviewPanelTests.kt b/implementations/android-sdk/uitests/src/main/kotlin/com/contentful/optimization/uitests/tests/PreviewPanelTests.kt index faadc2e7..c6f1640a 100644 --- a/implementations/android-sdk/uitests/src/main/kotlin/com/contentful/optimization/uitests/tests/PreviewPanelTests.kt +++ b/implementations/android-sdk/uitests/src/main/kotlin/com/contentful/optimization/uitests/tests/PreviewPanelTests.kt @@ -5,15 +5,21 @@ import androidx.test.platform.app.InstrumentationRegistry import androidx.test.uiautomator.By import androidx.test.uiautomator.UiDevice import com.contentful.optimization.uitests.support.AppLauncher +import com.contentful.optimization.uitests.support.PerTestRule import com.contentful.optimization.uitests.support.TestHelpers import com.contentful.optimization.uitests.support.clearProfileState import org.junit.Assert import org.junit.Before +import org.junit.Rule import org.junit.Test +import org.junit.rules.TestRule import org.junit.runner.RunWith @RunWith(AndroidJUnit4::class) class PreviewPanelTests { + @get:Rule + val rule: TestRule = PerTestRule.create() + private lateinit var device: UiDevice @Before diff --git a/implementations/android-sdk/uitests/src/main/kotlin/com/contentful/optimization/uitests/tests/ScreenTrackingTests.kt b/implementations/android-sdk/uitests/src/main/kotlin/com/contentful/optimization/uitests/tests/ScreenTrackingTests.kt index 0dc30366..b6ad25eb 100644 --- a/implementations/android-sdk/uitests/src/main/kotlin/com/contentful/optimization/uitests/tests/ScreenTrackingTests.kt +++ b/implementations/android-sdk/uitests/src/main/kotlin/com/contentful/optimization/uitests/tests/ScreenTrackingTests.kt @@ -4,15 +4,21 @@ import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.platform.app.InstrumentationRegistry import androidx.test.uiautomator.By import androidx.test.uiautomator.UiDevice +import com.contentful.optimization.uitests.support.PerTestRule import com.contentful.optimization.uitests.support.TestHelpers import com.contentful.optimization.uitests.support.clearProfileState import org.junit.Assert import org.junit.Before +import org.junit.Rule import org.junit.Test +import org.junit.rules.TestRule import org.junit.runner.RunWith @RunWith(AndroidJUnit4::class) class ScreenTrackingTests { + @get:Rule + val rule: TestRule = PerTestRule.create() + private lateinit var device: UiDevice @Before diff --git a/implementations/android-sdk/uitests/src/main/kotlin/com/contentful/optimization/uitests/tests/TapTrackingTests.kt b/implementations/android-sdk/uitests/src/main/kotlin/com/contentful/optimization/uitests/tests/TapTrackingTests.kt index e68ca7e9..5a9ee9b4 100644 --- a/implementations/android-sdk/uitests/src/main/kotlin/com/contentful/optimization/uitests/tests/TapTrackingTests.kt +++ b/implementations/android-sdk/uitests/src/main/kotlin/com/contentful/optimization/uitests/tests/TapTrackingTests.kt @@ -5,14 +5,20 @@ import androidx.test.platform.app.InstrumentationRegistry import androidx.test.uiautomator.By import androidx.test.uiautomator.UiDevice import com.contentful.optimization.uitests.support.AppLauncher +import com.contentful.optimization.uitests.support.PerTestRule import com.contentful.optimization.uitests.support.TestHelpers import com.contentful.optimization.uitests.support.clearProfileState import org.junit.Before +import org.junit.Rule import org.junit.Test +import org.junit.rules.TestRule import org.junit.runner.RunWith @RunWith(AndroidJUnit4::class) class TapTrackingTests { + @get:Rule + val rule: TestRule = PerTestRule.create() + private lateinit var device: UiDevice @Before diff --git a/implementations/android-sdk/uitests/src/main/kotlin/com/contentful/optimization/uitests/tests/UnidentifiedVariantsTests.kt b/implementations/android-sdk/uitests/src/main/kotlin/com/contentful/optimization/uitests/tests/UnidentifiedVariantsTests.kt index de39b27f..65f914aa 100644 --- a/implementations/android-sdk/uitests/src/main/kotlin/com/contentful/optimization/uitests/tests/UnidentifiedVariantsTests.kt +++ b/implementations/android-sdk/uitests/src/main/kotlin/com/contentful/optimization/uitests/tests/UnidentifiedVariantsTests.kt @@ -5,15 +5,21 @@ import androidx.test.platform.app.InstrumentationRegistry import androidx.test.uiautomator.By import androidx.test.uiautomator.UiDevice import com.contentful.optimization.uitests.support.AppLauncher +import com.contentful.optimization.uitests.support.PerTestRule import com.contentful.optimization.uitests.support.TestHelpers import com.contentful.optimization.uitests.support.clearProfileState import org.junit.Assert import org.junit.Before +import org.junit.Rule import org.junit.Test +import org.junit.rules.TestRule import org.junit.runner.RunWith @RunWith(AndroidJUnit4::class) class UnidentifiedVariantsTests { + @get:Rule + val rule: TestRule = PerTestRule.create() + private lateinit var device: UiDevice @Before 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..5e0739e3 --- /dev/null +++ b/implementations/android-sdk/views/src/main/kotlin/com/contentful/optimization/app/views/MainActivity.kt @@ -0,0 +1,227 @@ +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.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() + } + val simulateOffline = intent.getBooleanExtra("simulate_offline", false) + + // 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) { + } + if (simulateOffline) { + client.setOnline(false) + } + } + + 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) + renderEntries(entries) + } + } + } + + 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>) { + if (entries.isEmpty()) { + loadingIndicator.visibility = View.VISIBLE + return + } + loadingIndicator.visibility = View.GONE + entriesContainer.removeAllViews() + + entries.forEach { entry -> + val child = if (isNestedContent(entry)) { + NestedContentEntryViewBinder.create(this, entry) + } else { + ContentEntryViewBinder.create(this, entry) + } + 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..4840b5d9 --- /dev/null +++ b/implementations/android-sdk/views/src/main/kotlin/com/contentful/optimization/app/views/components/ContentEntryViewBinder.kt @@ -0,0 +1,108 @@ +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 { + + fun create(context: Context, entry: Map): View { + val entryId = entryId(entry) + + val view = OptimizedEntryView(context).apply { + accessibilityIdentifier = "content-entry-$entryId" + trackTaps = true + } + view.setContentRenderer { resolvedEntry -> + renderEntryColumn(context, resolvedEntry, entryId) + } + view.setEntry(entry) + return view + } + + internal fun renderEntryColumn( + context: Context, + resolvedEntry: Map, + entryId: String, + ): 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 textView = TextView(context).apply { + text = "No content" + 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 = "No content [Entry: $entryId]" + } + + // Resolve rich text after the view is attached so we can hook into its lifecycle. The + // suspending RichText.resolveText needs the client to be initialized; if the view is + // detached before the resolution finishes we drop the result silently. + 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..7918d313 --- /dev/null +++ b/implementations/android-sdk/views/src/main/kotlin/com/contentful/optimization/app/views/support/TestTagging.kt @@ -0,0 +1,47 @@ +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. + if (contentDescription == null) { + 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. + 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 @@ + + + + + + + + + +