diff --git a/.github/workflows/main-pipeline.yaml b/.github/workflows/main-pipeline.yaml index 9b0db815..9a4a9fd8 100644 --- a/.github/workflows/main-pipeline.yaml +++ b/.github/workflows/main-pipeline.yaml @@ -26,6 +26,7 @@ jobs: e2e_web_sdk_react: ${{ steps.filter.outputs.e2e_web_sdk_react }} e2e_react_web_sdk: ${{ steps.filter.outputs.e2e_react_web_sdk }} e2e_react_native_android: ${{ steps.filter.outputs.e2e_react_native_android }} + e2e_android: ${{ steps.filter.outputs.e2e_android }} e2e_ios: ${{ steps.filter.outputs.e2e_ios }} steps: - uses: namespacelabs/nscloud-checkout-action@938f5d2d403d6224d9a0c0dc559b1dae09c2ede4 # v8.1.1 @@ -126,6 +127,14 @@ jobs: - 'package.json' - 'pnpm-lock.yaml' - '.github/workflows/main-pipeline.yaml' + # Android native implementation E2E coverage scope. + e2e_android: + - 'implementations/android-sdk/**' + - 'lib/mocks/**' + - 'packages/android/**' + - 'package.json' + - 'pnpm-lock.yaml' + - '.github/workflows/main-pipeline.yaml' # iOS native implementation E2E coverage scope. e2e_ios: - 'implementations/ios-sdk/**' @@ -659,6 +668,156 @@ jobs: if-no-files-found: error retention-days: 1 + e2e-android-sdk: + name: ๐Ÿค– E2E Android Native + runs-on: namespace-profile-linux-16-vcpu-32-gb-ram-optimal + timeout-minutes: 45 + needs: [setup, changes] + if: needs.changes.outputs.e2e_android == 'true' + env: + CI: 'true' + GRADLE_OPTS: >- + -Dorg.gradle.daemon=false -Dorg.gradle.parallel=true -Dorg.gradle.jvmargs=-Xmx4g + -Dkotlin.daemon.jvm.options=-Xmx2g + steps: + - uses: namespacelabs/nscloud-checkout-action@938f5d2d403d6224d9a0c0dc559b1dae09c2ede4 # v8.1.1 + + - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + with: + node-version-file: '.nvmrc' + package-manager-cache: false + + - uses: pnpm/action-setup@903f9c1a6ebcba6cf41d87230be49611ac97822e # v6.0.3 + + - name: Set Android SDK environment variables + run: | + echo "ANDROID_SDK_ROOT=$HOME/.android/sdk" >> "$GITHUB_ENV" + echo "ANDROID_HOME=$HOME/.android/sdk" >> "$GITHUB_ENV" + + - name: Prepare cache directories + run: | + mkdir -p "$HOME/.android/sdk" "$HOME/.android/avd" "$HOME/.android/cache" + + - name: Set up caches (Namespace) + uses: namespacelabs/nscloud-cache-action@15799a6b54e5765f85b2aac25b3f0df43ed571c0 # v1.4.3 + with: + cache: | + pnpm + gradle + path: | + ~/.android/sdk + ~/.android/avd + ~/.android/cache + + - name: Install system dependencies (Android emulator) + run: | + sudo apt-get update + sudo apt-get install -y --no-install-recommends \ + ca-certificates curl unzip zip git \ + netcat-openbsd cpu-checker \ + libgl1 libnss3 libx11-6 libx11-xcb1 libxcomposite1 libxdamage1 libxrandr2 libxtst6 \ + libxi6 libxrender1 libxkbcommon0 libgbm1 libdbus-1-3 libdrm2 libpulse0 + sudo apt-get install -y --no-install-recommends libasound2 || sudo apt-get install -y --no-install-recommends libasound2t64 + + - name: Verify KVM is available + run: | + if [ ! -e /dev/kvm ]; then + echo "/dev/kvm not found; Android hardware acceleration will not work." >&2 + exit 1 + fi + ls -l /dev/kvm + sudo kvm-ok || true + + - name: Setup Java + uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0 + with: + distribution: 'temurin' + java-version: '17' + + - name: Setup Android SDK + uses: android-actions/setup-android@40fd30fb8d7440372e1316f5d1809ec01dcd3699 # v4.0.1 + + - name: Install JS dependencies + run: pnpm install --prefer-offline --frozen-lockfile + + - name: Build Android bridge JS bundle + run: pnpm --filter @contentful/optimization-android-bridge build + + - name: Build app and test APKs + working-directory: implementations/android-sdk + run: ./gradlew :app:assembleDebug :uitests:assembleDebug + + - name: Start Mock Server + run: | + pnpm --dir lib/mocks serve > /tmp/mock-server.log 2>&1 & + echo $! > /tmp/mock-server.pid + for i in {1..60}; do + if nc -z localhost 8000 2>/dev/null; then + echo "Mock server is ready" + break + fi + echo "Waiting for mock server... ($i/60)" + sleep 1 + done + if ! nc -z localhost 8000 2>/dev/null; then + echo "Mock server failed to start:" + cat /tmp/mock-server.log + exit 1 + fi + + - name: Run Android E2E Tests (emulator) + uses: reactivecircus/android-emulator-runner@e89f39f1abbbd05b1113a29cf4db69e7540cae5a # v2.37.0 + with: + api-level: 35 + arch: x86_64 + target: google_apis + profile: pixel_7 + avd-name: test + force-avd-creation: true + emulator-boot-timeout: 600 + cores: 6 + ram-size: 4096M + disk-size: 8G + disable-animations: true + emulator-options: -no-window -no-audio -no-boot-anim -gpu swiftshader_indirect + script: | + echo "Disabling animations..." + adb shell settings put global window_animation_scale 0 + adb shell settings put global transition_animation_scale 0 + adb shell settings put global animator_duration_scale 0 + echo "Installing APKs..." + adb install -r implementations/android-sdk/app/build/outputs/apk/debug/app-debug.apk + adb install -r implementations/android-sdk/uitests/build/outputs/apk/debug/uitests-debug.apk + echo "Setting up adb reverse port forwarding..." + adb reverse tcp:8000 tcp:8000 + sleep 3 + adb shell "for i in 1 2 3 4 5 6 7 8 9 10; do nc -z localhost 8000 2>/dev/null && echo 'Mock server tunnel verified' && exit 0; sleep 1; done; echo 'WARNING: tunnel verification timed out'" + echo "Running UI Automator 2 E2E tests..." + adb shell am instrument -w com.contentful.optimization.uitests/androidx.test.runner.AndroidJUnitRunner 2>&1 | tee /tmp/test-output.log + grep -q "FAILURES" /tmp/test-output.log && { echo "::error::Android UI tests failed"; exit 1; } || true + grep -q "Process crashed" /tmp/test-output.log && { echo "::error::Test process crashed"; exit 1; } || true + + - name: Upload logs on failure + if: failure() + run: | + echo "=== Mock Server Logs ===" + cat /tmp/mock-server.log || echo "No mock server logs found" + + - name: Stop Mock Server + if: always() + run: | + kill $(cat /tmp/mock-server.pid) 2>/dev/null || true + + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + if: always() + with: + name: ci-results-android-sdk + path: | + implementations/android-sdk/logs/ + /tmp/mock-server.log + /tmp/test-output.log + retention-days: 7 + e2e-ios-sdk-build: name: ๐ŸŽ Build iOS UI Test Bundles runs-on: namespace-profile-macos-apple-silicon-arm64-6-cpu-14-gb diff --git a/implementations/android-sdk/.gitignore b/implementations/android-sdk/.gitignore new file mode 100644 index 00000000..fc1112e2 --- /dev/null +++ b/implementations/android-sdk/.gitignore @@ -0,0 +1,5 @@ +.gradle/ +app/build/ +uitests/build/ +local.properties +logs/ diff --git a/implementations/android-sdk/AGENTS.md b/implementations/android-sdk/AGENTS.md new file mode 100644 index 00000000..fc14d470 --- /dev/null +++ b/implementations/android-sdk/AGENTS.md @@ -0,0 +1,69 @@ +# AGENTS.md + +Read the repository root `AGENTS.md`, then `implementations/AGENTS.md`, before this file. + +## Scope + +This is the native Android reference implementation for bridge and preview-panel validation work. It +uses a Jetpack Compose app shell with the Android SDK library module included via Gradle composite +build. + +## Key paths + +- `app/src/main/kotlin/com/contentful/optimization/app/` โ€” App source +- `app/src/main/kotlin/com/contentful/optimization/app/screens/` โ€” Screen composables +- `app/src/main/kotlin/com/contentful/optimization/app/components/` โ€” Reusable UI components +- `uitests/` โ€” UI Automator 2 E2E test module (`com.android.test`) +- `uitests/src/main/kotlin/.../uitests/tests/` โ€” Test files (1:1 mirror of iOS XCUITest suite) +- `uitests/src/main/kotlin/.../uitests/support/` โ€” Shared test helpers, app launcher, device + extensions +- `scripts/` โ€” Build and run scripts +- `build.gradle.kts` โ€” Root build config (plugin versions) +- `settings.gradle.kts` โ€” Project structure (includes SDK module + uitests via project.dir) +- `app/build.gradle.kts` โ€” App module build config and dependencies + +## Local rules + +- Keep this app focused on validating native Android integration behavior. Reusable SDK behavior + belongs in `packages/android/ContentfulOptimization`, and TypeScript bridge behavior belongs in + `packages/android/android-zipline-bridge`. +- The mock server must be running at `http://localhost:8000` before running the app. Use + `adb reverse tcp:8000 tcp:8000` to forward the port to the emulator. +- The app references the SDK via Gradle `include` + `project.dir` in `settings.gradle.kts`. After + SDK source changes, rebuild via `./gradlew :app:assembleDebug` from this directory. +- Keep accessibility identifiers (testTags) aligned with the iOS SwiftUI implementation and + `implementations/PREVIEW_PANEL_SCENARIOS.md`. +- Use `Modifier.testTag()` for app-level test identifiers. The root composable sets + `testTagsAsResourceId = true` so UI Automator 2 can discover them as `resource-id`. +- The SDK uses `Modifier.semantics { contentDescription = ... }` for its own identifiers (e.g., + `OptimizedEntry`'s `accessibilityIdentifier` parameter). +- Test launch arguments use intent extras: `--ez reset true` clears SDK SharedPreferences, + `--ez simulate_offline true` sets the client offline. + +## Commands + +- `pnpm serve:mocks` (from monorepo root) +- From `implementations/android-sdk/`: `./gradlew :app:assembleDebug` +- From `implementations/android-sdk/`: `./scripts/bootstrap.sh` +- Build bridge first: `pnpm --filter @contentful/optimization-android-bridge build` +- Build UI test APK: `./gradlew :uitests:assembleDebug` +- Run all UI tests: `./gradlew :uitests:connectedAndroidTest` +- Run single test class: + `./gradlew :uitests:connectedAndroidTest -Pandroid.testInstrumentationRunnerArguments.class=com.contentful.optimization.uitests.tests.AnalyticsTests` + +## UI tests + +- The `uitests/` module is a `com.android.test` Gradle module โ€” fully decoupled from app internals. +- Tests interact with the app purely through UI Automator 2's accessibility layer. +- Element discovery: `By.res("testTag")` for app `testTag` values, `By.desc("id")` for SDK + `contentDescription` elements (e.g., `content-entry-{id}`). +- Test names and accessibility identifiers match the iOS XCUITest suite at + `implementations/ios-sdk/uitests/Tests/` for cross-platform test parity. +- The mock server must be running and port-forwarded before running tests. + +## Usually validate + +- Run the app on emulator after changes to verify UI renders correctly. +- Verify accessibility identifiers match iOS counterparts when changing UI structure. +- Rebuild `@contentful/optimization-android-bridge` before testing when bridge source changed. +- After UI structure changes, run `./gradlew :uitests:assembleDebug` to verify test APK compiles. diff --git a/implementations/android-sdk/README.md b/implementations/android-sdk/README.md new file mode 100644 index 00000000..4d9ff1f8 --- /dev/null +++ b/implementations/android-sdk/README.md @@ -0,0 +1,90 @@ +
+ + + + Contentful + + +### Contentful Personalization & Analytics + +

Android SDK Reference Implementation

+ +[Readme](./README.md) ยท [Guides](https://contentful.github.io/optimization/documents/Guides.html) ยท +[Reference](https://contentful.github.io/optimization/) ยท [Contributing](../../CONTRIBUTING.md) + +
+ +--- + +> [!CAUTION] Pre-release. API surface is not yet stable. + +This is the native Android reference implementation for the +[Contentful Optimization Android SDK](../../packages/android/README.md). It demonstrates the minimal +integration pattern using Jetpack Compose and serves as a test target for UI Automator 2 E2E tests. + +## What this demonstrates + +- `OptimizationRoot` initialization with mock server configuration +- `OptimizedEntry` personalization with view and click tracking +- Nested entry resolution and recursive rendering +- Navigation with screen tracking via `ScreenTrackingEffect` +- Live updates behavior: default (global), explicit live, and locked variants +- `PreviewPanelOverlay` with audience/variant override controls +- Analytics event display for debugging tracked events +- All accessibility identifiers aligned with the iOS SwiftUI implementation for cross-platform E2E + parity + +## Prerequisites + +- Android SDK with `ANDROID_HOME` set +- Android emulator or connected device +- `adb` in PATH +- pnpm dependencies installed at monorepo root (`pnpm install`) +- Android bridge built: `pnpm --filter @contentful/optimization-android-bridge build` + +## Setup + +From the monorepo root: + +```sh +pnpm install +pnpm --filter @contentful/optimization-android-bridge build +``` + +## Running locally + +The bootstrap script starts the mock server, builds the app, and launches it on an emulator: + +```sh +cd implementations/android-sdk +./scripts/bootstrap.sh +``` + +Or manually: + +```sh +# Terminal 1: Start mock server +pnpm serve:mocks + +# Terminal 2: Build and install +cd implementations/android-sdk +adb reverse tcp:8000 tcp:8000 +./gradlew :app:assembleDebug +adb install -r app/build/outputs/apk/debug/app-debug.apk +adb shell am start -n com.contentful.optimization.app/.MainActivity +``` + +To launch with test arguments (clear state or simulate offline): + +```sh +adb shell am start -n com.contentful.optimization.app/.MainActivity --ez reset true +adb shell am start -n com.contentful.optimization.app/.MainActivity --ez simulate_offline true +``` + +## Related + +- [Android SDK](../../packages/android/README.md) +- [iOS SDK Reference Implementation](../ios-sdk/README.md) +- [React Native Reference Implementation](../react-native-sdk/README.md) +- [Preview Panel Scenarios](../PREVIEW_PANEL_SCENARIOS.md) +- [Mock Server](../../lib/mocks/README.md) diff --git a/implementations/android-sdk/app/build.gradle.kts b/implementations/android-sdk/app/build.gradle.kts new file mode 100644 index 00000000..83624004 --- /dev/null +++ b/implementations/android-sdk/app/build.gradle.kts @@ -0,0 +1,54 @@ +plugins { + id("com.android.application") + id("org.jetbrains.kotlin.android") + id("org.jetbrains.kotlin.plugin.compose") +} + +android { + namespace = "com.contentful.optimization.app" + compileSdk = 36 + + defaultConfig { + applicationId = "com.contentful.optimization.app" + minSdk = 24 + targetSdk = 35 + versionCode = 1 + versionName = "1.0" + } + + buildTypes { + release { + isMinifyEnabled = false + } + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_11 + targetCompatibility = JavaVersion.VERSION_11 + } + + buildFeatures { + compose = true + } +} + +kotlin { + compilerOptions { + jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_11) + } +} + +dependencies { + implementation(project(":ContentfulOptimization")) + + implementation(platform("androidx.compose:compose-bom:2024.12.01")) + implementation("androidx.compose.ui:ui") + implementation("androidx.compose.foundation:foundation") + implementation("androidx.compose.material3:material3") + implementation("androidx.lifecycle:lifecycle-runtime-compose:2.8.7") + implementation("androidx.activity:activity-compose:1.9.3") + implementation("androidx.navigation:navigation-compose:2.8.5") + + implementation("com.squareup.okhttp3:okhttp:4.12.0") + implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.1") +} diff --git a/implementations/android-sdk/app/src/main/AndroidManifest.xml b/implementations/android-sdk/app/src/main/AndroidManifest.xml new file mode 100644 index 00000000..494ad1f0 --- /dev/null +++ b/implementations/android-sdk/app/src/main/AndroidManifest.xml @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + diff --git a/implementations/android-sdk/app/src/main/kotlin/com/contentful/optimization/app/AppConfig.kt b/implementations/android-sdk/app/src/main/kotlin/com/contentful/optimization/app/AppConfig.kt new file mode 100644 index 00000000..9ebc00af --- /dev/null +++ b/implementations/android-sdk/app/src/main/kotlin/com/contentful/optimization/app/AppConfig.kt @@ -0,0 +1,22 @@ +package com.contentful.optimization.app + +object AppConfig { + const val clientId = "mock-client-id" + const val environment = "master" + const val experienceBaseUrl = "http://localhost:8000/experience/" + const val insightsBaseUrl = "http://localhost:8000/insights/" + + const val contentfulBaseUrl = "http://localhost:8000/contentful/" + const val contentfulSpaceId = "mock-space-id" + + val entryIds = listOf( + "1MwiFl4z7gkwqGYdvCmr8c", + "4ib0hsHWoSOnCVdDkizE8d", + "xFwgG3oNaOcjzWiGe4vXo", + "2Z2WLOx07InSewC3LUB3eX", + "5XHssysWUDECHzKLzoIsg1", + "6zqoWXyiSrf0ja7I2WGtYj", + "7pa5bOx8Z9NmNcr7mISvD", + "1JAU028vQ7v6nB2swl3NBo", + ) +} diff --git a/implementations/android-sdk/app/src/main/kotlin/com/contentful/optimization/app/ContentfulFetcher.kt b/implementations/android-sdk/app/src/main/kotlin/com/contentful/optimization/app/ContentfulFetcher.kt new file mode 100644 index 00000000..4db53a4e --- /dev/null +++ b/implementations/android-sdk/app/src/main/kotlin/com/contentful/optimization/app/ContentfulFetcher.kt @@ -0,0 +1,125 @@ +package com.contentful.optimization.app + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import okhttp3.OkHttpClient +import okhttp3.Request +import org.json.JSONArray +import org.json.JSONObject + +object ContentfulFetcher { + + private val httpClient = OkHttpClient() + + suspend fun fetchEntries(ids: List): List> { + val entries = mutableListOf>() + for (id in ids) { + fetchEntry(id)?.let { entries.add(it) } + } + return entries + } + + private suspend fun fetchEntry(id: String): Map? { + val url = "${AppConfig.contentfulBaseUrl}spaces/${AppConfig.contentfulSpaceId}" + + "/environments/${AppConfig.environment}/entries?sys.id=$id&include=10" + + return withContext(Dispatchers.IO) { + try { + val request = Request.Builder().url(url).build() + val response = httpClient.newCall(request).execute() + val body = response.body?.string() ?: return@withContext null + + val json = JSONObject(body) + val items = json.optJSONArray("items") ?: return@withContext null + if (items.length() == 0) return@withContext null + + val entry = jsonObjectToMap(items.getJSONObject(0)) + val includes = json.optJSONObject("includes")?.let { jsonObjectToMap(it) } + resolveLinks(entry, includes) + } catch (_: Exception) { + null + } + } + } + + @Suppress("UNCHECKED_CAST") + private fun resolveLinks( + entry: Map, + includes: Map?, + ): Map { + val lookup = mutableMapOf>() + + val includeEntries = includes?.get("Entry") as? List> + includeEntries?.forEach { e -> + val sys = e["sys"] as? Map + val id = sys?.get("id") as? String + if (id != null) lookup[id] = e + } + + val includeAssets = includes?.get("Asset") as? List> + includeAssets?.forEach { a -> + val sys = a["sys"] as? Map + val id = sys?.get("id") as? String + if (id != null) lookup[id] = a + } + + return resolveValue(entry, lookup) as? Map ?: entry + } + + @Suppress("UNCHECKED_CAST") + private fun resolveValue(value: Any, lookup: Map>, depth: Int = 0): Any { + if (depth >= 10) return value + + if (value is Map<*, *>) { + val dict = value as Map + val sys = dict["sys"] as? Map + if (sys != null) { + val type = sys["type"] as? String + val id = sys["id"] as? String + if (type == "Link" && id != null) { + val resolved = lookup[id] + if (resolved != null) { + return resolveValue(resolved, lookup, depth + 1) + } + } + } + + val result = mutableMapOf() + for ((key, v) in dict) { + result[key] = resolveValue(v, lookup, depth + 1) + } + return result + } + + if (value is List<*>) { + return value.map { resolveValue(it ?: return@map it, lookup, depth + 1) } + } + + return value + } + + private fun jsonObjectToMap(obj: JSONObject): Map { + val map = mutableMapOf() + for (key in obj.keys()) { + map[key] = jsonValueToKotlin(obj.get(key)) + } + return map + } + + private fun jsonArrayToList(arr: JSONArray): List { + val list = mutableListOf() + for (i in 0 until arr.length()) { + list.add(jsonValueToKotlin(arr.get(i))) + } + return list + } + + private fun jsonValueToKotlin(value: Any): Any { + return when (value) { + is JSONObject -> jsonObjectToMap(value) + is JSONArray -> jsonArrayToList(value) + JSONObject.NULL -> "null" + else -> value + } + } +} diff --git a/implementations/android-sdk/app/src/main/kotlin/com/contentful/optimization/app/EventStore.kt b/implementations/android-sdk/app/src/main/kotlin/com/contentful/optimization/app/EventStore.kt new file mode 100644 index 00000000..91de000d --- /dev/null +++ b/implementations/android-sdk/app/src/main/kotlin/com/contentful/optimization/app/EventStore.kt @@ -0,0 +1,66 @@ +package com.contentful.optimization.app + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch + +object EventStore { + + data class AnalyticsEvent( + val type: String, + val componentId: String?, + val viewDurationMs: Int?, + val viewId: String?, + val timestamp: Long, + ) + + data class ComponentStats( + var count: Int, + var latestViewDurationMs: Int?, + var latestViewId: String?, + ) + + private val _events = MutableStateFlow>(emptyList()) + val events: StateFlow> = _events.asStateFlow() + + private val _componentStats = MutableStateFlow>(emptyMap()) + val componentStats: StateFlow> = _componentStats.asStateFlow() + + private var collectJob: Job? = null + + fun subscribe(eventsFlow: SharedFlow>, scope: CoroutineScope) { + collectJob?.cancel() + collectJob = scope.launch { + eventsFlow.collect { dict -> processEvent(dict) } + } + } + + private fun processEvent(dict: Map) { + val type = dict["type"] as? String ?: return + + val event = AnalyticsEvent( + type = type, + componentId = dict["componentId"] as? String, + viewDurationMs = (dict["viewDurationMs"] as? Number)?.toInt(), + viewId = dict["viewId"] as? String, + timestamp = System.currentTimeMillis(), + ) + + _events.value = listOf(event) + _events.value + + if (type == "component") { + val cid = event.componentId ?: return + val current = _componentStats.value.toMutableMap() + val stats = current[cid]?.copy() ?: ComponentStats(count = 0, null, null) + stats.count++ + event.viewDurationMs?.let { stats.latestViewDurationMs = it } + event.viewId?.let { stats.latestViewId = it } + current[cid] = stats + _componentStats.value = current + } + } +} diff --git a/implementations/android-sdk/app/src/main/kotlin/com/contentful/optimization/app/MainActivity.kt b/implementations/android-sdk/app/src/main/kotlin/com/contentful/optimization/app/MainActivity.kt new file mode 100644 index 00000000..cfc3da9f --- /dev/null +++ b/implementations/android-sdk/app/src/main/kotlin/com/contentful/optimization/app/MainActivity.kt @@ -0,0 +1,57 @@ +package com.contentful.optimization.app + +import android.content.Context +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material3.Surface +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.Modifier +import androidx.compose.ui.semantics.semantics +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 + +class MainActivity : ComponentActivity() { + + @OptIn(ExperimentalComposeUiApi::class) + 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) + + setContent { + Surface( + modifier = Modifier + .fillMaxSize() + .semantics { testTagsAsResourceId = true }, + ) { + OptimizationRoot( + config = OptimizationConfig( + clientId = AppConfig.clientId, + environment = AppConfig.environment, + experienceBaseUrl = AppConfig.experienceBaseUrl, + insightsBaseUrl = AppConfig.insightsBaseUrl, + debug = true, + ), + trackViews = true, + trackTaps = true, + ) { + PreviewPanelOverlay(contentfulClient = MockPreviewContentfulClient()) { + MainScreen(simulateOffline = simulateOffline) + } + } + } + } + } +} diff --git a/implementations/android-sdk/app/src/main/kotlin/com/contentful/optimization/app/MockPreviewContentfulClient.kt b/implementations/android-sdk/app/src/main/kotlin/com/contentful/optimization/app/MockPreviewContentfulClient.kt new file mode 100644 index 00000000..79b44fa7 --- /dev/null +++ b/implementations/android-sdk/app/src/main/kotlin/com/contentful/optimization/app/MockPreviewContentfulClient.kt @@ -0,0 +1,73 @@ +package com.contentful.optimization.app + +import com.contentful.optimization.preview.ContentfulEntriesResult +import com.contentful.optimization.preview.ContentfulIncludes +import com.contentful.optimization.preview.PreviewContentfulClient +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import okhttp3.OkHttpClient +import okhttp3.Request +import org.json.JSONObject + +class MockPreviewContentfulClient( + private val baseUrl: String = AppConfig.contentfulBaseUrl, + private val spaceId: String = AppConfig.contentfulSpaceId, + private val environment: String = AppConfig.environment, + private val httpClient: OkHttpClient = OkHttpClient(), +) : PreviewContentfulClient { + + @Suppress("UNCHECKED_CAST") + override suspend fun getEntries( + contentType: String, + include: Int, + skip: Int, + limit: Int, + ): ContentfulEntriesResult = withContext(Dispatchers.IO) { + val url = "${baseUrl}spaces/$spaceId/environments/$environment/entries" + + "?content_type=$contentType&include=$include&skip=$skip&limit=$limit" + + val request = Request.Builder().url(url).build() + val response = httpClient.newCall(request).execute() + val body = response.body?.string() ?: throw Exception("Empty response") + val json = JSONObject(body) + + val includedEntries = json.optJSONArray("Entry")?.let { arr -> + (0 until arr.length()).map { jsonObjectToMap(arr.getJSONObject(it)) } + } ?: run { + val includesObj = json.optJSONObject("includes") + includesObj?.optJSONArray("Entry")?.let { arr -> + (0 until arr.length()).map { jsonObjectToMap(arr.getJSONObject(it)) } + } ?: emptyList() + } + + val items = json.optJSONArray("items")?.let { arr -> + (0 until arr.length()).map { jsonObjectToMap(arr.getJSONObject(it)) } + } ?: emptyList() + + ContentfulEntriesResult( + items = items, + total = json.optInt("total", 0), + skip = json.optInt("skip", 0), + limit = json.optInt("limit", 0), + includes = ContentfulIncludes(entries = includedEntries), + ) + } + + private fun jsonObjectToMap(obj: JSONObject): Map { + val map = mutableMapOf() + obj.keys().forEach { key -> + val value = obj.get(key) + map[key] = convertJSONValue(value) + } + return map + } + + private fun convertJSONValue(value: Any): Any { + return when (value) { + is JSONObject -> jsonObjectToMap(value) + is org.json.JSONArray -> (0 until value.length()).map { convertJSONValue(value.get(it)) } + JSONObject.NULL -> "null" + else -> value + } + } +} diff --git a/implementations/android-sdk/app/src/main/kotlin/com/contentful/optimization/app/components/AnalyticsEventDisplay.kt b/implementations/android-sdk/app/src/main/kotlin/com/contentful/optimization/app/components/AnalyticsEventDisplay.kt new file mode 100644 index 00000000..441bfa0e --- /dev/null +++ b/implementations/android-sdk/app/src/main/kotlin/com/contentful/optimization/app/components/AnalyticsEventDisplay.kt @@ -0,0 +1,105 @@ +package com.contentful.optimization.app.components + +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 + +@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 + .padding(16.dp) + .testTag("analytics-events-container"), + ) { + Text("Analytics Events", fontWeight = FontWeight.Bold) + + val eventsCountText = "Events: ${events.size}" + Text( + text = eventsCountText, + modifier = Modifier + .testTag("events-count") + .semantics { contentDescription = eventsCountText }, + ) + + if (events.isEmpty()) { + val noEventsText = "No events tracked yet" + Text( + text = noEventsText, + modifier = Modifier + .testTag("no-events-message") + .semantics { contentDescription = noEventsText }, + ) + } else { + val nonComponentEvents = events.filter { it.type != "component" } + nonComponentEvents.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") } + } + Text( + text = desc, + modifier = Modifier + .testTag(testId) + .semantics { contentDescription = desc }, + ) + } + + componentStats.keys.sorted().forEach { cid -> + val stats = componentStats[cid] ?: return@forEach + Column(modifier = Modifier.testTag("component-stats-$cid")) { + val countText = "Count: ${stats.count}" + Text( + text = countText, + modifier = Modifier + .testTag("event-count-$cid") + .semantics { contentDescription = countText }, + ) + + val durationText = "Duration: ${stats.latestViewDurationMs?.toString() ?: "N/A"}" + Text( + text = durationText, + modifier = Modifier + .testTag("event-duration-$cid") + .semantics { contentDescription = durationText }, + ) + + val viewIdText = "ViewId: ${stats.latestViewId ?: "N/A"}" + Text( + text = viewIdText, + modifier = Modifier + .testTag("event-view-id-$cid") + .semantics { contentDescription = viewIdText }, + ) + } + } + } + } +} diff --git a/implementations/android-sdk/app/src/main/kotlin/com/contentful/optimization/app/components/ContentEntryView.kt b/implementations/android-sdk/app/src/main/kotlin/com/contentful/optimization/app/components/ContentEntryView.kt new file mode 100644 index 00000000..71d28000 --- /dev/null +++ b/implementations/android-sdk/app/src/main/kotlin/com/contentful/optimization/app/components/ContentEntryView.kt @@ -0,0 +1,50 @@ +package com.contentful.optimization.app.components + +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.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.OptimizedEntry + +@Composable +fun ContentEntryView(entry: Map) { + val entryId = entryId(entry) + + OptimizedEntry( + entry = entry, + trackTaps = true, + accessibilityIdentifier = "content-entry-$entryId", + ) { resolvedEntry -> + EntryContent(entry = resolvedEntry, entryId = entryId) + } +} + +@Composable +private fun EntryContent(entry: Map, entryId: String) { + @Suppress("UNCHECKED_CAST") + val fields = entry["fields"] as? Map + val text = fields?.get("text") as? String ?: "No content" + + Column( + modifier = Modifier + .padding(16.dp) + .testTag("entry-text-$entryId") + .semantics(mergeDescendants = true) { + contentDescription = "$text [Entry: $entryId]" + }, + ) { + Text(text) + Text("[Entry: $entryId]") + } +} + +@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/app/src/main/kotlin/com/contentful/optimization/app/components/NestedContentEntryView.kt b/implementations/android-sdk/app/src/main/kotlin/com/contentful/optimization/app/components/NestedContentEntryView.kt new file mode 100644 index 00000000..58b833d6 --- /dev/null +++ b/implementations/android-sdk/app/src/main/kotlin/com/contentful/optimization/app/components/NestedContentEntryView.kt @@ -0,0 +1,69 @@ +package com.contentful.optimization.app.components + +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.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.OptimizedEntry + +@Composable +fun NestedContentEntryView(entry: Map) { + val entryId = entryId(entry) + + @Suppress("UNCHECKED_CAST") + val fields = entry["fields"] as? Map + val nestedArray = fields?.get("nested") as? List<*> ?: emptyList() + + @Suppress("UNCHECKED_CAST") + val nestedEntries = nestedArray.filterIsInstance>().filter { item -> + val sys = item["sys"] as? Map + sys?.get("id") != null + } + + Column { + OptimizedEntry( + entry = entry, + accessibilityIdentifier = "content-entry-$entryId", + ) { resolvedEntry -> + NestedEntryText(entry = resolvedEntry) + } + + nestedEntries.forEach { nestedEntry -> + NestedContentEntryView(entry = nestedEntry) + } + } +} + +@Composable +private fun NestedEntryText(entry: Map) { + val id = entryId(entry) + @Suppress("UNCHECKED_CAST") + val fields = entry["fields"] as? Map + val text = fields?.get("text") as? String ?: "No content" + + Column( + modifier = Modifier + .padding(16.dp) + .testTag("entry-text-$id") + .semantics(mergeDescendants = true) { + contentDescription = "$text [Entry: $id]" + }, + ) { + Text(text) + Text("[Entry: $id]") + } +} + +@Suppress("UNCHECKED_CAST") +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/app/src/main/kotlin/com/contentful/optimization/app/screens/LiveUpdatesTestScreen.kt b/implementations/android-sdk/app/src/main/kotlin/com/contentful/optimization/app/screens/LiveUpdatesTestScreen.kt new file mode 100644 index 00000000..99e38c23 --- /dev/null +++ b/implementations/android-sdk/app/src/main/kotlin/com/contentful/optimization/app/screens/LiveUpdatesTestScreen.kt @@ -0,0 +1,267 @@ +package com.contentful.optimization.app.screens + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Button +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.key +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +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.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.compose.LocalOptimizationClient +import com.contentful.optimization.compose.LocalTrackingConfig +import com.contentful.optimization.compose.OptimizationLazyColumn +import com.contentful.optimization.compose.OptimizedEntry +import com.contentful.optimization.compose.TrackingConfig +import kotlinx.coroutines.launch + +@Composable +fun LiveUpdatesTestScreen(onClose: () -> Unit) { + val client = LocalOptimizationClient.current + val scope = rememberCoroutineScope() + + var entry by remember { mutableStateOf?>(null) } + var isLoading by remember { mutableStateOf(true) } + var isIdentified by remember { mutableStateOf(false) } + var globalLiveUpdates by remember { mutableStateOf(false) } + var isPreviewPanelSimulated by remember { mutableStateOf(false) } + + LaunchedEffect(Unit) { + val entries = ContentfulFetcher.fetchEntries(listOf("2Z2WLOx07InSewC3LUB3eX")) + entry = entries.firstOrNull() + isLoading = false + } + + if (isLoading) { + CircularProgressIndicator() + } else if (entry != null) { + val currentEntry = entry!! + + key(globalLiveUpdates, isPreviewPanelSimulated) { + CompositionLocalProvider( + LocalTrackingConfig provides TrackingConfig( + trackViews = true, + trackTaps = false, + liveUpdates = globalLiveUpdates, + ), + ) { + OptimizationLazyColumn( + modifier = Modifier.testTag("live-updates-scroll-view"), + ) { + item { + Column( + modifier = Modifier.padding(16.dp), + horizontalAlignment = Alignment.Start, + ) { + Text("Live Updates Test Controls", fontWeight = FontWeight.Bold) + Row { + Button( + onClick = onClose, + modifier = Modifier.testTag("close-live-updates-test-button"), + ) { Text("Close") } + if (!isIdentified) { + Button( + onClick = { + scope.launch { + try { + client.identify( + userId = "charles", + traits = mapOf("identified" to true), + ) + } catch (_: Exception) {} + isIdentified = true + } + }, + modifier = Modifier.testTag("live-updates-identify-button"), + ) { Text("Identify") } + } else { + Button( + onClick = { + client.reset() + scope.launch { + try { + client.page(mapOf("url" to "live-updates-test")) + } catch (_: Exception) {} + } + isIdentified = false + }, + modifier = Modifier.testTag("live-updates-reset-button"), + ) { Text("Reset") } + } + Button( + onClick = { globalLiveUpdates = !globalLiveUpdates }, + modifier = Modifier.testTag("toggle-global-live-updates-button"), + ) { Text("Global: ${if (globalLiveUpdates) "ON" else "OFF"}") } + } + } + } + + item { + Column(modifier = Modifier.padding(horizontal = 16.dp)) { + Row { + Text("Identified: ") + val identifiedText = if (isIdentified) "Yes" else "No" + Text( + text = identifiedText, + modifier = Modifier + .testTag("identified-status") + .semantics { contentDescription = identifiedText }, + ) + } + Row { + Text("Global Live Updates: ") + val globalText = if (globalLiveUpdates) "ON" else "OFF" + Text( + text = globalText, + modifier = Modifier + .testTag("global-live-updates-status") + .semantics { contentDescription = globalText }, + ) + } + } + } + + item { + Column(modifier = Modifier.padding(horizontal = 16.dp)) { + Button( + onClick = { isPreviewPanelSimulated = !isPreviewPanelSimulated }, + modifier = Modifier.testTag("simulate-preview-panel-button"), + ) { + Text( + if (isPreviewPanelSimulated) "Close Preview Panel" + else "Simulate Preview Panel", + ) + } + Row { + Text("Preview Panel: ") + val panelText = if (isPreviewPanelSimulated) "Open" else "Closed" + Text( + text = panelText, + modifier = Modifier + .testTag("preview-panel-status") + .semantics { contentDescription = panelText }, + ) + } + } + } + + item { + Spacer(modifier = Modifier.height(16.dp)) + Column(modifier = Modifier.padding(horizontal = 16.dp)) { + Text("Default Behavior (inherits global setting)") + Text( + "No liveUpdates prop - inherits from OptimizationRoot (false)", + fontSize = 12.sp, + ) + OptimizedEntry( + entry = currentEntry, + accessibilityIdentifier = "default-personalization", + ) { resolvedEntry -> + LiveUpdatesEntryDisplay( + entry = resolvedEntry, + prefix = "default", + ) + } + } + } + + item { + Spacer(modifier = Modifier.height(16.dp)) + Column(modifier = Modifier.padding(horizontal = 16.dp)) { + Text("Live Updates Enabled (liveUpdates=true)") + Text( + "Always updates when personalization state changes", + fontSize = 12.sp, + ) + OptimizedEntry( + entry = currentEntry, + liveUpdates = true, + accessibilityIdentifier = "live-personalization", + ) { resolvedEntry -> + LiveUpdatesEntryDisplay( + entry = resolvedEntry, + prefix = "live", + ) + } + } + } + + item { + Spacer(modifier = Modifier.height(16.dp)) + Column(modifier = Modifier.padding(horizontal = 16.dp)) { + Text("Locked (liveUpdates=false)") + Text( + "Never updates - locks to first variant received", + fontSize = 12.sp, + ) + OptimizedEntry( + entry = currentEntry, + liveUpdates = false, + accessibilityIdentifier = "locked-personalization", + ) { resolvedEntry -> + LiveUpdatesEntryDisplay( + entry = resolvedEntry, + prefix = "locked", + ) + } + } + } + } + } + } + } else { + Column { + Text("No entry found") + Button( + onClick = onClose, + modifier = Modifier.testTag("close-live-updates-test-button"), + ) { Text("Close") } + } + } +} + +@Composable +private fun LiveUpdatesEntryDisplay(entry: Map, prefix: String) { + @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 ?: "" + + Column(modifier = Modifier.testTag("$prefix-container")) { + Text( + text = text, + modifier = Modifier + .testTag("$prefix-text") + .semantics { contentDescription = text }, + ) + val entryLabel = "Entry: $entryId" + Text( + text = entryLabel, + modifier = Modifier + .testTag("$prefix-entry-id") + .semantics { contentDescription = entryLabel }, + ) + } +} diff --git a/implementations/android-sdk/app/src/main/kotlin/com/contentful/optimization/app/screens/MainScreen.kt b/implementations/android-sdk/app/src/main/kotlin/com/contentful/optimization/app/screens/MainScreen.kt new file mode 100644 index 00000000..9dd61842 --- /dev/null +++ b/implementations/android-sdk/app/src/main/kotlin/com/contentful/optimization/app/screens/MainScreen.kt @@ -0,0 +1,126 @@ +package com.contentful.optimization.app.screens + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Button +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.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.unit.dp +import com.contentful.optimization.app.AppConfig +import com.contentful.optimization.app.ContentfulFetcher +import com.contentful.optimization.app.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 kotlinx.coroutines.launch +import org.json.JSONObject + +@Composable +fun MainScreen(simulateOffline: Boolean = false) { + val client = LocalOptimizationClient.current + val state by client.state.collectAsState() + 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) } + + LaunchedEffect(Unit) { + client.consent(true) + try { client.page(mapOf("url" to "app")) } catch (_: Exception) {} + if (simulateOffline) { + client.setOnline(false) + } + } + + val profileKey = remember(state.profile) { + state.profile?.let { + try { JSONObject(it).toString() } catch (_: Exception) { it.hashCode().toString() } + } + } + + LaunchedEffect(profileKey) { + if (state.profile != null) { + entries = ContentfulFetcher.fetchEntries(AppConfig.entryIds) + } + } + + if (showNavigationTest) { + NavigationTestScreen(onClose = { showNavigationTest = false }) + } else if (showLiveUpdatesTest) { + LiveUpdatesTestScreen(onClose = { showLiveUpdatesTest = false }) + } else { + Column { + Row(modifier = Modifier.padding(8.dp)) { + if (!isIdentified) { + Button( + onClick = { + isIdentified = true + scope.launch { + try { + client.identify( + userId = "charles", + traits = mapOf("identified" to true), + ) + } catch (_: Exception) {} + } + }, + modifier = Modifier.testTag("identify-button"), + ) { Text("Identify") } + } else { + Button( + onClick = { + client.reset() + isIdentified = false + scope.launch { + try { client.page(mapOf("url" to "app")) } catch (_: Exception) {} + } + }, + modifier = Modifier.testTag("reset-button"), + ) { Text("Reset") } + } + Button( + onClick = { showNavigationTest = true }, + modifier = Modifier.testTag("navigation-test-button"), + ) { Text("Navigation Test") } + Button( + onClick = { showLiveUpdatesTest = true }, + modifier = Modifier.testTag("live-updates-test-button"), + ) { Text("Live Updates Test") } + } + + 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) + } + } + item { + AnalyticsEventDisplay() + } + } + } + } + } +} diff --git a/implementations/android-sdk/app/src/main/kotlin/com/contentful/optimization/app/screens/NavigationTestScreen.kt b/implementations/android-sdk/app/src/main/kotlin/com/contentful/optimization/app/screens/NavigationTestScreen.kt new file mode 100644 index 00000000..25a3716a --- /dev/null +++ b/implementations/android-sdk/app/src/main/kotlin/com/contentful/optimization/app/screens/NavigationTestScreen.kt @@ -0,0 +1,119 @@ +package com.contentful.optimization.app.screens + +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.layout.Column +import androidx.compose.material3.Button +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.remember +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.navigation.compose.NavHost +import androidx.navigation.compose.composable +import androidx.navigation.compose.rememberNavController +import com.contentful.optimization.compose.LocalOptimizationClient +import com.contentful.optimization.compose.ScreenTrackingEffect + +@Composable +fun NavigationTestScreen(onClose: () -> Unit) { + val client = LocalOptimizationClient.current + val navController = rememberNavController() + val screenLog = remember { mutableStateListOf() } + + LaunchedEffect(Unit) { + client.events.collect { event -> + val type = event["type"] as? String + if (type == "screen" || type == "screenViewEvent") { + val name = event["name"] as? String + if (name != null) { + screenLog.add(name) + } + } + } + } + + BackHandler(onBack = onClose) + + NavHost(navController = navController, startDestination = "NavigationHome") { + composable("NavigationHome") { + ScreenTrackingEffect("NavigationHome") + Column { + Button( + onClick = onClose, + modifier = Modifier.testTag("close-navigation-test-button"), + ) { Text("Close") } + + Button( + onClick = { navController.navigate("ViewOne") }, + modifier = Modifier.testTag("go-to-view-one-button"), + ) { Text("Go to View One") } + + val logText = screenLog.joinToString(",") + Text( + text = logText, + modifier = Modifier + .testTag("screen-event-log") + .semantics { contentDescription = logText }, + ) + } + } + composable("ViewOne") { + ScreenTrackingEffect("NavigationViewOne") + NavigationViewContent( + suffix = "one", + screenLog = screenLog, + onNavigateNext = { navController.navigate("ViewTwo") }, + nextButtonTitle = "Go to View Two", + nextButtonTestId = "go-to-view-two-button", + ) + } + composable("ViewTwo") { + ScreenTrackingEffect("NavigationViewTwo") + NavigationViewContent( + suffix = "two", + screenLog = screenLog, + onNavigateNext = null, + nextButtonTitle = "", + nextButtonTestId = "", + ) + } + } +} + +@Composable +private fun NavigationViewContent( + suffix: String, + screenLog: List, + onNavigateNext: (() -> Unit)?, + nextButtonTitle: String, + nextButtonTestId: String, +) { + Column(modifier = Modifier.testTag("navigation-view-test-$suffix")) { + val lastEvent = screenLog.lastOrNull() ?: "" + Text( + text = lastEvent, + modifier = Modifier + .testTag("last-screen-event") + .semantics { contentDescription = lastEvent }, + ) + + val logText = screenLog.joinToString(",") + Text( + text = logText, + modifier = Modifier + .testTag("screen-event-log") + .semantics { contentDescription = logText }, + ) + + if (onNavigateNext != null) { + Button( + onClick = onNavigateNext, + modifier = Modifier.testTag(nextButtonTestId), + ) { Text(nextButtonTitle) } + } + } +} diff --git a/implementations/android-sdk/app/src/main/res/values/themes.xml b/implementations/android-sdk/app/src/main/res/values/themes.xml new file mode 100644 index 00000000..067dc8b2 --- /dev/null +++ b/implementations/android-sdk/app/src/main/res/values/themes.xml @@ -0,0 +1,4 @@ + + +