Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
275 changes: 200 additions & 75 deletions .github/workflows/main-pipeline.yaml

Large diffs are not rendered by default.

131 changes: 88 additions & 43 deletions implementations/android-sdk/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,66 +4,111 @@ 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**
Maestro flow set from `maestro/`. This mirrors the iOS `swiftui/` + `uikit/` pair at
`implementations/ios-sdk/`.

The Android E2E suite is [Maestro](https://maestro.dev) (`maestro/`): one flow set drives both apps
via `appId: ${APP_ID}`, run locally with `pnpm test:e2e` and in CI by the `e2e-android-maestro` job.
The old UiAutomator suite under `uitests/` has been retired (its CI job removed) and that module is
dormant pending deletion in a follow-up — do not add to it.

## 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
- `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` so the same Maestro `id:` 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`
- `maestro/` — the Maestro E2E flow set (one set, both apps); see `maestro/README.md`
- `uitests/` — retired UiAutomator module (`com.android.test`), dormant pending removal
- `scripts/` — build and run scripts; `run-e2e.sh` is the Maestro runner (manages the emulator, mock
server, and app installs), `ci-maestro-run.sh` is the CI runner with per-flow retry
- `build.gradle.kts` — Root build config (plugin versions)
- `settings.gradle.kts` — Project structure (includes SDK module + uitests via project.dir)
- `app/build.gradle.kts` — App module build config and dependencies
- `settings.gradle.kts` — Project structure (includes `:compose`, `:views`, `:shared`, `:uitests`,
and the SDK module via `project.dir`)

## Local rules

- Keep this app focused on validating native Android integration behavior. Reusable SDK behavior
belongs in `packages/android/ContentfulOptimization`, and TypeScript bridge behavior belongs in
`packages/universal/optimization-js-bridge`.
- The mock server must be running at `http://localhost:8000` before running the app. Use
`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.
- **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 Maestro flow set asserts the same
contract against both, so a one-sided change fails that app's 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 on the host at port `8000` before running either app. The apps
reach it via the emulator host alias `http://10.0.2.2:8000` (`AppConfig.mockHost`), so no
`adb reverse` is required; `10.0.2.2` survives adb-daemon restarts that wipe reverse forwards.
- 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 launch arguments use intent extras: `--ez reset true` clears SDK SharedPreferences,
`--ez simulate_offline true` sets the client offline.

## Test-ID contract

- **Compose impl:** `Modifier.testTag("foo-bar")` on the composable; the root composable sets
`testTagsAsResourceId = true` so the tag is exposed as the node's resource-id, matched by
Maestro's `id: "foo-bar"` selector.
- **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 `id: "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`, matched by
Maestro's text selector (a bare string or `text:`). 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 for a
clean unidentified start. (Offline simulation was removed — see
[`maestro/OFFLINE_TESTING.md`](./maestro/OFFLINE_TESTING.md).)

## Commands

- `pnpm serve:mocks` (from monorepo root)
- From `implementations/android-sdk/`: `./gradlew :app:assembleDebug`
- From `implementations/android-sdk/`: `./scripts/bootstrap.sh`
- Build bridge first: `pnpm --filter @contentful/optimization-js-bridge build`
- Build UI test APK: `./gradlew :uitests:assembleDebug`
- Run all UI tests: `./gradlew :uitests:connectedAndroidTest`
- Run single test class:
`./gradlew :uitests:connectedAndroidTest -Pandroid.testInstrumentationRunnerArguments.class=com.contentful.optimization.uitests.tests.AnalyticsTests`
- Build one app: `./gradlew :compose:assembleDebug` or `./gradlew :views:assembleDebug`
- Build both APKs for the matrix: `pnpm build:apks`
- Bootstrap Compose impl on emulator: `./scripts/bootstrap.sh`
- Run the Maestro suite against the Compose app: `pnpm test:e2e:compose`
- Run the Maestro suite against the Views app: `pnpm test:e2e:views`
- Default `pnpm test:e2e` runs both apps
- Run a single suite against an app: `pnpm test:e2e:compose -- --flow preview-panel`

## UI tests
## E2E tests (Maestro)

- 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.
- Flows live in `maestro/<suite>/*.yaml`; a `maestro/config.yaml` workspace file makes flow
discovery recurse into the per-suite subfolders.
- One flow set drives both apps. Each flow declares `appId: ${APP_ID}`; the runner passes the
package at runtime (`maestro test -e APP_ID=<pkg> maestro`).
- Selectors: `id:` matches the resource-id (app test tags, per the contract above); a bare string or
`text:` matches visible text and `contentDescription` (SDK `accessibilityIdentifier` elements).
- `stopApp` + a `reset` launch arg gives a clean unidentified start without `pm clear` (which wedges
the package manager on loaded CI emulators).
- Flow/test names match the iOS XCUITest suite at `implementations/ios-sdk/uitests/Tests/` for
cross-platform parity. The dwell/view-tracking contract stays out of E2E — it is owned by
`ViewTrackingControllerTest` (JVM unit) and the iOS suite.
- The mock server must be running before running the suite; the apps reach it via `10.0.2.2`.

## Usually validate

- Run the app on emulator after changes to verify UI renders correctly.
- Verify accessibility identifiers match iOS counterparts when changing UI structure.
- Build and assemble both APKs after Kotlin changes:
`./gradlew :compose:assembleDebug :views:assembleDebug`.
- After UI structure changes, run the Maestro 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.
- After UI structure changes, run `./gradlew :uitests:assembleDebug` to verify test APK compiles.
- Verify accessibility identifiers match iOS counterparts when changing UI structure.
38 changes: 13 additions & 25 deletions implementations/android-sdk/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,10 @@
> [!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.
[Contentful Optimization Android SDK](../../packages/android/README.md). It demonstrates the
integration pattern for both Jetpack Compose (`:compose`) and XML Views (`:views`), and is the
target for the shared [Maestro](https://maestro.dev) E2E suite (see
[`maestro/README.md`](./maestro/README.md)).

## What this demonstrates

Expand Down Expand Up @@ -66,35 +68,26 @@ Or manually:
# Terminal 1: Start mock server
pnpm serve:mocks

# Terminal 2: Build and install
# Terminal 2: Build and install (the app reaches the host mock via 10.0.2.2 — no adb reverse needed)
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
```

To launch with test arguments (clear state or simulate offline):
To launch with a clean SDK state (clears the persisted profile on cold start):

```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
```

## Android Studio

Open this directory (`implementations/android-sdk/`) as an Android Studio project. After Gradle
sync, three run configurations are available in the toolbar dropdown:
sync, build and launch either app on the selected device (`MainActivity` in `:compose` or `:views`),
set breakpoints in the app or SDK source, and run the JVM unit tests from the gutter.

- **App** — builds and launches `MainActivity` on the selected device.
- **All UI Tests** — runs the full UI Automator 2 instrumented suite.
- **Prepare Env** — standalone check that the mock server is reachable, the bridge JS bundle is
built, and `adb reverse` is set up.

**App** and **All UI Tests** both run **Prepare Env** as a "Before launch" step, so the run will
fail fast with instructions if the mock server is not up or the bridge bundle has not been built.

Before running anything from the IDE, in a separate terminal:
Before running the app from the IDE, in a separate terminal:

```sh
# From the monorepo root, build the bridge once (or after bridge source changes):
Expand All @@ -104,13 +97,8 @@ pnpm --filter @contentful/optimization-js-bridge build
pnpm --dir lib/mocks serve
```

To run or debug a single test, open any file under
`uitests/src/main/kotlin/com/contentful/optimization/uitests/tests/`, right-click a `@Test` method
or class, and choose **Run** or **Debug**. Android Studio creates a temporary instrumented-test
configuration and attaches the debugger automatically. Set breakpoints in test or production code —
they will be hit on the device. Note that ad-hoc gutter runs skip the **Prepare Env** check, so make
sure the mock server is running first (running **Prepare Env** once per session is enough as long as
`adb reverse` survives).
The E2E suite is [Maestro](https://maestro.dev), run from the command line rather than an IDE run
configuration — `pnpm test:e2e` (both apps) or see [`maestro/README.md`](./maestro/README.md).

## Related

Expand Down

This file was deleted.

Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ import com.contentful.optimization.app.screens.MainScreen
import com.contentful.optimization.compose.OptimizationRoot
import com.contentful.optimization.core.OptimizationConfig
import com.contentful.optimization.preview.PreviewPanelConfig
import com.contentful.optimization.shared.AppConfig
import com.contentful.optimization.shared.MockPreviewContentfulClient

class MainActivity : ComponentActivity() {

Expand All @@ -31,8 +33,6 @@ class MainActivity : ComponentActivity() {
.apply()
}

val simulateOffline = intent.getBooleanExtra("simulate_offline", false)

setContent {
Surface(
modifier = Modifier
Expand All @@ -54,7 +54,7 @@ class MainActivity : ComponentActivity() {
contentfulClient = MockPreviewContentfulClient(),
),
) {
MainScreen(simulateOffline = simulateOffline)
MainScreen()
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import com.contentful.optimization.app.EventStore
import com.contentful.optimization.shared.EventStore

@Composable
fun AnalyticsEventDisplay() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.dp
import com.contentful.optimization.compose.LocalOptimizationClient
import com.contentful.optimization.compose.OptimizedEntry
import com.contentful.optimization.shared.RichText

@Composable
fun ContentEntryView(entry: Map<String, Any>) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.dp
import com.contentful.optimization.compose.LocalOptimizationClient
import com.contentful.optimization.compose.OptimizedEntry
import com.contentful.optimization.shared.RichText

@Composable
fun NestedContentEntryView(entry: Map<String, Any>) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,9 +22,9 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.unit.dp
import com.contentful.optimization.app.AppConfig
import com.contentful.optimization.app.ContentfulFetcher
import com.contentful.optimization.app.EventStore
import com.contentful.optimization.shared.AppConfig
import com.contentful.optimization.shared.ContentfulFetcher
import com.contentful.optimization.shared.EventStore
import com.contentful.optimization.app.components.AnalyticsEventDisplay
import com.contentful.optimization.app.components.ContentEntryView
import com.contentful.optimization.app.components.NestedContentEntryView
Expand All @@ -36,7 +36,7 @@ import kotlinx.coroutines.launch
import org.json.JSONObject

@Composable
fun MainScreen(simulateOffline: Boolean = false) {
fun MainScreen() {
val client = LocalOptimizationClient.current
val state by client.state.collectAsState()
val scope = rememberCoroutineScope()
Expand All @@ -51,9 +51,6 @@ fun MainScreen(simulateOffline: Boolean = false) {
EventStore.subscribe(client.events, scope)
client.consent(true)
try { client.page(mapOf("url" to "app")) } catch (_: Exception) {}
if (simulateOffline) {
client.setOnline(false)
}
}

val profileKey = remember(state.profile) {
Expand Down
Loading
Loading