diff --git a/.github/workflows/main-pipeline.yaml b/.github/workflows/main-pipeline.yaml index 9b0db815..969292e0 100644 --- a/.github/workflows/main-pipeline.yaml +++ b/.github/workflows/main-pipeline.yaml @@ -26,6 +26,7 @@ jobs: e2e_web_sdk_react: ${{ steps.filter.outputs.e2e_web_sdk_react }} e2e_react_web_sdk: ${{ steps.filter.outputs.e2e_react_web_sdk }} e2e_react_native_android: ${{ steps.filter.outputs.e2e_react_native_android }} + e2e_android: ${{ steps.filter.outputs.e2e_android }} e2e_ios: ${{ steps.filter.outputs.e2e_ios }} steps: - uses: namespacelabs/nscloud-checkout-action@938f5d2d403d6224d9a0c0dc559b1dae09c2ede4 # v8.1.1 @@ -126,11 +127,21 @@ jobs: - 'package.json' - 'pnpm-lock.yaml' - '.github/workflows/main-pipeline.yaml' + # Android native implementation E2E coverage scope. + e2e_android: + - 'implementations/android-sdk/**' + - 'lib/mocks/**' + - 'packages/android/**' + - 'packages/universal/optimization-js-bridge/**' + - 'package.json' + - 'pnpm-lock.yaml' + - '.github/workflows/main-pipeline.yaml' # iOS native implementation E2E coverage scope. e2e_ios: - 'implementations/ios-sdk/**' - 'lib/mocks/**' - 'packages/ios/**' + - 'packages/universal/optimization-js-bridge/**' - 'package.json' - 'pnpm-lock.yaml' - '.github/workflows/main-pipeline.yaml' @@ -659,6 +670,238 @@ jobs: if-no-files-found: error retention-days: 1 + e2e-android-sdk-build: + name: 🤖 Build Android APKs + runs-on: namespace-profile-linux-16-vcpu-32-gb-ram-optimal + timeout-minutes: 30 + needs: [setup, changes] + if: needs.changes.outputs.e2e_android == 'true' + env: + CI: 'true' + GRADLE_OPTS: >- + -Dorg.gradle.daemon=false -Dorg.gradle.parallel=true -Dorg.gradle.jvmargs=-Xmx4g + -Dkotlin.daemon.jvm.options=-Xmx2g + steps: + - uses: namespacelabs/nscloud-checkout-action@938f5d2d403d6224d9a0c0dc559b1dae09c2ede4 # v8.1.1 + + - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + with: + node-version-file: '.nvmrc' + package-manager-cache: false + + - uses: pnpm/action-setup@903f9c1a6ebcba6cf41d87230be49611ac97822e # v6.0.3 + + - name: Set Android SDK environment variables + run: | + echo "ANDROID_SDK_ROOT=$HOME/.android/sdk" >> "$GITHUB_ENV" + echo "ANDROID_HOME=$HOME/.android/sdk" >> "$GITHUB_ENV" + + - name: Prepare cache directories + run: mkdir -p "$HOME/.android/sdk" "$HOME/.android/cache" + + - name: Set up caches (Namespace) + uses: namespacelabs/nscloud-cache-action@15799a6b54e5765f85b2aac25b3f0df43ed571c0 # v1.4.3 + with: + 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 + ~/.android/cache + + - name: Install system dependencies (Android emulator) + run: | + sudo apt-get update + sudo apt-get install -y --no-install-recommends \ + ca-certificates curl unzip zip git \ + netcat-openbsd cpu-checker \ + libgl1 libnss3 libx11-6 libx11-xcb1 libxcomposite1 libxdamage1 libxrandr2 libxtst6 \ + libxi6 libxrender1 libxkbcommon0 libgbm1 libdbus-1-3 libdrm2 libpulse0 + sudo apt-get install -y --no-install-recommends libasound2 || sudo apt-get install -y --no-install-recommends libasound2t64 + + - name: Verify KVM is available + run: | + if [ ! -e /dev/kvm ]; then + echo "/dev/kvm not found; Android hardware acceleration will not work." >&2 + exit 1 + fi + ls -l /dev/kvm + sudo kvm-ok || true + + - name: Setup Java + uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0 + with: + distribution: 'temurin' + java-version: '17' + + - name: Setup Android SDK + uses: android-actions/setup-android@40fd30fb8d7440372e1316f5d1809ec01dcd3699 # v4.0.1 + + - name: Install JS dependencies + run: pnpm install --prefer-offline --frozen-lockfile + + - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: android-apks + path: android-apks + + - name: Start Mock Server + run: | + pnpm --dir lib/mocks serve > /tmp/mock-server.log 2>&1 & + echo $! > /tmp/mock-server.pid + for i in {1..60}; do + if nc -z localhost 8000 2>/dev/null; then + echo "Mock server is ready" + break + fi + echo "Waiting for mock server... ($i/60)" + sleep 1 + done + if ! nc -z localhost 8000 2>/dev/null; then + echo "Mock server failed to start:" + cat /tmp/mock-server.log + exit 1 + fi + + - name: Run Android E2E Tests (emulator) + uses: reactivecircus/android-emulator-runner@e89f39f1abbbd05b1113a29cf4db69e7540cae5a # v2.37.0 + with: + api-level: 35 + arch: x86_64 + target: google_apis + profile: pixel_7 + avd-name: test + force-avd-creation: true + emulator-boot-timeout: 600 + cores: 6 + ram-size: 4096M + disk-size: 8G + disable-animations: true + emulator-options: -no-window -no-audio -no-boot-anim -gpu swiftshader_indirect + script: | + # The emulator-runner action invokes the script via `/usr/bin/sh -c`, not bash, so + # `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. + # 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 against $APP_PACKAGE..." + adb shell am instrument -w -e APP_PACKAGE "$APP_PACKAGE" com.contentful.optimization.uitests/androidx.test.runner.AndroidJUnitRunner 2>&1 | tee /tmp/test-output.log + grep -q "FAILURES" /tmp/test-output.log && { echo "::error::Android UI tests failed"; exit 1; } || true + grep -q "Process crashed" /tmp/test-output.log && { echo "::error::Test process crashed"; exit 1; } || true + grep -q "OK (" /tmp/test-output.log || { echo "::error::Android UI tests did not complete successfully (missing OK status)"; exit 1; } + + - name: Upload logs on failure + if: failure() + run: | + echo "=== Mock Server Logs ===" + cat /tmp/mock-server.log || echo "No mock server logs found" + + - name: Stop Mock Server + if: always() + run: | + kill $(cat /tmp/mock-server.pid) 2>/dev/null || true + + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + if: always() + with: + name: ci-results-android-sdk-${{ matrix.app }} + path: | + implementations/android-sdk/logs/ + /tmp/mock-server.log + /tmp/test-output.log + retention-days: 7 + e2e-ios-sdk-build: name: 🍎 Build iOS UI Test Bundles runs-on: namespace-profile-macos-apple-silicon-arm64-6-cpu-14-gb diff --git a/.github/workflows/notify-slack.yml b/.github/workflows/notify-slack.yml index 9eda3ed2..9bef12d9 100644 --- a/.github/workflows/notify-slack.yml +++ b/.github/workflows/notify-slack.yml @@ -34,6 +34,8 @@ jobs: )" has_docs=false + has_guides=false + has_concepts=false has_packages=false changed_packages="" @@ -49,12 +51,20 @@ jobs: case "$file" in documentation/drafts/*) ;; + documentation/guides/*) + has_docs=true + has_guides=true + ;; + documentation/concepts/*) + has_docs=true + has_concepts=true + ;; documentation/*) has_docs=true ;; - packages/ios/ios-jsc-bridge/*) + packages/universal/optimization-js-bridge/*) has_packages=true - add_package "@contentful/optimization-ios-bridge" + add_package "@contentful/optimization-js-bridge" ;; packages/node/node-sdk/*) has_packages=true @@ -103,14 +113,34 @@ jobs: )" if [ "$has_docs" = "true" ] && [ "$has_packages" = "true" ]; then - message_title="📝📦 Documentation and packages just landed" - message_body="Documentation has been updated, and package changes were merged. @TECH_WRITER@, fresh docs goodness just landed for your reading list!" + if [ "$has_guides" = "true" ] && [ "$has_concepts" = "true" ]; then + message_title="📚📦 Docs and packages just landed" + message_body="Guide, concept, and package updates all landed together. @TECH_WRITER@, there is fresh guide goodness in the mix for your reading list!" + elif [ "$has_guides" = "true" ]; then + message_title="🧭📦 Guides and packages just landed" + message_body="Fresh guide updates and package changes are live. @TECH_WRITER@, something new and useful just landed in your favorite corner of the docs!" + elif [ "$has_concepts" = "true" ]; then + message_title="💡📦 Concepts and packages just landed" + message_body="Concept docs and package updates moved forward together. A tidy little upgrade for readers and builders!" + else + message_title="📝📦 Docs and packages just landed" + message_body="Documentation and package updates were merged together. Better docs, fresher packages!" + fi elif [ "$has_packages" = "true" ]; then message_title="📦 Package changes just landed" message_body="Package updates were merged. Fresh bits are ready for the next build!" + elif [ "$has_guides" = "true" ] && [ "$has_concepts" = "true" ]; then + message_title="📚 Guides and concepts just landed" + message_body="Guide and concept documentation both got updates. @TECH_WRITER@, fresh guide goodness just landed for your reading list!" + elif [ "$has_guides" = "true" ]; then + message_title="🧭 Guide documentation just landed" + message_body="Fresh guide updates are live. @TECH_WRITER@, something new and useful just landed in your favorite corner of the docs!" + elif [ "$has_concepts" = "true" ]; then + message_title="💡 Concept documentation just landed" + message_body="Concept docs got a little clearer today. Nice boost for the next reader!" else message_title="📝 Documentation just landed" - message_body="Documentation has been updated. @TECH_WRITER@, fresh docs goodness just landed for your reading list!" + message_body="A documentation update was merged. Small polish, better docs!" fi { @@ -141,7 +171,7 @@ jobs: if [[ "$message_body" == *"@TECH_WRITER@"* ]]; then if [ -z "$SLACK_TECH_WRITER_ID" ]; then - echo "SLACK_TECH_WRITER_ID is required for documentation notifications." >&2 + echo "SLACK_TECH_WRITER_ID is required for guide notifications." >&2 exit 1 fi diff --git a/.gitignore b/.gitignore index cad761d8..ced9b0ff 100644 --- a/.gitignore +++ b/.gitignore @@ -70,14 +70,12 @@ local.properties *.keystore !debug.keystore !**/gradle/wrapper/gradle-wrapper.jar +**/android-sdk/.gradle/ +**/android-sdk/app/build/ +**/android-sdk/uitests/build/ +**/android-sdk/local.properties +**/android-sdk/logs/ -# Android Native -triage-out -.gradle/ -app/build/ -uitests/build/ -local.properties -logs/ # node.js # @@ -122,4 +120,5 @@ yarn-error.log # Local environment configuration - commented out since we use safe defaults # Uncomment this line if you need to override with local secrets # env.config.ts - +triage-out +.claude/ diff --git a/.prettierignore b/.prettierignore index 52103794..6f6617c5 100644 --- a/.prettierignore +++ b/.prettierignore @@ -6,4 +6,5 @@ pnpm-lock.yaml **/android/.gradle/** **/.bundle/** **/node_modules/** -packages/ios/ContentfulOptimization/Sources/ContentfulOptimization/Resources/optimization-ios-bridge.umd.js \ No newline at end of file +packages/ios/ContentfulOptimization/Sources/ContentfulOptimization/Resources/optimization-ios-bridge.umd.js +packages/android/ContentfulOptimization/src/main/assets/optimization-android-bridge.umd.js diff --git a/README.md b/README.md index 9696ff0c..869fe7e5 100644 --- a/README.md +++ b/README.md @@ -92,8 +92,8 @@ React Native support is available through [`@contentful/optimization-react-native`](./packages/react-native-sdk/README.md). Native iOS work is also present in this repository as a pre-release Swift Package under -[`packages/ios`](./packages/ios/README.md), backed by the -[`@contentful/optimization-ios-bridge`](./packages/ios/ios-jsc-bridge/README.md) JavaScriptCore +[`packages/ios`](./packages/ios/README.md), backed by the shared +[`@contentful/optimization-js-bridge`](./packages/universal/optimization-js-bridge/README.md) adapter and the [iOS reference app](./implementations/ios-sdk/README.md). Treat this surface as alpha implementation work rather than a stable public native SDK. diff --git a/documentation/concepts/react-native-sdk-interaction-tracking-mechanics.md b/documentation/concepts/react-native-sdk-interaction-tracking-mechanics.md index acfb3741..243033cb 100644 --- a/documentation/concepts/react-native-sdk-interaction-tracking-mechanics.md +++ b/documentation/concepts/react-native-sdk-interaction-tracking-mechanics.md @@ -47,7 +47,7 @@ Things you still have to enable yourself: - [3. Consent gating](#3-consent-gating) - ["Why is nothing tracking?"](#why-is-nothing-tracking) - [4. Entry view tracking mechanics](#4-entry-view-tracking-mechanics) - - [Default visibility and timing](#default-visibility-and-timing) + - [Default thresholds](#default-thresholds) - [The visibility state machine](#the-visibility-state-machine) - [Initial, periodic, and final events](#initial-periodic-and-final-events) - [App backgrounding and cleanup](#app-backgrounding-and-cleanup) @@ -86,7 +86,7 @@ relevant provider/component is mounted. | Event | When it fires | Required wiring | | --------------------------------- | -------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------- | | **Screen view** | Each time the active navigation route changes. | `` wrapping `NavigationContainer` (or `useScreenTracking` on each screen). | -| **Entry view (initial)** | When a wrapped entry has accumulated enough visible time (default 2000 ms at ≥ 80% visibility). | `` with view tracking enabled (the default). | +| **Entry view (initial)** | When a wrapped entry has accumulated enough visible time (default 2000 ms at ≥ 80% visibility). | `` with view tracking enabled (the default). | | **Entry view (periodic updates)** | Every `viewDurationUpdateIntervalMs` (default 5000 ms) while the entry remains visible. | Same as above. | | **Entry view (final)** | When visibility ends (scrolled away, unmounted, or app backgrounded) _if_ at least one event already fired. | Same as above. | | **Entry tap** | On touch end, when the touch moved less than 10 points from touch start, on a wrapped entry. | `` with tap tracking enabled (off by default; opt in via `trackTaps` or `onTap`). | @@ -226,18 +226,18 @@ Four checks, in order of likelihood: 1. **Consent.** Without `defaults.consent: true` or a user accept, only `identify`/`screen` go out. Set `logLevel: 'info'` to see blocked events in the console. 2. **Tap tracking opt-in.** Views default to `true`, taps default to `false`. -3. **Visibility requirement.** Defaults are strict (80% for 2 s). Scroll-by content never fires. +3. **Visibility threshold.** Defaults are strict (80% for 2 s). Scroll-by content never fires. 4. **No scroll context.** An entry below the fold without `` will never - pass the visibility requirement — `scrollY` is assumed `0`. + pass the visibility threshold — `scrollY` is assumed `0`. ## 4. Entry view tracking mechanics This section describes the internals of `useViewportTracking`, the hook `` uses under the hood. -### Default visibility and timing +### Default thresholds -The default entry view settings are: +The default entry view thresholds are: | Constant | Value | Meaning | | ------------------------------------------ | ------ | ------------------------------------------------------------------------------------------------------------------- | @@ -245,7 +245,7 @@ The default entry view settings are: | `DEFAULT_VIEW_TIME_MS` | `2000` | Minimum accumulated visible time (ms) before the **initial** view event fires. A.k.a. the "dwell time". | | `DEFAULT_VIEW_DURATION_UPDATE_INTERVAL_MS` | `5000` | Interval (ms) between **periodic** duration update events after the initial event. | -Tap tracking has one additional requirement: +Tap tracking has one additional threshold: | Constant | Value | Meaning | | ------------------------ | ----- | ------------------------------------------------------------------------------------------------------------------------------- | @@ -268,7 +268,7 @@ interface ViewCycleState { On every scroll tick or layout change, `checkVisibility()` computes the overlap between the entry's measured `{y, height}` and the current viewport `{scrollY, viewportHeight}` to derive a -`visibilityRatio`, and compares it to `minVisibleRatio`: +`visibilityRatio`, and compares it to `threshold`: - **not-visible → visible** — `onVisibilityStart` resets the cycle, mints a fresh `viewId`, sets `visibleSince = now`, and schedules the next fire. @@ -281,7 +281,7 @@ Within a cycle, events fire based on accumulated visible time. The schedule mirr `ElementViewObserver`: ``` -requiredMs_for_event_N = dwellTimeMs + N * viewDurationUpdateIntervalMs +requiredMs_for_event_N = viewTimeMs + N * viewDurationUpdateIntervalMs ``` So with defaults: @@ -344,7 +344,7 @@ no matter how far the user scrolls. ```tsx - + @@ -453,14 +453,10 @@ ones. | `trackEntryInteraction` | `{ views?, taps? }` | `{ views: true, taps: false }` | Default view/tap tracking for every ``. Omitted keys fall back to the defaults. | | `liveUpdates` | `boolean` | `false` | Global live-updates default. When `false`, `` locks to the first variant it sees. | | `previewPanel` | `PreviewPanelConfig` | `undefined` | Forces `liveUpdates = true` whenever the panel is open (cannot be overridden). | -| `onStatesReady` | `(states) => cleanup` | `undefined` | Registers app-level state subscribers when SDK state is ready. | | `defaults.consent` | `boolean \| undefined` | `undefined` | Initial consent state at startup. Overridden by `consent()` calls at runtime. | | `allowedEventTypes` | `EventType[]` | `['identify', 'screen']` | Event types permitted while consent is `undefined` or `false`. | -The "`{ views: true, taps: false }`" default is the root interaction-tracking context default. Use -`onStatesReady` when diagnostics or app-level observers should attach as soon as SDK state exists -and before provider children can emit `screen`, `eventStream`, or `blockedEventStream` updates. -Component-local state should still subscribe from hooks and effects under the provider. +The "`{ views: true, taps: false }`" default is the root interaction-tracking context default. ### OptimizedEntry props @@ -469,11 +465,11 @@ Component-local state should still subscribe from hooks and effects under the pr | `trackViews` | `boolean \| undefined` | `undefined` | Per-entry override for view tracking. `undefined` inherits from `trackEntryInteraction.views`. | | `trackTaps` | `boolean \| undefined` | `undefined` | Per-entry override for tap tracking. `undefined` inherits from `trackEntryInteraction.taps`. | | `onTap` | `(resolved) => void` | `undefined` | Implicitly enables tap tracking unless `trackTaps` is explicitly `false`. Fires after the click event. | -| `minVisibleRatio` | `number (0.0 – 1.0)` | `0.8` | Visibility ratio required to consider the entry visible. | -| `dwellTimeMs` | `number` | `2000` | Dwell time before the initial view event. | +| `threshold` | `number (0.0 – 1.0)` | `0.8` | Visibility ratio required to consider the entry visible. | +| `viewTimeMs` | `number` | `2000` | Dwell time before the initial view event. | | `viewDurationUpdateIntervalMs` | `number` | `5000` | Interval between periodic duration updates after the initial event. | | `liveUpdates` | `boolean \| undefined` | `undefined` | Per-entry live-updates override. See resolution order below. | -| `baselineEntry` | `Entry` | (required) | The baseline or optimized Contentful entry. | +| `entry` | `Entry` | (required) | The baseline or optimized Contentful entry. | | `children` | `ReactNode \| ((resolved) => ReactNode)` | (required) | Render prop receives the resolved variant; static children are rendered as-is. | Each default is defined by the SDK component and tracking hook behavior. @@ -601,7 +597,7 @@ function HomeScreen({ navigation }) { {posts.map((post) => ( navigation.navigate('BlogPostDetail', { post })} > diff --git a/eslint.config.ts b/eslint.config.ts index ef068608..25740ed7 100644 --- a/eslint.config.ts +++ b/eslint.config.ts @@ -4,6 +4,7 @@ import prettier from 'eslint-config-prettier' import { configs as lit } from 'eslint-plugin-lit' import { configs as wc } from 'eslint-plugin-wc' import { defineConfig, type Config } from 'eslint/config' +import { URL } from 'node:url' import typescript from 'typescript-eslint' // `eslint-config-love` currently exposes FlatConfig types that don't line up with ESLint v10 helpers. @@ -15,8 +16,7 @@ const strictConfigs = Array.isArray(typescript.configs.strict) const stylisticConfigs = Array.isArray(typescript.configs.stylistic) ? typescript.configs.stylistic : [typescript.configs.stylistic] -const url: URL = new URL('.', import.meta.url) -const { pathname: tsconfigRootDir } = url +const { pathname: tsconfigRootDir } = new URL('.', import.meta.url) export default defineConfig( { @@ -31,6 +31,9 @@ export default defineConfig( '**/dist', 'docs/media/**', '**/ios/**', + // Engine-targeted JS bridge glue compiled into the native SDKs; consolidated + // from the ios/android bridge packages, which were ignored under the rules above. + '**/optimization-js-bridge/**', '**/node_modules', ], }, @@ -86,16 +89,7 @@ export default defineConfig( lit['flat/recommended'], { // https://github.com/vitest-dev/vitest/issues/4543#issuecomment-1824628142 - files: [ - '**/src/**/*.test.ts', - '**/src/**/*.test.tsx', - '**/src/**/*.spec.ts', - '**/src/**/*.spec.tsx', - '**/test/**/*.ts', - '**/test/**/*.tsx', - '**/e2e/**/*.ts', - '**/e2e/**/*.tsx', - ], + files: ['**/src/**/*.test.ts', '**/src/**/*.spec.ts', '**/test/**/*.ts', '**/e2e/**/*.ts'], rules: { '@typescript-eslint/class-methods-use-this': 'off', '@typescript-eslint/init-declarations': 'off', diff --git a/implementations/android-sdk/AGENTS.md b/implementations/android-sdk/AGENTS.md index fc14d470..ecef16bc 100644 --- a/implementations/android-sdk/AGENTS.md +++ b/implementations/android-sdk/AGENTS.md @@ -4,66 +4,104 @@ 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/android/android-zipline-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-android-bridge build` -- Build UI test APK: `./gradlew :uitests:assembleDebug` -- Run all UI tests: `./gradlew :uitests:connectedAndroidTest` -- Run single test class: - `./gradlew :uitests:connectedAndroidTest -Pandroid.testInstrumentationRunnerArguments.class=com.contentful.optimization.uitests.tests.AnalyticsTests` +- Build bridge first: `pnpm --filter @contentful/optimization-js-bridge build` +- 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-android-bridge` before testing when bridge source changed. -- After UI structure changes, run `./gradlew :uitests:assembleDebug` to verify test APK compiles. diff --git a/implementations/android-sdk/README.md b/implementations/android-sdk/README.md index 41d85b24..ac782cee 100644 --- a/implementations/android-sdk/README.md +++ b/implementations/android-sdk/README.md @@ -29,7 +29,7 @@ integration pattern using Jetpack Compose and serves as a test target for UI Aut - Nested entry resolution and recursive rendering - Navigation with screen tracking via `ScreenTrackingEffect` - Live updates behavior: default (global), explicit live, and locked variants -- `PreviewPanelOverlay` with audience/variant override controls +- `PreviewPanelConfig` preview panel with audience/variant override controls - Analytics event display for debugging tracked events - All accessibility identifiers aligned with the iOS SwiftUI implementation for cross-platform E2E parity @@ -40,7 +40,7 @@ integration pattern using Jetpack Compose and serves as a test target for UI Aut - Android emulator or connected device - `adb` in PATH - pnpm dependencies installed at monorepo root (`pnpm install`) -- Android bridge built: `pnpm --filter @contentful/optimization-android-bridge build` +- Android bridge built: `pnpm --filter @contentful/optimization-js-bridge build` ## Setup @@ -48,7 +48,7 @@ From the monorepo root: ```sh pnpm install -pnpm --filter @contentful/optimization-android-bridge build +pnpm --filter @contentful/optimization-js-bridge build ``` ## Running locally @@ -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 ``` @@ -98,7 +98,7 @@ Before running anything from the IDE, in a separate terminal: ```sh # From the monorepo root, build the bridge once (or after bridge source changes): -pnpm --filter @contentful/optimization-android-bridge build +pnpm --filter @contentful/optimization-js-bridge build # Then start the mock server and leave it running: pnpm --dir lib/mocks serve 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 84% 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 8f77603b..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 @@ -16,7 +16,9 @@ import androidx.compose.ui.semantics.testTagsAsResourceId import com.contentful.optimization.app.screens.MainScreen import com.contentful.optimization.compose.OptimizationRoot import com.contentful.optimization.core.OptimizationConfig -import com.contentful.optimization.preview.PreviewPanelOverlay +import com.contentful.optimization.preview.PreviewPanelConfig +import com.contentful.optimization.shared.AppConfig +import com.contentful.optimization.shared.MockPreviewContentfulClient class MainActivity : ComponentActivity() { @@ -50,10 +52,11 @@ class MainActivity : ComponentActivity() { ), trackViews = true, trackTaps = true, + previewPanel = PreviewPanelConfig( + contentfulClient = MockPreviewContentfulClient(), + ), ) { - PreviewPanelOverlay(contentfulClient = MockPreviewContentfulClient()) { - MainScreen(simulateOffline = simulateOffline) - } + MainScreen(simulateOffline = simulateOffline) } } } 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 90% 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 441bfa0e..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 @@ -4,29 +4,20 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.padding import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue -import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Modifier import androidx.compose.ui.platform.testTag 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.compose.LocalOptimizationClient +import com.contentful.optimization.shared.EventStore @Composable fun AnalyticsEventDisplay() { - val client = LocalOptimizationClient.current val events by EventStore.events.collectAsState() val componentStats by EventStore.componentStats.collectAsState() - val scope = rememberCoroutineScope() - - LaunchedEffect(Unit) { - EventStore.subscribe(client.events, scope) - } Column( modifier = Modifier 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 73% 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 71d28000..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 @@ -4,12 +4,19 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.padding import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.platform.testTag import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.semantics import androidx.compose.ui.unit.dp +import com.contentful.optimization.compose.LocalOptimizationClient import com.contentful.optimization.compose.OptimizedEntry +import com.contentful.optimization.shared.RichText @Composable fun ContentEntryView(entry: Map) { @@ -26,9 +33,13 @@ fun ContentEntryView(entry: Map) { @Composable private fun EntryContent(entry: Map, entryId: String) { + val client = LocalOptimizationClient.current @Suppress("UNCHECKED_CAST") val fields = entry["fields"] as? Map - val text = fields?.get("text") as? String ?: "No content" + var text by remember(entry) { mutableStateOf("No content") } + LaunchedEffect(entry) { + text = RichText.resolveText(fields?.get("text"), client) + } Column( modifier = Modifier 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 80% 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 58b833d6..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 @@ -4,12 +4,19 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.padding import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.platform.testTag import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.semantics import androidx.compose.ui.unit.dp +import com.contentful.optimization.compose.LocalOptimizationClient import com.contentful.optimization.compose.OptimizedEntry +import com.contentful.optimization.shared.RichText @Composable fun NestedContentEntryView(entry: Map) { @@ -42,9 +49,13 @@ fun NestedContentEntryView(entry: Map) { @Composable private fun NestedEntryText(entry: Map) { val id = entryId(entry) + val client = LocalOptimizationClient.current @Suppress("UNCHECKED_CAST") val fields = entry["fields"] as? Map - val text = fields?.get("text") as? String ?: "No content" + var text by remember(entry) { mutableStateOf("No content") } + LaunchedEffect(entry) { + text = RichText.resolveText(fields?.get("text"), client) + } Column( modifier = Modifier 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 96% 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 99e38c23..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 @@ -143,7 +143,12 @@ fun LiveUpdatesTestScreen(onClose: () -> Unit) { item { Column(modifier = Modifier.padding(horizontal = 16.dp)) { Button( - onClick = { isPreviewPanelSimulated = !isPreviewPanelSimulated }, + onClick = { + isPreviewPanelSimulated = !isPreviewPanelSimulated + // Drive the SDK preview-panel flag so default/locked + // sections switch to live-update mode while open. + client.setPreviewPanelOpen(isPreviewPanelSimulated) + }, modifier = Modifier.testTag("simulate-preview-panel-button"), ) { Text( 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 64% 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 9dd61842..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 @@ -1,11 +1,16 @@ package com.contentful.optimization.app.screens +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll import androidx.compose.material3.Button import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue @@ -14,16 +19,19 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue 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.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 import com.contentful.optimization.app.components.isNestedContent import com.contentful.optimization.compose.LocalOptimizationClient -import com.contentful.optimization.compose.OptimizationLazyColumn +import com.contentful.optimization.compose.LocalScrollContext +import com.contentful.optimization.compose.ScrollContext import kotlinx.coroutines.launch import org.json.JSONObject @@ -34,11 +42,13 @@ fun MainScreen(simulateOffline: Boolean = false) { val scope = rememberCoroutineScope() var entries by remember { mutableStateOf>>(emptyList()) } - var isIdentified by remember { mutableStateOf(false) } var showNavigationTest by remember { mutableStateOf(false) } var showLiveUpdatesTest by remember { mutableStateOf(false) } + var flagSubscribed by remember { mutableStateOf(false) } + var viewportHeight by remember { mutableStateOf(0f) } LaunchedEffect(Unit) { + EventStore.subscribe(client.events, scope) client.consent(true) try { client.page(mapOf("url" to "app")) } catch (_: Exception) {} if (simulateOffline) { @@ -52,9 +62,19 @@ fun MainScreen(simulateOffline: Boolean = false) { } } + val isIdentified = remember(state.profile) { + @Suppress("UNCHECKED_CAST") + val traits = state.profile?.get("traits") as? Map + traits?.get("identified") == true + } + LaunchedEffect(profileKey) { if (state.profile != null) { entries = ContentfulFetcher.fetchEntries(AppConfig.entryIds) + if (!flagSubscribed) { + flagSubscribed = true + client.subscribeToFlag("boolean") + } } } @@ -68,7 +88,6 @@ fun MainScreen(simulateOffline: Boolean = false) { if (!isIdentified) { Button( onClick = { - isIdentified = true scope.launch { try { client.identify( @@ -84,7 +103,6 @@ fun MainScreen(simulateOffline: Boolean = false) { Button( onClick = { client.reset() - isIdentified = false scope.launch { try { client.page(mapOf("url" to "app")) } catch (_: Exception) {} } @@ -105,20 +123,32 @@ fun MainScreen(simulateOffline: Boolean = false) { if (entries.isEmpty()) { Text("Loading...") } else { - OptimizationLazyColumn( - modifier = Modifier.testTag("main-scroll-view"), - ) { - items(entries.size) { index -> - val entry = entries[index] - if (isNestedContent(entry)) { - NestedContentEntryView(entry = entry) - } else { - ContentEntryView(entry = entry) + + val scrollContext = remember(viewportHeight) { + ScrollContext(scrollY = 0f, viewportHeight = viewportHeight) + } + CompositionLocalProvider(LocalScrollContext provides scrollContext) { + Box( + modifier = Modifier + .weight(1f) + .onGloballyPositioned { viewportHeight = it.size.height.toFloat() }, + ) { + Column( + modifier = Modifier + .testTag("main-scroll-view") + .fillMaxSize() + .verticalScroll(rememberScrollState()), + ) { + entries.forEach { entry -> + if (isNestedContent(entry)) { + NestedContentEntryView(entry = entry) + } else { + ContentEntryView(entry = entry) + } + } + AnalyticsEventDisplay() } } - item { - AnalyticsEventDisplay() - } } } } 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 6d2a58ec..fc1c3e94 100755 --- a/implementations/android-sdk/scripts/bootstrap.sh +++ b/implementations/android-sdk/scripts/bootstrap.sh @@ -19,7 +19,7 @@ # Prerequisites: # - Android SDK installed with ANDROID_HOME set # - pnpm dependencies installed at monorepo root -# - Android bridge built: pnpm --filter @contentful/optimization-android-bridge build +# - Android bridge built: pnpm --filter @contentful/optimization-js-bridge build set -euo pipefail @@ -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/prepare-env.sh b/implementations/android-sdk/scripts/prepare-env.sh index a9c61f71..bcea9193 100755 --- a/implementations/android-sdk/scripts/prepare-env.sh +++ b/implementations/android-sdk/scripts/prepare-env.sh @@ -64,7 +64,7 @@ check_bridge_bundle() { log_error " ${bridge}" log_error "" log_error "Build it from the monorepo root:" - log_error " pnpm --filter @contentful/optimization-android-bridge build" + log_error " pnpm --filter @contentful/optimization-js-bridge build" return 1 } diff --git a/implementations/android-sdk/scripts/run-e2e.sh b/implementations/android-sdk/scripts/run-e2e.sh index 5499132c..96ad2964 100755 --- a/implementations/android-sdk/scripts/run-e2e.sh +++ b/implementations/android-sdk/scripts/run-e2e.sh @@ -33,7 +33,7 @@ # - Android SDK installed with adb and emulator in PATH (or ANDROID_HOME set) # - At least one AVD configured (or a physical device connected) # - pnpm dependencies installed at monorepo root -# - Android bridge built: pnpm --filter @contentful/optimization-android-bridge build +# - Android bridge built: pnpm --filter @contentful/optimization-js-bridge build # # Logs: # All logs are written to implementations/android-sdk/logs/: @@ -69,7 +69,11 @@ 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. Override via env var to switch +# between the Compose impl (default) and the XML Views impl. The Gradle module name + APK file +# name are derived from the package below in `resolve_app_module`. +APP_PACKAGE="${APP_PACKAGE:-com.contentful.optimization.app}" +APP_MODULE="" RED='\033[0;31m' GREEN='\033[0;32m' @@ -154,6 +158,21 @@ parse_args() { fi } +resolve_app_module() { + case "$APP_PACKAGE" in + com.contentful.optimization.app) + APP_MODULE="compose" + ;; + com.contentful.optimization.app.views) + APP_MODULE="views" + ;; + *) + echo "[ERROR] Unknown APP_PACKAGE: $APP_PACKAGE. Expected com.contentful.optimization.app or com.contentful.optimization.app.views." >&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"; } @@ -451,6 +470,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} @@ -474,7 +503,7 @@ build_bridge() { fi log_info "Building Android bridge JS bundle..." - pnpm --dir "$ROOT_DIR" --filter @contentful/optimization-android-bridge build + pnpm --dir "$ROOT_DIR" --filter @contentful/optimization-js-bridge build log_info "Bridge bundle built" } @@ -484,14 +513,14 @@ build_apks() { return 0 fi - log_info "Building app APK and test APK..." + log_info "Building $APP_MODULE app APK and test APK..." cd "$APP_DIR" - ./gradlew :app:assembleDebug :uitests:assembleDebug + ./gradlew ":${APP_MODULE}: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 @@ -518,7 +547,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}" @@ -565,10 +595,12 @@ run_tests() { main() { parse_args "$@" + resolve_app_module log_info "=== Android UI Automator 2 E2E Test Runner ===" log_info "Root directory: $ROOT_DIR" log_info "App directory: $APP_DIR" + log_info "Target app: $APP_PACKAGE (module :$APP_MODULE)" log_info "CI mode: $CI" [[ -n "$TEST_CLASS" ]] && log_info "Test class: $TEST_CLASS" [[ -n "$TEST_METHOD" ]] && log_info "Test method: $TEST_METHOD" @@ -576,6 +608,7 @@ main() { verify_device start_mock_server setup_adb + force_stop_other_apps build_bridge build_apks install_apks 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 new file mode 100644 index 00000000..20b95c1d --- /dev/null +++ b/implementations/android-sdk/uitests/build.gradle.kts @@ -0,0 +1,47 @@ +plugins { + id("com.android.test") + id("org.jetbrains.kotlin.android") +} + +android { + namespace = "com.contentful.optimization.uitests" + compileSdk = 36 + + defaultConfig { + minSdk = 24 + targetSdk = 35 + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + // Run this UI Automator suite in its own instrumentation process rather + // than inside the app process. The tests force-stop and relaunch the app + // (AppLauncher.relaunchClean / clearProfileState); without self- + // instrumenting, `am force-stop` would SIGKILL the test runner itself. + experimentalProperties["android.experimental.self-instrumenting"] = true + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_11 + targetCompatibility = JavaVersion.VERSION_11 + } + + // 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 { + compilerOptions { + jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_11) + } +} + +dependencies { + implementation("androidx.test.uiautomator:uiautomator:2.3.0") + implementation("androidx.test:runner:1.6.2") + implementation("androidx.test:rules:1.5.0") + implementation("androidx.test:core:1.6.1") + implementation("androidx.test.ext:junit:1.1.5") + implementation("androidx.lifecycle:lifecycle-process:2.8.7") + implementation("androidx.lifecycle:lifecycle-runtime:2.8.7") +} 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 new file mode 100644 index 00000000..703ab3cf --- /dev/null +++ b/implementations/android-sdk/uitests/src/main/kotlin/com/contentful/optimization/uitests/support/AppLauncher.kt @@ -0,0 +1,56 @@ +package com.contentful.optimization.uitests.support + +import android.content.ComponentName +import android.content.Intent +import androidx.test.platform.app.InstrumentationRegistry +import androidx.test.uiautomator.By +import androidx.test.uiautomator.UiDevice +import androidx.test.uiautomator.Until + +object AppLauncher { + // 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 + val intent = Intent(Intent.ACTION_MAIN).apply { + component = ComponentName(APP_PACKAGE, MAIN_ACTIVITY) + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK) + for ((key, value) in extras) { + putExtra(key, value) + } + } + context.startActivity(intent) + device.wait(Until.hasObject(By.pkg(APP_PACKAGE).depth(0)), TestHelpers.ELEMENT_TIMEOUT) + } + + fun relaunchClean(device: UiDevice) { + forceStop(device) + launchApp(device, extras = mapOf("reset" to true)) + device.wait( + Until.hasObject(By.res("identify-button")), + TestHelpers.ELEMENT_TIMEOUT + ) + } + + fun bringToForeground(device: UiDevice) { + val context = InstrumentationRegistry.getInstrumentation().context + val intent = Intent(Intent.ACTION_MAIN).apply { + component = ComponentName(APP_PACKAGE, MAIN_ACTIVITY) + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + } + context.startActivity(intent) + device.wait(Until.hasObject(By.pkg(APP_PACKAGE).depth(0)), TestHelpers.ELEMENT_TIMEOUT) + } + + fun forceStop(device: UiDevice) { + device.executeShellCommand("am force-stop $APP_PACKAGE") + Thread.sleep(500) + } +} diff --git a/implementations/android-sdk/uitests/src/main/kotlin/com/contentful/optimization/uitests/support/DeviceExtensions.kt b/implementations/android-sdk/uitests/src/main/kotlin/com/contentful/optimization/uitests/support/DeviceExtensions.kt new file mode 100644 index 00000000..2e84651c --- /dev/null +++ b/implementations/android-sdk/uitests/src/main/kotlin/com/contentful/optimization/uitests/support/DeviceExtensions.kt @@ -0,0 +1,91 @@ +package com.contentful.optimization.uitests.support + +import androidx.test.uiautomator.By +import androidx.test.uiautomator.StaleObjectException +import androidx.test.uiautomator.UiDevice +import androidx.test.uiautomator.Until + +// A high step count slows the swipe so it ends at a low velocity with minimal +// fling momentum, leaving a scrolled entry at a predictable position for +// dwell-sensitive view-tracking assertions. +private const val MOMENTUM_FREE_STEPS = 160 + +// A fast swipe for bulk scrolling and quick entry transits, so a transiting +// entry never rests long enough to trip the dwell timer. Dwell-sensitive +// positioning (the cycle-reset jiggle) uses the momentum-free path instead. +private const val FAST_SWIPE_STEPS = 12 + +// Quick bulk swipes (mirrors the iOS `swipeUp(times:)`/`swipeDown(times:)`): used +// to move content a lot, fast, so a transiting entry never rests long enough to +// trip the dwell timer. Momentum-free precision is `scrollByOffset`'s job. +fun UiDevice.swipeUpMultiple(times: Int, scrollViewId: String = "main-scroll-view") { + repeat(times) { scrollByOffset(dy = 1100, scrollViewId = scrollViewId, fast = true) } +} + +fun UiDevice.swipeDownMultiple(times: Int, scrollViewId: String = "main-scroll-view") { + repeat(times) { scrollByOffset(dy = -1100, scrollViewId = scrollViewId, fast = true) } +} + +/** + * Scrolls the scroll view by a precise pixel offset. A positive [dy] reveals + * lower content; a negative [dy] reveals upper content. The gesture is + * momentum-free (slow, many small steps) by default so a tracked entry rests at + * a predictable position; pass [fast] = true for a quick transit that an entry + * should pass through without dwelling. Mirrors the iOS `scrollByOffset` helper. + */ +fun UiDevice.scrollByOffset( + dy: Int, + scrollViewId: String = "main-scroll-view", + fast: Boolean = false, +) { + val bounds = try { + findObject(By.res(scrollViewId))?.visibleBounds + } catch (_: StaleObjectException) { + null + } ?: return + val centerX = bounds.centerX() + // Anchor near the bottom when revealing lower content, near the top when + // revealing upper content, so the gesture endpoint stays on screen. + val anchorY = if (dy > 0) { + bounds.top + bounds.height() * 9 / 10 + } else { + bounds.top + bounds.height() / 10 + } + val endY = (anchorY - dy).coerceIn(bounds.top + 5, bounds.bottom - 5) + if (endY == anchorY) return + swipe(centerX, anchorY, centerX, endY, if (fast) FAST_SWIPE_STEPS else MOMENTUM_FREE_STEPS) +} + +fun clearProfileState(device: UiDevice, requireFreshAppInstance: Boolean = false) { + if (requireFreshAppInstance) { + AppLauncher.relaunchClean(device) + device.wait(Until.hasObject(By.res("identify-button")), TestHelpers.ELEMENT_TIMEOUT) + return + } + + val closeLiveUpdates = device.findObject(By.res("close-live-updates-test-button")) + if (closeLiveUpdates != null) { + TestHelpers.tapElement(device, closeLiveUpdates) + Thread.sleep(500) + } + + val closeNavigation = device.findObject(By.res("close-navigation-test-button")) + if (closeNavigation != null) { + TestHelpers.tapElement(device, closeNavigation) + Thread.sleep(500) + } + + val resetButton = device.findObject(By.res("reset-button")) + if (resetButton != null) { + TestHelpers.tapElement(device, resetButton) + device.wait(Until.hasObject(By.res("identify-button")), TestHelpers.ELEMENT_TIMEOUT) + return + } + + if (device.wait(Until.hasObject(By.res("identify-button")), 1_500L) == true) { + return + } + + AppLauncher.relaunchClean(device) + device.wait(Until.hasObject(By.res("identify-button")), TestHelpers.ELEMENT_TIMEOUT) +} 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 new file mode 100644 index 00000000..91182bc2 --- /dev/null +++ b/implementations/android-sdk/uitests/src/main/kotlin/com/contentful/optimization/uitests/support/TestHelpers.kt @@ -0,0 +1,325 @@ +package com.contentful.optimization.uitests.support + +import android.view.accessibility.AccessibilityNodeInfo +import androidx.test.platform.app.InstrumentationRegistry +import androidx.test.uiautomator.By +import androidx.test.uiautomator.BySelector +import androidx.test.uiautomator.StaleObjectException +import androidx.test.uiautomator.UiDevice +import androidx.test.uiautomator.UiObject2 +import androidx.test.uiautomator.UiScrollable +import androidx.test.uiautomator.UiSelector +import androidx.test.uiautomator.Until +import org.junit.Assert + +object TestHelpers { + const val ELEMENT_TIMEOUT = 20_000L + const val EXTENDED_TIMEOUT = 30_000L + private const val POLL_INTERVAL = 150L + + fun waitForElement( + device: UiDevice, + selector: BySelector, + timeout: Long = ELEMENT_TIMEOUT, + ): UiObject2 { + val deadline = System.currentTimeMillis() + timeout + while (System.currentTimeMillis() < deadline) { + val element = device.findObject(selector) + if (element != null) return element + Thread.sleep(POLL_INTERVAL) + } + throw AssertionError("Element did not appear within ${timeout}ms") + } + + fun tapElement(device: UiDevice, element: UiObject2, singleClick: Boolean = false) { + performAccessibilityClick(element) + if (singleClick) return + Thread.sleep(100) + try { + val bounds = element.visibleBounds + device.click(bounds.centerX(), bounds.centerY()) + } catch (_: StaleObjectException) { + // Element disappeared after accessibility click — click already worked + } + } + + private fun performAccessibilityClick(element: UiObject2): Boolean { + try { + val automation = InstrumentationRegistry.getInstrumentation().uiAutomation + val root = automation.rootInActiveWindow ?: return false + val resourceId = element.resourceName + val contentDesc = element.contentDescription + val text = try { element.text } catch (_: Exception) { null } + val node = findAccessibilityNode(root, resourceId, contentDesc, text) + if (node != null) { + return clickNodeOrClickableAncestor(node) + } + return false + } catch (_: Exception) { + return false + } + } + + private fun findAccessibilityNode( + root: AccessibilityNodeInfo, + resourceId: String?, + contentDesc: String?, + text: String? = null, + ): AccessibilityNodeInfo? { + if (resourceId != null) { + val nodeRid = root.viewIdResourceName + if (nodeRid != null && (nodeRid == resourceId || nodeRid.endsWith(":id/$resourceId"))) { + return root + } + } + if (contentDesc != null && root.contentDescription?.toString() == contentDesc) { + return root + } + if (text != null && resourceId == null && contentDesc == null && root.text?.toString() == text) { + return root + } + for (i in 0 until root.childCount) { + val child = root.getChild(i) ?: continue + val result = findAccessibilityNode(child, resourceId, contentDesc, text) + if (result != null) return result + } + return null + } + + private fun clickNodeOrClickableAncestor(node: AccessibilityNodeInfo): Boolean { + if (node.isClickable) { + return node.performAction(AccessibilityNodeInfo.ACTION_CLICK) + } + var current = node.parent + while (current != null) { + if (current.isClickable) { + return current.performAction(AccessibilityNodeInfo.ACTION_CLICK) + } + current = current.parent + } + return node.performAction(AccessibilityNodeInfo.ACTION_CLICK) + } + + fun waitAndTap( + device: UiDevice, + selector: BySelector, + timeout: Long = ELEMENT_TIMEOUT, + singleClick: Boolean = false, + ) { + val element = waitForElement(device, selector, timeout) + device.waitForIdle(1500L) + tapElement(device, element, singleClick = singleClick) + } + + fun findElement(device: UiDevice, testId: String): UiObject2? { + device.findObject(By.res(testId))?.let { return it } + device.findObject(By.desc(testId))?.let { return it } + device.findObject(By.text(testId))?.let { return it } + return null + } + + fun extractText(element: UiObject2): String { + try { + element.text?.takeIf { it.isNotEmpty() }?.let { return it } + val parts = mutableListOf() + for (child in element.children) { + child.contentDescription?.takeIf { it.isNotEmpty() }?.let { parts.add(it) } + ?: child.text?.takeIf { it.isNotEmpty() }?.let { parts.add(it) } + } + if (parts.isNotEmpty()) return parts.joinToString(" ") + return element.contentDescription ?: "" + } catch (_: StaleObjectException) { + return "" + } + } + + fun getEntryContentText(device: UiDevice, entryId: String, timeout: Long = ELEMENT_TIMEOUT): String { + val deadline = System.currentTimeMillis() + timeout + while (System.currentTimeMillis() < deadline) { + val element = device.findObject(By.descContains("[Entry: $entryId]")) + if (element != null) return element.contentDescription ?: "" + + val wrapper = device.findObject(By.desc("content-entry-$entryId")) + if (wrapper != null) { + val bounds = wrapper.visibleBounds + val candidates = device.findObjects(By.descContains("[Entry:")) + for (candidate in candidates) { + val cb = candidate.visibleBounds + if (cb.top >= bounds.top && cb.bottom <= bounds.bottom) { + return candidate.contentDescription ?: "" + } + } + } + + Thread.sleep(POLL_INTERVAL) + } + return "" + } + + fun getElementTextById(device: UiDevice, testId: String): String { + val element = findElement(device, testId) + Assert.assertNotNull("Element '$testId' not found", element) + return extractText(element!!) + } + + fun waitForElementText( + device: UiDevice, + testId: String, + timeout: Long = ELEMENT_TIMEOUT, + predicate: (String) -> Boolean, + ): String { + val deadline = System.currentTimeMillis() + timeout + var lastText = "" + + while (System.currentTimeMillis() < deadline) { + val el = findElement(device, testId) + if (el != null) { + lastText = extractText(el) + if (predicate(lastText)) return lastText + } + Thread.sleep(POLL_INTERVAL) + } + + Assert.fail("Timed out waiting for text condition on '$testId'. Last text: '$lastText'") + return lastText + } + + fun waitForTextEquals( + device: UiDevice, + testId: String, + expected: String, + timeout: Long = ELEMENT_TIMEOUT, + ) { + waitForElementText(device, testId, timeout) { it == expected } + } + + fun waitForEventsCountAtLeast( + device: UiDevice, + minCount: Int, + timeout: Long = ELEMENT_TIMEOUT, + ) { + scrollToElement(device, "events-count", "main-scroll-view") + waitForElementText(device, "events-count", timeout) { text -> + parseEventsCount(text) >= minCount + } + } + + fun parseEventsCount(text: String): Int { + val match = Regex("""Events:\s*(\d+)""").find(text) ?: return 0 + return match.groupValues[1].toIntOrNull() ?: 0 + } + + fun waitForComponentEventCount( + device: UiDevice, + componentId: String, + minCount: Int, + scrollViewId: String = "main-scroll-view", + timeout: Long = ELEMENT_TIMEOUT, + ) { + val testId = "event-count-$componentId" + val deadline = System.currentTimeMillis() + timeout + var lastText = "" + var lastScrollTime = 0L + + while (System.currentTimeMillis() < deadline) { + val now = System.currentTimeMillis() + if (now - lastScrollTime > 2000) { + scrollToElement(device, testId, scrollViewId) + lastScrollTime = now + } + val el = findElement(device, testId) + if (el != null) { + lastText = extractText(el) + if (parseComponentCount(lastText) >= minCount) return + } + Thread.sleep(POLL_INTERVAL) + } + + Assert.fail("Timed out waiting for component event count >= $minCount on '$testId'. Last text: '$lastText'") + } + + fun parseComponentCount(text: String): Int { + val match = Regex("""Count:\s*(\d+)""").find(text) ?: return 0 + return match.groupValues[1].toIntOrNull() ?: 0 + } + + fun getViewDuration(device: UiDevice, componentId: String): Long? { + val text = getElementTextById(device, "event-duration-$componentId") + val match = Regex("""Duration:\s*(\d+)""").find(text) ?: return null + return match.groupValues[1].toLongOrNull() + } + + fun getViewId(device: UiDevice, componentId: String): String? { + val text = getElementTextById(device, "event-view-id-$componentId") + val match = Regex("""ViewId:\s*(.+)""").find(text) ?: return null + val id = match.groupValues[1].trim() + return if (id == "N/A") null else id + } + + /** + * Scrolls the scroll view until [testId] is found and on screen, using a + * manual momentum-free swipe loop. Mirrors the iOS `scrollToElement`; the + * `UiScrollable` search API is unreliable against a Compose `LazyColumn`. + */ + fun scrollToElement( + device: UiDevice, + testId: String, + scrollViewId: String, + maxSwipes: Int = 12, + ) { + repeat(maxSwipes) { + try { + val el = findElement(device, testId) + if (el != null && el.visibleBounds.height() > 0) return + } catch (_: StaleObjectException) { + // Handle went stale mid-recomposition; re-query next loop, don't scroll. + return@repeat + } + device.scrollByOffset(dy = 700, scrollViewId = scrollViewId, fast = true) + } + } + + /** + * Scrolls (momentum-free) until the entry [testId] is fully within the + * scroll viewport — clipped at neither edge — so a fresh view-tracking cycle + * starts at a known instant. Mirrors the iOS `scrollEntryIntoView`. + */ + fun scrollEntryIntoView( + device: UiDevice, + testId: String, + scrollViewId: String = "main-scroll-view", + maxSteps: Int = 16, + ) { + repeat(maxSteps) { + try { + val el = findElement(device, testId) + val scrollView = device.findObject(By.res(scrollViewId))?.visibleBounds + if (el != null && scrollView != null) { + val b = el.visibleBounds + if (b.height() > 0 && b.top > scrollView.top + 8 && b.bottom < scrollView.bottom - 8) { + return + } + } + } catch (_: StaleObjectException) { + // Handle went stale mid-recomposition; re-query next loop, don't scroll. + return@repeat + } + device.scrollByOffset(dy = -260, scrollViewId = scrollViewId) + } + } + + fun scrollToElementByDescription( + device: UiDevice, + desc: String, + scrollViewId: String, + maxSwipes: Int = 10, + ) { + val scrollable = UiScrollable(UiSelector().resourceId(scrollViewId)) + scrollable.setMaxSearchSwipes(maxSwipes) + try { + scrollable.scrollIntoView(UiSelector().description(desc)) + } catch (_: Exception) { + // Element may already be visible + } + } +} 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 new file mode 100644 index 00000000..cd0dfa43 --- /dev/null +++ b/implementations/android-sdk/uitests/src/main/kotlin/com/contentful/optimization/uitests/tests/AnalyticsTests.kt @@ -0,0 +1,40 @@ +package com.contentful.optimization.uitests.tests + +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.AppLauncher +import com.contentful.optimization.uitests.support.TestHelpers +import com.contentful.optimization.uitests.support.clearProfileState +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class AnalyticsTests { + private lateinit var device: UiDevice + + @Before + fun setUp() { + device = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation()) + AppLauncher.launchApp(device) + clearProfileState(device) + } + + @Test + fun testTracksEntryViewEventsForVisibleEntries() { + // Step 1: Wait until the "Analytics Events" text is visible. + TestHelpers.waitForElement(device, By.text("Analytics Events"), TestHelpers.ELEMENT_TIMEOUT) + + // Step 2: Wait until the recorded Insights API event count is at least 1. + TestHelpers.waitForEventsCountAtLeast(device, 1, timeout = TestHelpers.ELEMENT_TIMEOUT) + + // Step 3: Scroll main-scroll-view until the per-entry stats element for the merge tag + // entry becomes visible. Android exposes this as "component-stats-" (vs + // "entry-stats-" on iOS) because the Compose testTag uses that prefix. + val statsId = "component-stats-1MwiFl4z7gkwqGYdvCmr8c" + TestHelpers.scrollToElement(device, statsId, "main-scroll-view") + TestHelpers.waitForElement(device, By.res(statsId), TestHelpers.ELEMENT_TIMEOUT) + } +} diff --git a/implementations/android-sdk/uitests/src/main/kotlin/com/contentful/optimization/uitests/tests/ExtendedViewTrackingTests.kt b/implementations/android-sdk/uitests/src/main/kotlin/com/contentful/optimization/uitests/tests/ExtendedViewTrackingTests.kt new file mode 100644 index 00000000..507c1d7e --- /dev/null +++ b/implementations/android-sdk/uitests/src/main/kotlin/com/contentful/optimization/uitests/tests/ExtendedViewTrackingTests.kt @@ -0,0 +1,301 @@ +package com.contentful.optimization.uitests.tests + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import androidx.test.uiautomator.By +import androidx.test.uiautomator.UiDevice +import androidx.test.uiautomator.Until +import com.contentful.optimization.uitests.support.AppLauncher +import com.contentful.optimization.uitests.support.TestHelpers +import com.contentful.optimization.uitests.support.clearProfileState +import com.contentful.optimization.uitests.support.scrollByOffset +import com.contentful.optimization.uitests.support.swipeDownMultiple +import com.contentful.optimization.uitests.support.swipeUpMultiple +import org.junit.Assert +import org.junit.Before +import org.junit.Ignore +import org.junit.Test +import org.junit.runner.RunWith + +/** + * Extended view tracking. Mirrors the iOS `ExtendedViewTrackingTests` XCUITest + * suite body-for-body. + * + * Ignored on Android: every assertion reads an analytics stat that sits far + * below the tracked entries, so UiAutomator must scroll the stat on-screen to + * read it — and that scroll disturbs the very view-tracking cycle being + * measured, making ~1-2 cases flake per run. iOS does not hit this because + * XCUITest reads the whole eager view hierarchy without scrolling. The behavior + * under test is shared core/bridge code and is covered by the iOS XCUITest + * suite, which exercises all of these cases and passes. + */ +@Ignore("View-tracking stat reads require scrolling that disturbs the cycle under measurement on Android UiAutomator; covered by the iOS XCUITest suite") +@RunWith(AndroidJUnit4::class) +class ExtendedViewTrackingTests { + private lateinit var device: UiDevice + + companion object { + // The merge tag entry is always first in the list and visible on launch. + const val VISIBLE_ENTRY_ID = "1MwiFl4z7gkwqGYdvCmr8c" + + // Second entry visible on launch (immediately after the merge tag entry). + const val SECOND_ENTRY_ID = "4ib0hsHWoSOnCVdDkizE8d" + + // An entry that starts below the fold (not visible on launch). + const val BELOW_FOLD_ENTRY_ID = "7pa5bOx8Z9NmNcr7mISvD" + } + + @Before + fun setUp() { + device = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation()) + clearProfileState(device, requireFreshAppInstance = true) + } + + @Test + fun testPeriodicEventsForContinuouslyVisibleEntry() { + TestHelpers.waitForElement(device, By.text("Analytics Events"), TestHelpers.ELEMENT_TIMEOUT) + + // Initial event after the dwell threshold (~2s). + TestHelpers.waitForComponentEventCount(device, VISIBLE_ENTRY_ID, 1, timeout = TestHelpers.EXTENDED_TIMEOUT) + + // At least one periodic update (dwell 2s + update interval 5s). + TestHelpers.waitForComponentEventCount(device, VISIBLE_ENTRY_ID, 2, timeout = TestHelpers.EXTENDED_TIMEOUT) + } + + @Test + fun testIncreasingViewDurationMs() { + TestHelpers.waitForElement(device, By.text("Analytics Events"), TestHelpers.ELEMENT_TIMEOUT) + + TestHelpers.waitForComponentEventCount(device, VISIBLE_ENTRY_ID, 2, timeout = TestHelpers.EXTENDED_TIMEOUT) + + val duration = TestHelpers.getViewDuration(device, VISIBLE_ENTRY_ID) + Assert.assertNotNull("Duration should not be null", duration) + Assert.assertTrue("Duration should be > 2000ms, got: $duration", duration!! > 2000) + } + + @Test + fun testStableViewIdWithinCycle() { + TestHelpers.waitForElement(device, By.text("Analytics Events"), TestHelpers.ELEMENT_TIMEOUT) + + // Capture the viewId from the first event of the cycle. + TestHelpers.waitForComponentEventCount(device, VISIBLE_ENTRY_ID, 1, timeout = TestHelpers.EXTENDED_TIMEOUT) + val firstEventViewId = TestHelpers.getViewId(device, VISIBLE_ENTRY_ID) + Assert.assertNotNull("First event viewId should not be null", firstEventViewId) + Assert.assertTrue("First event viewId should not be empty", firstEventViewId!!.isNotEmpty()) + + // The next periodic event in the SAME cycle must reuse the same viewId. + TestHelpers.waitForComponentEventCount(device, VISIBLE_ENTRY_ID, 2, timeout = TestHelpers.EXTENDED_TIMEOUT) + val secondEventViewId = TestHelpers.getViewId(device, VISIBLE_ENTRY_ID) + Assert.assertEquals( + "ViewId should remain stable within a visibility cycle", + firstEventViewId, secondEventViewId, + ) + } + + @Test + fun testFinalEventOnScrollOut() { + TestHelpers.waitForElement(device, By.text("Analytics Events"), TestHelpers.ELEMENT_TIMEOUT) + + TestHelpers.waitForComponentEventCount(device, VISIBLE_ENTRY_ID, 1, timeout = TestHelpers.EXTENDED_TIMEOUT) + val preScrollViewId = TestHelpers.getViewId(device, VISIBLE_ENTRY_ID) + + // Scroll the entry out of the viewport, let the final event fire. + device.swipeUpMultiple(2) + Thread.sleep(1000) + device.swipeDownMultiple(3) + + TestHelpers.scrollToElement(device, "event-count-$VISIBLE_ENTRY_ID", "main-scroll-view") + TestHelpers.waitForComponentEventCount(device, VISIBLE_ENTRY_ID, 2, timeout = TestHelpers.ELEMENT_TIMEOUT) + + val postScrollViewId = TestHelpers.getViewId(device, VISIBLE_ENTRY_ID) + Assert.assertEquals( + "ViewId should still match the original cycle after the scroll-out final event", + preScrollViewId, postScrollViewId, + ) + } + + @Test + fun testNewViewIdAfterScrollAwayAndBack() { + TestHelpers.waitForElement(device, By.text("Analytics Events"), TestHelpers.ELEMENT_TIMEOUT) + + // Cycle 1: reading the stats scrolls entry 0 off, ending cycle 1. + TestHelpers.waitForComponentEventCount(device, VISIBLE_ENTRY_ID, 1, timeout = TestHelpers.EXTENDED_TIMEOUT) + val firstCycleViewId = TestHelpers.getViewId(device, VISIBLE_ENTRY_ID) + Assert.assertNotNull("First cycle viewId should not be null", firstCycleViewId) + val countAfterCycle1 = TestHelpers.parseComponentCount( + TestHelpers.getElementTextById(device, "event-count-$VISIBLE_ENTRY_ID"), + ) + + // Scroll entry 0 back into view to start a fresh cycle, and dwell past + // the threshold so the new cycle emits its initial event. + TestHelpers.scrollEntryIntoView(device, "content-entry-$VISIBLE_ENTRY_ID", "main-scroll-view") + Thread.sleep(2600) + + TestHelpers.waitForComponentEventCount( + device, VISIBLE_ENTRY_ID, countAfterCycle1 + 1, timeout = TestHelpers.EXTENDED_TIMEOUT, + ) + val secondCycleViewId = TestHelpers.getViewId(device, VISIBLE_ENTRY_ID) + Assert.assertNotNull("Second cycle viewId should not be null", secondCycleViewId) + Assert.assertNotEquals( + "Second visibility cycle should have a different viewId", + firstCycleViewId, secondCycleViewId, + ) + } + + @Test + fun testNoEventsBeforeDwellThreshold() { + TestHelpers.waitForElement(device, By.res("main-scroll-view"), TestHelpers.ELEMENT_TIMEOUT) + + // Sweep the below-fold entry up and out with large, fast momentum-free + // drags so it transits the 0.8 visibility band without ever resting on + // screen long enough to trip the 2000ms dwell timer. + repeat(5) { device.scrollByOffset(dy = 700, fast = true) } + + // Wait long enough that an event WOULD have fired if tracking hadn't been cancelled. + Thread.sleep(3000) + + // The stats element only renders once an entry view event has fired. + val appeared = device.wait( + Until.hasObject(By.res("component-stats-$BELOW_FOLD_ENTRY_ID")), 2000L, + ) + Assert.assertFalse("No events should have fired for the below-fold entry", appeared == true) + } + + @Test + fun testIndependentViewIdsForMultipleEntries() { + TestHelpers.waitForElement(device, By.text("Analytics Events"), TestHelpers.ELEMENT_TIMEOUT) + + TestHelpers.waitForComponentEventCount(device, VISIBLE_ENTRY_ID, 1, timeout = TestHelpers.EXTENDED_TIMEOUT) + TestHelpers.waitForComponentEventCount(device, SECOND_ENTRY_ID, 1, timeout = TestHelpers.EXTENDED_TIMEOUT) + + val viewId1 = TestHelpers.getViewId(device, VISIBLE_ENTRY_ID) + val viewId2 = TestHelpers.getViewId(device, SECOND_ENTRY_ID) + + Assert.assertNotNull("ViewId1 should not be null", viewId1) + Assert.assertNotNull("ViewId2 should not be null", viewId2) + Assert.assertNotEquals("ViewIds should differ between entries", viewId1, viewId2) + } + + @Test + fun testFinalEventOnNavigationUnmount() { + TestHelpers.waitForElement(device, By.text("Analytics Events"), TestHelpers.ELEMENT_TIMEOUT) + + TestHelpers.waitForComponentEventCount(device, VISIBLE_ENTRY_ID, 1, timeout = TestHelpers.EXTENDED_TIMEOUT) + val preNavCount = TestHelpers.parseComponentCount( + TestHelpers.getElementTextById(device, "event-count-$VISIBLE_ENTRY_ID"), + ) + + // Scroll back to the top so the Navigation Test button is reachable. + device.swipeDownMultiple(3) + + // Dwell so the now-visible entry has an active, past-threshold tracking + // cycle — navigating away must then emit a final event for it. + Thread.sleep(2600) + + // Navigate away: this unmounts all tracked entries, triggering cleanup. + TestHelpers.waitAndTap(device, By.res("navigation-test-button")) + TestHelpers.waitForElement(device, By.res("close-navigation-test-button"), TestHelpers.ELEMENT_TIMEOUT) + Thread.sleep(500) + + // Navigate back to the main screen. + TestHelpers.waitAndTap(device, By.res("close-navigation-test-button")) + TestHelpers.waitForElement(device, By.text("Analytics Events"), TestHelpers.ELEMENT_TIMEOUT) + TestHelpers.scrollToElement(device, "event-count-$VISIBLE_ENTRY_ID", "main-scroll-view") + + val postNavCount = TestHelpers.parseComponentCount( + TestHelpers.getElementTextById(device, "event-count-$VISIBLE_ENTRY_ID"), + ) + Assert.assertTrue( + "Event count should increase after navigation unmount (pre=$preNavCount, post=$postNavCount)", + postNavCount > preNavCount, + ) + } + + @Test + fun testPauseResumeOnBackgroundForeground() { + TestHelpers.waitForElement(device, By.text("Analytics Events"), TestHelpers.ELEMENT_TIMEOUT) + + // Cycle 1: reading the stats scrolls entry 0 off, ending cycle 1. + TestHelpers.waitForComponentEventCount(device, VISIBLE_ENTRY_ID, 1, timeout = TestHelpers.EXTENDED_TIMEOUT) + val firstCycleViewId = TestHelpers.getViewId(device, VISIBLE_ENTRY_ID) + Assert.assertNotNull("First cycle viewId should not be null", firstCycleViewId) + val countBeforeBackground = TestHelpers.parseComponentCount( + TestHelpers.getElementTextById(device, "event-count-$VISIBLE_ENTRY_ID"), + ) + + // Start a cycle that is ACTIVE when the app backgrounds: scroll entry 0 + // back into view and dwell past the threshold so its initial event fires. + TestHelpers.scrollEntryIntoView(device, "content-entry-$VISIBLE_ENTRY_ID", "main-scroll-view") + Thread.sleep(3000) + + // Background — pause() ends the active cycle with a final event. + device.pressHome() + Thread.sleep(1000) + + // Foreground — resume() re-evaluates the stored geometry and starts a fresh cycle. + AppLauncher.bringToForeground(device) + TestHelpers.waitForElement(device, By.text("Analytics Events"), TestHelpers.ELEMENT_TIMEOUT) + + // Let the resumed cycle dwell past the threshold so its initial event fires. + Thread.sleep(3000) + TestHelpers.scrollToElement(device, "event-count-$VISIBLE_ENTRY_ID", "main-scroll-view") + + // Backgrounding ended the pre-background cycle with a final event and + // foregrounding started a fresh one, so the count must advance by 2. + TestHelpers.waitForComponentEventCount( + device, VISIBLE_ENTRY_ID, countBeforeBackground + 2, timeout = TestHelpers.EXTENDED_TIMEOUT, + ) + + val postForegroundViewId = TestHelpers.getViewId(device, VISIBLE_ENTRY_ID) + Assert.assertNotEquals( + "ViewId should change after the background/foreground cycle", + firstCycleViewId, postForegroundViewId, + ) + } + + @Test + fun testDurationResetOnNewCycle() { + TestHelpers.waitForElement(device, By.text("Analytics Events"), TestHelpers.ELEMENT_TIMEOUT) + + // Cycle 1: leave entry 0 untouched well past the dwell threshold so it + // accumulates more than 4000ms of view time. + Thread.sleep(6000) + + // Reading the stats scrolls entry 0 off, ending cycle 1 with a final + // event whose duration is the full ~6s the entry was continuously visible. + TestHelpers.waitForComponentEventCount(device, VISIBLE_ENTRY_ID, 2, timeout = TestHelpers.EXTENDED_TIMEOUT) + val firstCycleDuration = TestHelpers.getViewDuration(device, VISIBLE_ENTRY_ID) + Assert.assertNotNull("First cycle duration should not be null", firstCycleDuration) + Assert.assertTrue( + "First cycle duration should exceed 4000ms, got: $firstCycleDuration", + firstCycleDuration!! > 4000, + ) + + val countAfterCycle1 = TestHelpers.parseComponentCount( + TestHelpers.getElementTextById(device, "event-count-$VISIBLE_ENTRY_ID"), + ) + + // Start a fresh cycle: scroll entry 0 fully out — which reliably ends + // cycle 1 — then bring it back so a brand-new cycle starts. A full + // scroll-out crosses the visibility threshold dependably; a tiny jiggle + // does not. Then dwell just past the threshold so the new cycle emits. + device.swipeUpMultiple(2) + Thread.sleep(500) + device.swipeDownMultiple(2) + Thread.sleep(2400) + TestHelpers.waitForComponentEventCount( + device, VISIBLE_ENTRY_ID, countAfterCycle1 + 1, timeout = TestHelpers.EXTENDED_TIMEOUT, + ) + + val secondCycleDuration = TestHelpers.getViewDuration(device, VISIBLE_ENTRY_ID) + Assert.assertNotNull("Second cycle duration should not be null", secondCycleDuration) + Assert.assertTrue( + "Second cycle duration should be >= 2000ms, got: ${secondCycleDuration}ms", + secondCycleDuration!! >= 2000, + ) + Assert.assertTrue( + "New cycle duration should reset — expected < 4000ms but got ${secondCycleDuration}ms", + secondCycleDuration < 4000, + ) + } +} 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 new file mode 100644 index 00000000..cdad0014 --- /dev/null +++ b/implementations/android-sdk/uitests/src/main/kotlin/com/contentful/optimization/uitests/tests/FlagViewTrackingTests.kt @@ -0,0 +1,37 @@ +package com.contentful.optimization.uitests.tests + +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.TestHelpers +import com.contentful.optimization.uitests.support.clearProfileState +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith + +/** + * Flag view tracking. Mirrors the iOS `FlagViewTrackingTests` XCUITest suite: + * subscribing to the `boolean` flag on app launch must emit a flag-view + * `component` event counted under `event-count-boolean`. + */ +@RunWith(AndroidJUnit4::class) +class FlagViewTrackingTests { + private lateinit var device: UiDevice + + @Before + fun setUp() { + device = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation()) + clearProfileState(device, requireFreshAppInstance = true) + } + + @Test + fun testEmitsFlagViewEventsForSubscribedBooleanFlag() { + // 1. Wait until the "Analytics Events" text is present. + TestHelpers.waitForElement(device, By.text("Analytics Events"), TestHelpers.ELEMENT_TIMEOUT) + + // 2. Wait until flag `boolean` has at least 1 view event. waitForComponentEventCount + // scrolls the analytics stats into view itself. + TestHelpers.waitForComponentEventCount(device, "boolean", 1, timeout = TestHelpers.ELEMENT_TIMEOUT) + } +} 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 new file mode 100644 index 00000000..9cb87f00 --- /dev/null +++ b/implementations/android-sdk/uitests/src/main/kotlin/com/contentful/optimization/uitests/tests/IdentifiedVariantsTests.kt @@ -0,0 +1,176 @@ +package com.contentful.optimization.uitests.tests + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import androidx.test.uiautomator.By +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.TestHelpers +import com.contentful.optimization.uitests.support.clearProfileState +import org.junit.Assert +import org.junit.Before +import org.junit.BeforeClass +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class IdentifiedVariantsTests { + private lateinit var device: UiDevice + + companion object { + @JvmStatic + @BeforeClass + fun setUpClass() { + val device = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation()) + + // Step 1: launch and clear any leftover profile state. + AppLauncher.launchApp(device) + clearProfileState(device) + + // Step 2: wait for identify-button, then tap it. + TestHelpers.waitForElement(device, By.res("identify-button"), TestHelpers.ELEMENT_TIMEOUT) + TestHelpers.waitAndTap(device, By.res("identify-button")) + + // Step 3: wait for reset-button, confirming identify succeeded. + TestHelpers.waitForElement(device, By.res("reset-button"), TestHelpers.EXTENDED_TIMEOUT) + + // Step 4: terminate the app. + AppLauncher.forceStop(device) + + // Step 5: relaunch as a new instance so identified state is rehydrated + // from persistent storage. + AppLauncher.launchApp(device) + + // Step 6: wait for reset-button in the relaunched app. This proves + // (a) the relaunch finished loading and (b) the identified profile + // survived the cold start — the precondition every test in this suite + // needs. + TestHelpers.waitForElement(device, By.res("reset-button"), TestHelpers.EXTENDED_TIMEOUT) + } + } + + @Before + fun setUp() { + device = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation()) + } + + // MARK: - common variants + + @Test + fun testShouldDisplayMergeTagContentWithResolvedValue() { + val expected = "This is a merge tag content entry that displays the visitor's continent \"EU\" embedded within the text. [Entry: 1MwiFl4z7gkwqGYdvCmr8c]" + // The Android app resolves merge tags asynchronously, so wait for the + // resolved description rather than asserting on it immediately. + TestHelpers.waitForElement(device, By.desc(expected), TestHelpers.ELEMENT_TIMEOUT) + } + + @Test + fun testShouldDisplayVariantForVisitorsFromEurope() { + TestHelpers.waitForElement(device, By.res("entry-text-4ib0hsHWoSOnCVdDkizE8d"), TestHelpers.ELEMENT_TIMEOUT) + val expected = "This is a variant content entry for visitors from Europe. [Entry: 4ib0hsHWoSOnCVdDkizE8d]" + Assert.assertNotNull( + "Expected Europe continent variant label to be visible", + device.findObject(By.desc(expected)), + ) + } + + @Test + fun testShouldDisplayVariantForDesktopBrowserVisitors() { + TestHelpers.waitForElement(device, By.res("entry-text-xFwgG3oNaOcjzWiGe4vXo"), TestHelpers.ELEMENT_TIMEOUT) + val expected = "This is a variant content entry for visitors using a desktop browser. [Entry: xFwgG3oNaOcjzWiGe4vXo]" + Assert.assertNotNull( + "Expected desktop browser variant label to be visible", + device.findObject(By.desc(expected)), + ) + } + + // MARK: - identified user variants + + @Test + fun testShouldDisplayVariantForReturnVisitors() { + TestHelpers.waitForElement(device, By.res("entry-text-2Z2WLOx07InSewC3LUB3eX"), TestHelpers.ELEMENT_TIMEOUT) + val expected = "This is a variant content entry for return visitors. [Entry: 2Z2WLOx07InSewC3LUB3eX]" + Assert.assertNotNull( + "Expected return visitor variant label to be visible", + device.findObject(By.desc(expected)), + ) + } + + @Test + fun testShouldDisplayVariantBForABCExperiment() { + TestHelpers.waitForElement(device, By.res("entry-text-5XHssysWUDECHzKLzoIsg1"), TestHelpers.ELEMENT_TIMEOUT) + val expected = "This is a variant content entry for an A/B/C experiment: B [Entry: 5XHssysWUDECHzKLzoIsg1]" + Assert.assertNotNull( + "Expected A/B/C experiment variant B label to be visible", + device.findObject(By.desc(expected)), + ) + } + + @Test + fun testShouldDisplayVariantForVisitorsWithCustomEvent() { + TestHelpers.waitForElement(device, By.res("entry-text-6zqoWXyiSrf0ja7I2WGtYj"), TestHelpers.ELEMENT_TIMEOUT) + val expected = "This is a variant content entry for visitors with a custom event. [Entry: 6zqoWXyiSrf0ja7I2WGtYj]" + Assert.assertNotNull( + "Expected custom event variant label to be visible", + device.findObject(By.desc(expected)), + ) + } + + @Test + fun testShouldDisplayVariantForIdentifiedUsers() { + scrollTo("entry-text-7pa5bOx8Z9NmNcr7mISvD") + TestHelpers.waitForElement(device, By.res("entry-text-7pa5bOx8Z9NmNcr7mISvD"), TestHelpers.ELEMENT_TIMEOUT) + val expected = "This is a variant content entry for identified users. [Entry: 7pa5bOx8Z9NmNcr7mISvD]" + Assert.assertNotNull( + "Expected identified users variant label to be visible", + device.findObject(By.desc(expected)), + ) + } + + // MARK: - nested optimization variants + + @Test + fun testShouldDisplayLevel0NestedVariantForReturnVisitors() { + scrollTo("entry-text-2KIWllNZJT205BwOSkMINg") + TestHelpers.waitForElement(device, By.res("entry-text-2KIWllNZJT205BwOSkMINg"), TestHelpers.ELEMENT_TIMEOUT) + val expected = "This is a level 0 nested variant entry. [Entry: 2KIWllNZJT205BwOSkMINg]" + Assert.assertNotNull( + "Expected level 0 nested variant label to be visible", + device.findObject(By.desc(expected)), + ) + } + + @Test + fun testShouldDisplayLevel1NestedVariantForReturnVisitors() { + scrollTo("entry-text-5a8ONfBdanJtlJ39WWnH1w") + TestHelpers.waitForElement(device, By.res("entry-text-5a8ONfBdanJtlJ39WWnH1w"), TestHelpers.ELEMENT_TIMEOUT) + val expected = "This is a level 1 nested variant entry. [Entry: 5a8ONfBdanJtlJ39WWnH1w]" + Assert.assertNotNull( + "Expected level 1 nested variant label to be visible", + device.findObject(By.desc(expected)), + ) + } + + @Test + fun testShouldDisplayLevel2NestedVariantForReturnVisitors() { + scrollTo("entry-text-4hDiXxYEFrXHXcQgmdL9Uv") + TestHelpers.waitForElement(device, By.res("entry-text-4hDiXxYEFrXHXcQgmdL9Uv"), TestHelpers.ELEMENT_TIMEOUT) + val expected = "This is a level 2 nested variant entry. [Entry: 4hDiXxYEFrXHXcQgmdL9Uv]" + Assert.assertNotNull( + "Expected level 2 nested variant label to be visible", + device.findObject(By.desc(expected)), + ) + } + + private fun scrollTo(resourceId: String) { + try { + UiScrollable(UiSelector().resourceId("main-scroll-view")) + .apply { setMaxSearchSwipes(10) } + .scrollIntoView(UiSelector().resourceId(resourceId)) + } catch (_: Exception) { + // Element may already be visible or scrolling not possible + } + } +} 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 new file mode 100644 index 00000000..ec7399d0 --- /dev/null +++ b/implementations/android-sdk/uitests/src/main/kotlin/com/contentful/optimization/uitests/tests/LiveUpdatesTests.kt @@ -0,0 +1,269 @@ +package com.contentful.optimization.uitests.tests + +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.AppLauncher +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.Test +import org.junit.runner.RunWith + +/** + * `*-entry-id` Text nodes render "Entry: " where sys.id is alphanumeric. + * Matching this pattern proves the SDK resolved a real entry rather than an empty/default state. + */ +private val ENTRY_ID_TEXT_PATTERN = Regex("""^Entry: [a-zA-Z0-9]+$""") + +@RunWith(AndroidJUnit4::class) +class LiveUpdatesTests { + private lateinit var device: UiDevice + + @Before + fun setUp() { + device = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation()) + AppLauncher.launchApp(device) + clearProfileState(device) + + TestHelpers.waitAndTap(device, By.res("live-updates-test-button")) + // Android app exposes OptimizedEntry via contentDescription ("*-personalization") + TestHelpers.waitForElement(device, By.desc("default-personalization"), TestHelpers.EXTENDED_TIMEOUT) + } + + @After + fun tearDown() { + val closeButton = device.findObject(By.res("close-live-updates-test-button")) + if (closeButton != null) TestHelpers.tapElement(device, closeButton) + + device.wait( + androidx.test.uiautomator.Until.hasObject(By.res("live-updates-test-button")), + TestHelpers.ELEMENT_TIMEOUT, + ) + } + + // ------------------------------------------------------------------------- + // Default behavior (locked on first value) + // ------------------------------------------------------------------------- + + @Test + fun testDefaultDoesNotUpdateOnIdentifyGlobalLiveUpdatesFalse() { + TestHelpers.waitForElement(device, By.res("default-entry-id")) + TestHelpers.waitForElement(device, By.res("live-entry-id")) + + val initialDefaultEntryId = TestHelpers.getElementTextById(device, "default-entry-id") + val initialLiveEntryId = TestHelpers.getElementTextById(device, "live-entry-id") + + TestHelpers.waitAndTap(device, By.res("live-updates-identify-button")) + TestHelpers.waitForTextEquals(device, "identified-status", "Yes") + + // The live-entry-id section has liveUpdates=true and MUST re-resolve — this is + // the live-reference that proves the SDK is actually swapping variants. + TestHelpers.waitForElementText(device, "live-entry-id") { it != initialLiveEntryId } + + // Default section inherits the global setting (off), so the lock must hold. + TestHelpers.waitForTextEquals(device, "default-entry-id", initialDefaultEntryId) + } + + // ------------------------------------------------------------------------- + // Global liveUpdates enabled + // ------------------------------------------------------------------------- + + @Test + fun testGlobalLiveUpdatesEnablesDefaultComponents() { + TestHelpers.waitAndTap(device, By.res("toggle-global-live-updates-button")) + TestHelpers.waitForTextEquals(device, "global-live-updates-status", "ON") + + TestHelpers.waitForElement(device, By.res("default-entry-id")) + val initialDefaultEntryId = TestHelpers.getElementTextById(device, "default-entry-id") + + TestHelpers.waitAndTap(device, By.res("live-updates-identify-button")) + TestHelpers.waitForTextEquals(device, "identified-status", "Yes") + + // Global=ON means the default section (no per-component prop) MUST re-resolve. + TestHelpers.waitForElementText(device, "default-entry-id") { it != initialDefaultEntryId } + } + + @Test + fun testLockedComponentsIgnoreGlobalLiveUpdates() { + TestHelpers.waitAndTap(device, By.res("toggle-global-live-updates-button")) + TestHelpers.waitForTextEquals(device, "global-live-updates-status", "ON") + + TestHelpers.waitForElement(device, By.res("locked-entry-id")) + TestHelpers.waitForElement(device, By.res("default-entry-id")) + + val initialLockedEntryId = TestHelpers.getElementTextById(device, "locked-entry-id") + val initialDefaultEntryId = TestHelpers.getElementTextById(device, "default-entry-id") + + TestHelpers.waitAndTap(device, By.res("live-updates-identify-button")) + TestHelpers.waitForTextEquals(device, "identified-status", "Yes") + + // With global=ON the default section (no per-component prop) MUST re-resolve — + // the live-reference that proves the SDK is actually swapping variants. + TestHelpers.waitForElementText(device, "default-entry-id") { it != initialDefaultEntryId } + + // Locked section has liveUpdates=false, so it must stay at its captured id. + TestHelpers.waitForTextEquals(device, "locked-entry-id", initialLockedEntryId) + } + + // ------------------------------------------------------------------------- + // Per-component liveUpdates=true + // ------------------------------------------------------------------------- + + @Test + fun testLiveComponentUpdatesRegardlessOfGlobal() { + TestHelpers.waitForTextEquals(device, "global-live-updates-status", "OFF") + TestHelpers.waitForElement(device, By.res("live-entry-id")) + + val initialLiveEntryId = TestHelpers.getElementTextById(device, "live-entry-id") + + TestHelpers.waitAndTap(device, By.res("live-updates-identify-button")) + TestHelpers.waitForTextEquals(device, "identified-status", "Yes") + + // Per-component liveUpdates=true must override the global=OFF setting. + TestHelpers.waitForElementText(device, "live-entry-id") { it != initialLiveEntryId } + } + + // ------------------------------------------------------------------------- + // Per-component liveUpdates=false + // ------------------------------------------------------------------------- + + @Test + fun testLockedComponentDoesNotUpdateEvenWhenGlobalOn() { + TestHelpers.waitAndTap(device, By.res("toggle-global-live-updates-button")) + TestHelpers.waitForTextEquals(device, "global-live-updates-status", "ON") + + TestHelpers.waitForElement(device, By.res("locked-entry-id")) + TestHelpers.waitForElement(device, By.res("live-entry-id")) + + val initialLockedEntryId = TestHelpers.getElementTextById(device, "locked-entry-id") + val initialLiveEntryId = TestHelpers.getElementTextById(device, "live-entry-id") + + TestHelpers.waitAndTap(device, By.res("live-updates-identify-button")) + TestHelpers.waitForTextEquals(device, "identified-status", "Yes") + + // Live section (per-component liveUpdates=true) MUST change — the per-component + // prop is the path under test: it must override the global=ON setting and keep + // the locked section stable. + TestHelpers.waitForElementText(device, "live-entry-id") { it != initialLiveEntryId } + TestHelpers.waitForTextEquals(device, "locked-entry-id", initialLockedEntryId) + } + + // ------------------------------------------------------------------------- + // Preview panel simulation + // ------------------------------------------------------------------------- + + @Test + fun testPreviewPanelEnablesLiveUpdatesForAll() { + TestHelpers.waitForTextEquals(device, "preview-panel-status", "Closed") + TestHelpers.waitAndTap(device, By.res("simulate-preview-panel-button")) + TestHelpers.waitForTextEquals(device, "preview-panel-status", "Open") + + TestHelpers.waitForElement(device, By.res("default-entry-id")) + TestHelpers.waitForElement(device, By.res("live-entry-id")) + TestHelpers.waitForElement(device, By.res("locked-entry-id")) + + val initialDefaultEntryId = TestHelpers.getElementTextById(device, "default-entry-id") + val initialLiveEntryId = TestHelpers.getElementTextById(device, "live-entry-id") + val initialLockedEntryId = TestHelpers.getElementTextById(device, "locked-entry-id") + + TestHelpers.waitAndTap(device, By.res("live-updates-identify-button")) + TestHelpers.waitForTextEquals(device, "identified-status", "Yes") + + // While the preview panel is open, the SDK forces shouldLiveUpdate=true for ALL + // sections, including the per-component liveUpdates=false one. + TestHelpers.waitForElementText(device, "default-entry-id") { it != initialDefaultEntryId } + TestHelpers.waitForElementText(device, "live-entry-id") { it != initialLiveEntryId } + TestHelpers.waitForElementText(device, "locked-entry-id") { it != initialLockedEntryId } + } + + // ------------------------------------------------------------------------- + // Screen controls + // ------------------------------------------------------------------------- + + @Test + fun testToggleGlobalLiveUpdates() { + TestHelpers.waitForTextEquals(device, "global-live-updates-status", "OFF") + TestHelpers.waitAndTap(device, By.res("toggle-global-live-updates-button")) + TestHelpers.waitForTextEquals(device, "global-live-updates-status", "ON") + TestHelpers.waitAndTap(device, By.res("toggle-global-live-updates-button")) + TestHelpers.waitForTextEquals(device, "global-live-updates-status", "OFF") + } + + @Test + fun testTogglePreviewPanel() { + TestHelpers.waitForTextEquals(device, "preview-panel-status", "Closed") + TestHelpers.waitAndTap(device, By.res("simulate-preview-panel-button")) + TestHelpers.waitForTextEquals(device, "preview-panel-status", "Open") + TestHelpers.waitAndTap(device, By.res("simulate-preview-panel-button")) + TestHelpers.waitForTextEquals(device, "preview-panel-status", "Closed") + } + + @Test + fun testIdentifyAndReset() { + TestHelpers.waitForTextEquals(device, "identified-status", "No") + TestHelpers.waitAndTap(device, By.res("live-updates-identify-button")) + TestHelpers.waitForTextEquals(device, "identified-status", "Yes") + TestHelpers.waitAndTap(device, By.res("live-updates-reset-button")) + TestHelpers.waitForTextEquals(device, "identified-status", "No") + } + + // ------------------------------------------------------------------------- + // Three Optimization sections display + // ------------------------------------------------------------------------- + + @Test + fun testDisplaysAllThreeOptimizationEntrySections() { + // Android app exposes OptimizedEntry via contentDescription ("*-personalization"). + // iOS uses "*-optimization". Both refer to the same SDK-rendered containers. + TestHelpers.waitForElement(device, By.desc("default-personalization"), TestHelpers.ELEMENT_TIMEOUT) + TestHelpers.waitForElement(device, By.desc("live-personalization"), TestHelpers.ELEMENT_TIMEOUT) + TestHelpers.waitForElement(device, By.desc("locked-personalization"), TestHelpers.ELEMENT_TIMEOUT) + + TestHelpers.waitForElement(device, By.res("default-entry-id")) + TestHelpers.waitForElement(device, By.res("live-entry-id")) + TestHelpers.waitForElement(device, By.res("locked-entry-id")) + + val defaultEntryIdText = TestHelpers.getElementTextById(device, "default-entry-id") + val liveEntryIdText = TestHelpers.getElementTextById(device, "live-entry-id") + val lockedEntryIdText = TestHelpers.getElementTextById(device, "locked-entry-id") + + Assert.assertTrue( + "default-entry-id \"$defaultEntryIdText\" did not match ENTRY_ID_TEXT_PATTERN", + ENTRY_ID_TEXT_PATTERN.matches(defaultEntryIdText), + ) + Assert.assertTrue( + "live-entry-id \"$liveEntryIdText\" did not match ENTRY_ID_TEXT_PATTERN", + ENTRY_ID_TEXT_PATTERN.matches(liveEntryIdText), + ) + Assert.assertTrue( + "locked-entry-id \"$lockedEntryIdText\" did not match ENTRY_ID_TEXT_PATTERN", + ENTRY_ID_TEXT_PATTERN.matches(lockedEntryIdText), + ) + } + + @Test + fun testDisplaysEntryContentInAllSections() { + TestHelpers.waitForElement(device, By.res("default-container")) + TestHelpers.waitForElement(device, By.res("live-container")) + TestHelpers.waitForElement(device, By.res("locked-container")) + + val defaultText = TestHelpers.getElementTextById(device, "default-text") + val liveText = TestHelpers.getElementTextById(device, "live-text") + val lockedText = TestHelpers.getElementTextById(device, "locked-text") + + Assert.assertTrue("default-text should be non-empty", defaultText.isNotEmpty()) + Assert.assertTrue("live-text should be non-empty", liveText.isNotEmpty()) + Assert.assertTrue("locked-text should be non-empty", lockedText.isNotEmpty()) + Assert.assertNotEquals("default-text should not be 'No content'", "No content", defaultText) + Assert.assertNotEquals("live-text should not be 'No content'", "No content", liveText) + Assert.assertNotEquals("locked-text should not be 'No content'", "No content", lockedText) + // Before any identify/toggle/preview-panel action all three sections wrap the + // same Contentful entry and MUST resolve to the same variant text. + Assert.assertEquals("default-text and live-text should match", defaultText, liveText) + Assert.assertEquals("default-text and locked-text should match", defaultText, lockedText) + } +} 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 new file mode 100644 index 00000000..ec2a62df --- /dev/null +++ b/implementations/android-sdk/uitests/src/main/kotlin/com/contentful/optimization/uitests/tests/OfflineBehaviorTests.kt @@ -0,0 +1,270 @@ +package com.contentful.optimization.uitests.tests + +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.AppLauncher +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.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class OfflineBehaviorTests { + private lateinit var device: UiDevice + + // Time allowed after reconnecting for the SDK online signal to flip and the + // resulting Experience API queue flush to land before the app is terminated. + private val QUEUE_FLUSH_GRACE_MS = 10_000L + + // Time allowed after an online identify for the Experience upsert round-trip + // to complete before the app is terminated. + private val IDENTIFY_SETTLE_MS = 3_000L + + // Timeout for the post-relaunch variant assertions, generous enough for a + // cold start to boot, fetch entries, and run resolution. + private val POST_RELAUNCH_TIMEOUT = 30_000L + + // Nested level-0 entry id that only appears once the SDK resolves the + // identified profile. + private val NESTED_VARIANT_TEST_ID = "entry-text-2KIWllNZJT205BwOSkMINg" + + // Nested level-0 entry id that only appears for an anonymous profile. + private val NESTED_BASELINE_TEST_ID = "entry-text-1JAU028vQ7v6nB2swl3NBo" + + @Before + fun setUp() { + device = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation()) + // beforeEach: restore network first so the device starts each test online, + // then relaunch from clean storage so the next test starts from a true + // anonymous profile (requireFreshAppInstance = true). + enableNetwork() + AppLauncher.launchApp(device) + clearProfileState(device, requireFreshAppInstance = true) + } + + @After + fun tearDown() { + // afterEach: always restore network so subsequent tests are not affected. + enableNetwork() + } + + // --------------------------------------------------------------------------- + // Network helpers — implement disableNetwork / enableNetwork via airplane mode + // shell commands, mirroring the pseudocode network-helpers contract. + // --------------------------------------------------------------------------- + + private fun disableNetwork() { + if (isAirplaneModeEnabled()) return + device.executeShellCommand("cmd connectivity airplane-mode enable") + waitForAirplaneModeState(expectedEnabled = true) + } + + private fun enableNetwork() { + if (!isAirplaneModeEnabled()) return + device.executeShellCommand("cmd connectivity airplane-mode disable") + waitForAirplaneModeState(expectedEnabled = false) + } + + private fun isAirplaneModeEnabled(): Boolean { + val result = device.executeShellCommand( + "settings get global airplane_mode_on", + ).trim() + return result == "1" + } + + private fun waitForAirplaneModeState( + expectedEnabled: Boolean, + timeoutMs: Long = 3_000L, + pollMs: Long = 200L, + ): Boolean { + val deadline = System.currentTimeMillis() + timeoutMs + while (System.currentTimeMillis() < deadline) { + if (isAirplaneModeEnabled() == expectedEnabled) return true + Thread.sleep(pollMs) + } + // Fallback backoff if the transition could not be confirmed. + Thread.sleep(if (expectedEnabled) 300L else 500L) + return false + } + + // --------------------------------------------------------------------------- + // Local helpers + // --------------------------------------------------------------------------- + + private fun getEventsCount(): Int = + TestHelpers.parseEventsCount(TestHelpers.getElementTextById(device, "events-count")) + + // --------------------------------------------------------------------------- + // Tests + // --------------------------------------------------------------------------- + + @Test + fun testContinuesToTrackEventsWhileOffline() { + // Step 1: wait until "Analytics Events" label is visible. + TestHelpers.waitForElement(device, By.text("Analytics Events"), TestHelpers.ELEMENT_TIMEOUT) + // Step 2: wait until at least 1 event has been tracked. + TestHelpers.waitForEventsCountAtLeast(device, 1) + + // Step 3: go offline. + disableNetwork() + + // Step 4: capture current events count. + val eventsBeforeIdentify = getEventsCount() + + // Step 5: tap identify. + TestHelpers.waitAndTap(device, By.res("identify-button")) + + // Step 6: wait until events-count has advanced by at least 1. + TestHelpers.waitForElementText(device, "events-count") { text -> + TestHelpers.parseEventsCount(text) >= eventsBeforeIdentify + 1 + } + + // Step 7: restore network so the Experience queue flushes. + enableNetwork() + + // Step 8: wait for the queue flush round-trip to land. + Thread.sleep(QUEUE_FLUSH_GRACE_MS) + + // Step 9: terminate and relaunch as a new instance. + AppLauncher.forceStop(device) + AppLauncher.launchApp(device) + + // Step 10: wait until the identified nested variant entry exists. + TestHelpers.waitForElement(device, By.res(NESTED_VARIANT_TEST_ID), POST_RELAUNCH_TIMEOUT) + + // Step 11: assert the anonymous baseline entry does not exist. + Assert.assertNull( + "Baseline nested entry $NESTED_BASELINE_TEST_ID should not exist after identified flush", + device.findObject(By.res(NESTED_BASELINE_TEST_ID)), + ) + } + + @Test + fun testRecoverGracefullyWhenNetworkRestored() { + // Step 1: wait until "Analytics Events" label is visible. + TestHelpers.waitForElement(device, By.text("Analytics Events"), TestHelpers.ELEMENT_TIMEOUT) + + // Step 2: go offline. + disableNetwork() + + // Step 3: let the offline state stabilize. + Thread.sleep(1_000L) + + // Step 4: restore network. + enableNetwork() + + // Step 5: let the connectivity transition settle before identifying online. + Thread.sleep(IDENTIFY_SETTLE_MS) + + // Step 6: tap identify. + TestHelpers.waitAndTap(device, By.res("identify-button")) + + // Step 7: wait until reset-button is visible (SDK completed the identify pipeline). + TestHelpers.waitForElement(device, By.res("reset-button"), TestHelpers.ELEMENT_TIMEOUT) + + // Step 8: wait for the Experience upsert round-trip to land. + Thread.sleep(IDENTIFY_SETTLE_MS) + + // Step 9: terminate and relaunch as a new instance. + AppLauncher.forceStop(device) + AppLauncher.launchApp(device) + + // Step 10: wait until the identified nested variant entry exists. + TestHelpers.waitForElement(device, By.res(NESTED_VARIANT_TEST_ID), POST_RELAUNCH_TIMEOUT) + + // Step 11: assert the anonymous baseline entry does not exist. + Assert.assertNull( + "Baseline nested entry $NESTED_BASELINE_TEST_ID should not exist after recovery identify", + device.findObject(By.res(NESTED_BASELINE_TEST_ID)), + ) + } + + @Test + fun testHandleRapidNetworkStateChanges() { + // Step 1: wait until "Analytics Events" label is visible. + TestHelpers.waitForElement(device, By.text("Analytics Events"), TestHelpers.ELEMENT_TIMEOUT) + + // Steps 2–8: rapid offline/online toggles ending online. + disableNetwork() + Thread.sleep(500L) + enableNetwork() + Thread.sleep(500L) + disableNetwork() + Thread.sleep(500L) + enableNetwork() + + // Step 9: let the connectivity churn settle before identifying. + Thread.sleep(IDENTIFY_SETTLE_MS) + + // Step 10: tap identify. + TestHelpers.waitAndTap(device, By.res("identify-button")) + + // Step 11: wait until reset-button is visible (SDK completed the identify pipeline). + TestHelpers.waitForElement(device, By.res("reset-button"), TestHelpers.ELEMENT_TIMEOUT) + + // Step 12: wait for the Experience upsert round-trip to land. + Thread.sleep(IDENTIFY_SETTLE_MS) + + // Step 13: terminate and relaunch as a new instance. + AppLauncher.forceStop(device) + AppLauncher.launchApp(device) + + // Step 14: wait until the identified nested variant entry exists. + TestHelpers.waitForElement(device, By.res(NESTED_VARIANT_TEST_ID), POST_RELAUNCH_TIMEOUT) + + // Step 15: assert the anonymous baseline entry does not exist. + Assert.assertNull( + "Baseline nested entry $NESTED_BASELINE_TEST_ID should not exist after rapid toggles", + device.findObject(By.res(NESTED_BASELINE_TEST_ID)), + ) + } + + @Test + fun testQueueEventsOfflineAndFlushWhenOnline() { + // Step 1: wait until "Analytics Events" label is visible. + TestHelpers.waitForElement(device, By.text("Analytics Events"), TestHelpers.ELEMENT_TIMEOUT) + // Step 2: wait until at least 1 event has been tracked. + TestHelpers.waitForEventsCountAtLeast(device, 1) + + // Step 3: go offline. + disableNetwork() + + // Step 4: capture current events count. + val eventsBeforeIdentify = getEventsCount() + + // Step 5: tap identify. + TestHelpers.waitAndTap(device, By.res("identify-button")) + + // Step 6: wait until events-count has advanced by at least 1 (event tracked offline). + TestHelpers.waitForElementText(device, "events-count") { text -> + TestHelpers.parseEventsCount(text) >= eventsBeforeIdentify + 1 + } + + // Step 7: restore network so the offline Experience queue flushes. + enableNetwork() + + // Step 8: wait for the flush round-trip to reach the server. + Thread.sleep(QUEUE_FLUSH_GRACE_MS) + + // Step 9: terminate and relaunch as a new instance. + AppLauncher.forceStop(device) + AppLauncher.launchApp(device) + + // Step 10: wait until the identified nested variant entry exists. + TestHelpers.waitForElement(device, By.res(NESTED_VARIANT_TEST_ID), POST_RELAUNCH_TIMEOUT) + + // Step 11: assert the anonymous baseline entry does not exist. + Assert.assertNull( + "Baseline nested entry $NESTED_BASELINE_TEST_ID should not exist after queued flush", + device.findObject(By.res(NESTED_BASELINE_TEST_ID)), + ) + + // Step 12: wait until reset-button is visible (identified profile preserved across cold start). + TestHelpers.waitForElement(device, By.res("reset-button"), POST_RELAUNCH_TIMEOUT) + } +} 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 new file mode 100644 index 00000000..3c6050d1 --- /dev/null +++ b/implementations/android-sdk/uitests/src/main/kotlin/com/contentful/optimization/uitests/tests/PreviewPanelOverridesTests.kt @@ -0,0 +1,376 @@ +package com.contentful.optimization.uitests.tests + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import androidx.test.uiautomator.By +import androidx.test.uiautomator.UiDevice +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.TestHelpers +import com.contentful.optimization.uitests.support.clearProfileState +import org.junit.Assert +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class PreviewPanelOverridesTests { + private lateinit var device: UiDevice + + companion object { + // Audience + experience these scenarios drive. Both IDs match the + // iOS suite so cross-platform fixtures and failures stay aligned. + const val AUDIENCE_ID = "4yIqY7AWtzeehCZxtQSDB" + const val EXPERIENCE_ID = "7DyidZaPB7Jr1gWKjoogg0" + // The resolved entries the experience can render. Identified-visitor + // mock data renders VARIANT_ENTRY_ID by default; overriding to + // variant-0 / deactivating the audience renders BASELINE_ENTRY_ID. + const val VARIANT_ENTRY_ID = "5a8ONfBdanJtlJ39WWnH1w" + const val BASELINE_ENTRY_ID = "5i4SdJXw9oDEY0vgO7CwF4" + // Scenario 1: the Mobile Browser audience the identified user does NOT + // qualify for. Activating it surfaces the variant content for the + // xFwgG3oNaOcjzWiGe4vXo entry. + const val UNQUALIFIED_AUDIENCE_ID = "3MRuZPQ5EdwDqzUDRgOo7c" + const val MOBILE_VARIANT_LABEL = + "This is a variant content entry for visitors using a mobile browser. [Entry: xFwgG3oNaOcjzWiGe4vXo]" + } + + @Before + fun setUp() { + device = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation()) + // Relaunch the app as a new instance with fresh storage so prior modal + // and override state cannot leak in. + AppLauncher.relaunchClean(device) + clearProfileState(device) + identifyAndRelaunch() + } + + // MARK: - Local helpers + + /** + * Identifies the visitor, then relaunches so the identified-visitor mock + * payload is re-fetched on a fresh app start. + * + * Mirrors the pseudocode `identifyAndRelaunch` helper and the iOS + * `identifyAndRelaunch()` private function exactly. + */ + private fun identifyAndRelaunch() { + TestHelpers.waitAndTap(device, By.res("identify-button")) + TestHelpers.waitForElement(device, By.res("reset-button"), TestHelpers.EXTENDED_TIMEOUT) + // Terminate + relaunch so the identified-visitor mock payload is + // re-fetched on a fresh start. The `reset` extra is NOT set here so + // the identified profile just persisted is preserved. + AppLauncher.forceStop(device) + AppLauncher.launchApp(device) + // Identified-visitor profile should render the variant entry by default. + // Asserting this here turns a previously-silent setup misalignment into + // a clear precondition failure if the mock data ever drifts. + assertEntryVisible(VARIANT_ENTRY_ID, "entry-text-$VARIANT_ENTRY_ID missing — identified-visitor variant never rendered") + } + + private fun openPanel() { + TestHelpers.waitAndTap(device, By.desc("preview-panel-fab")) + TestHelpers.waitForElement(device, By.text("Preview Panel")) + Thread.sleep(2000) + } + + private fun closePanel() { + device.pressBack() + Thread.sleep(500) + } + + private fun scrollPanelToElement(desc: String) { + val panel = device.findObject(By.desc("preview-panel-list")) + val bounds = panel?.visibleBounds + + val centerX = bounds?.centerX() ?: (device.displayWidth / 2) + val startY = bounds?.let { it.top + (it.height() * 3 / 4) } ?: (device.displayHeight * 3 / 4) + val endY = bounds?.let { it.top + (it.height() / 4) } ?: (device.displayHeight / 4) + + for (i in 0 until 8) { + // Wait for any in-flight scroll/animation to settle. Compose batches + // accessibility-tree updates during a fling and the tree we query + // here would otherwise be stale. + device.waitForIdle(1500L) + val el = device.wait(Until.hasObject(By.descContains(desc)), 500L)?.let { + device.findObject(By.descContains(desc)) + } + if (el != null) { + val elBounds = el.visibleBounds + if (elBounds.height() >= 5 && bounds != null && + elBounds.top >= bounds.top && elBounds.bottom <= bounds.bottom + ) { + return + } + } + device.swipe(centerX, startY, centerX, endY, 25) + } + } + + private fun waitForDefinitionsLoaded() { + val deadline = System.currentTimeMillis() + TestHelpers.EXTENDED_TIMEOUT + while (System.currentTimeMillis() < deadline) { + if (device.findObject(By.text("Loading definitions...")) == null) return + Thread.sleep(150) + } + } + + private fun tapVariantPicker(variantDesc: String): Boolean { + val picker = device.findObject(By.descContains(variantDesc)) ?: return false + val panel = device.findObject(By.desc("preview-panel-list")) ?: return false + val panelBounds = panel.visibleBounds + val pickerBounds = picker.visibleBounds + + if (pickerBounds.height() < 5 || + pickerBounds.top < panelBounds.top || + pickerBounds.bottom > panelBounds.bottom + ) { + return false + } + + device.click(pickerBounds.centerX(), pickerBounds.centerY()) + Thread.sleep(500) + return true + } + + private fun expandTargetAudienceAndTapVariant() { + waitForDefinitionsLoaded() + + val expandDesc = "audience-expand-$AUDIENCE_ID" + val variantDesc = "variant-picker-$EXPERIENCE_ID-0" + + scrollPanelToElement(expandDesc) + // audience-expand is a binary toggle, so single-click only. The default + // tapElement double-click (accessibility-click + coordinate click 100ms + // later) would expand then immediately re-collapse it. audience-toggle + // survives the double-click because it is a set-state radio. + val expandEl = TestHelpers.waitForElement( + device, By.descContains(expandDesc), TestHelpers.EXTENDED_TIMEOUT, + ) + TestHelpers.tapElement(device, expandEl, singleClick = true) + Thread.sleep(1000) + + scrollPanelToElement(variantDesc) + + val deadline = System.currentTimeMillis() + TestHelpers.EXTENDED_TIMEOUT + while (System.currentTimeMillis() < deadline) { + if (tapVariantPicker(variantDesc)) return + scrollPanelToElement(variantDesc) + Thread.sleep(500) + } + + Assert.fail("$variantDesc not found after expanding audience") + } + + /** + * Mirrors iOS `assertEntryVisible`: assert that the entry with the given + * id has its `entry-text-` testTag rendered in main-scroll-view. + * + * This is the strong form of the previous `assertEntryContentContains` + * fuzzy-substring check — it fails when the *wrong* entry was resolved + * rather than just when some accidental substring is missing. + */ + private fun assertEntryVisible(entryId: String, message: String) { + val tag = "entry-text-$entryId" + // Bring the entry into view if it's offscreen. Swallow the scroll + // exception so the assertion below produces the clearer failure. + try { + UiScrollable(UiSelector().resourceId("main-scroll-view")) + .apply { setMaxSearchSwipes(10) } + .scrollIntoView(UiSelector().resourceId(tag)) + } catch (_: Exception) { + } + val deadline = System.currentTimeMillis() + TestHelpers.EXTENDED_TIMEOUT + while (System.currentTimeMillis() < deadline) { + if (device.findObject(By.res(tag)) != null) return + Thread.sleep(150) + } + val visibleEntryTags = device.findObjects(By.res(java.util.regex.Pattern.compile("entry-text-.*"))) + .mapNotNull { it.resourceName } + Assert.fail("$message (entry-text-$entryId not found; visible entry-text-* tags: $visibleEntryTags)") + } + + // MARK: - Scenarios + + /** + * Scenario 1: turning on an audience that the identified visitor does not + * qualify for activates an experience whose variant content then renders + * on screen. + */ + @Test + fun testScenario1ActivatingUnqualifiedAudienceRendersItsVariant() { + openPanel() + waitForDefinitionsLoaded() + scrollPanelToElement("audience-toggle-$UNQUALIFIED_AUDIENCE_ID-on") + // singleClick: the toggle is a set-state radio; singleClick avoids the + // coordinate-click fallback landing on a different row after re-sort. + TestHelpers.waitAndTap(device, By.desc("audience-toggle-$UNQUALIFIED_AUDIENCE_ID-on"), singleClick = true) + closePanel() + + // The variant content for the Mobile Browser experience renders using + // the *original* baseline entry id (xFwgG3oNaOcjzWiGe4vXo) in the label. + val deadline = System.currentTimeMillis() + TestHelpers.EXTENDED_TIMEOUT + while (System.currentTimeMillis() < deadline) { + if (device.findObject(By.desc(MOBILE_VARIANT_LABEL)) != null) return + Thread.sleep(150) + } + Assert.fail( + "Expected mobile variant content after activating Mobile Browser audience " + + "(label: $MOBILE_VARIANT_LABEL)", + ) + } + + /** + * Scenario 2: turning off an audience the identified visitor does qualify + * for forces the experience to fall back to its baseline entry. + */ + @Test + fun testScenario2DeactivatingQualifiedAudienceRendersBaseline() { + openPanel() + waitForDefinitionsLoaded() + scrollPanelToElement("audience-toggle-$AUDIENCE_ID-off") + // singleClick: deactivating the audience demotes it in `sortAudiences`, + // so the row re-sorts between the accessibility click and the default + // coordinate-click fallback, landing the second tap on whichever audience + // now occupies the original screen position. + TestHelpers.waitAndTap(device, By.desc("audience-toggle-$AUDIENCE_ID-off"), singleClick = true) + closePanel() + + assertEntryVisible(BASELINE_ENTRY_ID, "Expected baseline entry after deactivating audience") + } + + /** + * Scenario 3: after deactivating a qualified audience, tapping the + * audience's default toggle removes the override and restores the original + * variant resolution. + */ + @Test + fun testScenario3ResettingAudienceOverrideRestoresVariant() { + openPanel() + waitForDefinitionsLoaded() + scrollPanelToElement("audience-toggle-$AUDIENCE_ID-off") + TestHelpers.waitAndTap(device, By.desc("audience-toggle-$AUDIENCE_ID-off"), singleClick = true) + TestHelpers.waitAndTap(device, By.desc("audience-toggle-$AUDIENCE_ID-default"), singleClick = true) + closePanel() + + assertEntryVisible(VARIANT_ENTRY_ID, "Expected variant entry after resetting audience override") + } + + /** + * Scenario 4: explicitly picking the index-0 (baseline) variant for an + * experience forces that experience to render its baseline entry, even + * when the visitor qualifies for a non-baseline variant. + */ + @Test + fun testScenario4SettingVariantOverrideToZeroRendersBaseline() { + openPanel() + expandTargetAudienceAndTapVariant() + closePanel() + + assertEntryVisible(BASELINE_ENTRY_ID, "Expected baseline after variant-0 override") + } + + /** + * Scenario 5: after forcing a variant override, tapping the per-experience + * reset control removes only that override and restores the original + * variant resolution. On Android the `reset-variant-` button invokes + * the reset directly with no confirmation dialog (same as iOS). + */ + @Test + fun testScenario5ResettingSingleVariantOverrideRestoresVariant() { + openPanel() + expandTargetAudienceAndTapVariant() + + val resetDesc = "reset-variant-$EXPERIENCE_ID" + scrollPanelToElement(resetDesc) + TestHelpers.waitAndTap(device, By.desc(resetDesc)) + closePanel() + + assertEntryVisible(VARIANT_ENTRY_ID, "Expected variant entry after resetting single variant override") + } + + /** + * Scenario 6: after forcing a variant override, tapping the panel's + * reset-all control and confirming via the native AlertDialog clears every + * override and restores the original variant resolution. On Android the + * confirmation is a Material3 AlertDialog — confirmed by tapping the + * button with text "Reset" (no inline `reset-all-confirm` view as in RN). + */ + @Test + fun testScenario6ResetAllRestoresVariantContent() { + openPanel() + expandTargetAudienceAndTapVariant() + + scrollPanelToElement("reset-all-overrides") + TestHelpers.waitAndTap(device, By.desc("reset-all-overrides")) + // Confirm the native AlertDialog. + TestHelpers.waitAndTap(device, By.text("Reset")) + + closePanel() + + assertEntryVisible(VARIANT_ENTRY_ID, "Expected variant entry after reset-all") + } + + /** + * Scenario 7: deactivating an audience and then triggering the in-panel + * refresh (which re-hits the experience API) keeps the audience override + * in place so the experience still resolves to its baseline. + */ + @Test + fun testScenario7OverrideSurvivesAPIRefresh() { + openPanel() + waitForDefinitionsLoaded() + scrollPanelToElement("audience-toggle-$AUDIENCE_ID-off") + TestHelpers.waitAndTap(device, By.desc("audience-toggle-$AUDIENCE_ID-off"), singleClick = true) + + scrollPanelToElement("preview-refresh-button") + TestHelpers.waitAndTap(device, By.desc("preview-refresh-button")) + closePanel() + + assertEntryVisible(BASELINE_ENTRY_ID, "Expected baseline still rendering after API refresh") + } + + /** + * Scenario 8: a cold relaunch with cleared storage discards all overrides + * — the variant renders again and the overrides section reports that none + * remain. + */ + @Test + fun testScenario8DestroyRemountClearsOverrides() { + openPanel() + waitForDefinitionsLoaded() + scrollPanelToElement("audience-toggle-$AUDIENCE_ID-off") + TestHelpers.waitAndTap(device, By.desc("audience-toggle-$AUDIENCE_ID-off"), singleClick = true) + closePanel() + + assertEntryVisible(BASELINE_ENTRY_ID, "Expected baseline after deactivating audience (pre-relaunch)") + + // Cold relaunch with fresh storage, then re-identify and rehydrate. + AppLauncher.relaunchClean(device) + identifyAndRelaunch() + + // Override must be gone — variant renders again. + assertEntryVisible(VARIANT_ENTRY_ID, "Expected variant entry after destroy/remount cleared overrides") + + // The Overrides section should show its empty state. The empty-state + // text sits below the fold so the panel content must be scrolled to + // reveal it. On Android, reset-all-overrides lives in the fixed footer + // (outside preview-panel-list), so scrollPanelToElement exhausts its + // swipe budget rather than returning early — this is expected. After + // the swipes the Overrides section is in or near the viewport. Use a + // timed wait so the accessibility tree can settle after the final swipe + // before asserting, mirroring the defensive pattern used by + // assertEntryVisible throughout this suite. + openPanel() + waitForDefinitionsLoaded() + scrollPanelToElement("reset-all-overrides") + val found = device.wait(Until.hasObject(By.text("No active overrides")), TestHelpers.EXTENDED_TIMEOUT) + Assert.assertTrue( + "Expected 'No active overrides' empty-state text in Overrides section", + found, + ) + 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 new file mode 100644 index 00000000..faadc2e7 --- /dev/null +++ b/implementations/android-sdk/uitests/src/main/kotlin/com/contentful/optimization/uitests/tests/PreviewPanelTests.kt @@ -0,0 +1,153 @@ +package com.contentful.optimization.uitests.tests + +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.AppLauncher +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.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class PreviewPanelTests { + private lateinit var device: UiDevice + + @Before + fun setUp() { + device = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation()) + AppLauncher.launchApp(device) + clearProfileState(device) + TestHelpers.waitForElement(device, By.res("identify-button")) + } + + private fun openPreviewPanel() { + TestHelpers.waitAndTap(device, By.desc("preview-panel-fab")) + TestHelpers.waitForElement(device, By.text("Preview Panel")) + Thread.sleep(2000) + } + + private fun scrollToPreviewElement(testId: String, maxSwipes: Int = 20) { + for (i in 0 until maxSwipes) { + if (TestHelpers.findElement(device, testId) != null) return + val panelBounds = device.findObject(By.desc("preview-panel-list"))?.visibleBounds + val centerX = panelBounds?.centerX() ?: (device.displayWidth / 2) + val startY = panelBounds?.let { it.top + (it.height() * 3 / 4) } ?: (device.displayHeight * 3 / 4) + val endY = panelBounds?.let { it.top + (it.height() / 4) } ?: (device.displayHeight / 4) + device.swipe(centerX, startY, centerX, endY, 10) + Thread.sleep(300) + } + } + + // FAB Visibility + + @Test + fun testFABIsVisible() { + val fab = device.findObject(By.desc("preview-panel-fab")) + Assert.assertNotNull("Preview panel FAB should be visible on main screen", fab) + } + + // Profile Data Loading + + @Test + fun testShowsProfileData() { + openPreviewPanel() + Thread.sleep(2000) + val noData = device.findObject(By.desc("no-profile-data")) + Assert.assertNull("Should not show 'No profile data' after initialization", noData) + } + + @Test + fun testShowsAllExpectedProfileKeys() { + openPreviewPanel() + + val expectedKeys = listOf("audiences", "id", "location", "random", "session", "stableId", "traits") + for (key in expectedKeys) { + scrollToPreviewElement("profile-item-$key") + val item = TestHelpers.findElement(device, "profile-item-$key") + Assert.assertNotNull("Expected profile key '$key' not found in preview panel", item) + } + } + + @Test + fun testShowsLocationData() { + openPreviewPanel() + scrollToPreviewElement("profile-item-location") + + val locationItem = TestHelpers.findElement(device, "profile-item-location") + Assert.assertNotNull("Profile location item not found", locationItem) + + val text = TestHelpers.extractText(locationItem!!) + Assert.assertTrue( + "Expected location to contain Berlin or DE, got: $text", + text.contains("Berlin") || text.contains("DE"), + ) + } + + // Debug Section + + @Test + fun testShowsConsentAccepted() { + openPreviewPanel() + scrollToPreviewElement("debug-consent") + + val consent = TestHelpers.findElement(device, "debug-consent") + Assert.assertNotNull("Debug consent element not found", consent) + + val text = TestHelpers.extractText(consent!!) + Assert.assertTrue( + "Expected consent to contain 'Accepted', got: $text", + text.contains("Accepted"), + ) + } + + @Test + fun testShowsCanPersonalize() { + openPreviewPanel() + scrollToPreviewElement("debug-can-personalize") + + val canPersonalize = TestHelpers.findElement(device, "debug-can-personalize") + Assert.assertNotNull("Debug canPersonalize element not found", canPersonalize) + + val text = TestHelpers.extractText(canPersonalize!!) + Assert.assertTrue( + "Expected canPersonalize to contain 'Yes', got: $text", + text.contains("Yes"), + ) + } + + // Refresh + + @Test + fun testRefreshButtonWorks() { + openPreviewPanel() + scrollToPreviewElement("preview-refresh-button") + + TestHelpers.waitAndTap(device, By.desc("preview-refresh-button")) + + Thread.sleep(2000) + val noData = device.findObject(By.desc("no-profile-data")) + Assert.assertNull("Profile data should persist after refresh", noData) + } + + // Profile After Identify + + @Test + fun testProfileUpdatesAfterIdentify() { + TestHelpers.waitAndTap(device, By.res("identify-button")) + TestHelpers.waitForElement(device, By.res("reset-button"), TestHelpers.EXTENDED_TIMEOUT) + + openPreviewPanel() + + Thread.sleep(2000) + val noData = device.findObject(By.desc("no-profile-data")) + Assert.assertNull("Should show profile data after identify", noData) + + scrollToPreviewElement("profile-item-id") + val idItem = TestHelpers.findElement(device, "profile-item-id") + Assert.assertNotNull("Profile id should be present after identify", idItem) + } +} 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 new file mode 100644 index 00000000..0dc30366 --- /dev/null +++ b/implementations/android-sdk/uitests/src/main/kotlin/com/contentful/optimization/uitests/tests/ScreenTrackingTests.kt @@ -0,0 +1,88 @@ +package com.contentful.optimization.uitests.tests + +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.TestHelpers +import com.contentful.optimization.uitests.support.clearProfileState +import org.junit.Assert +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class ScreenTrackingTests { + private lateinit var device: UiDevice + + @Before + fun setUp() { + device = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation()) + clearProfileState(device, requireFreshAppInstance = true) + } + + private fun navigateToTestScreen() { + TestHelpers.waitAndTap(device, By.res("navigation-test-button"), TestHelpers.EXTENDED_TIMEOUT) + TestHelpers.waitForElement(device, By.res("go-to-view-one-button"), TestHelpers.EXTENDED_TIMEOUT) + } + + @Test + fun testTrackSingleViewVisit() { + navigateToTestScreen() + TestHelpers.waitAndTap(device, By.res("go-to-view-one-button")) + + TestHelpers.waitForElement(device, By.res("navigation-view-test-one"), TestHelpers.EXTENDED_TIMEOUT) + TestHelpers.waitForElement(device, By.res("screen-event-log"), TestHelpers.EXTENDED_TIMEOUT) + TestHelpers.waitForTextEquals( + device, "screen-event-log", "NavigationHome,NavigationViewOne", + timeout = TestHelpers.EXTENDED_TIMEOUT, + ) + } + + @Test + fun testTrackMultipleViewVisitsInOrder() { + navigateToTestScreen() + TestHelpers.waitAndTap(device, By.res("go-to-view-one-button")) + + TestHelpers.waitForElement(device, By.res("navigation-view-test-one"), TestHelpers.EXTENDED_TIMEOUT) + TestHelpers.waitAndTap(device, By.res("go-to-view-two-button"), TestHelpers.EXTENDED_TIMEOUT) + + TestHelpers.waitForElement(device, By.res("navigation-view-test-two"), TestHelpers.EXTENDED_TIMEOUT) + TestHelpers.waitForElement(device, By.res("screen-event-log"), TestHelpers.EXTENDED_TIMEOUT) + + val logText = TestHelpers.waitForElementText( + device, "screen-event-log", timeout = TestHelpers.EXTENDED_TIMEOUT, + ) { text -> + text.contains("NavigationViewTwo") + } + + val viewOneIndex = logText.indexOf("NavigationViewOne") + val viewTwoIndex = logText.indexOf("NavigationViewTwo") + + Assert.assertTrue("ViewOne not found in log", viewOneIndex >= 0) + Assert.assertTrue("ViewTwo not found in log", viewTwoIndex >= 0) + Assert.assertTrue("ViewOne should come before ViewTwo", viewOneIndex < viewTwoIndex) + } + + @Test + fun testTrackRevisitingViewOneAfterViewTwo() { + navigateToTestScreen() + TestHelpers.waitAndTap(device, By.res("go-to-view-one-button")) + + TestHelpers.waitForElement(device, By.res("navigation-view-test-one"), TestHelpers.EXTENDED_TIMEOUT) + TestHelpers.waitAndTap(device, By.res("go-to-view-two-button"), TestHelpers.EXTENDED_TIMEOUT) + + TestHelpers.waitForElement(device, By.res("navigation-view-test-two"), TestHelpers.EXTENDED_TIMEOUT) + + device.pressBack() + + TestHelpers.waitForElement(device, By.res("navigation-view-test-one"), TestHelpers.EXTENDED_TIMEOUT) + TestHelpers.waitForElement(device, By.res("screen-event-log"), TestHelpers.EXTENDED_TIMEOUT) + TestHelpers.waitForTextEquals( + device, + "screen-event-log", + "NavigationHome,NavigationViewOne,NavigationViewTwo,NavigationViewOne", + timeout = TestHelpers.EXTENDED_TIMEOUT, + ) + } +} 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 new file mode 100644 index 00000000..e68ca7e9 --- /dev/null +++ b/implementations/android-sdk/uitests/src/main/kotlin/com/contentful/optimization/uitests/tests/TapTrackingTests.kt @@ -0,0 +1,60 @@ +package com.contentful.optimization.uitests.tests + +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.AppLauncher +import com.contentful.optimization.uitests.support.TestHelpers +import com.contentful.optimization.uitests.support.clearProfileState +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class TapTrackingTests { + private lateinit var device: UiDevice + + @Before + fun setUp() { + device = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation()) + AppLauncher.launchApp(device) + clearProfileState(device) + } + + @Test + fun testEmitsComponentClickWhenTappingContentEntry() { + // Step 1: wait for "Analytics Events" text to be visible + TestHelpers.waitForElement(device, By.text("Analytics Events"), TestHelpers.ELEMENT_TIMEOUT) + + // Step 2: tap the content entry + val entry = TestHelpers.waitForElement(device, By.desc("content-entry-1MwiFl4z7gkwqGYdvCmr8c")) + TestHelpers.tapElement(device, entry) + + // Step 3: wait until at least 1 event has been tracked + TestHelpers.waitForEventsCountAtLeast(device, 1) + + // Step 4: scroll to and assert the component_click event element is visible + val testId = "event-component_click-1MwiFl4z7gkwqGYdvCmr8c" + TestHelpers.scrollToElement(device, testId, "main-scroll-view") + TestHelpers.waitForElement(device, By.res(testId), TestHelpers.ELEMENT_TIMEOUT) + } + + @Test + fun testEmitsComponentClickForDifferentEntry() { + // Step 1: wait for "Analytics Events" text to be visible + TestHelpers.waitForElement(device, By.text("Analytics Events"), TestHelpers.ELEMENT_TIMEOUT) + + // Step 2: tap the content entry + val entry = TestHelpers.waitForElement(device, By.desc("content-entry-2Z2WLOx07InSewC3LUB3eX")) + TestHelpers.tapElement(device, entry) + + // Step 3: wait until at least 1 event has been tracked + TestHelpers.waitForEventsCountAtLeast(device, 1) + + // Step 4: scroll to and assert the component_click event element is visible + val testId = "event-component_click-2Z2WLOx07InSewC3LUB3eX" + TestHelpers.scrollToElement(device, testId, "main-scroll-view") + TestHelpers.waitForElement(device, By.res(testId), TestHelpers.ELEMENT_TIMEOUT) + } +} 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 new file mode 100644 index 00000000..de39b27f --- /dev/null +++ b/implementations/android-sdk/uitests/src/main/kotlin/com/contentful/optimization/uitests/tests/UnidentifiedVariantsTests.kt @@ -0,0 +1,190 @@ +package com.contentful.optimization.uitests.tests + +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.AppLauncher +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.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class UnidentifiedVariantsTests { + private lateinit var device: UiDevice + + @Before + fun setUp() { + device = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation()) + clearProfileState(device, requireFreshAppInstance = true) + } + + // Drives the unidentified -> identified round-trip the baseline tests rely on. + // The home-screen optimized entries lock on their first resolved value, so a + // mid-test identify does not re-resolve them; only a relaunch makes the SDK + // re-run audience evaluation against the now-identified profile. + private fun identifyAndRelaunch() { + TestHelpers.waitForElement(device, By.res("identify-button")) + TestHelpers.waitAndTap(device, By.res("identify-button")) + TestHelpers.waitForElement(device, By.res("reset-button")) + AppLauncher.forceStop(device) + AppLauncher.launchApp(device) + TestHelpers.waitForElement(device, By.res("main-scroll-view"), TestHelpers.EXTENDED_TIMEOUT) + } + + // MARK: - common variants + + @Test + fun testDisplaysMergeTagContentWithResolvedValue() { + val expectedLabel = "This is a merge tag content entry that displays the visitor's continent \"EU\" embedded within the text. [Entry: 1MwiFl4z7gkwqGYdvCmr8c]" + // The Android app resolves merge tags asynchronously, so wait for the + // resolved description rather than asserting on it immediately. + TestHelpers.waitForElement(device, By.desc(expectedLabel), TestHelpers.ELEMENT_TIMEOUT) + } + + @Test + fun testDisplaysVariantForVisitorsFromEurope() { + TestHelpers.waitForElement(device, By.res("entry-text-4ib0hsHWoSOnCVdDkizE8d")) + val expectedLabel = "This is a variant content entry for visitors from Europe. [Entry: 4ib0hsHWoSOnCVdDkizE8d]" + Assert.assertNotNull( + "Expected Europe variant content", + device.findObject(By.desc(expectedLabel)), + ) + } + + @Test + fun testDisplaysVariantForDesktopBrowserVisitors() { + TestHelpers.waitForElement(device, By.res("entry-text-xFwgG3oNaOcjzWiGe4vXo")) + val expectedLabel = "This is a variant content entry for visitors using a desktop browser. [Entry: xFwgG3oNaOcjzWiGe4vXo]" + Assert.assertNotNull( + "Expected desktop-browser variant content", + device.findObject(By.desc(expectedLabel)), + ) + } + + // MARK: - unidentified user variants + + @Test + fun testDisplaysVariantForNewVisitors() { + TestHelpers.waitForElement(device, By.res("entry-text-2Z2WLOx07InSewC3LUB3eX")) + val expectedLabel = "This is a variant content entry for new visitors. [Entry: 2Z2WLOx07InSewC3LUB3eX]" + Assert.assertNotNull( + "Expected new-visitor variant content", + device.findObject(By.desc(expectedLabel)), + ) + } + + @Test + fun testDisplaysVariantBForABCExperiment() { + TestHelpers.waitForElement(device, By.res("entry-text-5XHssysWUDECHzKLzoIsg1")) + val expectedLabel = "This is a variant content entry for an A/B/C experiment: B [Entry: 5XHssysWUDECHzKLzoIsg1]" + Assert.assertNotNull( + "Expected A/B/C experiment variant B", + device.findObject(By.desc(expectedLabel)), + ) + } + + @Test + fun testDisplaysBaselineForVisitorsWithOrWithoutCustomEvent() { + TestHelpers.waitForElement(device, By.res("entry-text-6zqoWXyiSrf0ja7I2WGtYj")) + val baselineLabel = "This is a baseline content entry for all visitors with or without a custom event. [Entry: 6zqoWXyiSrf0ja7I2WGtYj]" + Assert.assertNotNull( + "Expected baseline custom-event content", + device.findObject(By.desc(baselineLabel)), + ) + + identifyAndRelaunch() + + val variantLabel = "This is a variant content entry for visitors with a custom event. [Entry: 6zqoWXyiSrf0ja7I2WGtYj]" + TestHelpers.waitForElement(device, By.desc(variantLabel)) + Assert.assertNull( + "Baseline custom-event content should be gone after identify", + device.findObject(By.desc(baselineLabel)), + ) + } + + @Test + fun testDisplaysBaselineForAllIdentifiedOrUnidentifiedUsers() { + TestHelpers.scrollToElement(device, "entry-text-7pa5bOx8Z9NmNcr7mISvD", "main-scroll-view") + TestHelpers.waitForElement(device, By.res("entry-text-7pa5bOx8Z9NmNcr7mISvD")) + val baselineLabel = "This is a baseline content entry for all identified or unidentified users. [Entry: 7pa5bOx8Z9NmNcr7mISvD]" + Assert.assertNotNull( + "Expected baseline all-users content", + device.findObject(By.desc(baselineLabel)), + ) + + identifyAndRelaunch() + + val variantLabel = "This is a variant content entry for identified users. [Entry: 7pa5bOx8Z9NmNcr7mISvD]" + TestHelpers.waitForElement(device, By.desc(variantLabel)) + Assert.assertNull( + "Baseline all-users content should be gone after identify", + device.findObject(By.desc(baselineLabel)), + ) + } + + // MARK: - nested optimization baselines + + @Test + fun testDisplaysLevel0NestedBaselineForNewVisitors() { + TestHelpers.scrollToElement(device, "entry-text-1JAU028vQ7v6nB2swl3NBo", "main-scroll-view") + TestHelpers.waitForElement(device, By.res("entry-text-1JAU028vQ7v6nB2swl3NBo")) + val baselineLabel = "This is a level 0 nested baseline entry. [Entry: 1JAU028vQ7v6nB2swl3NBo]" + Assert.assertNotNull( + "Expected level 0 nested baseline content", + device.findObject(By.desc(baselineLabel)), + ) + + identifyAndRelaunch() + + TestHelpers.scrollToElement(device, "entry-text-2KIWllNZJT205BwOSkMINg", "main-scroll-view") + TestHelpers.waitForElement(device, By.res("entry-text-2KIWllNZJT205BwOSkMINg")) + Assert.assertNull( + "Level 0 nested baseline content should be gone after identify", + device.findObject(By.res("entry-text-1JAU028vQ7v6nB2swl3NBo")), + ) + } + + @Test + fun testDisplaysLevel1NestedBaselineForNewVisitors() { + TestHelpers.scrollToElement(device, "entry-text-5i4SdJXw9oDEY0vgO7CwF4", "main-scroll-view") + TestHelpers.waitForElement(device, By.res("entry-text-5i4SdJXw9oDEY0vgO7CwF4")) + val baselineLabel = "This is a level 1 nested baseline entry. [Entry: 5i4SdJXw9oDEY0vgO7CwF4]" + Assert.assertNotNull( + "Expected level 1 nested baseline content", + device.findObject(By.desc(baselineLabel)), + ) + + identifyAndRelaunch() + + TestHelpers.scrollToElement(device, "entry-text-5a8ONfBdanJtlJ39WWnH1w", "main-scroll-view") + TestHelpers.waitForElement(device, By.res("entry-text-5a8ONfBdanJtlJ39WWnH1w")) + Assert.assertNull( + "Level 1 nested baseline content should be gone after identify", + device.findObject(By.res("entry-text-5i4SdJXw9oDEY0vgO7CwF4")), + ) + } + + @Test + fun testDisplaysLevel2NestedBaselineForNewVisitors() { + TestHelpers.scrollToElement(device, "entry-text-uaNY4YJ0HFPAX3gKXiRdX", "main-scroll-view") + TestHelpers.waitForElement(device, By.res("entry-text-uaNY4YJ0HFPAX3gKXiRdX")) + val baselineLabel = "This is a level 2 nested baseline entry. [Entry: uaNY4YJ0HFPAX3gKXiRdX]" + Assert.assertNotNull( + "Expected level 2 nested baseline content", + device.findObject(By.desc(baselineLabel)), + ) + + identifyAndRelaunch() + + TestHelpers.scrollToElement(device, "entry-text-4hDiXxYEFrXHXcQgmdL9Uv", "main-scroll-view") + TestHelpers.waitForElement(device, By.res("entry-text-4hDiXxYEFrXHXcQgmdL9Uv")) + Assert.assertNull( + "Level 2 nested baseline content should be gone after identify", + device.findObject(By.res("entry-text-uaNY4YJ0HFPAX3gKXiRdX")), + ) + } +} 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..1b5e2c62 --- /dev/null +++ b/implementations/android-sdk/views/src/main/kotlin/com/contentful/optimization/app/views/support/TestTagging.kt @@ -0,0 +1,67 @@ +package com.contentful.optimization.app.views.support + +import android.os.Bundle +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`. + * + * Also: suppress the accessibility-triggered `ACTION_CLICK`. The shared UI Automator helper + * `TestHelpers.tapElement` issues BOTH an accessibility click and a coordinate click on every + * tap to defeat Compose's gesture debouncing. On a stock Android `Button`, both clicks fire the + * `OnClickListener`, which silently double-fires every toggle. Compose's accessibility tree + * routes the same action through a semantics handler that the gesture system effectively + * deduplicates, so on the Compose impl one logical tap = one logical click. Treating the + * accessibility click as a no-op gives the Views impl the same observable behavior, while + * coordinate clicks still fire the listener normally (so TalkBack users can still operate the + * UI through long-press / explore-by-touch). + */ +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) + } + + override fun performAccessibilityAction( + host: View, + action: Int, + args: Bundle?, + ): Boolean { + if (action == AccessibilityNodeInfoCompat.ACTION_CLICK) return true + return super.performAccessibilityAction(host, action, args) + } + }, + ) +} 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 @@ + + + + + + + + + +