From 59ea5b52aa2aa25fdfaccae76a3bba0482bdc014 Mon Sep 17 00:00:00 2001 From: Alex Freas Date: Wed, 6 May 2026 18:41:15 +0200 Subject: [PATCH 01/70] Add android-zipline-bridge TypeScript package mirroring ios-jsc-bridge --- .../android/android-zipline-bridge/AGENTS.md | 23 + .../android-zipline-bridge/package.json | 30 ++ .../android-zipline-bridge/rslib.config.ts | 66 +++ .../android-zipline-bridge/src/index.ts | 488 ++++++++++++++++++ .../tsconfig.build.json | 19 + .../android-zipline-bridge/tsconfig.json | 8 + pnpm-lock.yaml | 25 + pnpm-workspace.yaml | 1 + 8 files changed, 660 insertions(+) create mode 100644 packages/android/android-zipline-bridge/AGENTS.md create mode 100644 packages/android/android-zipline-bridge/package.json create mode 100644 packages/android/android-zipline-bridge/rslib.config.ts create mode 100644 packages/android/android-zipline-bridge/src/index.ts create mode 100644 packages/android/android-zipline-bridge/tsconfig.build.json create mode 100644 packages/android/android-zipline-bridge/tsconfig.json diff --git a/packages/android/android-zipline-bridge/AGENTS.md b/packages/android/android-zipline-bridge/AGENTS.md new file mode 100644 index 00000000..43e47880 --- /dev/null +++ b/packages/android/android-zipline-bridge/AGENTS.md @@ -0,0 +1,23 @@ +# AGENTS.md + +Read the repository root `AGENTS.md`, then `packages/AGENTS.md`, then `packages/android/AGENTS.md`, +before this file. + +## Scope + +This package compiles the shared TypeScript bridge source into a UMD bundle for the Android SDK. The +bridge source (`src/index.ts`) is identical to `packages/ios/ios-jsc-bridge/src/index.ts` and must +stay in sync. + +## Local rules + +- Do not diverge bridge source from the iOS bridge without documenting the reason in this file. +- The postbuild script copies the UMD bundle into `../ContentfulOptimization/src/main/assets/`. Do + not hand-edit the asset copy. +- If a QuickJS-specific workaround is ever needed, isolate it here rather than in the shared bridge + source. + +## Commands + +- `pnpm build` — clean + build the UMD bundle and copy to Android assets +- `pnpm typecheck` — type-check bridge source diff --git a/packages/android/android-zipline-bridge/package.json b/packages/android/android-zipline-bridge/package.json new file mode 100644 index 00000000..a03e8afd --- /dev/null +++ b/packages/android/android-zipline-bridge/package.json @@ -0,0 +1,30 @@ +{ + "name": "@contentful/optimization-android-bridge", + "version": "0.0.0", + "license": "MIT", + "type": "module", + "main": "./dist/optimization-android-bridge.umd.js", + "files": [ + "dist/**/*" + ], + "scripts": { + "build": "pnpm clean && pnpm build:dist", + "build:ci": "pnpm build:dist", + "build:dist": "rslib build", + "postbuild": "cp dist/optimization-android-bridge.umd.js ../ContentfulOptimization/src/main/assets/", + "clean": "rimraf ./.rslib ./dist ./coverage .tsbuildinfo", + "test:unit": "echo 'No tests yet'", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@contentful/optimization-core": "workspace:*" + }, + "devDependencies": { + "@rslib/core": "catalog:", + "@types/node": "catalog:", + "build-tools": "workspace:*", + "rimraf": "catalog:", + "tslib": "catalog:", + "typescript": "catalog:" + } +} diff --git a/packages/android/android-zipline-bridge/rslib.config.ts b/packages/android/android-zipline-bridge/rslib.config.ts new file mode 100644 index 00000000..fcd67be9 --- /dev/null +++ b/packages/android/android-zipline-bridge/rslib.config.ts @@ -0,0 +1,66 @@ +import { defineConfig } from '@rslib/core' +import { ensureUmdDefaultExport, getPackageName } from 'build-tools' +import path from 'node:path' +import { fileURLToPath } from 'node:url' + +/* eslint-disable @typescript-eslint/naming-convention -- standardized var names */ +const __filename = fileURLToPath(import.meta.url) +const __dirname = path.dirname(__filename) +const packageName = getPackageName(__dirname, '@contentful/optimization-android-bridge') +/* eslint-enable @typescript-eslint/naming-convention -- standardized var names */ + +export default defineConfig({ + source: { + tsconfigPath: './tsconfig.build.json', + define: { + __OPTIMIZATION_VERSION__: JSON.stringify(process.env.RELEASE_VERSION ?? '0.0.0'), + __OPTIMIZATION_PACKAGE_NAME__: JSON.stringify(packageName), + }, + }, + + resolve: { + alias: { + '@contentful/optimization-api-client': path.resolve( + __dirname, + '../../universal/api-client/src/', + ), + '@contentful/optimization-api-schemas': path.resolve( + __dirname, + '../../universal/api-schemas/src/', + ), + '@contentful/optimization-core': path.resolve(__dirname, '../../universal/core-sdk/src/'), + }, + }, + + output: { + target: 'web', + }, + + lib: [ + { + bundle: true, + autoExtension: false, + autoExternal: false, + format: 'umd', + umdName: 'OptimizationBridge', + source: { + entry: { + 'optimization-android-bridge.umd': './src/index.ts', + }, + }, + output: { + distPath: { root: 'dist' }, + filename: { js: '[name].js' }, + sourceMap: true, + cleanDistPath: true, + minify: true, + }, + dts: false, + tools: { + rspack: (config) => { + ensureUmdDefaultExport(config) + }, + }, + }, + ], +}) diff --git a/packages/android/android-zipline-bridge/src/index.ts b/packages/android/android-zipline-bridge/src/index.ts new file mode 100644 index 00000000..4a4f20a4 --- /dev/null +++ b/packages/android/android-zipline-bridge/src/index.ts @@ -0,0 +1,488 @@ +import type { Traits } from '@contentful/optimization-api-client/api-schemas' +import { + CoreStateful, + type CoreStatefulConfig, + effect, + signals, +} from '@contentful/optimization-core' +import { + type AudienceDefinition, + type ContentfulEntry, + type ExperienceDefinition, + PreviewOverrideManager, + buildPreviewModel, + createAudienceDefinitions, + createExperienceDefinitions, + createExperienceNameMap, +} from '@contentful/optimization-core/preview-support' + +type ResolveOptimizedEntryParams = Parameters + +interface BridgeConfig { + clientId: string + environment: string + experienceBaseUrl?: string + insightsBaseUrl?: string + defaults?: { + consent?: boolean + profile?: unknown + changes?: unknown + optimizations?: unknown + } +} + +interface BridgeState { + profile: unknown + consent: boolean | undefined + canPersonalize: boolean + changes: unknown + selectedPersonalizations: unknown +} + +interface TrackViewPayload { + componentId: string + viewId: string + experienceId?: string + variantIndex: number + viewDurationMs: number + sticky?: boolean +} + +interface TrackClickPayload { + componentId: string + experienceId?: string + variantIndex: number +} + +interface Bridge { + initialize(config: BridgeConfig): void + identify( + payload: { userId: string; traits?: Traits }, + onSuccess: (json: string) => void, + onError: (error: string) => void, + ): void + page( + payload: Record, + onSuccess: (json: string) => void, + onError: (error: string) => void, + ): void + getProfile(): string | null + getState(): string + destroy(): void + + // Async with callbacks + screen( + payload: { name: string; properties?: Record }, + onSuccess: (json: string) => void, + onError: (error: string) => void, + ): void + flush(onSuccess: (json: string) => void, onError: (error: string) => void): void + trackView( + payload: TrackViewPayload, + onSuccess: (json: string) => void, + onError: (error: string) => void, + ): void + trackClick( + payload: TrackClickPayload, + onSuccess: (json: string) => void, + onError: (error: string) => void, + ): void + + // Synchronous + consent(accept: boolean): void + reset(): void + personalizeEntry( + baseline: Record, + personalizations?: Array>, + ): string + setOnline(isOnline: boolean): void + + // Preview panel + setPreviewPanelOpen(open: boolean): void + overrideAudience(audienceId: string, qualified: boolean, experienceIds: string[]): void + overrideVariant(experienceId: string, variantIndex: number): void + resetAudienceOverride(audienceId: string): void + resetVariantOverride(experienceId: string): void + resetAllOverrides(): void + loadDefinitions(audienceEntries: unknown[], experienceEntries: unknown[]): string + getPreviewState(): string +} + +let instance: CoreStateful | null = null +let disposeEffect: (() => void) | null = null +let disposeEventEffect: (() => void) | null = null +let overrideManager: PreviewOverrideManager | null = null +let audienceDefinitions: AudienceDefinition[] | null = null +let experienceDefinitions: ExperienceDefinition[] | null = null +let audienceNameMap: Record = {} +let experienceNameMap: Record = {} + +const bridge: Bridge = { + initialize(config: BridgeConfig) { + if (instance) { + bridge.destroy() + } + + audienceDefinitions = null + experienceDefinitions = null + audienceNameMap = {} + experienceNameMap = {} + + const coreConfig: CoreStatefulConfig = { + clientId: config.clientId, + environment: config.environment, + api: { + experienceBaseUrl: config.experienceBaseUrl, + insightsBaseUrl: config.insightsBaseUrl, + }, + } + + instance = new CoreStateful(coreConfig) + + // Apply stored defaults before any other operations + if (config.defaults) { + if (config.defaults.consent !== undefined) { + instance.consent(config.defaults.consent) + } + if (config.defaults.profile !== undefined) { + signals.profile.value = config.defaults.profile as typeof signals.profile.value + } + if (config.defaults.changes !== undefined) { + signals.changes.value = config.defaults.changes as typeof signals.changes.value + } + if (config.defaults.optimizations !== undefined) { + signals.selectedOptimizations.value = config.defaults + .optimizations as typeof signals.selectedOptimizations.value + } + } + instance.consent(true) + + // Create the override manager — registers a state interceptor that + // preserves overrides across API refreshes and correctly appends + // new experience entries when overriding audiences the user was never in. + const g = globalThis as Record + + overrideManager = new PreviewOverrideManager({ + selectedOptimizations: signals.selectedOptimizations, + profile: signals.profile, + stateInterceptors: instance.interceptors.state, + onOverridesChanged: () => { + if (typeof g.__nativeOnOverridesChanged === 'function') { + ;(g.__nativeOnOverridesChanged as (json: string) => void)(bridge.getPreviewState()) + } + }, + }) + + disposeEffect = effect(() => { + const state: BridgeState = { + profile: signals.profile.value ?? null, + consent: signals.consent.value, + canPersonalize: signals.canOptimize.value, + changes: signals.changes.value ?? null, + selectedPersonalizations: signals.selectedOptimizations.value ?? null, + } + + if (typeof g.__nativeOnStateChange === 'function') { + ;(g.__nativeOnStateChange as (json: string) => void)(JSON.stringify(state)) + } + }) + + disposeEventEffect = effect(() => { + const evt = signals.event.value + if (evt && typeof g.__nativeOnEventEmitted === 'function') { + ;(g.__nativeOnEventEmitted as (json: string) => void)(JSON.stringify(evt)) + } + }) + }, + + identify(payload, onSuccess, onError) { + if (!instance) { + onError('SDK not initialized. Call initialize() first.') + return + } + + instance + .identify(payload) + .then((data) => { + onSuccess(JSON.stringify(data ?? null)) + }) + .catch((err: unknown) => { + onError(err instanceof Error ? err.message : String(err)) + }) + }, + + page(payload, onSuccess, onError) { + if (!instance) { + onError('SDK not initialized. Call initialize() first.') + return + } + + instance + .page(payload) + .then((data) => { + onSuccess(JSON.stringify(data ?? null)) + }) + .catch((err: unknown) => { + onError(err instanceof Error ? err.message : String(err)) + }) + }, + + screen(payload, onSuccess, onError) { + if (!instance) { + onError('SDK not initialized. Call initialize() first.') + return + } + + instance + .screen({ + name: payload.name, + properties: (payload.properties ?? {}) as Record, + }) + .then((data) => { + onSuccess(JSON.stringify(data ?? null)) + }) + .catch((err: unknown) => { + onError(err instanceof Error ? err.message : String(err)) + }) + }, + + flush(onSuccess, onError) { + if (!instance) { + onError('SDK not initialized. Call initialize() first.') + return + } + + instance + .flush() + .then(() => { + onSuccess(JSON.stringify(null)) + }) + .catch((err: unknown) => { + onError(err instanceof Error ? err.message : String(err)) + }) + }, + + trackView(payload, onSuccess, onError) { + if (!instance) { + onError('SDK not initialized. Call initialize() first.') + return + } + + instance + .trackView(payload) + .then((data) => { + onSuccess(JSON.stringify(data ?? null)) + }) + .catch((err: unknown) => { + onError(err instanceof Error ? err.message : String(err)) + }) + }, + + trackClick(payload, onSuccess, onError) { + if (!instance) { + onError('SDK not initialized. Call initialize() first.') + return + } + + instance + .trackClick(payload) + .then(() => { + onSuccess(JSON.stringify(null)) + }) + .catch((err: unknown) => { + onError(err instanceof Error ? err.message : String(err)) + }) + }, + + consent(accept: boolean) { + if (!instance) return + instance.consent(accept) + }, + + reset() { + if (!instance) return + overrideManager?.resetAll() + instance.reset() + }, + + setOnline(isOnline: boolean) { + signals.online.value = isOnline + }, + + personalizeEntry( + baseline: Record, + personalizations?: Array>, + ): string { + if (!instance) return JSON.stringify({ entry: baseline }) + const result = instance.resolveOptimizedEntry( + baseline as unknown as ResolveOptimizedEntryParams[0], + personalizations as unknown as ResolveOptimizedEntryParams[1], + ) + return JSON.stringify(result) + }, + + setPreviewPanelOpen(open: boolean) { + if (!instance) return + signals.previewPanelOpen.value = open + }, + + overrideAudience(audienceId: string, qualified: boolean, experienceIds: string[]) { + if (!overrideManager) return + if (qualified) { + overrideManager.activateAudience(audienceId, experienceIds) + } else { + overrideManager.deactivateAudience(audienceId, experienceIds) + } + }, + + overrideVariant(experienceId: string, variantIndex: number) { + overrideManager?.setVariantOverride(experienceId, variantIndex) + }, + + resetAudienceOverride(audienceId: string) { + overrideManager?.resetAudienceOverride(audienceId) + }, + + resetVariantOverride(experienceId: string) { + overrideManager?.resetOptimizationOverride(experienceId) + }, + + resetAllOverrides() { + overrideManager?.resetAll() + }, + + loadDefinitions(audienceEntries: unknown[], experienceEntries: unknown[]): string { + try { + const audEntries = audienceEntries as ContentfulEntry[] + const expEntries = experienceEntries as ContentfulEntry[] + + audienceDefinitions = createAudienceDefinitions(audEntries) + experienceDefinitions = createExperienceDefinitions(expEntries) + experienceNameMap = createExperienceNameMap(expEntries) + audienceNameMap = {} + for (const def of audienceDefinitions) { + audienceNameMap[def.id] = def.name + } + + return JSON.stringify({ + audienceCount: audienceDefinitions.length, + experienceCount: experienceDefinitions.length, + }) + } catch (err: unknown) { + audienceDefinitions = null + experienceDefinitions = null + audienceNameMap = {} + experienceNameMap = {} + return JSON.stringify({ + error: err instanceof Error ? err.message : String(err), + }) + } + }, + + getPreviewState(): string { + const overrides = overrideManager?.getOverrides() ?? { + audiences: {}, + selectedOptimizations: {}, + } + const baselineOptimizations = overrideManager?.getBaselineSelectedOptimizations() + + // Transform audience overrides to the shape Swift expects: Record + const audienceOverrides: Record = {} + for (const [id, aud] of Object.entries(overrides.audiences)) { + audienceOverrides[id] = aud.isActive + } + + // Transform variant overrides to the shape Swift expects: Record + const variantOverrides: Record = {} + for (const [id, opt] of Object.entries(overrides.selectedOptimizations)) { + variantOverrides[id] = opt.variantIndex + } + + // Derive default variant indices from the baseline + const defaultVariantIndices: Record = {} + if (baselineOptimizations) { + for (const sel of baselineOptimizations) { + if (variantOverrides[sel.experienceId] !== undefined) { + defaultVariantIndices[sel.experienceId] = sel.variantIndex + } + } + } + + // Compute the pre-baked UI model when definitions have been loaded by the host. + // Null when loadDefinitions() has not yet been called — iOS renders an empty state. + const previewModel = + audienceDefinitions && experienceDefinitions + ? { + ...buildPreviewModel({ + audienceDefinitions, + experienceDefinitions, + signals: { + profile: signals.profile.value, + selectedOptimizations: signals.selectedOptimizations.value, + consent: signals.consent.value, + isLoading: false, + }, + overrides, + baselineSelectedOptimizations: baselineOptimizations, + }), + audienceNameMap, + experienceNameMap, + } + : null + + return JSON.stringify({ + profile: signals.profile.value ?? null, + consent: signals.consent.value, + canPersonalize: signals.canOptimize.value, + changes: signals.changes.value ?? null, + selectedPersonalizations: signals.selectedOptimizations.value ?? null, + previewPanelOpen: signals.previewPanelOpen.value, + audienceOverrides, + variantOverrides, + defaultAudienceQualifications: overrideManager?.getBaselineAudienceQualifications() ?? {}, + defaultVariantIndices, + previewModel, + }) + }, + + getProfile(): string | null { + const p = signals.profile.value + return p ? JSON.stringify(p) : null + }, + + getState(): string { + const state: BridgeState = { + profile: signals.profile.value ?? null, + consent: signals.consent.value, + canPersonalize: signals.canOptimize.value, + changes: signals.changes.value ?? null, + selectedPersonalizations: signals.selectedOptimizations.value ?? null, + } + return JSON.stringify(state) + }, + + destroy() { + overrideManager?.destroy() + overrideManager = null + audienceDefinitions = null + experienceDefinitions = null + audienceNameMap = {} + experienceNameMap = {} + if (disposeEventEffect) { + disposeEventEffect() + disposeEventEffect = null + } + if (disposeEffect) { + disposeEffect() + disposeEffect = null + } + if (instance) { + instance.destroy() + instance = null + } + }, +} + +;(globalThis as Record).__bridge = bridge + +export default bridge diff --git a/packages/android/android-zipline-bridge/tsconfig.build.json b/packages/android/android-zipline-bridge/tsconfig.build.json new file mode 100644 index 00000000..61d32eb5 --- /dev/null +++ b/packages/android/android-zipline-bridge/tsconfig.build.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "composite": true, + "incremental": true, + "noEmit": false, + "baseUrl": "./src", + "emitDeclarationOnly": true, + "lib": ["ESNext", "DOM"], + "module": "ESNext", + "moduleResolution": "bundler", + "outDir": "dist", + "rootDir": "./src", + "tsBuildInfoFile": "./.tsbuildinfo" + }, + "include": ["./src/**/*"], + "exclude": ["./src/**/*.test.*"], + "extends": "../../../tsconfig.base.json", + "references": [{ "path": "../../universal/core-sdk/tsconfig.build.json" }] +} diff --git a/packages/android/android-zipline-bridge/tsconfig.json b/packages/android/android-zipline-bridge/tsconfig.json new file mode 100644 index 00000000..d557830f --- /dev/null +++ b/packages/android/android-zipline-bridge/tsconfig.json @@ -0,0 +1,8 @@ +{ + "compilerOptions": { + "lib": ["ESNext", "DOM"], + "types": [] + }, + "extends": "../../../tsconfig.base.json", + "include": ["./src/**/*"] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6c985860..415fdcf4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -235,6 +235,31 @@ importers: specifier: ^5.8.3 version: 5.9.3 + packages/android/android-zipline-bridge: + dependencies: + '@contentful/optimization-core': + specifier: workspace:* + version: link:../../universal/core-sdk + devDependencies: + '@rslib/core': + specifier: 'catalog:' + version: 0.19.6(@microsoft/api-extractor@7.57.7(@types/node@24.10.13))(typescript@5.9.3) + '@types/node': + specifier: ^24.0.13 + version: 24.10.13 + build-tools: + specifier: workspace:* + version: link:../../../lib/build-tools + rimraf: + specifier: 'catalog:' + version: 6.1.3 + tslib: + specifier: 'catalog:' + version: 2.8.1 + typescript: + specifier: ^5.8.3 + version: 5.9.3 + packages/ios/ios-jsc-bridge: dependencies: '@contentful/optimization-core': diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index cf150115..ce193d38 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -4,6 +4,7 @@ packages: - packages/web/* - packages/web/frameworks/* - packages/ios/* + - packages/android/* - packages/node/node-sdk - packages/universal/* From a6f5ded855c42eaf8f78a40411afe868ee0aa443 Mon Sep 17 00:00:00 2001 From: Alex Freas Date: Wed, 6 May 2026 18:42:17 +0200 Subject: [PATCH 02/70] Add Android Gradle module structure with polyfill assets --- .../android/ContentfulOptimization/AGENTS.md | 38 ++++++ .../ContentfulOptimization/build.gradle.kts | 41 +++++++ .../consumer-proguard-rules.pro | 5 + .../ContentfulOptimization/gradle.properties | 3 + .../settings.gradle.kts | 16 +++ .../src/main/AndroidManifest.xml | 5 + .../main/assets/polyfills/abort-controller.js | 25 ++++ .../src/main/assets/polyfills/console.js | 17 +++ .../src/main/assets/polyfills/crypto.js | 11 ++ .../src/main/assets/polyfills/fetch.js | 81 +++++++++++++ .../assets/polyfills/promise-utilities.js | 16 +++ .../main/assets/polyfills/text-encoding.js | 39 +++++++ .../src/main/assets/polyfills/timers.js | 41 +++++++ .../src/main/assets/polyfills/url.js | 108 ++++++++++++++++++ 14 files changed, 446 insertions(+) create mode 100644 packages/android/ContentfulOptimization/AGENTS.md create mode 100644 packages/android/ContentfulOptimization/build.gradle.kts create mode 100644 packages/android/ContentfulOptimization/consumer-proguard-rules.pro create mode 100644 packages/android/ContentfulOptimization/gradle.properties create mode 100644 packages/android/ContentfulOptimization/settings.gradle.kts create mode 100644 packages/android/ContentfulOptimization/src/main/AndroidManifest.xml create mode 100644 packages/android/ContentfulOptimization/src/main/assets/polyfills/abort-controller.js create mode 100644 packages/android/ContentfulOptimization/src/main/assets/polyfills/console.js create mode 100644 packages/android/ContentfulOptimization/src/main/assets/polyfills/crypto.js create mode 100644 packages/android/ContentfulOptimization/src/main/assets/polyfills/fetch.js create mode 100644 packages/android/ContentfulOptimization/src/main/assets/polyfills/promise-utilities.js create mode 100644 packages/android/ContentfulOptimization/src/main/assets/polyfills/text-encoding.js create mode 100644 packages/android/ContentfulOptimization/src/main/assets/polyfills/timers.js create mode 100644 packages/android/ContentfulOptimization/src/main/assets/polyfills/url.js diff --git a/packages/android/ContentfulOptimization/AGENTS.md b/packages/android/ContentfulOptimization/AGENTS.md new file mode 100644 index 00000000..9306585e --- /dev/null +++ b/packages/android/ContentfulOptimization/AGENTS.md @@ -0,0 +1,38 @@ +# AGENTS.md + +Read the repository root `AGENTS.md`, then `packages/AGENTS.md`, then `packages/android/AGENTS.md`, +before this file. + +## Scope + +This directory is the Android library module (AAR) for the Contentful Optimization SDK. It contains +the Kotlin native runtime, Zipline (QuickJS) bridge integration, polyfill implementations, and +public API surface. + +## Key paths + +- `src/main/kotlin/com/contentful/optimization/bridge/` — Zipline context manager and callback + manager +- `src/main/kotlin/com/contentful/optimization/core/` — public API, data models, config +- `src/main/kotlin/com/contentful/optimization/polyfills/` — native polyfill implementations +- `src/main/kotlin/com/contentful/optimization/storage/` — SharedPreferences persistence +- `src/main/kotlin/com/contentful/optimization/handlers/` — lifecycle and network handlers +- `src/main/assets/` — JS bridge bundle and polyfill scripts (copied from android-zipline-bridge + build) + +## Local rules + +- All QuickJs access must go through `ZiplineContextManager`. Never call `quickJs.evaluate()` from + outside the manager. +- All JS engine calls must happen on the dedicated `quickJsDispatcher` thread. The manager enforces + this. +- Do not hand-edit files in `src/main/assets/`. They are copied from the bridge build and iOS + polyfill sources. +- Keep bridge call signatures and JSON payload shapes aligned with + `android-zipline-bridge/src/index.ts`. + +## Commands + +- Gradle build commands require Android SDK. Use `./gradlew build` from this directory. +- Run `pnpm --filter @contentful/optimization-android-bridge build` to rebuild the JS bridge bundle + before Gradle build. diff --git a/packages/android/ContentfulOptimization/build.gradle.kts b/packages/android/ContentfulOptimization/build.gradle.kts new file mode 100644 index 00000000..6cb29b1a --- /dev/null +++ b/packages/android/ContentfulOptimization/build.gradle.kts @@ -0,0 +1,41 @@ +plugins { + id("com.android.library") + id("org.jetbrains.kotlin.android") +} + +android { + namespace = "com.contentful.optimization" + compileSdk = 35 + + defaultConfig { + minSdk = 24 + targetSdk = 35 + consumerProguardFiles("consumer-proguard-rules.pro") + } + + buildTypes { + release { + isMinifyEnabled = false + } + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_11 + targetCompatibility = JavaVersion.VERSION_11 + } + + kotlinOptions { + jvmTarget = "11" + } +} + +dependencies { + implementation("app.cash.zipline:zipline-android:1.27.0") + implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.1") + implementation("androidx.lifecycle:lifecycle-process:2.8.7") + implementation("com.squareup.okhttp3:okhttp:4.12.0") + implementation("org.json:json:20240303") + + testImplementation("junit:junit:4.13.2") + testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.8.1") +} diff --git a/packages/android/ContentfulOptimization/consumer-proguard-rules.pro b/packages/android/ContentfulOptimization/consumer-proguard-rules.pro new file mode 100644 index 00000000..4f086286 --- /dev/null +++ b/packages/android/ContentfulOptimization/consumer-proguard-rules.pro @@ -0,0 +1,5 @@ +-keep class com.contentful.optimization.core.** { *; } +-keep class com.contentful.optimization.bridge.Native { *; } +-keep class com.contentful.optimization.bridge.NativeImpl { *; } +# Zipline ships its own consumer ProGuard rules; this line is defensive. +-keep class app.cash.zipline.** { *; } diff --git a/packages/android/ContentfulOptimization/gradle.properties b/packages/android/ContentfulOptimization/gradle.properties new file mode 100644 index 00000000..e6961679 --- /dev/null +++ b/packages/android/ContentfulOptimization/gradle.properties @@ -0,0 +1,3 @@ +org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 +android.useAndroidX=true +kotlin.code.style=official diff --git a/packages/android/ContentfulOptimization/settings.gradle.kts b/packages/android/ContentfulOptimization/settings.gradle.kts new file mode 100644 index 00000000..249fcdff --- /dev/null +++ b/packages/android/ContentfulOptimization/settings.gradle.kts @@ -0,0 +1,16 @@ +pluginManagement { + repositories { + google() + mavenCentral() + gradlePluginPortal() + } +} + +dependencyResolution { + repositories { + google() + mavenCentral() + } +} + +rootProject.name = "ContentfulOptimization" diff --git a/packages/android/ContentfulOptimization/src/main/AndroidManifest.xml b/packages/android/ContentfulOptimization/src/main/AndroidManifest.xml new file mode 100644 index 00000000..3cb3262d --- /dev/null +++ b/packages/android/ContentfulOptimization/src/main/AndroidManifest.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/packages/android/ContentfulOptimization/src/main/assets/polyfills/abort-controller.js b/packages/android/ContentfulOptimization/src/main/assets/polyfills/abort-controller.js new file mode 100644 index 00000000..600d17a1 --- /dev/null +++ b/packages/android/ContentfulOptimization/src/main/assets/polyfills/abort-controller.js @@ -0,0 +1,25 @@ +var AbortController = (function () { + function AC() { + this.signal = { + aborted: false, + reason: undefined, + addEventListener: function () {}, + removeEventListener: function () {}, + } + } + AC.prototype.abort = function (reason) { + this.signal.aborted = true + this.signal.reason = reason || new Error('AbortError') + } + return AC +})() + +var AbortSignal = { + timeout: function (ms) { + var ac = new AbortController() + setTimeout(function () { + ac.abort(new Error('TimeoutError')) + }, ms) + return ac.signal + }, +} diff --git a/packages/android/ContentfulOptimization/src/main/assets/polyfills/console.js b/packages/android/ContentfulOptimization/src/main/assets/polyfills/console.js new file mode 100644 index 00000000..53bb6fa7 --- /dev/null +++ b/packages/android/ContentfulOptimization/src/main/assets/polyfills/console.js @@ -0,0 +1,17 @@ +var console = { + log: function () { + __nativeLog('log', Array.prototype.slice.call(arguments).join(' ')) + }, + warn: function () { + __nativeLog('warn', Array.prototype.slice.call(arguments).join(' ')) + }, + error: function () { + __nativeLog('error', Array.prototype.slice.call(arguments).join(' ')) + }, + info: function () { + __nativeLog('info', Array.prototype.slice.call(arguments).join(' ')) + }, + debug: function () { + __nativeLog('debug', Array.prototype.slice.call(arguments).join(' ')) + }, +} diff --git a/packages/android/ContentfulOptimization/src/main/assets/polyfills/crypto.js b/packages/android/ContentfulOptimization/src/main/assets/polyfills/crypto.js new file mode 100644 index 00000000..6fb3bc43 --- /dev/null +++ b/packages/android/ContentfulOptimization/src/main/assets/polyfills/crypto.js @@ -0,0 +1,11 @@ +var crypto = { + randomUUID: function () { + return __nativeRandomUUID() + }, + getRandomValues: function (arr) { + for (var i = 0; i < arr.length; i++) { + arr[i] = Math.floor(Math.random() * 256) + } + return arr + }, +} diff --git a/packages/android/ContentfulOptimization/src/main/assets/polyfills/fetch.js b/packages/android/ContentfulOptimization/src/main/assets/polyfills/fetch.js new file mode 100644 index 00000000..5ac5c246 --- /dev/null +++ b/packages/android/ContentfulOptimization/src/main/assets/polyfills/fetch.js @@ -0,0 +1,81 @@ +var __fetchCallbacks = {} +var __nextFetchId = 1 + +function fetch(input, init) { + var url = typeof input === 'string' ? input : input.toString() + var method = init && init.method ? init.method : 'GET' + var headers = init && init.headers ? init.headers : {} + var body = init && init.body ? init.body : null + + var headersJSON + if (typeof headers === 'object' && headers !== null && typeof headers.forEach === 'function') { + var h = {} + headers.forEach(function (value, key) { + h[key] = value + }) + headersJSON = JSON.stringify(h) + } else { + headersJSON = JSON.stringify(headers) + } + + var id = __nextFetchId++ + + return new Promise(function (resolve, reject) { + __fetchCallbacks[id] = { resolve: resolve, reject: reject } + __nativeFetch(url, method, headersJSON, body, id) + }) +} + +function __fetchComplete(id, statusCode, headersJSON, bodyText, errorMessage) { + var cb = __fetchCallbacks[id] + delete __fetchCallbacks[id] + if (!cb) return + + if (errorMessage) { + cb.reject(new Error(errorMessage)) + return + } + + var responseHeaders = {} + try { + responseHeaders = JSON.parse(headersJSON || '{}') + } catch (e) {} + + var response = { + ok: statusCode >= 200 && statusCode < 300, + status: statusCode, + statusText: '' + statusCode, + headers: { + get: function (name) { + var lower = name.toLowerCase() + for (var key in responseHeaders) { + if (key.toLowerCase() === lower) return responseHeaders[key] + } + return null + }, + has: function (name) { + return this.get(name) !== null + }, + forEach: function (fn) { + for (var key in responseHeaders) { + fn(responseHeaders[key], key) + } + }, + }, + json: function () { + try { + return Promise.resolve(JSON.parse(bodyText)) + } catch (e) { + return Promise.reject(e) + } + }, + text: function () { + return Promise.resolve(bodyText) + }, + clone: function () { + return Object.assign({}, response) + }, + } + + cb.resolve(response) +} diff --git a/packages/android/ContentfulOptimization/src/main/assets/polyfills/promise-utilities.js b/packages/android/ContentfulOptimization/src/main/assets/polyfills/promise-utilities.js new file mode 100644 index 00000000..e530718b --- /dev/null +++ b/packages/android/ContentfulOptimization/src/main/assets/polyfills/promise-utilities.js @@ -0,0 +1,16 @@ +if (typeof queueMicrotask === 'undefined') { + var queueMicrotask = function (fn) { + Promise.resolve().then(fn) + } +} + +if (typeof Promise.withResolvers === 'undefined') { + Promise.withResolvers = function () { + var resolve, reject + var promise = new Promise(function (res, rej) { + resolve = res + reject = rej + }) + return { promise: promise, resolve: resolve, reject: reject } + } +} diff --git a/packages/android/ContentfulOptimization/src/main/assets/polyfills/text-encoding.js b/packages/android/ContentfulOptimization/src/main/assets/polyfills/text-encoding.js new file mode 100644 index 00000000..ba15991d --- /dev/null +++ b/packages/android/ContentfulOptimization/src/main/assets/polyfills/text-encoding.js @@ -0,0 +1,39 @@ +if (typeof TextEncoder === 'undefined') { + var TextEncoder = (function () { + function TE() { + this.encoding = 'utf-8' + } + TE.prototype.encode = function (str) { + var arr = [] + for (var i = 0; i < str.length; i++) { + var c = str.charCodeAt(i) + if (c < 128) { + arr.push(c) + } else if (c < 2048) { + arr.push(192 | (c >> 6), 128 | (c & 63)) + } else { + arr.push(224 | (c >> 12), 128 | ((c >> 6) & 63), 128 | (c & 63)) + } + } + return new Uint8Array(arr) + } + return TE + })() +} + +if (typeof TextDecoder === 'undefined') { + var TextDecoder = (function () { + function TD() { + this.encoding = 'utf-8' + } + TD.prototype.decode = function (buf) { + var arr = new Uint8Array(buf) + var result = '' + for (var i = 0; i < arr.length; i++) { + result += String.fromCharCode(arr[i]) + } + return result + } + return TD + })() +} diff --git a/packages/android/ContentfulOptimization/src/main/assets/polyfills/timers.js b/packages/android/ContentfulOptimization/src/main/assets/polyfills/timers.js new file mode 100644 index 00000000..6286162c --- /dev/null +++ b/packages/android/ContentfulOptimization/src/main/assets/polyfills/timers.js @@ -0,0 +1,41 @@ +var __timerCallbacks = {} +var __nextTimerId = 1 + +function setTimeout(fn, delay) { + var id = __nextTimerId++ + __timerCallbacks[id] = fn + __nativeSetTimeout(id, delay || 0) + return id +} + +function clearTimeout(id) { + delete __timerCallbacks[id] + __nativeClearTimeout(id) +} + +function __timerFired(id) { + var fn = __timerCallbacks[id] + delete __timerCallbacks[id] + if (fn) fn() +} + +var __intervalTimers = {} + +function setInterval(fn, delay) { + var intervalId = __nextTimerId++ + function repeat() { + fn() + if (intervalId in __intervalTimers) { + __intervalTimers[intervalId] = setTimeout(repeat, delay) + } + } + __intervalTimers[intervalId] = setTimeout(repeat, delay || 0) + return intervalId +} + +function clearInterval(id) { + if (id in __intervalTimers) { + clearTimeout(__intervalTimers[id]) + delete __intervalTimers[id] + } +} diff --git a/packages/android/ContentfulOptimization/src/main/assets/polyfills/url.js b/packages/android/ContentfulOptimization/src/main/assets/polyfills/url.js new file mode 100644 index 00000000..2b62ec00 --- /dev/null +++ b/packages/android/ContentfulOptimization/src/main/assets/polyfills/url.js @@ -0,0 +1,108 @@ +var URLSearchParams = (function () { + function USP(init) { + this._params = [] + if (typeof init === 'string') { + var s = init.charAt(0) === '?' ? init.substring(1) : init + if (s) { + var pairs = s.split('&') + for (var i = 0; i < pairs.length; i++) { + var idx = pairs[i].indexOf('=') + if (idx > -1) { + this._params.push([ + decodeURIComponent(pairs[i].substring(0, idx)), + decodeURIComponent(pairs[i].substring(idx + 1)), + ]) + } else { + this._params.push([decodeURIComponent(pairs[i]), '']) + } + } + } + } + } + USP.prototype.set = function (name, value) { + for (var i = this._params.length - 1; i >= 0; i--) { + if (this._params[i][0] === name) this._params.splice(i, 1) + } + this._params.push([name, value]) + } + USP.prototype.get = function (name) { + for (var i = 0; i < this._params.length; i++) { + if (this._params[i][0] === name) return this._params[i][1] + } + return null + } + USP.prototype.has = function (name) { + return this.get(name) !== null + } + USP.prototype.append = function (name, value) { + this._params.push([name, value]) + } + USP.prototype.delete = function (name) { + for (var i = this._params.length - 1; i >= 0; i--) { + if (this._params[i][0] === name) this._params.splice(i, 1) + } + } + USP.prototype.toString = function () { + return this._params + .map(function (p) { + return encodeURIComponent(p[0]) + '=' + encodeURIComponent(p[1]) + }) + .join('&') + } + USP.prototype.forEach = function (fn) { + for (var i = 0; i < this._params.length; i++) { + fn(this._params[i][1], this._params[i][0]) + } + } + return USP +})() + +var URL = (function () { + function URLPolyfill(urlStr, base) { + var full = urlStr + if (base && urlStr.indexOf('://') === -1) { + var b = base.replace(/\/+$/, '') + full = b + '/' + urlStr.replace(/^\/+/, '') + } + this.href = full + + var protocolEnd = full.indexOf('://') + if (protocolEnd > -1) { + this.protocol = full.substring(0, protocolEnd + 1) + var rest = full.substring(protocolEnd + 3) + var pathStart = rest.indexOf('/') + if (pathStart > -1) { + this.host = rest.substring(0, pathStart) + var pathAndQuery = rest.substring(pathStart) + var queryStart = pathAndQuery.indexOf('?') + if (queryStart > -1) { + this.pathname = pathAndQuery.substring(0, queryStart) + this.search = pathAndQuery.substring(queryStart) + } else { + this.pathname = pathAndQuery + this.search = '' + } + } else { + this.host = rest + this.pathname = '/' + this.search = '' + } + } else { + this.protocol = '' + this.host = '' + this.pathname = full + this.search = '' + } + + this.hostname = this.host.split(':')[0] + this.origin = this.protocol ? this.protocol + '//' + this.host : '' + this.searchParams = new URLSearchParams(this.search) + } + + URLPolyfill.prototype.toString = function () { + var qs = this.searchParams.toString() + return this.origin + this.pathname + (qs ? '?' + qs : '') + } + + return URLPolyfill +})() From 29f9c2c2dab8b41a0f69d8187160a876a52a01f2 Mon Sep 17 00:00:00 2001 From: Alex Freas Date: Wed, 6 May 2026 18:43:36 +0200 Subject: [PATCH 03/70] Port core data models to Kotlin (config, state, errors, preview DTOs, payloads) --- .../optimization/core/DiagnosticLogger.kt | 30 +++ .../contentful/optimization/core/JSONValue.kt | 54 +++++ .../optimization/core/OptimizationConfig.kt | 48 ++++ .../optimization/core/OptimizationError.kt | 8 + .../optimization/core/OptimizationState.kt | 41 ++++ .../optimization/core/PersonalizedResult.kt | 6 + .../optimization/core/PreviewState.kt | 215 ++++++++++++++++++ .../optimization/core/TrackClickPayload.kt | 17 ++ .../optimization/core/TrackViewPayload.kt | 23 ++ 9 files changed, 442 insertions(+) create mode 100644 packages/android/ContentfulOptimization/src/main/kotlin/com/contentful/optimization/core/DiagnosticLogger.kt create mode 100644 packages/android/ContentfulOptimization/src/main/kotlin/com/contentful/optimization/core/JSONValue.kt create mode 100644 packages/android/ContentfulOptimization/src/main/kotlin/com/contentful/optimization/core/OptimizationConfig.kt create mode 100644 packages/android/ContentfulOptimization/src/main/kotlin/com/contentful/optimization/core/OptimizationError.kt create mode 100644 packages/android/ContentfulOptimization/src/main/kotlin/com/contentful/optimization/core/OptimizationState.kt create mode 100644 packages/android/ContentfulOptimization/src/main/kotlin/com/contentful/optimization/core/PersonalizedResult.kt create mode 100644 packages/android/ContentfulOptimization/src/main/kotlin/com/contentful/optimization/core/PreviewState.kt create mode 100644 packages/android/ContentfulOptimization/src/main/kotlin/com/contentful/optimization/core/TrackClickPayload.kt create mode 100644 packages/android/ContentfulOptimization/src/main/kotlin/com/contentful/optimization/core/TrackViewPayload.kt diff --git a/packages/android/ContentfulOptimization/src/main/kotlin/com/contentful/optimization/core/DiagnosticLogger.kt b/packages/android/ContentfulOptimization/src/main/kotlin/com/contentful/optimization/core/DiagnosticLogger.kt new file mode 100644 index 00000000..f14fb81b --- /dev/null +++ b/packages/android/ContentfulOptimization/src/main/kotlin/com/contentful/optimization/core/DiagnosticLogger.kt @@ -0,0 +1,30 @@ +package com.contentful.optimization.core + +import android.util.Log + +object DiagnosticLogger { + private const val TAG = "ContentfulOptimization" + + @Volatile + private var enabled = false + + fun setEnabled(enabled: Boolean) { + this.enabled = enabled + } + + fun debug(message: () -> String) { + if (enabled) Log.d(TAG, message()) + } + + fun info(message: () -> String) { + if (enabled) Log.i(TAG, message()) + } + + fun warning(message: () -> String) { + if (enabled) Log.w(TAG, message()) + } + + fun error(message: () -> String) { + if (enabled) Log.e(TAG, message()) + } +} diff --git a/packages/android/ContentfulOptimization/src/main/kotlin/com/contentful/optimization/core/JSONValue.kt b/packages/android/ContentfulOptimization/src/main/kotlin/com/contentful/optimization/core/JSONValue.kt new file mode 100644 index 00000000..69260aa6 --- /dev/null +++ b/packages/android/ContentfulOptimization/src/main/kotlin/com/contentful/optimization/core/JSONValue.kt @@ -0,0 +1,54 @@ +package com.contentful.optimization.core + +import org.json.JSONArray +import org.json.JSONObject + +sealed class JSONValue { + data object Null : JSONValue() + data class Bool(val value: Boolean) : JSONValue() + data class Number(val value: Double) : JSONValue() + data class Str(val value: String) : JSONValue() + data class Array(val value: List) : JSONValue() + data class Obj(val value: Map) : JSONValue() + + val stringValue: String? get() = (this as? Str)?.value + val boolValue: Boolean? get() = (this as? Bool)?.value + val intValue: Int? get() = (this as? Number)?.value?.toInt() + val doubleValue: Double? get() = (this as? Number)?.value + val arrayValue: List? get() = (this as? Array)?.value + val objectValue: Map? get() = (this as? Obj)?.value + + operator fun get(key: String): JSONValue? = (this as? Obj)?.value?.get(key) + + fun toFoundation(): Any? = when (this) { + is Null -> null + is Bool -> value + is Number -> value + is Str -> value + is Array -> value.map { it.toFoundation() } + is Obj -> value.mapValues { it.value.toFoundation() } + } + + fun toStringArray(): List? = + (this as? Array)?.value?.mapNotNull { it.stringValue } + + companion object { + fun fromAny(value: Any?): JSONValue = when (value) { + null, JSONObject.NULL -> Null + is Boolean -> Bool(value) + is Int -> Number(value.toDouble()) + is Long -> Number(value.toDouble()) + is Float -> Number(value.toDouble()) + is Double -> Number(value) + is String -> Str(value) + is JSONArray -> Array((0 until value.length()).map { fromAny(value.get(it)) }) + is JSONObject -> Obj(value.keys().asSequence().associateWith { fromAny(value.get(it)) }) + is List<*> -> Array(value.map { fromAny(it) }) + is Map<*, *> -> { + @Suppress("UNCHECKED_CAST") + Obj((value as Map).mapValues { fromAny(it.value) }) + } + else -> Str(value.toString()) + } + } +} diff --git a/packages/android/ContentfulOptimization/src/main/kotlin/com/contentful/optimization/core/OptimizationConfig.kt b/packages/android/ContentfulOptimization/src/main/kotlin/com/contentful/optimization/core/OptimizationConfig.kt new file mode 100644 index 00000000..526c84d5 --- /dev/null +++ b/packages/android/ContentfulOptimization/src/main/kotlin/com/contentful/optimization/core/OptimizationConfig.kt @@ -0,0 +1,48 @@ +package com.contentful.optimization.core + +import org.json.JSONObject + +data class StorageDefaults( + var consent: Boolean? = null, + var profile: Map? = null, + var changes: List>? = null, + var personalizations: List>? = null, +) + +data class OptimizationConfig( + val clientId: String, + val environment: String = "master", + val experienceBaseUrl: String? = null, + val insightsBaseUrl: String? = null, + var defaults: StorageDefaults? = null, + val debug: Boolean = false, +) { + fun toJSON(): String { + val obj = JSONObject() + obj.put("clientId", clientId) + obj.put("environment", environment) + experienceBaseUrl?.let { obj.put("experienceBaseUrl", it) } + insightsBaseUrl?.let { obj.put("insightsBaseUrl", it) } + + defaults?.let { d -> + val defaultsObj = JSONObject() + d.consent?.let { defaultsObj.put("consent", it) } + d.profile?.let { defaultsObj.put("profile", JSONObject(it)) } + d.changes?.let { defaultsObj.put("changes", toJSONArray(it)) } + d.personalizations?.let { defaultsObj.put("optimizations", toJSONArray(it)) } + if (defaultsObj.length() > 0) { + obj.put("defaults", defaultsObj) + } + } + + return obj.toString() + } + + private fun toJSONArray(list: List>): org.json.JSONArray { + val arr = org.json.JSONArray() + for (item in list) { + arr.put(JSONObject(item)) + } + return arr + } +} diff --git a/packages/android/ContentfulOptimization/src/main/kotlin/com/contentful/optimization/core/OptimizationError.kt b/packages/android/ContentfulOptimization/src/main/kotlin/com/contentful/optimization/core/OptimizationError.kt new file mode 100644 index 00000000..dc5732a4 --- /dev/null +++ b/packages/android/ContentfulOptimization/src/main/kotlin/com/contentful/optimization/core/OptimizationError.kt @@ -0,0 +1,8 @@ +package com.contentful.optimization.core + +sealed class OptimizationError(message: String) : Exception(message) { + class NotInitialized : OptimizationError("SDK not initialized. Call initialize() first.") + class BridgeError(msg: String) : OptimizationError("JS Bridge error: $msg") + class ResourceLoadError(msg: String) : OptimizationError("Resource load error: $msg") + class ConfigError(msg: String) : OptimizationError("Config error: $msg") +} diff --git a/packages/android/ContentfulOptimization/src/main/kotlin/com/contentful/optimization/core/OptimizationState.kt b/packages/android/ContentfulOptimization/src/main/kotlin/com/contentful/optimization/core/OptimizationState.kt new file mode 100644 index 00000000..9b05643c --- /dev/null +++ b/packages/android/ContentfulOptimization/src/main/kotlin/com/contentful/optimization/core/OptimizationState.kt @@ -0,0 +1,41 @@ +package com.contentful.optimization.core + +import org.json.JSONArray +import org.json.JSONObject + +data class OptimizationState( + val profile: Map? = null, + val consent: Boolean? = null, + val canPersonalize: Boolean = false, + val changes: List>? = null, +) { + companion object { + val EMPTY = OptimizationState() + } + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is OptimizationState) return false + return sortedJson(profile) == sortedJson(other.profile) && + consent == other.consent && + canPersonalize == other.canPersonalize && + sortedJson(changes) == sortedJson(other.changes) + } + + override fun hashCode(): Int { + var result = sortedJson(profile).hashCode() + result = 31 * result + (consent?.hashCode() ?: 0) + result = 31 * result + canPersonalize.hashCode() + result = 31 * result + sortedJson(changes).hashCode() + return result + } + + private fun sortedJson(value: Any?): String { + if (value == null) return "null" + return when (value) { + is Map<*, *> -> JSONObject(value as Map).toString() + is List<*> -> JSONArray(value).toString() + else -> value.toString() + } + } +} diff --git a/packages/android/ContentfulOptimization/src/main/kotlin/com/contentful/optimization/core/PersonalizedResult.kt b/packages/android/ContentfulOptimization/src/main/kotlin/com/contentful/optimization/core/PersonalizedResult.kt new file mode 100644 index 00000000..dd338acb --- /dev/null +++ b/packages/android/ContentfulOptimization/src/main/kotlin/com/contentful/optimization/core/PersonalizedResult.kt @@ -0,0 +1,6 @@ +package com.contentful.optimization.core + +data class PersonalizedResult( + val entry: Map, + val personalization: Map?, +) diff --git a/packages/android/ContentfulOptimization/src/main/kotlin/com/contentful/optimization/core/PreviewState.kt b/packages/android/ContentfulOptimization/src/main/kotlin/com/contentful/optimization/core/PreviewState.kt new file mode 100644 index 00000000..a7d5be27 --- /dev/null +++ b/packages/android/ContentfulOptimization/src/main/kotlin/com/contentful/optimization/core/PreviewState.kt @@ -0,0 +1,215 @@ +package com.contentful.optimization.core + +import org.json.JSONArray +import org.json.JSONObject + +data class PreviewState( + val profile: JSONValue?, + val consent: Boolean?, + val canPersonalize: Boolean, + val changes: List?, + val selectedPersonalizations: List?, + val previewPanelOpen: Boolean, + val audienceOverrides: Map?, + val variantOverrides: Map?, + val defaultAudienceQualifications: Map?, + val defaultVariantIndices: Map?, + val previewModel: PreviewModelDTO?, +) { + companion object { + fun fromJSON(json: String): PreviewState? { + return try { + val obj = JSONObject(json) + PreviewState( + profile = if (obj.isNull("profile")) null else JSONValue.fromAny(obj.get("profile")), + consent = if (obj.isNull("consent")) null else obj.optBoolean("consent"), + canPersonalize = obj.optBoolean("canPersonalize", false), + changes = obj.optJSONArray("changes")?.let { parseChanges(it) }, + selectedPersonalizations = obj.optJSONArray("selectedPersonalizations")?.let { parsePersonalizations(it) }, + previewPanelOpen = obj.optBoolean("previewPanelOpen", false), + audienceOverrides = obj.optJSONObject("audienceOverrides")?.let { parseBoolMap(it) }, + variantOverrides = obj.optJSONObject("variantOverrides")?.let { parseIntMap(it) }, + defaultAudienceQualifications = obj.optJSONObject("defaultAudienceQualifications")?.let { parseBoolMap(it) }, + defaultVariantIndices = obj.optJSONObject("defaultVariantIndices")?.let { parseIntMap(it) }, + previewModel = obj.optJSONObject("previewModel")?.let { PreviewModelDTO.fromJSON(it) }, + ) + } catch (_: Exception) { + null + } + } + + private fun parseChanges(arr: JSONArray): List = + (0 until arr.length()).map { PreviewChange.fromJSON(arr.getJSONObject(it)) } + + private fun parsePersonalizations(arr: JSONArray): List = + (0 until arr.length()).map { SelectedPersonalization.fromJSON(arr.getJSONObject(it)) } + + private fun parseBoolMap(obj: JSONObject): Map = + obj.keys().asSequence().associateWith { obj.getBoolean(it) } + + private fun parseIntMap(obj: JSONObject): Map = + obj.keys().asSequence().associateWith { obj.getInt(it) } + } +} + +data class AudienceDefinitionDTO( + val id: String, + val name: String, + val description: String?, +) { + companion object { + fun fromJSON(obj: JSONObject) = AudienceDefinitionDTO( + id = obj.getString("id"), + name = obj.getString("name"), + description = obj.optString("description", null), + ) + } +} + +data class VariantDistributionDTO( + val index: Int, + val variantRef: String, + val percentage: Int?, + val name: String?, +) { + companion object { + fun fromJSON(obj: JSONObject) = VariantDistributionDTO( + index = obj.getInt("index"), + variantRef = obj.getString("variantRef"), + percentage = if (obj.isNull("percentage")) null else obj.optInt("percentage"), + name = obj.optString("name", null), + ) + } +} + +data class ExperienceDefinitionDTO( + val id: String, + val name: String, + val type: String, + val distribution: List, + val audience: AudienceRef?, + val currentVariantIndex: Int, + val isOverridden: Boolean, + val naturalVariantIndex: Int?, +) { + data class AudienceRef(val id: String) { + companion object { + fun fromJSON(obj: JSONObject) = AudienceRef(id = obj.getString("id")) + } + } + + companion object { + fun fromJSON(obj: JSONObject) = ExperienceDefinitionDTO( + id = obj.getString("id"), + name = obj.getString("name"), + type = obj.getString("type"), + distribution = obj.getJSONArray("distribution").let { arr -> + (0 until arr.length()).map { VariantDistributionDTO.fromJSON(arr.getJSONObject(it)) } + }, + audience = obj.optJSONObject("audience")?.let { AudienceRef.fromJSON(it) }, + currentVariantIndex = obj.getInt("currentVariantIndex"), + isOverridden = obj.getBoolean("isOverridden"), + naturalVariantIndex = if (obj.isNull("naturalVariantIndex")) null else obj.optInt("naturalVariantIndex"), + ) + } +} + +data class AudienceWithExperiencesDTO( + val audience: AudienceDefinitionDTO, + val experiences: List, + val isQualified: Boolean, + val isActive: Boolean, + val overrideState: String, +) { + companion object { + fun fromJSON(obj: JSONObject) = AudienceWithExperiencesDTO( + audience = AudienceDefinitionDTO.fromJSON(obj.getJSONObject("audience")), + experiences = obj.getJSONArray("experiences").let { arr -> + (0 until arr.length()).map { ExperienceDefinitionDTO.fromJSON(arr.getJSONObject(it)) } + }, + isQualified = obj.getBoolean("isQualified"), + isActive = obj.getBoolean("isActive"), + overrideState = obj.getString("overrideState"), + ) + } +} + +data class PreviewModelDTO( + val audiencesWithExperiences: List, + val unassociatedExperiences: List, + val hasData: Boolean, + val sdkVariantIndices: Map, + val audienceNameMap: Map, + val experienceNameMap: Map, +) { + companion object { + fun fromJSON(obj: JSONObject) = PreviewModelDTO( + audiencesWithExperiences = obj.getJSONArray("audiencesWithExperiences").let { arr -> + (0 until arr.length()).map { AudienceWithExperiencesDTO.fromJSON(arr.getJSONObject(it)) } + }, + unassociatedExperiences = obj.getJSONArray("unassociatedExperiences").let { arr -> + (0 until arr.length()).map { ExperienceDefinitionDTO.fromJSON(arr.getJSONObject(it)) } + }, + hasData = obj.getBoolean("hasData"), + sdkVariantIndices = obj.getJSONObject("sdkVariantIndices").let { m -> + m.keys().asSequence().associateWith { m.getInt(it) } + }, + audienceNameMap = obj.getJSONObject("audienceNameMap").let { m -> + m.keys().asSequence().associateWith { m.getString(it) } + }, + experienceNameMap = obj.getJSONObject("experienceNameMap").let { m -> + m.keys().asSequence().associateWith { m.getString(it) } + }, + ) + } +} + +data class SelectedPersonalization( + val experienceId: String, + val variantIndex: Int, + val variants: Map?, + val sticky: Boolean?, +) { + companion object { + fun fromJSON(obj: JSONObject) = SelectedPersonalization( + experienceId = obj.getString("experienceId"), + variantIndex = obj.getInt("variantIndex"), + variants = obj.optJSONObject("variants")?.let { m -> + m.keys().asSequence().associateWith { m.getString(it) } + }, + sticky = if (obj.isNull("sticky")) null else obj.optBoolean("sticky"), + ) + } +} + +data class PreviewChange( + val audienceId: String?, + val qualified: Boolean?, + val name: String?, + val key: String?, + val type: String?, + val meta: PreviewChangeMeta?, +) { + companion object { + fun fromJSON(obj: JSONObject) = PreviewChange( + audienceId = obj.optString("audienceId", null), + qualified = if (obj.isNull("qualified")) null else obj.optBoolean("qualified"), + name = obj.optString("name", null), + key = obj.optString("key", null), + type = obj.optString("type", null), + meta = obj.optJSONObject("meta")?.let { PreviewChangeMeta.fromJSON(it) }, + ) + } +} + +data class PreviewChangeMeta( + val experienceId: String, + val variantIndex: Int, +) { + companion object { + fun fromJSON(obj: JSONObject) = PreviewChangeMeta( + experienceId = obj.getString("experienceId"), + variantIndex = obj.getInt("variantIndex"), + ) + } +} diff --git a/packages/android/ContentfulOptimization/src/main/kotlin/com/contentful/optimization/core/TrackClickPayload.kt b/packages/android/ContentfulOptimization/src/main/kotlin/com/contentful/optimization/core/TrackClickPayload.kt new file mode 100644 index 00000000..a3e49de9 --- /dev/null +++ b/packages/android/ContentfulOptimization/src/main/kotlin/com/contentful/optimization/core/TrackClickPayload.kt @@ -0,0 +1,17 @@ +package com.contentful.optimization.core + +import org.json.JSONObject + +data class TrackClickPayload( + val componentId: String, + val experienceId: String? = null, + val variantIndex: Int, +) { + fun toJSON(): String { + val obj = JSONObject() + obj.put("componentId", componentId) + obj.put("variantIndex", variantIndex) + experienceId?.let { obj.put("experienceId", it) } + return obj.toString() + } +} diff --git a/packages/android/ContentfulOptimization/src/main/kotlin/com/contentful/optimization/core/TrackViewPayload.kt b/packages/android/ContentfulOptimization/src/main/kotlin/com/contentful/optimization/core/TrackViewPayload.kt new file mode 100644 index 00000000..0e6802c9 --- /dev/null +++ b/packages/android/ContentfulOptimization/src/main/kotlin/com/contentful/optimization/core/TrackViewPayload.kt @@ -0,0 +1,23 @@ +package com.contentful.optimization.core + +import org.json.JSONObject + +data class TrackViewPayload( + val componentId: String, + val viewId: String, + val experienceId: String? = null, + val variantIndex: Int, + val viewDurationMs: Int, + val sticky: Boolean? = null, +) { + fun toJSON(): String { + val obj = JSONObject() + obj.put("componentId", componentId) + obj.put("viewId", viewId) + obj.put("variantIndex", variantIndex) + obj.put("viewDurationMs", viewDurationMs) + experienceId?.let { obj.put("experienceId", it) } + sticky?.let { obj.put("sticky", it) } + return obj.toString() + } +} From ee57d725174628a365acf8462ed3b931c62c49c8 Mon Sep 17 00:00:00 2001 From: Alex Freas Date: Wed, 6 May 2026 18:45:04 +0200 Subject: [PATCH 04/70] Port bridge layer to Kotlin (ZiplineContextManager, callbacks, polyfills, Native interface) --- .../bridge/BridgeCallbackManager.kt | 44 +++ .../bridge/ZiplineContextManager.kt | 291 ++++++++++++++++++ .../optimization/polyfills/NativePolyfills.kt | 160 ++++++++++ .../polyfills/PolyfillScriptLoader.kt | 29 ++ 4 files changed, 524 insertions(+) create mode 100644 packages/android/ContentfulOptimization/src/main/kotlin/com/contentful/optimization/bridge/BridgeCallbackManager.kt create mode 100644 packages/android/ContentfulOptimization/src/main/kotlin/com/contentful/optimization/bridge/ZiplineContextManager.kt create mode 100644 packages/android/ContentfulOptimization/src/main/kotlin/com/contentful/optimization/polyfills/NativePolyfills.kt create mode 100644 packages/android/ContentfulOptimization/src/main/kotlin/com/contentful/optimization/polyfills/PolyfillScriptLoader.kt diff --git a/packages/android/ContentfulOptimization/src/main/kotlin/com/contentful/optimization/bridge/BridgeCallbackManager.kt b/packages/android/ContentfulOptimization/src/main/kotlin/com/contentful/optimization/bridge/BridgeCallbackManager.kt new file mode 100644 index 00000000..ca8f3921 --- /dev/null +++ b/packages/android/ContentfulOptimization/src/main/kotlin/com/contentful/optimization/bridge/BridgeCallbackManager.kt @@ -0,0 +1,44 @@ +package com.contentful.optimization.bridge + +import java.util.concurrent.atomic.AtomicInteger + +class BridgeCallbackManager { + private val nextId = AtomicInteger(1) + private val callbacks = mutableMapOf Unit>() + + data class CallbackNames(val success: String, val error: String) + + fun registerCallback( + prefix: String, + onSuccess: (String) -> Unit, + onError: (String) -> Unit, + ): CallbackNames { + val id = nextId.getAndIncrement() + val successName = "__${prefix}Callback_${id}_success" + val errorName = "__${prefix}Callback_${id}_error" + + callbacks[successName] = { json -> + callbacks.remove(successName) + callbacks.remove(errorName) + onSuccess(json) + } + callbacks[errorName] = { errorMsg -> + callbacks.remove(successName) + callbacks.remove(errorName) + onError(errorMsg) + } + + return CallbackNames(successName, errorName) + } + + fun invokeCallback(name: String, value: String): Boolean { + val cb = callbacks[name] ?: return false + cb(value) + return true + } + + fun removeCallback(successName: String, errorName: String) { + callbacks.remove(successName) + callbacks.remove(errorName) + } +} diff --git a/packages/android/ContentfulOptimization/src/main/kotlin/com/contentful/optimization/bridge/ZiplineContextManager.kt b/packages/android/ContentfulOptimization/src/main/kotlin/com/contentful/optimization/bridge/ZiplineContextManager.kt new file mode 100644 index 00000000..cfb3ed80 --- /dev/null +++ b/packages/android/ContentfulOptimization/src/main/kotlin/com/contentful/optimization/bridge/ZiplineContextManager.kt @@ -0,0 +1,291 @@ +package com.contentful.optimization.bridge + +import android.content.res.AssetManager +import app.cash.zipline.QuickJs +import com.contentful.optimization.core.DiagnosticLogger +import com.contentful.optimization.core.OptimizationConfig +import com.contentful.optimization.core.OptimizationError +import com.contentful.optimization.core.PreviewState +import com.contentful.optimization.polyfills.NativeImpl +import com.contentful.optimization.polyfills.PolyfillScriptLoader +import com.contentful.optimization.polyfills.TimerStore +import com.contentful.optimization.polyfills.escapeForJS +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.asCoroutineDispatcher +import kotlinx.coroutines.cancel +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import org.json.JSONObject +import java.util.concurrent.Executors + +class ZiplineContextManager { + private var quickJs: QuickJs? = null + private val callbackManager = BridgeCallbackManager() + private var timerStore: TimerStore? = null + + private val quickJsThread = Executors.newSingleThreadExecutor { r -> + Thread(r, "contentful-quickjs").apply { isDaemon = true } + } + val quickJsDispatcher: CoroutineDispatcher = quickJsThread.asCoroutineDispatcher() + private var quickJsScope: CoroutineScope? = null + + var onLog: ((String, String) -> Unit)? = null + var onStateChange: ((Map) -> Unit)? = null + var onEvent: ((Map) -> Unit)? = null + var onOverridesChanged: ((PreviewState) -> Unit)? = null + + suspend fun initialize(config: OptimizationConfig, assets: AssetManager) { + withContext(quickJsDispatcher) { + val qjs = QuickJs.create() + val store = TimerStore() + val scope = CoroutineScope(quickJsDispatcher) + quickJsScope = scope + timerStore = store + + val nativeImpl = NativeImpl( + scope = scope, + timerStore = store, + evaluateJS = { script -> qjs.evaluate(script, "native-callback.js") }, + logger = { level, msg -> onLog?.invoke(level, msg) }, + ) + + qjs.set("__native", Native::class.java, nativeImpl) + qjs.evaluate(NativeImpl.BOOTSTRAP_SCRIPT, "native-bootstrap.js") + + val polyfillScripts = PolyfillScriptLoader.loadAll(assets) + for (script in polyfillScripts) { + qjs.evaluate(script, "polyfill.js") + } + + val bundleSource = loadBundleSource(assets) + qjs.evaluate(bundleSource, "optimization-android-bridge.umd.js") + + val bridgeCheck = qjs.evaluate("typeof __bridge", "bridge-check.js") as? String + if (bridgeCheck != "object") { + qjs.close() + throw OptimizationError.BridgeError( + "__bridge not found after bundle evaluation (got: $bridgeCheck)" + ) + } + + registerCallbacks(qjs) + + val configJSON = config.toJSON() + qjs.evaluate("__bridge.initialize($configJSON)", "bridge-init.js") + + quickJs = qjs + } + } + + private fun registerCallbacks(qjs: QuickJs) { + qjs.evaluate( + """ + globalThis.__nativeOnStateChange = function(json) { + __native.log("__stateChange__", json); + }; + globalThis.__nativeOnEventEmitted = function(json) { + __native.log("__eventEmitted__", json); + }; + globalThis.__nativeOnOverridesChanged = function(json) { + __native.log("__overridesChanged__", json); + }; + """.trimIndent(), + "callback-registration.js" + ) + + val originalOnLog = onLog + onLog = { level, msg -> + when (level) { + "__stateChange__" -> handleStateChange(msg) + "__eventEmitted__" -> handleEvent(msg) + "__overridesChanged__" -> handleOverridesChanged(msg) + else -> originalOnLog?.invoke(level, msg) + } + } + } + + suspend fun callAsync( + method: String, + payload: String, + completion: (Result) -> Unit, + ) { + val qjs = quickJs + if (qjs == null) { + completion(Result.failure(OptimizationError.NotInitialized())) + return + } + + var didComplete = false + val completeOnce: (Result) -> Unit = { result -> + if (!didComplete) { + didComplete = true + completion(result) + } + } + + val names = callbackManager.registerCallback( + prefix = method, + onSuccess = { json -> + CoroutineScope(Dispatchers.Main).launch { + completeOnce(Result.success(json)) + } + }, + onError = { errorMsg -> + CoroutineScope(Dispatchers.Main).launch { + completeOnce(Result.failure(OptimizationError.BridgeError(errorMsg))) + } + }, + ) + + withContext(quickJsDispatcher) { + qjs.evaluate( + """ + globalThis.${names.success} = function(json) { + __native.log("__callback__${names.success}", json); + }; + globalThis.${names.error} = function(errorMsg) { + __native.log("__callback__${names.error}", errorMsg); + }; + """.trimIndent(), + "callback-setup.js" + ) + + val originalOnLog = onLog + val callbackOnLog: (String, String) -> Unit = { level, msg -> + when (level) { + "__callback__${names.success}" -> { + callbackManager.invokeCallback(names.success, msg) + onLog = originalOnLog + } + "__callback__${names.error}" -> { + callbackManager.invokeCallback(names.error, msg) + onLog = originalOnLog + } + else -> originalOnLog?.invoke(level, msg) + } + } + onLog = callbackOnLog + + val args = if (payload.isEmpty()) { + "${names.success}, ${names.error}" + } else { + "$payload, ${names.success}, ${names.error}" + } + + try { + qjs.evaluate("__bridge.$method($args)", "bridge-call-$method.js") + } catch (e: Exception) { + callbackManager.removeCallback(names.success, names.error) + onLog = originalOnLog + CoroutineScope(Dispatchers.Main).launch { + completeOnce(Result.failure(OptimizationError.BridgeError(e.message ?: "Unknown JS error"))) + } + } + } + } + + suspend fun callSync(method: String, args: String = ""): String? { + val qjs = quickJs ?: return null + return withContext(quickJsDispatcher) { + val script = if (args.isEmpty()) "__bridge.$method()" else "__bridge.$method($args)" + try { + val result = qjs.evaluate(script, "bridge-sync-$method.js") + result?.toString() + } catch (e: Exception) { + onLog?.invoke("exception", "[$method] ${e.message}") + null + } + } + } + + suspend fun evaluate(script: String): String? { + val qjs = quickJs ?: return null + return withContext(quickJsDispatcher) { + try { + qjs.evaluate(script, "eval.js")?.toString() + } catch (_: Exception) { + null + } + } + } + + suspend fun destroy() { + withContext(quickJsDispatcher) { + timerStore?.cancelAll() + timerStore = null + try { + quickJs?.evaluate("__bridge.destroy()", "bridge-destroy.js") + } catch (_: Exception) { + // ignore errors during teardown + } + quickJs?.close() + quickJs = null + quickJsScope?.cancel() + quickJsScope = null + } + } + + private fun loadBundleSource(assets: AssetManager): String { + return try { + assets.open("optimization-android-bridge.umd.js").bufferedReader().use { it.readText() } + } catch (e: Exception) { + throw OptimizationError.ResourceLoadError( + "optimization-android-bridge.umd.js not found in assets: ${e.message}" + ) + } + } + + private fun handleStateChange(json: String) { + val dict = parseJSONDict(json) ?: return + CoroutineScope(Dispatchers.Main).launch { + onStateChange?.invoke(dict) + } + } + + private fun handleEvent(json: String) { + val dict = parseJSONDict(json) ?: return + CoroutineScope(Dispatchers.Main).launch { + onEvent?.invoke(dict) + } + } + + private fun handleOverridesChanged(json: String) { + val state = PreviewState.fromJSON(json) ?: return + CoroutineScope(Dispatchers.Main).launch { + onOverridesChanged?.invoke(state) + } + } + + private fun parseJSONDict(json: String): Map? { + return try { + val obj = JSONObject(json) + jsonObjectToMap(obj) + } catch (_: Exception) { + null + } + } + + companion object { + fun jsonObjectToMap(obj: JSONObject): Map { + val map = mutableMapOf() + for (key in obj.keys()) { + 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 -> Unit + else -> value + } + } + } +} diff --git a/packages/android/ContentfulOptimization/src/main/kotlin/com/contentful/optimization/polyfills/NativePolyfills.kt b/packages/android/ContentfulOptimization/src/main/kotlin/com/contentful/optimization/polyfills/NativePolyfills.kt new file mode 100644 index 00000000..d1912954 --- /dev/null +++ b/packages/android/ContentfulOptimization/src/main/kotlin/com/contentful/optimization/polyfills/NativePolyfills.kt @@ -0,0 +1,160 @@ +package com.contentful.optimization.polyfills + +import com.contentful.optimization.core.DiagnosticLogger +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import okhttp3.Call +import okhttp3.Callback +import okhttp3.MediaType.Companion.toMediaTypeOrNull +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.RequestBody.Companion.toRequestBody +import okhttp3.Response +import org.json.JSONObject +import java.io.IOException +import java.util.UUID +import java.util.concurrent.ConcurrentHashMap + +class TimerStore { + private val timers = ConcurrentHashMap() + + fun set(id: Int, job: Job) { + timers[id] = job + } + + fun cancel(id: Int) { + timers[id]?.cancel() + timers.remove(id) + } + + fun fired(id: Int) { + timers.remove(id) + } + + fun cancelAll() { + for ((_, job) in timers) { + job.cancel() + } + timers.clear() + } +} + +fun escapeForJS(value: String): String = + value + .replace("\\", "\\\\") + .replace("\"", "\\\"") + .replace("'", "\\'") + .replace("\n", "\\n") + .replace("\r", "\\r") + .replace("\t", "\\t") + +interface Native { + fun log(level: String, msg: String) + fun setTimeout(id: Int, delayMs: Int) + fun clearTimeout(id: Int) + fun randomUUID(): String + fun fetch(url: String, method: String, headers: String, body: String, callbackId: Int) +} + +class NativeImpl( + private val scope: CoroutineScope, + private val timerStore: TimerStore, + private val evaluateJS: (String) -> Unit, + private val logger: (String, String) -> Unit, +) : Native { + private val okHttpClient = OkHttpClient() + + override fun log(level: String, msg: String) { + logger(level, msg) + } + + override fun setTimeout(id: Int, delayMs: Int) { + val job = scope.launch { + delay(delayMs.toLong().coerceAtLeast(0)) + timerStore.fired(id) + evaluateJS("__timerFired($id)") + } + timerStore.set(id, job) + } + + override fun clearTimeout(id: Int) { + timerStore.cancel(id) + } + + override fun randomUUID(): String = UUID.randomUUID().toString() + + override fun fetch(url: String, method: String, headers: String, body: String, callbackId: Int) { + DiagnosticLogger.debug { "[fetch] $method $url" } + + val requestBuilder = Request.Builder().url(url) + + try { + val headersObj = JSONObject(headers) + for (key in headersObj.keys()) { + requestBuilder.addHeader(key, headersObj.getString(key)) + } + } catch (_: Exception) { + // headers was empty or invalid JSON — proceed without + } + + val requestBody = if (body.isNotEmpty() && method != "GET" && method != "HEAD") { + val contentType = "application/json".toMediaTypeOrNull() + body.toRequestBody(contentType) + } else { + if (method == "POST" || method == "PUT" || method == "PATCH") { + "".toRequestBody(null) + } else { + null + } + } + + val request = requestBuilder.method(method, requestBody).build() + + okHttpClient.newCall(request).enqueue(object : Callback { + override fun onResponse(call: Call, response: Response) { + val responseBody = response.body?.string() ?: "" + val responseHeaders = JSONObject() + for (name in response.headers.names()) { + responseHeaders.put(name, response.header(name)) + } + val statusCode = response.code + val bodySize = responseBody.length + DiagnosticLogger.debug { "[fetch] Response $statusCode from $url ($bodySize bytes)" } + if (statusCode >= 400) { + DiagnosticLogger.error { "[fetch] Error body: $responseBody" } + } + + val escapedBody = escapeForJS(responseBody) + val escapedHeaders = escapeForJS(responseHeaders.toString()) + + scope.launch { + evaluateJS( + "__fetchComplete($callbackId, $statusCode, \"$escapedHeaders\", \"$escapedBody\", \"\")" + ) + } + } + + override fun onFailure(call: Call, e: IOException) { + DiagnosticLogger.error { "[fetch] Network error for $url: ${e.message}" } + val escaped = escapeForJS(e.message ?: "Network error") + scope.launch { + evaluateJS( + "__fetchComplete($callbackId, 0, \"{}\", \"\", \"$escaped\")" + ) + } + } + }) + } + + companion object { + const val BOOTSTRAP_SCRIPT = """ + globalThis.__nativeLog = function(l, m) { return __native.log(l, m); }; + globalThis.__nativeSetTimeout = function(id, ms) { return __native.setTimeout(id, ms); }; + globalThis.__nativeClearTimeout = function(id) { return __native.clearTimeout(id); }; + globalThis.__nativeRandomUUID = function() { return __native.randomUUID(); }; + globalThis.__nativeFetch = function(u,m,h,b,cb) { return __native.fetch(u, m, h, b, cb); }; + """ + } +} diff --git a/packages/android/ContentfulOptimization/src/main/kotlin/com/contentful/optimization/polyfills/PolyfillScriptLoader.kt b/packages/android/ContentfulOptimization/src/main/kotlin/com/contentful/optimization/polyfills/PolyfillScriptLoader.kt new file mode 100644 index 00000000..37c10349 --- /dev/null +++ b/packages/android/ContentfulOptimization/src/main/kotlin/com/contentful/optimization/polyfills/PolyfillScriptLoader.kt @@ -0,0 +1,29 @@ +package com.contentful.optimization.polyfills + +import android.content.res.AssetManager +import com.contentful.optimization.core.OptimizationError + +object PolyfillScriptLoader { + private val fileNames = listOf( + "console", + "timers", + "fetch", + "crypto", + "url", + "abort-controller", + "promise-utilities", + "text-encoding", + ) + + fun loadAll(assets: AssetManager): List { + return fileNames.map { name -> + try { + assets.open("polyfills/$name.js").bufferedReader().use { it.readText() } + } catch (e: Exception) { + throw OptimizationError.ResourceLoadError( + "Missing or unreadable polyfill resource: polyfills/$name.js — ${e.message}" + ) + } + } + } +} From 2a82a895a322a8283f2a1c09dcae272ad9c1b736 Mon Sep 17 00:00:00 2001 From: Alex Freas Date: Wed, 6 May 2026 18:45:30 +0200 Subject: [PATCH 05/70] Port storage layer to Kotlin (PersistentStore, SharedPreferencesStore) --- .../optimization/storage/PersistentStore.kt | 13 ++ .../storage/SharedPreferencesStore.kt | 134 ++++++++++++++++++ 2 files changed, 147 insertions(+) create mode 100644 packages/android/ContentfulOptimization/src/main/kotlin/com/contentful/optimization/storage/PersistentStore.kt create mode 100644 packages/android/ContentfulOptimization/src/main/kotlin/com/contentful/optimization/storage/SharedPreferencesStore.kt diff --git a/packages/android/ContentfulOptimization/src/main/kotlin/com/contentful/optimization/storage/PersistentStore.kt b/packages/android/ContentfulOptimization/src/main/kotlin/com/contentful/optimization/storage/PersistentStore.kt new file mode 100644 index 00000000..de16590d --- /dev/null +++ b/packages/android/ContentfulOptimization/src/main/kotlin/com/contentful/optimization/storage/PersistentStore.kt @@ -0,0 +1,13 @@ +package com.contentful.optimization.storage + +interface PersistentStore { + var profile: Map? + var consent: Boolean? + var changes: List>? + var personalizations: List>? + var anonymousId: String? + var debug: Boolean + + fun load() + fun clear() +} diff --git a/packages/android/ContentfulOptimization/src/main/kotlin/com/contentful/optimization/storage/SharedPreferencesStore.kt b/packages/android/ContentfulOptimization/src/main/kotlin/com/contentful/optimization/storage/SharedPreferencesStore.kt new file mode 100644 index 00000000..da998cd5 --- /dev/null +++ b/packages/android/ContentfulOptimization/src/main/kotlin/com/contentful/optimization/storage/SharedPreferencesStore.kt @@ -0,0 +1,134 @@ +package com.contentful.optimization.storage + +import android.content.Context +import android.content.SharedPreferences +import com.contentful.optimization.bridge.ZiplineContextManager +import org.json.JSONArray +import org.json.JSONObject + +class SharedPreferencesStore(context: Context) : PersistentStore { + private val prefs: SharedPreferences = + context.getSharedPreferences("com.contentful.optimization", Context.MODE_PRIVATE) + private val keyPrefix = "com.contentful.optimization." + private val cache = mutableMapOf() + + override fun load() { + val keys = listOf("profile", "consent", "changes", "personalizations", "anonymousId", "debug") + for (key in keys) { + val fullKey = keyPrefix + key + val stored = prefs.getString(fullKey, null) ?: continue + when (key) { + "consent" -> cache[key] = stored + "anonymousId" -> cache[key] = stored + "debug" -> cache[key] = stored == "true" + else -> { + try { + cache[key] = parseJSON(stored) + } catch (_: Exception) { + // skip unparseable stored values + } + } + } + } + } + + override fun clear() { + val keys = listOf("profile", "consent", "changes", "personalizations", "anonymousId", "debug") + val editor = prefs.edit() + for (key in keys) { + editor.remove(keyPrefix + key) + } + editor.apply() + cache.clear() + } + + override var profile: Map? + get() { + @Suppress("UNCHECKED_CAST") + return cache["profile"] as? Map + } + set(value) { + cache["profile"] = value + writeJSON(value, "profile") + } + + override var consent: Boolean? + get() { + return when (cache["consent"] as? String) { + "accepted" -> true + "denied" -> false + else -> null + } + } + set(value) { + val translated = value?.let { if (it) "accepted" else "denied" } + cache["consent"] = translated + writeString(translated, "consent") + } + + override var changes: List>? + get() { + @Suppress("UNCHECKED_CAST") + return cache["changes"] as? List> + } + set(value) { + cache["changes"] = value + writeJSON(value, "changes") + } + + override var personalizations: List>? + get() { + @Suppress("UNCHECKED_CAST") + return cache["personalizations"] as? List> + } + set(value) { + cache["personalizations"] = value + writeJSON(value, "personalizations") + } + + override var anonymousId: String? + get() = cache["anonymousId"] as? String + set(value) { + cache["anonymousId"] = value + writeString(value, "anonymousId") + } + + override var debug: Boolean + get() = cache["debug"] as? Boolean ?: false + set(value) { + cache["debug"] = value + writeString(if (value) "true" else "false", "debug") + } + + private fun writeJSON(value: Any?, key: String) { + val fullKey = keyPrefix + key + if (value != null) { + val json = when (value) { + is Map<*, *> -> JSONObject(value as Map).toString() + is List<*> -> JSONArray(value).toString() + else -> value.toString() + } + prefs.edit().putString(fullKey, json).apply() + } else { + prefs.edit().remove(fullKey).apply() + } + } + + private fun writeString(value: String?, key: String) { + val fullKey = keyPrefix + key + if (value != null) { + prefs.edit().putString(fullKey, value).apply() + } else { + prefs.edit().remove(fullKey).apply() + } + } + + private fun parseJSON(json: String): Any { + return if (json.trimStart().startsWith("[")) { + val arr = JSONArray(json) + (0 until arr.length()).map { ZiplineContextManager.jsonObjectToMap(arr.getJSONObject(it)) } + } else { + ZiplineContextManager.jsonObjectToMap(JSONObject(json)) + } + } +} From 5d9b1bb892fa3c3683ac49820974014a7ef58d4d Mon Sep 17 00:00:00 2001 From: Alex Freas Date: Wed, 6 May 2026 18:45:49 +0200 Subject: [PATCH 06/70] Port handler layer to Kotlin (AppLifecycleHandler, NetworkMonitor) --- .../handlers/AppLifecycleHandler.kt | 36 ++++++++++++ .../optimization/handlers/NetworkMonitor.kt | 58 +++++++++++++++++++ 2 files changed, 94 insertions(+) create mode 100644 packages/android/ContentfulOptimization/src/main/kotlin/com/contentful/optimization/handlers/AppLifecycleHandler.kt create mode 100644 packages/android/ContentfulOptimization/src/main/kotlin/com/contentful/optimization/handlers/NetworkMonitor.kt diff --git a/packages/android/ContentfulOptimization/src/main/kotlin/com/contentful/optimization/handlers/AppLifecycleHandler.kt b/packages/android/ContentfulOptimization/src/main/kotlin/com/contentful/optimization/handlers/AppLifecycleHandler.kt new file mode 100644 index 00000000..ac16aa89 --- /dev/null +++ b/packages/android/ContentfulOptimization/src/main/kotlin/com/contentful/optimization/handlers/AppLifecycleHandler.kt @@ -0,0 +1,36 @@ +package com.contentful.optimization.handlers + +import androidx.lifecycle.DefaultLifecycleObserver +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.ProcessLifecycleOwner +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch + +class AppLifecycleHandler( + private val onBackground: suspend () -> Unit, + private val onForeground: (() -> Unit)? = null, +) : DefaultLifecycleObserver { + + init { + ProcessLifecycleOwner.get().lifecycle.addObserver(this) + } + + override fun onStop(owner: LifecycleOwner) { + CoroutineScope(Dispatchers.Main).launch { + try { + onBackground() + } catch (_: Exception) { + // best-effort flush on background + } + } + } + + override fun onStart(owner: LifecycleOwner) { + onForeground?.invoke() + } + + fun stop() { + ProcessLifecycleOwner.get().lifecycle.removeObserver(this) + } +} diff --git a/packages/android/ContentfulOptimization/src/main/kotlin/com/contentful/optimization/handlers/NetworkMonitor.kt b/packages/android/ContentfulOptimization/src/main/kotlin/com/contentful/optimization/handlers/NetworkMonitor.kt new file mode 100644 index 00000000..2d84e38c --- /dev/null +++ b/packages/android/ContentfulOptimization/src/main/kotlin/com/contentful/optimization/handlers/NetworkMonitor.kt @@ -0,0 +1,58 @@ +package com.contentful.optimization.handlers + +import android.content.Context +import android.net.ConnectivityManager +import android.net.Network +import android.net.NetworkCapabilities +import android.net.NetworkRequest +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch + +class NetworkMonitor( + context: Context, + private val onConnectivityChanged: (isOnline: Boolean) -> Unit, + private val onReconnected: suspend () -> Unit, +) { + private val connectivityManager = + context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager + private var wasConnected = true + + private val callback = object : ConnectivityManager.NetworkCallback() { + override fun onAvailable(network: Network) { + CoroutineScope(Dispatchers.Main).launch { + handleConnectivityChange(true) + } + } + + override fun onLost(network: Network) { + CoroutineScope(Dispatchers.Main).launch { + handleConnectivityChange(false) + } + } + } + + init { + val request = NetworkRequest.Builder() + .addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) + .build() + connectivityManager.registerNetworkCallback(request, callback) + } + + private suspend fun handleConnectivityChange(isConnected: Boolean) { + onConnectivityChanged(isConnected) + + if (isConnected && !wasConnected) { + try { + onReconnected() + } catch (_: Exception) { + // best-effort flush on reconnect + } + } + wasConnected = isConnected + } + + fun stop() { + connectivityManager.unregisterNetworkCallback(callback) + } +} From 5853b0c8705c52e6c9923b37e2fcb756747f2903 Mon Sep 17 00:00:00 2001 From: Alex Freas Date: Wed, 6 May 2026 18:47:00 +0200 Subject: [PATCH 07/70] Port OptimizationClient to Kotlin with StateFlow, suspend functions, and bridge wiring --- .../optimization/core/OptimizationClient.kt | 370 ++++++++++++++++++ 1 file changed, 370 insertions(+) create mode 100644 packages/android/ContentfulOptimization/src/main/kotlin/com/contentful/optimization/core/OptimizationClient.kt diff --git a/packages/android/ContentfulOptimization/src/main/kotlin/com/contentful/optimization/core/OptimizationClient.kt b/packages/android/ContentfulOptimization/src/main/kotlin/com/contentful/optimization/core/OptimizationClient.kt new file mode 100644 index 00000000..4159ba3e --- /dev/null +++ b/packages/android/ContentfulOptimization/src/main/kotlin/com/contentful/optimization/core/OptimizationClient.kt @@ -0,0 +1,370 @@ +package com.contentful.optimization.core + +import android.content.Context +import com.contentful.optimization.bridge.ZiplineContextManager +import com.contentful.optimization.handlers.AppLifecycleHandler +import com.contentful.optimization.handlers.NetworkMonitor +import com.contentful.optimization.polyfills.escapeForJS +import com.contentful.optimization.storage.SharedPreferencesStore +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import org.json.JSONArray +import org.json.JSONObject +import kotlin.coroutines.resume +import kotlin.coroutines.resumeWithException +import kotlin.coroutines.suspendCoroutine + +class OptimizationClient(private val applicationContext: Context) { + + private val _state = MutableStateFlow(OptimizationState.EMPTY) + val state: StateFlow = _state.asStateFlow() + + private val _isInitialized = MutableStateFlow(false) + val isInitialized: StateFlow = _isInitialized.asStateFlow() + + private val _selectedPersonalizations = MutableStateFlow>?>(null) + val selectedPersonalizations: StateFlow>?> = _selectedPersonalizations.asStateFlow() + + private val _isPreviewPanelOpen = MutableStateFlow(false) + val isPreviewPanelOpen: StateFlow = _isPreviewPanelOpen.asStateFlow() + + private val _previewState = MutableStateFlow(null) + val previewState: StateFlow = _previewState.asStateFlow() + + private val _events = MutableSharedFlow>(extraBufferCapacity = 64) + val events: SharedFlow> = _events.asSharedFlow() + + private val bridge = ZiplineContextManager() + private val store = SharedPreferencesStore(applicationContext) + private var appLifecycleHandler: AppLifecycleHandler? = null + private var networkMonitor: NetworkMonitor? = null + private val log = DiagnosticLogger + + init { + bridge.onStateChange = { dict -> handleStateUpdate(dict) } + bridge.onEvent = { dict -> _events.tryEmit(dict) } + bridge.onOverridesChanged = { state -> _previewState.value = state } + } + + // MARK: - Public API + + suspend fun initialize(config: OptimizationConfig) { + log.setEnabled(config.debug) + log.info { "[init] Starting SDK initialization (clientId=${config.clientId}, env=${config.environment})" } + + store.load() + val mergedConfig = config.copy( + defaults = (config.defaults ?: StorageDefaults()).let { d -> + d.copy( + consent = d.consent ?: store.consent, + profile = d.profile ?: store.profile, + changes = d.changes ?: store.changes, + personalizations = d.personalizations ?: store.personalizations, + ) + } + ) + + bridge.onLog = { level, msg -> log.debug { "[js:$level] $msg" } } + + bridge.initialize(mergedConfig, applicationContext.assets) + _isInitialized.value = true + log.info { "[init] SDK initialized successfully" } + + appLifecycleHandler = AppLifecycleHandler( + onBackground = { flush() }, + ) + networkMonitor = NetworkMonitor( + context = applicationContext, + onConnectivityChanged = { isOnline -> setOnline(isOnline) }, + onReconnected = { flush() }, + ) + } + + suspend fun identify( + userId: String, + traits: Map? = null, + ): Map? { + return bridgeCallAsyncJSON("identify") { + val obj = JSONObject() + obj.put("userId", userId) + traits?.let { obj.put("traits", JSONObject(it)) } + obj.toString() + } + } + + suspend fun page(properties: Map? = null): Map? { + return bridgeCallAsyncJSON("page") { + JSONObject(properties ?: emptyMap()).toString() + } + } + + suspend fun screen(name: String, properties: Map? = null): Map? { + return bridgeCallAsyncJSON("screen") { + val obj = JSONObject() + obj.put("name", name) + properties?.let { obj.put("properties", JSONObject(it)) } + obj.toString() + } + } + + suspend fun flush() { + bridgeCallAsyncVoid("flush", "") + } + + suspend fun trackView(payload: TrackViewPayload): Map? { + return bridgeCallAsyncJSON("trackView") { payload.toJSON() } + } + + suspend fun trackClick(payload: TrackClickPayload): Map? { + return bridgeCallAsyncJSON("trackClick") { payload.toJSON() } + } + + fun consent(accept: Boolean) { + bridgeCallSyncWhenInitialized("consent", if (accept) "true" else "false") + } + + fun reset() { + if (!_isInitialized.value) return + bridgeCallSyncWhenInitialized("reset") + store.clear() + } + + fun setOnline(isOnline: Boolean) { + bridgeCallSyncWhenInitialized("setOnline", if (isOnline) "true" else "false") + } + + suspend fun personalizeEntry( + baseline: Map, + personalizations: List>? = null, + ): PersonalizedResult { + if (!_isInitialized.value) { + return PersonalizedResult(entry = baseline, personalization = null) + } + + return try { + val baselineJSON = JSONObject(baseline).toString() + val args = if (personalizations != null) { + val pJSON = JSONArray(personalizations).toString() + "$baselineJSON, $pJSON" + } else { + baselineJSON + } + + val resultStr = bridge.callSync("personalizeEntry", args) + if (resultStr == null || resultStr == "null" || resultStr == "undefined") { + return PersonalizedResult(entry = baseline, personalization = null) + } + + val dict = parseJSONDict(resultStr) + ?: return PersonalizedResult(entry = baseline, personalization = null) + + @Suppress("UNCHECKED_CAST") + val entry = dict["entry"] as? Map ?: baseline + @Suppress("UNCHECKED_CAST") + val personalization = dict["personalization"] as? Map + PersonalizedResult(entry = entry, personalization = personalization) + } catch (_: Exception) { + PersonalizedResult(entry = baseline, personalization = null) + } + } + + suspend fun getProfile(): Map? { + val result = bridge.callSync("getProfile") + if (result == null || result == "null" || result == "undefined") return null + return parseJSONDict(result) + } + + fun getState(): OptimizationState = _state.value + + // MARK: - Preview Panel + + fun setPreviewPanelOpen(open: Boolean) { + _isPreviewPanelOpen.value = open + bridgeCallSyncWhenInitialized("setPreviewPanelOpen", if (open) "true" else "false") + } + + fun overrideAudience(id: String, qualified: Boolean, experienceIds: List) { + val escapedId = escapeForJS(id) + val escapedIds = experienceIds.joinToString(",") { "'${escapeForJS(it)}'" } + bridgeCallSyncWhenInitialized("overrideAudience", "'$escapedId', $qualified, [$escapedIds]") + } + + fun overrideVariant(experienceId: String, variantIndex: Int) { + val escapedId = escapeForJS(experienceId) + bridgeCallSyncWhenInitialized("overrideVariant", "'$escapedId', $variantIndex") + } + + fun resetAudienceOverride(id: String) { + val escapedId = escapeForJS(id) + bridgeCallSyncWhenInitialized("resetAudienceOverride", "'$escapedId'") + } + + fun resetVariantOverride(experienceId: String) { + val escapedId = escapeForJS(experienceId) + bridgeCallSyncWhenInitialized("resetVariantOverride", "'$escapedId'") + } + + fun resetAllOverrides() { + bridgeCallSyncWhenInitialized("resetAllOverrides") + } + + suspend fun loadDefinitions( + audiences: List>, + experiences: List>, + ) { + val audienceJSON = JSONArray(audiences).toString() + val experienceJSON = JSONArray(experiences).toString() + bridge.callSync("loadDefinitions", "$audienceJSON, $experienceJSON") + } + + suspend fun refreshPreviewState() { + _previewState.value = getPreviewState() + } + + suspend fun getPreviewState(): PreviewState? { + val result = bridge.callSync("getPreviewState") + if (result == null || result == "null" || result == "undefined") { + log.warning { "[preview] getPreviewState returned nil" } + return null + } + return PreviewState.fromJSON(result) + } + + suspend fun destroy() { + appLifecycleHandler?.stop() + appLifecycleHandler = null + networkMonitor?.stop() + networkMonitor = null + + bridge.destroy() + _isInitialized.value = false + _state.value = OptimizationState.EMPTY + _selectedPersonalizations.value = null + store.clear() + } + + // MARK: - Testing + + suspend fun testOnlySetLogHandler(handler: (String, String) -> Unit) { + bridge.onLog = { level, msg -> + log.debug { "[js:$level] $msg" } + handler(level, msg) + } + } + + suspend fun testOnlyEvaluateScript(script: String): String? { + return bridge.evaluate(script) + } + + // MARK: - Private + + private fun requireInitialized() { + if (!_isInitialized.value) throw OptimizationError.NotInitialized() + } + + private fun bridgeCallSyncWhenInitialized(method: String, args: String = "") { + if (!_isInitialized.value) return + CoroutineScope(bridge.quickJsDispatcher).launch { + bridge.callSync(method, args) + } + } + + private suspend fun bridgeCallAsyncJSON( + method: String, + buildPayload: () -> String, + ): Map? { + requireInitialized() + val payload = buildPayload() + log.debug { "[bridge] Calling $method async" } + return withContext(Dispatchers.Main) { + suspendCoroutine { continuation -> + CoroutineScope(bridge.quickJsDispatcher).launch { + bridge.callAsync(method, payload) { result -> + result.fold( + onSuccess = { json -> + log.debug { "[bridge] $method succeeded (${json.take(200)})" } + continuation.resume(parseJSONDict(json)) + }, + onFailure = { error -> + log.error { "[bridge] $method failed: ${error.message}" } + continuation.resumeWithException(error) + } + ) + } + } + } + } + } + + private suspend fun bridgeCallAsyncVoid(method: String, payload: String) { + requireInitialized() + withContext(Dispatchers.Main) { + suspendCoroutine { continuation -> + CoroutineScope(bridge.quickJsDispatcher).launch { + bridge.callAsync(method, payload) { result -> + result.fold( + onSuccess = { continuation.resume(Unit) }, + onFailure = { continuation.resumeWithException(it) } + ) + } + } + } + } + } + + private fun handleStateUpdate(dict: Map) { + @Suppress("UNCHECKED_CAST") + val profile = extractJSONValue(dict["profile"]) as? Map + @Suppress("UNCHECKED_CAST") + val changes = extractJSONArray(dict["changes"]) as? List> + val consent = dict["consent"] as? Boolean + + _state.value = OptimizationState( + profile = profile, + consent = consent, + canPersonalize = dict["canPersonalize"] as? Boolean ?: false, + changes = changes, + ) + + @Suppress("UNCHECKED_CAST") + val personalizations = extractJSONArray(dict["selectedPersonalizations"]) as? List> + _selectedPersonalizations.value = personalizations + + store.profile = profile + store.consent = consent + store.changes = changes + store.personalizations = personalizations + @Suppress("UNCHECKED_CAST") + store.anonymousId = (profile?.get("id") as? String) ?: store.anonymousId + } + + companion object { + private fun extractJSONValue(value: Any?): Any? { + if (value == null || value == Unit) return null + return value + } + + private fun extractJSONArray(value: Any?): Any? { + if (value == null || value == Unit) return null + return value + } + + fun parseJSONDict(json: String): Map? { + if (json == "null") return null + return try { + ZiplineContextManager.jsonObjectToMap(JSONObject(json)) + } catch (_: Exception) { + DiagnosticLogger.warning { "[parse] JSON parse failed — input: ${json.take(200)}" } + null + } + } + } +} From 7da42baa7d4bb5561460a37e4724530dbeb321fc Mon Sep 17 00:00:00 2001 From: Alex Freas Date: Wed, 6 May 2026 18:47:40 +0200 Subject: [PATCH 08/70] Update Android SDK AGENTS.md and README to reflect implemented package structure --- packages/android/AGENTS.md | 30 ++++++++++++++++++++---- packages/android/README.md | 47 +++++++++++++++++++++++++++++++------- 2 files changed, 64 insertions(+), 13 deletions(-) diff --git a/packages/android/AGENTS.md b/packages/android/AGENTS.md index d7805001..04f4df2a 100644 --- a/packages/android/AGENTS.md +++ b/packages/android/AGENTS.md @@ -4,11 +4,31 @@ Read the repository root `AGENTS.md`, then `packages/AGENTS.md`, before this fil ## Scope -This directory contains documentation-only placeholder material for planned Android SDK work. +This directory owns native Android package work, including the Kotlin Android library module under +`ContentfulOptimization/` and the Zipline (QuickJS) bridge package under `android-zipline-bridge/`. + +## Key paths + +- `ContentfulOptimization/` — Android library module (AAR), public Kotlin API, native runtime, + assets, and tests +- `android-zipline-bridge/` — TypeScript bridge compiled to a QuickJS-compatible UMD bundle +- `README.md` — package status and public-facing notes ## Local rules -- Keep the README aligned with current repository reality. -- Do not invent package scripts, build outputs, or implementation details that do not exist yet. -- If the task is to scaffold a real Android package, add the necessary workspace files and replace - this placeholder guidance in the same change. +- Keep Kotlin bridge calls, JSON payload shapes, and callback behavior aligned with + `android-zipline-bridge/src/index.ts`. +- Keep the bridge bundle flow one-way: edit TypeScript bridge source, build the bridge package, and + let its build copy the generated UMD into Android assets. +- Do not hand-edit `ContentfulOptimization/src/main/assets/optimization-android-bridge.umd.js`. +- Native Kotlin SDK behavior belongs in `ContentfulOptimization/`. Shared optimization logic belongs + in `packages/universal/core-sdk`. +- Keep Android preview-panel behavior aligned with iOS and React Native preview-panel behavior when + changing shared preview contracts. + +## Cross-boundary validation + +- Use the nearest child `AGENTS.md` for bridge or Kotlin module commands. +- Rebuild the bridge before relying on Kotlin or Android test results when bridge source changed. +- Keep the bridge source (`android-zipline-bridge/src/index.ts`) in sync with + `packages/ios/ios-jsc-bridge/src/index.ts`. diff --git a/packages/android/README.md b/packages/android/README.md index af72d4dc..adbede54 100644 --- a/packages/android/README.md +++ b/packages/android/README.md @@ -1,16 +1,47 @@ # Optimization Android SDK -Android SDK support is planned for a later pre-release phase and is not implemented in this -repository. +Native Android (Kotlin) SDK for the Contentful Optimization SDK Suite. Uses a hybrid +native-JavaScript architecture where Kotlin owns UI, persistence, and lifecycle while a shared +JavaScript core (via Zipline/QuickJS) handles personalization logic, audience qualification, event +batching, and preview overrides. ## Current status -- Kotlin and Java Android SDK layers are planned. -- No Android platform package is published from this monorepo yet. -- Current mobile support is available through the JavaScript React Native SDK: - [@contentful/optimization-react-native](../react-native-sdk/README.md). +> [!CAUTION] Pre-release. API surface is not yet stable. + +- Kotlin Android library module under `ContentfulOptimization/` +- Zipline (QuickJS) JavaScript engine integration +- Shared TypeScript bridge under `android-zipline-bridge/` +- Phases 1-2 (core bridge + public API + infrastructure) are implemented +- Phases 3-5 (Compose UI layer, preview panel, polish + distribution) are planned + +## Architecture + +The SDK mirrors the iOS SDK architecture: + +- **Zipline (QuickJS)** replaces JavaScriptCore as the JavaScript engine +- **`ZiplineContextManager`** manages the JS runtime on a dedicated single-thread dispatcher +- **`NativePolyfills`** provides native Kotlin implementations for fetch, timers, crypto, console, + and URL — the same polyfill JS scripts are shared with iOS +- **`OptimizationClient`** exposes reactive state via `StateFlow` and async operations via `suspend` + functions +- **`SharedPreferencesStore`** persists SDK state across app launches + +## Key differences from iOS + +| Aspect | iOS | Android | +| -------------- | ---------------------- | ---------------------------------- | +| JS engine | JavaScriptCore | Zipline (QuickJS) | +| Threading | Main thread | Dedicated single-thread dispatcher | +| Reactive state | `@Published` / Combine | `StateFlow` / `SharedFlow` | +| Async | `async`/`await` | `suspend` functions | +| Persistence | `UserDefaults` | `SharedPreferences` | +| Lifecycle | `NotificationCenter` | `ProcessLifecycleOwner` | +| Network | `NWPathMonitor` | `ConnectivityManager` | +| HTTP | `URLSession` | `OkHttp` | ## When to use this directory -Use this README as a status marker only. Do not expect install commands, package exports, or public -SDK setup instructions here until a native Android SDK package is implemented. +Use this SDK when building a native Android application that needs Contentful personalization and +analytics. For React Native applications, use +[@contentful/optimization-react-native](../react-native-sdk/README.md) instead. From 1adc82978bbaeb489b1d41bb66668bb54a679f61 Mon Sep 17 00:00:00 2001 From: Alex Freas Date: Thu, 7 May 2026 15:13:10 +0200 Subject: [PATCH 09/70] Port tracking layer to Kotlin (TrackingMetadata, ViewTrackingController) --- .../optimization/tracking/TrackingMetadata.kt | 12 ++ .../tracking/ViewTrackingController.kt | 172 ++++++++++++++++++ 2 files changed, 184 insertions(+) create mode 100644 packages/android/ContentfulOptimization/src/main/kotlin/com/contentful/optimization/tracking/TrackingMetadata.kt create mode 100644 packages/android/ContentfulOptimization/src/main/kotlin/com/contentful/optimization/tracking/ViewTrackingController.kt diff --git a/packages/android/ContentfulOptimization/src/main/kotlin/com/contentful/optimization/tracking/TrackingMetadata.kt b/packages/android/ContentfulOptimization/src/main/kotlin/com/contentful/optimization/tracking/TrackingMetadata.kt new file mode 100644 index 00000000..caa1ecaa --- /dev/null +++ b/packages/android/ContentfulOptimization/src/main/kotlin/com/contentful/optimization/tracking/TrackingMetadata.kt @@ -0,0 +1,12 @@ +package com.contentful.optimization.tracking + +class TrackingMetadata( + entry: Map, + personalization: Map?, +) { + @Suppress("UNCHECKED_CAST") + val componentId: String = (entry["sys"] as? Map)?.get("id") as? String ?: "" + val experienceId: String? = personalization?.get("experienceId") as? String + val variantIndex: Int = personalization?.get("variantIndex") as? Int ?: 0 + val sticky: Boolean? = personalization?.get("sticky") as? Boolean +} diff --git a/packages/android/ContentfulOptimization/src/main/kotlin/com/contentful/optimization/tracking/ViewTrackingController.kt b/packages/android/ContentfulOptimization/src/main/kotlin/com/contentful/optimization/tracking/ViewTrackingController.kt new file mode 100644 index 00000000..f39a2594 --- /dev/null +++ b/packages/android/ContentfulOptimization/src/main/kotlin/com/contentful/optimization/tracking/ViewTrackingController.kt @@ -0,0 +1,172 @@ +package com.contentful.optimization.tracking + +import androidx.lifecycle.DefaultLifecycleObserver +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.ProcessLifecycleOwner +import com.contentful.optimization.core.OptimizationClient +import com.contentful.optimization.core.TrackViewPayload +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import java.util.UUID + +class ViewTrackingController( + private val client: OptimizationClient, + entry: Map, + personalization: Map?, + private val threshold: Double = 0.8, + private val viewTimeMs: Int = 2000, + private val viewDurationUpdateIntervalMs: Int = 5000, +) : DefaultLifecycleObserver { + + var isVisible: Boolean = false + private set + + private val metadata = TrackingMetadata(entry, personalization) + private val scope = CoroutineScope(Dispatchers.Main) + + private var viewId: String? = null + private var visibleSinceMs: Long? = null + private var accumulatedMs: Double = 0.0 + private var attempts: Int = 0 + private var timerJob: Job? = null + + init { + ProcessLifecycleOwner.get().lifecycle.addObserver(this) + } + + fun updateVisibility( + elementY: Float, + elementHeight: Float, + scrollY: Float, + viewportHeight: Float, + ) { + if (elementHeight <= 0f) return + + val visibleTop = maxOf(elementY, scrollY) + val visibleBottom = minOf(elementY + elementHeight, scrollY + viewportHeight) + val visibleHeight = maxOf(0f, visibleBottom - visibleTop) + val visibilityRatio = visibleHeight / elementHeight + + val nowVisible = visibilityRatio >= threshold + + if (nowVisible && !isVisible) { + onBecameVisible() + } else if (!nowVisible && isVisible) { + onBecameInvisible() + } + } + + fun onDisappear() { + if (isVisible) { + onBecameInvisible() + } + } + + fun destroy() { + timerJob?.cancel() + timerJob = null + ProcessLifecycleOwner.get().lifecycle.removeObserver(this) + } + + override fun onStop(owner: LifecycleOwner) { + pause() + } + + override fun onStart(owner: LifecycleOwner) { + resume() + } + + private fun pause() { + pauseAccumulation() + timerJob?.cancel() + timerJob = null + if (attempts > 0) { + emitEvent() + } + isVisible = false + resetCycle() + } + + private fun resume() { + isVisible = false + } + + private fun onBecameVisible() { + isVisible = true + viewId = UUID.randomUUID().toString() + visibleSinceMs = System.currentTimeMillis() + accumulatedMs = 0.0 + attempts = 0 + scheduleNextFire() + } + + private fun onBecameInvisible() { + isVisible = false + timerJob?.cancel() + timerJob = null + flushAccumulatedTime() + if (attempts > 0) { + emitEvent() + } + resetCycle() + } + + private fun flushAccumulatedTime() { + val since = visibleSinceMs ?: return + accumulatedMs += (System.currentTimeMillis() - since).toDouble() + visibleSinceMs = System.currentTimeMillis() + } + + private fun pauseAccumulation() { + val since = visibleSinceMs ?: return + accumulatedMs += (System.currentTimeMillis() - since).toDouble() + visibleSinceMs = null + } + + private fun scheduleNextFire() { + flushAccumulatedTime() + val requiredMs = viewTimeMs.toDouble() + attempts.toDouble() * viewDurationUpdateIntervalMs.toDouble() + val remainingMs = maxOf(0.0, requiredMs - accumulatedMs) + + timerJob?.cancel() + timerJob = scope.launch { + delay(remainingMs.toLong()) + timerFired() + } + } + + private fun timerFired() { + flushAccumulatedTime() + emitEvent() + attempts += 1 + scheduleNextFire() + } + + private fun emitEvent() { + val currentViewId = viewId ?: return + val payload = TrackViewPayload( + componentId = metadata.componentId, + viewId = currentViewId, + experienceId = metadata.experienceId, + variantIndex = metadata.variantIndex, + viewDurationMs = accumulatedMs.toInt(), + sticky = metadata.sticky, + ) + scope.launch { + try { + client.trackView(payload) + } catch (_: Exception) { + } + } + } + + private fun resetCycle() { + viewId = null + visibleSinceMs = null + accumulatedMs = 0.0 + attempts = 0 + } +} From 00655492a95cd5a49271c3f9ad32b594f11feaed Mon Sep 17 00:00:00 2001 From: Alex Freas Date: Thu, 7 May 2026 15:13:19 +0200 Subject: [PATCH 10/70] Port Compose UI layer (OptimizationRoot, OptimizedEntry, LazyColumn, screen/view/click tracking) --- .../ContentfulOptimization/build.gradle.kts | 11 ++ .../consumer-proguard-rules.pro | 2 + .../compose/ClickTrackingModifier.kt | 38 ++++++ .../optimization/compose/CompositionLocals.kt | 24 ++++ .../compose/OptimizationLazyColumn.kt | 47 ++++++++ .../optimization/compose/OptimizationRoot.kt | 50 ++++++++ .../optimization/compose/OptimizedEntry.kt | 111 ++++++++++++++++++ .../compose/ScreenTrackingEffect.kt | 15 +++ .../compose/ViewTrackingLayout.kt | 68 +++++++++++ 9 files changed, 366 insertions(+) create mode 100644 packages/android/ContentfulOptimization/src/main/kotlin/com/contentful/optimization/compose/ClickTrackingModifier.kt create mode 100644 packages/android/ContentfulOptimization/src/main/kotlin/com/contentful/optimization/compose/CompositionLocals.kt create mode 100644 packages/android/ContentfulOptimization/src/main/kotlin/com/contentful/optimization/compose/OptimizationLazyColumn.kt create mode 100644 packages/android/ContentfulOptimization/src/main/kotlin/com/contentful/optimization/compose/OptimizationRoot.kt create mode 100644 packages/android/ContentfulOptimization/src/main/kotlin/com/contentful/optimization/compose/OptimizedEntry.kt create mode 100644 packages/android/ContentfulOptimization/src/main/kotlin/com/contentful/optimization/compose/ScreenTrackingEffect.kt create mode 100644 packages/android/ContentfulOptimization/src/main/kotlin/com/contentful/optimization/compose/ViewTrackingLayout.kt diff --git a/packages/android/ContentfulOptimization/build.gradle.kts b/packages/android/ContentfulOptimization/build.gradle.kts index 6cb29b1a..25bdb678 100644 --- a/packages/android/ContentfulOptimization/build.gradle.kts +++ b/packages/android/ContentfulOptimization/build.gradle.kts @@ -1,6 +1,7 @@ plugins { id("com.android.library") id("org.jetbrains.kotlin.android") + id("org.jetbrains.kotlin.plugin.compose") } android { @@ -27,6 +28,10 @@ android { kotlinOptions { jvmTarget = "11" } + + buildFeatures { + compose = true + } } dependencies { @@ -36,6 +41,12 @@ dependencies { implementation("com.squareup.okhttp3:okhttp:4.12.0") implementation("org.json:json:20240303") + 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") + testImplementation("junit:junit:4.13.2") testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.8.1") } diff --git a/packages/android/ContentfulOptimization/consumer-proguard-rules.pro b/packages/android/ContentfulOptimization/consumer-proguard-rules.pro index 4f086286..9ce8be96 100644 --- a/packages/android/ContentfulOptimization/consumer-proguard-rules.pro +++ b/packages/android/ContentfulOptimization/consumer-proguard-rules.pro @@ -1,4 +1,6 @@ -keep class com.contentful.optimization.core.** { *; } +-keep class com.contentful.optimization.compose.** { *; } +-keep class com.contentful.optimization.preview.** { *; } -keep class com.contentful.optimization.bridge.Native { *; } -keep class com.contentful.optimization.bridge.NativeImpl { *; } # Zipline ships its own consumer ProGuard rules; this line is defensive. diff --git a/packages/android/ContentfulOptimization/src/main/kotlin/com/contentful/optimization/compose/ClickTrackingModifier.kt b/packages/android/ContentfulOptimization/src/main/kotlin/com/contentful/optimization/compose/ClickTrackingModifier.kt new file mode 100644 index 00000000..acc54971 --- /dev/null +++ b/packages/android/ContentfulOptimization/src/main/kotlin/com/contentful/optimization/compose/ClickTrackingModifier.kt @@ -0,0 +1,38 @@ +package com.contentful.optimization.compose + +import androidx.compose.foundation.clickable +import androidx.compose.runtime.Composable +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Modifier +import com.contentful.optimization.core.OptimizationClient +import com.contentful.optimization.core.TrackClickPayload +import com.contentful.optimization.tracking.TrackingMetadata +import kotlinx.coroutines.launch + +@Composable +fun Modifier.trackClicks( + entry: Map, + personalization: Map?, + enabled: Boolean, + client: OptimizationClient, + onTap: ((Map) -> Unit)? = null, +): Modifier { + if (!enabled) return this + + val scope = rememberCoroutineScope() + return this.clickable { + val metadata = TrackingMetadata(entry, personalization) + val payload = TrackClickPayload( + componentId = metadata.componentId, + experienceId = metadata.experienceId, + variantIndex = metadata.variantIndex, + ) + scope.launch { + try { + client.trackClick(payload) + } catch (_: Exception) { + } + } + onTap?.invoke(entry) + } +} diff --git a/packages/android/ContentfulOptimization/src/main/kotlin/com/contentful/optimization/compose/CompositionLocals.kt b/packages/android/ContentfulOptimization/src/main/kotlin/com/contentful/optimization/compose/CompositionLocals.kt new file mode 100644 index 00000000..0e759efd --- /dev/null +++ b/packages/android/ContentfulOptimization/src/main/kotlin/com/contentful/optimization/compose/CompositionLocals.kt @@ -0,0 +1,24 @@ +package com.contentful.optimization.compose + +import androidx.compose.runtime.compositionLocalOf +import androidx.compose.runtime.staticCompositionLocalOf +import com.contentful.optimization.core.OptimizationClient + +val LocalOptimizationClient = staticCompositionLocalOf { + error("No OptimizationClient provided. Wrap your content in OptimizationRoot.") +} + +data class TrackingConfig( + val trackViews: Boolean = true, + val trackTaps: Boolean = false, + val liveUpdates: Boolean = false, +) + +val LocalTrackingConfig = compositionLocalOf { TrackingConfig() } + +data class ScrollContext( + val scrollY: Float = 0f, + val viewportHeight: Float = 0f, +) + +val LocalScrollContext = compositionLocalOf { null } diff --git a/packages/android/ContentfulOptimization/src/main/kotlin/com/contentful/optimization/compose/OptimizationLazyColumn.kt b/packages/android/ContentfulOptimization/src/main/kotlin/com/contentful/optimization/compose/OptimizationLazyColumn.kt new file mode 100644 index 00000000..de7fe1a9 --- /dev/null +++ b/packages/android/ContentfulOptimization/src/main/kotlin/com/contentful/optimization/compose/OptimizationLazyColumn.kt @@ -0,0 +1,47 @@ +package com.contentful.optimization.compose + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.unit.dp + +@Composable +fun OptimizationLazyColumn( + modifier: Modifier = Modifier, + state: LazyListState = rememberLazyListState(), + contentPadding: PaddingValues = PaddingValues(0.dp), + verticalArrangement: Arrangement.Vertical = Arrangement.Top, + horizontalAlignment: Alignment.Horizontal = Alignment.Start, + content: LazyListScope.() -> Unit, +) { + var viewportHeight by remember { mutableStateOf(0f) } + + val scrollContext = remember(viewportHeight) { + ScrollContext(scrollY = 0f, viewportHeight = viewportHeight) + } + + CompositionLocalProvider(LocalScrollContext provides scrollContext) { + LazyColumn( + modifier = modifier.onGloballyPositioned { coordinates -> + viewportHeight = coordinates.size.height.toFloat() + }, + state = state, + contentPadding = contentPadding, + verticalArrangement = verticalArrangement, + horizontalAlignment = horizontalAlignment, + content = content, + ) + } +} diff --git a/packages/android/ContentfulOptimization/src/main/kotlin/com/contentful/optimization/compose/OptimizationRoot.kt b/packages/android/ContentfulOptimization/src/main/kotlin/com/contentful/optimization/compose/OptimizationRoot.kt new file mode 100644 index 00000000..0ffeb320 --- /dev/null +++ b/packages/android/ContentfulOptimization/src/main/kotlin/com/contentful/optimization/compose/OptimizationRoot.kt @@ -0,0 +1,50 @@ +package com.contentful.optimization.compose + +import androidx.compose.foundation.layout.Box +import androidx.compose.material3.CircularProgressIndicator +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 +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import com.contentful.optimization.core.OptimizationClient +import com.contentful.optimization.core.OptimizationConfig + +@Composable +fun OptimizationRoot( + config: OptimizationConfig, + trackViews: Boolean = true, + trackTaps: Boolean = false, + liveUpdates: Boolean = false, + modifier: Modifier = Modifier, + content: @Composable () -> Unit, +) { + val context = LocalContext.current + val client = remember { OptimizationClient(context.applicationContext) } + val isInitialized by client.isInitialized.collectAsState() + + LaunchedEffect(Unit) { + client.initialize(config) + } + + CompositionLocalProvider( + LocalOptimizationClient provides client, + LocalTrackingConfig provides TrackingConfig( + trackViews = trackViews, + trackTaps = trackTaps, + liveUpdates = liveUpdates, + ), + ) { + if (isInitialized) { + content() + } else { + Box(modifier = modifier, contentAlignment = Alignment.Center) { + CircularProgressIndicator() + } + } + } +} diff --git a/packages/android/ContentfulOptimization/src/main/kotlin/com/contentful/optimization/compose/OptimizedEntry.kt b/packages/android/ContentfulOptimization/src/main/kotlin/com/contentful/optimization/compose/OptimizedEntry.kt new file mode 100644 index 00000000..e6dcd0bc --- /dev/null +++ b/packages/android/ContentfulOptimization/src/main/kotlin/com/contentful/optimization/compose/OptimizedEntry.kt @@ -0,0 +1,111 @@ +package com.contentful.optimization.compose + +import androidx.compose.foundation.layout.Box +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.produceState +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import com.contentful.optimization.core.PersonalizedResult + +@Suppress("UNCHECKED_CAST") +@Composable +fun OptimizedEntry( + entry: Map, + viewTimeMs: Int = 2000, + threshold: Double = 0.8, + viewDurationUpdateIntervalMs: Int = 5000, + liveUpdates: Boolean? = null, + trackViews: Boolean? = null, + trackTaps: Boolean? = null, + accessibilityIdentifier: String? = null, + onTap: ((Map) -> Unit)? = null, + content: @Composable (Map) -> Unit, +) { + val client = LocalOptimizationClient.current + val trackingConfig = LocalTrackingConfig.current + + val selectedPersonalizations by client.selectedPersonalizations.collectAsState() + val isPreviewPanelOpen by client.isPreviewPanelOpen.collectAsState() + + var lockedPersonalizations by remember { mutableStateOf>?>(null) } + var isLocked by remember { mutableStateOf(false) } + + val isPersonalized = remember(entry) { + val fields = entry["fields"] as? Map + fields?.containsKey("nt_experiences") == true + } + + val shouldLiveUpdate = liveUpdates ?: (trackingConfig.liveUpdates || isPreviewPanelOpen) + val effectivePersonalizations = if (shouldLiveUpdate) selectedPersonalizations else lockedPersonalizations + + val viewsEnabled = trackViews ?: trackingConfig.trackViews + val tapsEnabled = when { + trackTaps == false -> false + trackTaps != null || onTap != null -> true + else -> trackingConfig.trackTaps + } + + LaunchedEffect(selectedPersonalizations) { + if (isPersonalized && !shouldLiveUpdate && !isLocked && selectedPersonalizations != null) { + lockedPersonalizations = selectedPersonalizations + isLocked = true + } + } + + LaunchedEffect(isPreviewPanelOpen) { + if (isPersonalized && !isPreviewPanelOpen && isLocked) { + lockedPersonalizations = selectedPersonalizations + } + } + + val result by produceState( + initialValue = PersonalizedResult(entry = entry, personalization = null), + key1 = entry, + key2 = effectivePersonalizations, + ) { + value = if (isPersonalized) { + client.personalizeEntry( + baseline = entry, + personalizations = effectivePersonalizations, + ) + } else { + PersonalizedResult(entry = entry, personalization = null) + } + } + + val modifier = Modifier + .trackViews( + entry = entry, + personalization = result.personalization, + threshold = threshold, + viewTimeMs = viewTimeMs, + viewDurationUpdateIntervalMs = viewDurationUpdateIntervalMs, + enabled = viewsEnabled, + client = client, + ) + .trackClicks( + entry = entry, + personalization = result.personalization, + enabled = tapsEnabled, + client = client, + onTap = onTap, + ) + .let { mod -> + if (accessibilityIdentifier != null) { + mod.semantics { contentDescription = accessibilityIdentifier } + } else { + mod + } + } + + Box(modifier = modifier) { + content(result.entry) + } +} diff --git a/packages/android/ContentfulOptimization/src/main/kotlin/com/contentful/optimization/compose/ScreenTrackingEffect.kt b/packages/android/ContentfulOptimization/src/main/kotlin/com/contentful/optimization/compose/ScreenTrackingEffect.kt new file mode 100644 index 00000000..77b6cba1 --- /dev/null +++ b/packages/android/ContentfulOptimization/src/main/kotlin/com/contentful/optimization/compose/ScreenTrackingEffect.kt @@ -0,0 +1,15 @@ +package com.contentful.optimization.compose + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect + +@Composable +fun ScreenTrackingEffect(screenName: String) { + val client = LocalOptimizationClient.current + LaunchedEffect(screenName) { + try { + client.screen(name = screenName) + } catch (_: Exception) { + } + } +} diff --git a/packages/android/ContentfulOptimization/src/main/kotlin/com/contentful/optimization/compose/ViewTrackingLayout.kt b/packages/android/ContentfulOptimization/src/main/kotlin/com/contentful/optimization/compose/ViewTrackingLayout.kt new file mode 100644 index 00000000..d4b510f8 --- /dev/null +++ b/packages/android/ContentfulOptimization/src/main/kotlin/com/contentful/optimization/compose/ViewTrackingLayout.kt @@ -0,0 +1,68 @@ +package com.contentful.optimization.compose + +import android.content.res.Resources +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.LayoutCoordinates +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.layout.positionInRoot +import com.contentful.optimization.core.OptimizationClient +import com.contentful.optimization.tracking.ViewTrackingController + +@Composable +fun Modifier.trackViews( + entry: Map, + personalization: Map?, + threshold: Double, + viewTimeMs: Int, + viewDurationUpdateIntervalMs: Int, + enabled: Boolean, + client: OptimizationClient, +): Modifier { + if (!enabled) return this + + val scrollContext = LocalScrollContext.current + val controller = remember(entry, personalization) { + ViewTrackingController( + client = client, + entry = entry, + personalization = personalization, + threshold = threshold, + viewTimeMs = viewTimeMs, + viewDurationUpdateIntervalMs = viewDurationUpdateIntervalMs, + ) + } + + DisposableEffect(controller) { + onDispose { + controller.onDisappear() + controller.destroy() + } + } + + return this.onGloballyPositioned { coordinates -> + updateControllerVisibility(controller, coordinates, scrollContext) + } +} + +private fun updateControllerVisibility( + controller: ViewTrackingController, + coordinates: LayoutCoordinates, + scrollContext: ScrollContext?, +) { + val posInRoot = coordinates.positionInRoot() + val elementY = posInRoot.y + val elementHeight = coordinates.size.height.toFloat() + + val vpHeight = scrollContext?.viewportHeight + ?: (Resources.getSystem().displayMetrics.heightPixels.toFloat()) + + controller.updateVisibility( + elementY = elementY, + elementHeight = elementHeight, + scrollY = scrollContext?.scrollY ?: 0f, + viewportHeight = vpHeight, + ) +} From 73f9b26621d119c13f68b26aef33e308498ae88a Mon Sep 17 00:00:00 2001 From: Alex Freas Date: Thu, 7 May 2026 15:19:09 +0200 Subject: [PATCH 11/70] Port preview panel to Compose (theme, components, overlay, ViewModel, Contentful client, Activity) --- .../ContentfulOptimization/build.gradle.kts | 2 + .../src/main/AndroidManifest.xml | 7 + .../optimization/preview/PreviewComponents.kt | 715 ++++++++++++++++++ .../preview/PreviewContentfulClient.kt | 147 ++++ .../preview/PreviewPanelActivity.kt | 86 +++ .../preview/PreviewPanelContent.kt | 538 +++++++++++++ .../preview/PreviewPanelOverlay.kt | 74 ++ .../optimization/preview/PreviewTheme.kt | 99 +++ .../optimization/preview/PreviewViewModel.kt | 127 ++++ 9 files changed, 1795 insertions(+) create mode 100644 packages/android/ContentfulOptimization/src/main/kotlin/com/contentful/optimization/preview/PreviewComponents.kt create mode 100644 packages/android/ContentfulOptimization/src/main/kotlin/com/contentful/optimization/preview/PreviewContentfulClient.kt create mode 100644 packages/android/ContentfulOptimization/src/main/kotlin/com/contentful/optimization/preview/PreviewPanelActivity.kt create mode 100644 packages/android/ContentfulOptimization/src/main/kotlin/com/contentful/optimization/preview/PreviewPanelContent.kt create mode 100644 packages/android/ContentfulOptimization/src/main/kotlin/com/contentful/optimization/preview/PreviewPanelOverlay.kt create mode 100644 packages/android/ContentfulOptimization/src/main/kotlin/com/contentful/optimization/preview/PreviewTheme.kt create mode 100644 packages/android/ContentfulOptimization/src/main/kotlin/com/contentful/optimization/preview/PreviewViewModel.kt diff --git a/packages/android/ContentfulOptimization/build.gradle.kts b/packages/android/ContentfulOptimization/build.gradle.kts index 25bdb678..02813155 100644 --- a/packages/android/ContentfulOptimization/build.gradle.kts +++ b/packages/android/ContentfulOptimization/build.gradle.kts @@ -45,7 +45,9 @@ dependencies { implementation("androidx.compose.ui:ui") implementation("androidx.compose.foundation:foundation") implementation("androidx.compose.material3:material3") + implementation("androidx.compose.material:material-icons-core") implementation("androidx.lifecycle:lifecycle-runtime-compose:2.8.7") + implementation("androidx.activity:activity-compose:1.9.3") testImplementation("junit:junit:4.13.2") testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.8.1") diff --git a/packages/android/ContentfulOptimization/src/main/AndroidManifest.xml b/packages/android/ContentfulOptimization/src/main/AndroidManifest.xml index 3cb3262d..08654437 100644 --- a/packages/android/ContentfulOptimization/src/main/AndroidManifest.xml +++ b/packages/android/ContentfulOptimization/src/main/AndroidManifest.xml @@ -2,4 +2,11 @@ + + + + diff --git a/packages/android/ContentfulOptimization/src/main/kotlin/com/contentful/optimization/preview/PreviewComponents.kt b/packages/android/ContentfulOptimization/src/main/kotlin/com/contentful/optimization/preview/PreviewComponents.kt new file mode 100644 index 00000000..3853502e --- /dev/null +++ b/packages/android/ContentfulOptimization/src/main/kotlin/com/contentful/optimization/preview/PreviewComponents.kt @@ -0,0 +1,715 @@ +package com.contentful.optimization.preview + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.expandVertically +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.shrinkVertically +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.shadow +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import com.contentful.optimization.core.AudienceWithExperiencesDTO +import com.contentful.optimization.core.ExperienceDefinitionDTO +import com.contentful.optimization.core.VariantDistributionDTO + +// MARK: - Badge + +enum class BadgeVariant { + API, OVERRIDE, MANUAL, INFO, EXPERIMENT, PERSONALIZATION, QUALIFIED, PRIMARY; + + val backgroundColor + @Composable get() = when (this) { + API -> PreviewTheme.Colors.Badge.api + OVERRIDE -> PreviewTheme.Colors.Badge.override_ + MANUAL -> PreviewTheme.Colors.Badge.manual + INFO -> PreviewTheme.Colors.Background.tertiary + EXPERIMENT -> PreviewTheme.Colors.Badge.experiment + PERSONALIZATION -> PreviewTheme.Colors.Badge.personalization + QUALIFIED -> PreviewTheme.Colors.Status.qualified + PRIMARY -> PreviewTheme.Colors.CP.normal + } + + val textColor + @Composable get() = when (this) { + INFO -> PreviewTheme.Colors.TextColor.secondary + else -> PreviewTheme.Colors.TextColor.inverse + } +} + +@Composable +fun PreviewBadge(label: String, variant: BadgeVariant) { + Text( + text = label, + style = TextStyle( + fontSize = PreviewTheme.FontSize.xs, + fontWeight = FontWeight.Medium, + color = variant.textColor, + ), + modifier = Modifier + .background(variant.backgroundColor, RoundedCornerShape(PreviewTheme.Radius.sm)) + .padding(horizontal = PreviewTheme.Spacing.sm, vertical = 2.dp), + ) +} + +// MARK: - Action Button + +enum class ActionButtonVariant { + ACTIVATE, DEACTIVATE, RESET, PRIMARY, SECONDARY, DESTRUCTIVE; + + val backgroundColor + @Composable get() = when (this) { + ACTIVATE -> PreviewTheme.Colors.Action.activate + DEACTIVATE -> PreviewTheme.Colors.Action.deactivate + RESET -> PreviewTheme.Colors.Action.reset + PRIMARY -> PreviewTheme.Colors.CP.normal + SECONDARY -> PreviewTheme.Colors.Background.primary + DESTRUCTIVE -> PreviewTheme.Colors.Action.destructive + } + + val textColor + @Composable get() = when (this) { + SECONDARY -> PreviewTheme.Colors.TextColor.primary + else -> PreviewTheme.Colors.TextColor.inverse + } +} + +@Composable +fun PreviewActionButton( + label: String, + variant: ActionButtonVariant, + onClick: () -> Unit, + disabled: Boolean = false, + accessibilityID: String? = null, +) { + val modifier = Modifier + .alpha(if (disabled) PreviewTheme.Opacity.disabled else 1f) + .let { mod -> + if (variant == ActionButtonVariant.SECONDARY) { + mod + .border(1.dp, PreviewTheme.Colors.Border.secondary, RoundedCornerShape(PreviewTheme.Radius.md)) + .background(variant.backgroundColor, RoundedCornerShape(PreviewTheme.Radius.md)) + } else { + mod.background(variant.backgroundColor, RoundedCornerShape(PreviewTheme.Radius.md)) + } + } + .clickable(enabled = !disabled, onClick = onClick) + .padding(horizontal = PreviewTheme.Spacing.md, vertical = PreviewTheme.Spacing.sm) + .let { mod -> + if (accessibilityID != null) mod.semantics { contentDescription = accessibilityID } + else mod + } + + Text( + text = label, + style = TextStyle( + fontSize = PreviewTheme.FontSize.sm, + fontWeight = FontWeight.Medium, + color = variant.textColor, + ), + modifier = modifier, + ) +} + +// MARK: - Audience Toggle (Three-State) + +enum class AudienceOverrideState(val value: String) { + ON("on"), OFF("off"), DEFAULT("default"); + + companion object { + fun from(raw: String) = entries.find { it.value == raw } ?: DEFAULT + } +} + +@Composable +fun AudienceToggle( + value: AudienceOverrideState, + onValueChange: (AudienceOverrideState) -> Unit, + disabled: Boolean = false, + audienceId: String? = null, +) { + val states = listOf( + AudienceOverrideState.ON to "On", + AudienceOverrideState.DEFAULT to "Default", + AudienceOverrideState.OFF to "Off", + ) + + Row( + modifier = Modifier + .alpha(if (disabled) PreviewTheme.Opacity.disabled else 1f) + .background(PreviewTheme.Colors.Background.tertiary, RoundedCornerShape(PreviewTheme.Radius.md)) + .padding(2.dp), + ) { + states.forEach { (state, label) -> + val isSelected = value == state + val bgColor = if (isSelected) { + when (state) { + AudienceOverrideState.ON -> PreviewTheme.Colors.Action.activate + AudienceOverrideState.OFF -> PreviewTheme.Colors.Action.deactivate + AudienceOverrideState.DEFAULT -> PreviewTheme.Colors.CP.normal + } + } else { + PreviewTheme.Colors.Background.tertiary + } + val textColor = if (isSelected) PreviewTheme.Colors.TextColor.inverse else PreviewTheme.Colors.TextColor.secondary + + Text( + text = label, + style = TextStyle( + fontSize = PreviewTheme.FontSize.sm, + fontWeight = if (isSelected) FontWeight.SemiBold else FontWeight.Medium, + color = textColor, + ), + modifier = Modifier + .clickable(enabled = !disabled) { onValueChange(state) } + .background(bgColor, RoundedCornerShape(PreviewTheme.Radius.sm)) + .padding(horizontal = PreviewTheme.Spacing.md, vertical = PreviewTheme.Spacing.xs) + .let { mod -> + val aid = audienceId + if (aid != null) mod.semantics { contentDescription = "audience-toggle-$aid-${state.value}" } + else mod + }, + ) + } + } +} + +// MARK: - Search Bar + +@Composable +fun PreviewSearchBar( + text: String, + onTextChange: (String) -> Unit, + placeholder: String = "Search audiences and experiences...", +) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + .height(40.dp) + .background(PreviewTheme.Colors.Background.primary, RoundedCornerShape(PreviewTheme.Radius.md)) + .border(1.dp, PreviewTheme.Colors.Border.secondary, RoundedCornerShape(PreviewTheme.Radius.md)) + .padding(horizontal = PreviewTheme.Spacing.md), + ) { + Text( + text = "🔍", + style = TextStyle(fontSize = PreviewTheme.FontSize.sm, color = PreviewTheme.Colors.TextColor.muted), + ) + Spacer(modifier = Modifier.width(PreviewTheme.Spacing.sm)) + Box(modifier = Modifier.weight(1f)) { + if (text.isEmpty()) { + Text( + text = placeholder, + style = TextStyle(fontSize = PreviewTheme.FontSize.sm, color = PreviewTheme.Colors.TextColor.muted), + ) + } + BasicTextField( + value = text, + onValueChange = onTextChange, + textStyle = TextStyle(fontSize = PreviewTheme.FontSize.sm, color = PreviewTheme.Colors.TextColor.primary), + singleLine = true, + modifier = Modifier.fillMaxWidth(), + ) + } + if (text.isNotEmpty()) { + Text( + text = "✕", + style = TextStyle(fontSize = PreviewTheme.FontSize.sm, color = PreviewTheme.Colors.TextColor.muted), + modifier = Modifier.clickable { onTextChange("") }, + ) + } + } +} + +// MARK: - Section Card + +@Composable +fun SectionCard( + title: String, + collapsible: Boolean = false, + initiallyCollapsed: Boolean = false, + content: @Composable () -> Unit, +) { + var isCollapsed by remember { mutableStateOf(initiallyCollapsed) } + + Column( + modifier = Modifier + .fillMaxWidth() + .background(PreviewTheme.Colors.Background.primary, RoundedCornerShape(PreviewTheme.Radius.lg)) + .border(1.dp, PreviewTheme.Colors.Border.primary, RoundedCornerShape(PreviewTheme.Radius.lg)) + .padding(PreviewTheme.Spacing.md), + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + .let { if (collapsible) it.clickable { isCollapsed = !isCollapsed } else it }, + ) { + Text( + text = title, + style = TextStyle( + fontSize = PreviewTheme.FontSize.lg, + fontWeight = FontWeight.SemiBold, + color = PreviewTheme.Colors.TextColor.primary, + ), + ) + Spacer(modifier = Modifier.weight(1f)) + if (collapsible) { + Text( + text = if (isCollapsed) "▶" else "▼", + style = TextStyle( + fontSize = PreviewTheme.FontSize.lg, + fontWeight = FontWeight.Bold, + color = PreviewTheme.Colors.CP.hover, + ), + ) + } + } + + AnimatedVisibility( + visible = !isCollapsed, + enter = fadeIn() + expandVertically(), + exit = fadeOut() + shrinkVertically(), + ) { + Column(modifier = Modifier.padding(top = PreviewTheme.Spacing.sm)) { + content() + } + } + } +} + +// MARK: - Qualification Indicator + +@Composable +fun QualificationIndicator() { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(PreviewTheme.Spacing.xs), + ) { + Box( + modifier = Modifier + .size(8.dp) + .background(PreviewTheme.Colors.Action.activate, CircleShape), + ) + Text( + text = "Qualified", + style = TextStyle( + fontSize = PreviewTheme.FontSize.xs, + fontWeight = FontWeight.Medium, + color = PreviewTheme.Colors.Action.activate, + ), + ) + } +} + +// MARK: - JSON Viewer + +@Composable +fun PreviewJsonViewer(data: String, title: String = "JSON Data") { + var isExpanded by remember { mutableStateOf(false) } + val previewText = if (isExpanded) data else { + val lines = data.split("\n") + if (lines.size > 3) lines.take(3).joinToString("\n") + "\n ..." else data + } + + Column(verticalArrangement = Arrangement.spacedBy(PreviewTheme.Spacing.sm)) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth().clickable { isExpanded = !isExpanded }, + ) { + Text( + text = title, + style = TextStyle( + fontSize = PreviewTheme.FontSize.sm, + fontWeight = FontWeight.SemiBold, + color = PreviewTheme.Colors.TextColor.primary, + ), + ) + Spacer(modifier = Modifier.weight(1f)) + Text( + text = if (isExpanded) "▼" else "▶", + style = TextStyle( + fontSize = PreviewTheme.FontSize.lg, + fontWeight = FontWeight.Bold, + color = PreviewTheme.Colors.CP.hover, + ), + ) + } + + Text( + text = previewText, + style = TextStyle( + fontSize = PreviewTheme.FontSize.xs, + fontFamily = FontFamily.Monospace, + color = PreviewTheme.Colors.TextColor.secondary, + ), + modifier = Modifier + .fillMaxWidth() + .background(PreviewTheme.Colors.Background.tertiary, RoundedCornerShape(PreviewTheme.Radius.sm)) + .padding(PreviewTheme.Spacing.sm), + ) + + if (isExpanded) { + Text( + text = "Close", + style = TextStyle( + fontSize = PreviewTheme.FontSize.md, + fontWeight = FontWeight.SemiBold, + color = PreviewTheme.Colors.CP.hover, + ), + modifier = Modifier + .align(Alignment.CenterHorizontally) + .clickable { isExpanded = false } + .background(PreviewTheme.Colors.Background.tertiary, RoundedCornerShape(PreviewTheme.Radius.sm)) + .padding(vertical = PreviewTheme.Spacing.sm, horizontal = PreviewTheme.Spacing.lg), + ) + } + } +} + +// MARK: - List Item Row + +@Composable +fun ListItemRow( + label: String, + value: String? = null, + subtitle: String? = null, + badge: Pair? = null, + action: Triple Unit>? = null, + actionAccessibilityID: String? = null, +) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(PreviewTheme.Spacing.md), + modifier = Modifier.fillMaxWidth().padding(vertical = PreviewTheme.Spacing.sm), + ) { + Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(PreviewTheme.Spacing.xs)) { + Row(horizontalArrangement = Arrangement.spacedBy(PreviewTheme.Spacing.sm)) { + Text( + text = label, + style = TextStyle( + fontSize = PreviewTheme.FontSize.sm, + fontWeight = FontWeight.Medium, + color = PreviewTheme.Colors.TextColor.primary, + ), + ) + if (badge != null) { + PreviewBadge(label = badge.first, variant = badge.second) + } + } + if (value != null) { + Text( + text = value, + style = TextStyle(fontSize = PreviewTheme.FontSize.xs, color = PreviewTheme.Colors.TextColor.secondary), + maxLines = 2, + ) + } + if (subtitle != null) { + Text( + text = subtitle, + style = TextStyle( + fontSize = PreviewTheme.FontSize.xs, + fontFamily = FontFamily.Monospace, + color = PreviewTheme.Colors.TextColor.muted, + ), + ) + } + } + + if (action != null) { + PreviewActionButton( + label = action.first, + variant = action.second, + onClick = action.third, + accessibilityID = actionAccessibilityID, + ) + } + } +} + +// MARK: - Collapse Toggle Button + +@Composable +fun CollapseToggleButton(allExpanded: Boolean, onToggle: () -> Unit) { + Text( + text = if (allExpanded) "Collapse all" else "Expand all", + style = TextStyle( + fontSize = PreviewTheme.FontSize.sm, + fontWeight = FontWeight.Medium, + color = PreviewTheme.Colors.CP.normal, + ), + modifier = Modifier.clickable(onClick = onToggle), + ) +} + +// MARK: - Variant Selector + +@Composable +fun VariantSelector( + experience: ExperienceDefinitionDTO, + isAudienceActive: Boolean, + onSelectVariant: (Int) -> Unit, +) { + val isExperiment = experience.type == "nt_experiment" + val variants = if (experience.distribution.isNotEmpty()) { + experience.distribution + } else { + val count = maxOf(experience.currentVariantIndex + 1, 2) + (0 until count).map { VariantDistributionDTO(index = it, variantRef = "", percentage = null, name = null) } + } + + Column(verticalArrangement = Arrangement.spacedBy(PreviewTheme.Spacing.sm)) { + variants.forEach { variant -> + val isSelected = experience.currentVariantIndex == variant.index + val borderColor = if (isSelected) PreviewTheme.Colors.CP.normal else PreviewTheme.Colors.Border.primary + val borderWidth = if (isSelected) 2.dp else 1.dp + + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + .alpha(if (isAudienceActive) 1f else PreviewTheme.Opacity.muted) + .background(PreviewTheme.Colors.Background.primary, RoundedCornerShape(PreviewTheme.Radius.lg)) + .border(borderWidth, borderColor, RoundedCornerShape(PreviewTheme.Radius.lg)) + .clickable { onSelectVariant(variant.index) } + .padding(horizontal = PreviewTheme.Spacing.lg, vertical = PreviewTheme.Spacing.md) + .semantics { contentDescription = "variant-picker-${experience.id}-${variant.index}" }, + ) { + val variantLabel = if (!variant.name.isNullOrEmpty()) variant.name else { + if (variant.index == 0) "Baseline" else "Variant ${variant.index}" + } + Text( + text = variantLabel, + style = TextStyle( + fontSize = PreviewTheme.FontSize.sm, + fontWeight = FontWeight.Medium, + color = if (isAudienceActive) PreviewTheme.Colors.TextColor.primary else PreviewTheme.Colors.TextColor.muted, + ), + ) + + if (isExperiment && variant.percentage != null) { + Spacer(modifier = Modifier.width(PreviewTheme.Spacing.sm)) + Text( + text = "${variant.percentage}%", + style = TextStyle(fontSize = PreviewTheme.FontSize.sm, color = PreviewTheme.Colors.TextColor.muted), + ) + } + + if (experience.naturalVariantIndex == variant.index) { + Spacer(modifier = Modifier.width(PreviewTheme.Spacing.sm)) + QualificationIndicator() + } + + Spacer(modifier = Modifier.weight(1f)) + + // Radio button + Box( + modifier = Modifier + .size(20.dp) + .border( + 2.dp, + if (isSelected) PreviewTheme.Colors.CP.normal else PreviewTheme.Colors.Border.secondary, + CircleShape, + ), + contentAlignment = Alignment.Center, + ) { + if (isSelected) { + Box( + modifier = Modifier + .size(10.dp) + .background(PreviewTheme.Colors.CP.normal, CircleShape), + ) + } + } + } + } + } +} + +// MARK: - Experience Card + +@Composable +fun ExperienceCard( + experience: ExperienceDefinitionDTO, + isAudienceActive: Boolean, + onSelectVariant: (Int) -> Unit, +) { + val isExperiment = experience.type == "nt_experiment" + + Column( + verticalArrangement = Arrangement.spacedBy(PreviewTheme.Spacing.sm), + modifier = Modifier + .fillMaxWidth() + .shadow(1.dp, RoundedCornerShape(PreviewTheme.Radius.md)) + .background(PreviewTheme.Colors.Background.primary, RoundedCornerShape(PreviewTheme.Radius.md)) + .border(1.dp, PreviewTheme.Colors.Border.primary, RoundedCornerShape(PreviewTheme.Radius.md)) + .padding(PreviewTheme.Spacing.md), + ) { + Row(horizontalArrangement = Arrangement.spacedBy(PreviewTheme.Spacing.sm)) { + PreviewBadge( + label = if (isExperiment) "Experiment" else "Personalization", + variant = if (isExperiment) BadgeVariant.EXPERIMENT else BadgeVariant.PERSONALIZATION, + ) + if (experience.isOverridden) { + PreviewBadge(label = "Override", variant = BadgeVariant.OVERRIDE) + } + } + + Text( + text = experience.name, + style = TextStyle( + fontSize = PreviewTheme.FontSize.sm, + fontWeight = FontWeight.Medium, + color = PreviewTheme.Colors.TextColor.primary, + ), + maxLines = 2, + ) + + VariantSelector( + experience = experience, + isAudienceActive = isAudienceActive, + onSelectVariant = onSelectVariant, + ) + } +} + +// MARK: - Audience Item Header + +@Composable +fun AudienceItemHeader( + audience: AudienceWithExperiencesDTO, + isExpanded: Boolean, + onToggleExpand: () -> Unit, + onToggleOverride: (AudienceOverrideState) -> Unit, +) { + val overrideState = AudienceOverrideState.from(audience.overrideState) + + Column( + verticalArrangement = Arrangement.spacedBy(PreviewTheme.Spacing.sm), + modifier = Modifier + .padding(horizontal = PreviewTheme.Spacing.md, vertical = PreviewTheme.Spacing.sm), + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth().clickable(onClick = onToggleExpand), + ) { + Text( + text = if (isExpanded) "▼" else "▶", + style = TextStyle( + fontSize = PreviewTheme.FontSize.xl, + color = PreviewTheme.Colors.CP.hover, + ), + ) + Spacer(modifier = Modifier.width(PreviewTheme.Spacing.sm)) + Text( + text = audience.audience.name, + style = TextStyle( + fontSize = PreviewTheme.FontSize.sm, + fontWeight = FontWeight.Medium, + color = PreviewTheme.Colors.TextColor.primary, + ), + maxLines = 1, + modifier = Modifier.weight(1f, fill = false), + ) + if (audience.isQualified) { + Spacer(modifier = Modifier.width(PreviewTheme.Spacing.sm)) + QualificationIndicator() + } + Spacer(modifier = Modifier.weight(1f)) + Text( + text = "${audience.experiences.size} experience${if (audience.experiences.size == 1) "" else "s"}", + style = TextStyle(fontSize = PreviewTheme.FontSize.xs, color = PreviewTheme.Colors.TextColor.muted), + ) + } + + if (!audience.audience.description.isNullOrEmpty()) { + Text( + text = audience.audience.description, + style = TextStyle(fontSize = PreviewTheme.FontSize.xs, color = PreviewTheme.Colors.TextColor.secondary), + ) + } + + AudienceToggle( + value = overrideState, + onValueChange = onToggleOverride, + audienceId = audience.audience.id, + ) + } +} + +// MARK: - Audience Item + +@Composable +fun AudienceItem( + audience: AudienceWithExperiencesDTO, + isExpanded: Boolean, + onToggleExpand: () -> Unit, + onToggleOverride: (AudienceOverrideState) -> Unit, + onSelectVariant: (String, Int) -> Unit, +) { + Column( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(PreviewTheme.Radius.md)) + .background(PreviewTheme.Colors.Background.secondary), + ) { + AudienceItemHeader( + audience = audience, + isExpanded = isExpanded, + onToggleExpand = onToggleExpand, + onToggleOverride = onToggleOverride, + ) + + AnimatedVisibility( + visible = isExpanded, + enter = fadeIn() + expandVertically(), + exit = fadeOut() + shrinkVertically(), + ) { + Column( + verticalArrangement = Arrangement.spacedBy(PreviewTheme.Spacing.sm), + modifier = Modifier + .padding(horizontal = PreviewTheme.Spacing.md) + .padding(bottom = PreviewTheme.Spacing.md), + ) { + audience.experiences.forEach { experience -> + ExperienceCard( + experience = experience, + isAudienceActive = audience.isQualified, + onSelectVariant = { variantIndex -> + onSelectVariant(experience.id, variantIndex) + }, + ) + } + } + } + } +} diff --git a/packages/android/ContentfulOptimization/src/main/kotlin/com/contentful/optimization/preview/PreviewContentfulClient.kt b/packages/android/ContentfulOptimization/src/main/kotlin/com/contentful/optimization/preview/PreviewContentfulClient.kt new file mode 100644 index 00000000..91a325db --- /dev/null +++ b/packages/android/ContentfulOptimization/src/main/kotlin/com/contentful/optimization/preview/PreviewContentfulClient.kt @@ -0,0 +1,147 @@ +package com.contentful.optimization.preview + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.withContext +import okhttp3.OkHttpClient +import okhttp3.Request +import org.json.JSONObject +import java.io.IOException + +interface PreviewContentfulClient { + suspend fun getEntries(contentType: String, include: Int, skip: Int, limit: Int): ContentfulEntriesResult +} + +data class ContentfulEntriesResult( + val items: List>, + val total: Int, + val skip: Int, + val limit: Int, + val includes: ContentfulIncludes = ContentfulIncludes(), +) + +data class ContentfulIncludes( + val entries: List> = emptyList(), +) + +sealed class ContentfulPreviewError(message: String) : Exception(message) { + class InvalidURL : ContentfulPreviewError("Invalid Contentful API URL") + class InvalidResponse : ContentfulPreviewError("Invalid response from Contentful API") + class HttpError(val statusCode: Int) : ContentfulPreviewError("Contentful API returned HTTP $statusCode") + class InvalidJSON : ContentfulPreviewError("Failed to parse Contentful API response") +} + +class ContentfulHTTPPreviewClient( + private val spaceId: String, + private val accessToken: String, + private val environment: String = "master", + 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 = "https://cdn.contentful.com/spaces/$spaceId/environments/$environment/entries" + + "?content_type=$contentType&include=$include&skip=$skip&limit=$limit" + + val request = Request.Builder() + .url(url) + .addHeader("Authorization", "Bearer $accessToken") + .addHeader("Content-Type", "application/json") + .build() + + val response = try { + httpClient.newCall(request).execute() + } catch (e: IOException) { + throw ContentfulPreviewError.InvalidResponse() + } + + if (!response.isSuccessful) { + throw ContentfulPreviewError.HttpError(response.code) + } + + val body = response.body?.string() ?: throw ContentfulPreviewError.InvalidJSON() + val json = try { + JSONObject(body) + } catch (_: Exception) { + throw ContentfulPreviewError.InvalidJSON() + } + + val includesJSON = json.optJSONObject("includes") + val includedEntries = includesJSON?.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 + } + } +} + +private const val BATCH_SIZE = 100 + +suspend fun fetchAllEntries( + client: PreviewContentfulClient, + contentType: String, + include: Int = 10, +): ContentfulEntriesResult { + val allItems = mutableListOf>() + val allIncludes = mutableListOf>() + var skip = 0 + var total: Int + + do { + val result = client.getEntries(contentType = contentType, include = include, skip = skip, limit = BATCH_SIZE) + allItems.addAll(result.items) + allIncludes.addAll(result.includes.entries) + total = result.total + skip += result.items.size + } while (skip < total) + + return ContentfulEntriesResult( + items = allItems, + total = total, + skip = 0, + limit = allItems.size, + includes = ContentfulIncludes(entries = allIncludes), + ) +} + +suspend fun fetchAudienceAndExperienceEntries( + client: PreviewContentfulClient, +): Pair = coroutineScope { + val audiences = async { fetchAllEntries(client, "nt_audience") } + val experiences = async { fetchAllEntries(client, "nt_experience", include = 10) } + Pair(audiences.await(), experiences.await()) +} diff --git a/packages/android/ContentfulOptimization/src/main/kotlin/com/contentful/optimization/preview/PreviewPanelActivity.kt b/packages/android/ContentfulOptimization/src/main/kotlin/com/contentful/optimization/preview/PreviewPanelActivity.kt new file mode 100644 index 00000000..becbea4f --- /dev/null +++ b/packages/android/ContentfulOptimization/src/main/kotlin/com/contentful/optimization/preview/PreviewPanelActivity.kt @@ -0,0 +1,86 @@ +package com.contentful.optimization.preview + +import android.app.Activity +import android.os.Bundle +import android.view.Gravity +import android.view.ViewGroup +import android.widget.FrameLayout +import android.widget.ImageButton +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.compose.runtime.CompositionLocalProvider +import com.contentful.optimization.compose.LocalOptimizationClient +import com.contentful.optimization.core.OptimizationClient + +class PreviewPanelActivity : ComponentActivity() { + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + val client = sharedClient ?: run { finish(); return } + val contentfulClient = sharedContentfulClient + + setContent { + CompositionLocalProvider(LocalOptimizationClient provides client) { + PreviewPanelContent(contentfulClient = contentfulClient) + } + } + } + + override fun onResume() { + super.onResume() + sharedClient?.setPreviewPanelOpen(true) + } + + override fun onPause() { + super.onPause() + if (isFinishing) { + sharedClient?.setPreviewPanelOpen(false) + } + } + + companion object { + private var sharedClient: OptimizationClient? = null + private var sharedContentfulClient: PreviewContentfulClient? = null + + fun addFloatingButton( + activity: Activity, + client: OptimizationClient, + contentfulClient: PreviewContentfulClient? = null, + ): ImageButton { + sharedClient = client + sharedContentfulClient = contentfulClient + + val button = ImageButton(activity).apply { + setBackgroundColor(0xFFEADDFF.toInt()) + contentDescription = "preview-panel-fab" + setPadding(16, 16, 16, 16) + elevation = 8f + + val params = FrameLayout.LayoutParams( + dpToPx(activity, 56), + dpToPx(activity, 56), + ).apply { + gravity = Gravity.BOTTOM or Gravity.END + marginEnd = dpToPx(activity, 24) + bottomMargin = dpToPx(activity, 24) + } + layoutParams = params + + setOnClickListener { + val intent = android.content.Intent(activity, PreviewPanelActivity::class.java) + activity.startActivity(intent) + } + } + + val decorView = activity.window.decorView as? ViewGroup + decorView?.addView(button) + + return button + } + + private fun dpToPx(activity: Activity, dp: Int): Int { + return (dp * activity.resources.displayMetrics.density).toInt() + } + } +} diff --git a/packages/android/ContentfulOptimization/src/main/kotlin/com/contentful/optimization/preview/PreviewPanelContent.kt b/packages/android/ContentfulOptimization/src/main/kotlin/com/contentful/optimization/preview/PreviewPanelContent.kt new file mode 100644 index 00000000..beebd0ae --- /dev/null +++ b/packages/android/ContentfulOptimization/src/main/kotlin/com/contentful/optimization/preview/PreviewPanelContent.kt @@ -0,0 +1,538 @@ +package com.contentful.optimization.preview + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +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 kotlinx.coroutines.launch +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import com.contentful.optimization.compose.LocalOptimizationClient +import com.contentful.optimization.core.JSONValue +import org.json.JSONObject + +@Composable +fun PreviewPanelContent( + contentfulClient: PreviewContentfulClient? = null, +) { + val client = LocalOptimizationClient.current + val context = LocalContext.current + val viewModel = remember { + PreviewViewModel( + client = client, + contentfulClient = contentfulClient, + applicationContext = context.applicationContext, + ) + } + + LaunchedEffect(Unit) { + client.refreshPreviewState() + viewModel.loadDefinitions() + } + + PreviewPanelMain(viewModel = viewModel) +} + +@Composable +private fun PreviewPanelMain(viewModel: PreviewViewModel) { + val client = viewModel.client + val previewState by client.previewState.collectAsState() + val searchQuery by viewModel.searchQuery.collectAsState() + val expandedAudiences by viewModel.expandedAudiences.collectAsState() + val isLoadingDefinitions by viewModel.isLoadingDefinitions.collectAsState() + val definitionsError by viewModel.definitionsError.collectAsState() + + val previewModel = previewState?.previewModel + val audienceOverrides = previewState?.audienceOverrides ?: emptyMap() + val variantOverrides = previewState?.variantOverrides ?: emptyMap() + val audienceNameMap = previewModel?.audienceNameMap ?: emptyMap() + val experienceNameMap = previewModel?.experienceNameMap ?: emptyMap() + val hasOverrides = audienceOverrides.isNotEmpty() || variantOverrides.isNotEmpty() + val filteredAudiences = viewModel.filteredAudiences(previewModel) + + var showResetAlert by remember { mutableStateOf(false) } + + Column( + modifier = Modifier + .fillMaxSize() + .background(PreviewTheme.Colors.Background.secondary), + ) { + // Header + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = PreviewTheme.Spacing.lg, vertical = PreviewTheme.Spacing.md), + ) { + Text( + text = "Preview Panel", + style = TextStyle( + fontSize = PreviewTheme.FontSize.lg, + fontWeight = FontWeight.SemiBold, + color = PreviewTheme.Colors.TextColor.primary, + ), + ) + Spacer(modifier = Modifier.weight(1f)) + ConsentBadge(previewState?.consent) + } + + // Search bar + if (previewModel?.audiencesWithExperiences?.isNotEmpty() == true) { + Box( + modifier = Modifier + .padding(horizontal = PreviewTheme.Spacing.lg) + .padding(bottom = PreviewTheme.Spacing.md), + ) { + PreviewSearchBar( + text = searchQuery, + onTextChange = { viewModel.setSearchQuery(it) }, + ) + } + } + + // Scrollable content + Column( + verticalArrangement = Arrangement.spacedBy(PreviewTheme.Spacing.lg), + modifier = Modifier + .weight(1f) + .verticalScroll(rememberScrollState()) + .padding(horizontal = PreviewTheme.Spacing.lg) + .padding(bottom = PreviewTheme.Spacing.lg) + .semantics { contentDescription = "preview-panel-list" }, + ) { + if (isLoadingDefinitions) { + Row( + horizontalArrangement = Arrangement.spacedBy(PreviewTheme.Spacing.sm), + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(PreviewTheme.Spacing.md), + ) { + CircularProgressIndicator() + Text( + text = "Loading definitions...", + style = TextStyle(fontSize = PreviewTheme.FontSize.sm, color = PreviewTheme.Colors.TextColor.muted), + ) + } + } + + if (definitionsError != null) { + Row( + horizontalArrangement = Arrangement.spacedBy(PreviewTheme.Spacing.sm), + modifier = Modifier + .fillMaxWidth() + .background(PreviewTheme.Colors.Background.primary, RoundedCornerShape(PreviewTheme.Radius.md)) + .padding(PreviewTheme.Spacing.md), + ) { + Text(text = "⚠️", style = TextStyle(color = PreviewTheme.Colors.Action.reset)) + Text( + text = definitionsError ?: "", + style = TextStyle(fontSize = PreviewTheme.FontSize.xs, color = PreviewTheme.Colors.TextColor.secondary), + ) + } + } + + // Audience section + AudienceSection( + filteredAudiences = filteredAudiences, + searchQuery = searchQuery, + expandedAudiences = expandedAudiences, + viewModel = viewModel, + ) + + // Profile section + ProfileSection(previewState?.profile) + + // Debug section + DebugSection(previewState?.consent, previewState?.canPersonalize ?: false, client) + + // Overrides section + OverridesSection( + audienceOverrides = audienceOverrides, + variantOverrides = variantOverrides, + audienceNameMap = audienceNameMap, + experienceNameMap = experienceNameMap, + hasOverrides = hasOverrides, + viewModel = viewModel, + ) + } + + // Footer + PanelFooter( + hasOverrides = hasOverrides, + onResetClick = { showResetAlert = true }, + ) + } + + if (showResetAlert) { + AlertDialog( + onDismissRequest = { showResetAlert = false }, + title = { Text("Reset to Actual State") }, + text = { Text("This will clear all manual overrides and restore SDK state to values last received from the API. Continue?") }, + dismissButton = { + TextButton(onClick = { showResetAlert = false }) { Text("Cancel") } + }, + confirmButton = { + TextButton(onClick = { + viewModel.resetAllOverrides() + showResetAlert = false + }) { Text("Reset") } + }, + ) + } +} + +@Composable +private fun ConsentBadge(consent: Boolean?) { + val text = when (consent) { + true -> "Yes" + false -> "No" + null -> "—" + } + Text( + text = "Consent: $text", + style = TextStyle( + fontSize = PreviewTheme.FontSize.xs, + fontWeight = FontWeight.Medium, + color = PreviewTheme.Colors.TextColor.secondary, + ), + modifier = Modifier + .background(PreviewTheme.Colors.Background.tertiary, RoundedCornerShape(PreviewTheme.Radius.sm)) + .padding(horizontal = PreviewTheme.Spacing.md, vertical = PreviewTheme.Spacing.xs), + ) +} + +@Composable +private fun AudienceSection( + filteredAudiences: List, + searchQuery: String, + expandedAudiences: Set, + viewModel: PreviewViewModel, +) { + SectionCard(title = "Audiences & Experiences (${filteredAudiences.size})") { + if (filteredAudiences.size > 1) { + Row(modifier = Modifier.fillMaxWidth()) { + Spacer(modifier = Modifier.weight(1f)) + CollapseToggleButton( + allExpanded = viewModel.allExpanded(filteredAudiences), + onToggle = { viewModel.toggleExpandAll(filteredAudiences) }, + ) + } + } + + if (filteredAudiences.isEmpty()) { + val message = if (searchQuery.isEmpty()) "No audience data" + else "No results found for \"$searchQuery\"" + Text( + text = message, + style = TextStyle(fontSize = PreviewTheme.FontSize.sm, color = PreviewTheme.Colors.TextColor.muted), + modifier = Modifier + .fillMaxWidth() + .padding(vertical = PreviewTheme.Spacing.lg), + ) + } else { + Column(verticalArrangement = Arrangement.spacedBy(PreviewTheme.Spacing.sm)) { + filteredAudiences.forEach { audience -> + AudienceItem( + audience = audience, + isExpanded = expandedAudiences.contains(audience.audience.id), + onToggleExpand = { viewModel.toggleExpand(audience.audience.id) }, + onToggleOverride = { state -> + viewModel.setAudienceOverride( + audienceId = audience.audience.id, + state = state, + experienceIds = audience.experiences.map { it.id }, + ) + }, + onSelectVariant = { expId, variant -> + viewModel.setVariantOverride(experienceId = expId, variantIndex = variant) + }, + ) + } + } + } + } +} + +@Composable +private fun ProfileSection(profile: JSONValue?) { + SectionCard(title = "Profile", collapsible = true) { + val profileMap = profile?.let { jsonValueToMap(it) } + + if (profileMap != null) { + Column(verticalArrangement = Arrangement.spacedBy(PreviewTheme.Spacing.md)) { + profileMap.keys.sorted().forEach { key -> + Row( + modifier = Modifier + .fillMaxWidth() + .semantics { contentDescription = "profile-item-$key" }, + ) { + Text( + text = key, + style = TextStyle( + fontSize = PreviewTheme.FontSize.xs, + fontWeight = FontWeight.SemiBold, + color = PreviewTheme.Colors.TextColor.primary, + ), + ) + Spacer(modifier = Modifier.weight(1f)) + Text( + text = stringValue(profileMap[key]), + style = TextStyle(fontSize = PreviewTheme.FontSize.xs, color = PreviewTheme.Colors.TextColor.secondary), + maxLines = 2, + ) + } + } + + HorizontalDivider() + + val profileJson = try { + JSONObject(profileMap).toString(2) + } catch (_: Exception) { + "{}" + } + PreviewJsonViewer(data = profileJson, title = "Full Profile JSON") + } + } else { + Text( + text = "No profile data", + style = TextStyle(fontSize = PreviewTheme.FontSize.sm, color = PreviewTheme.Colors.TextColor.muted), + modifier = Modifier + .fillMaxWidth() + .padding(vertical = PreviewTheme.Spacing.lg) + .semantics { contentDescription = "no-profile-data" }, + ) + } + } +} + +@Composable +private fun DebugSection( + consent: Boolean?, + canPersonalize: Boolean, + client: com.contentful.optimization.core.OptimizationClient, +) { + val scope = rememberCoroutineScope() + + SectionCard(title = "Debug", collapsible = true) { + Column(verticalArrangement = Arrangement.spacedBy(PreviewTheme.Spacing.md)) { + Row( + modifier = Modifier + .fillMaxWidth() + .semantics { contentDescription = "debug-consent" }, + ) { + Text( + text = "Consent", + style = TextStyle( + fontSize = PreviewTheme.FontSize.sm, + fontWeight = FontWeight.Medium, + color = PreviewTheme.Colors.TextColor.primary, + ), + ) + Spacer(modifier = Modifier.weight(1f)) + Text( + text = when (consent) { + true -> "Accepted" + false -> "Declined" + null -> "Pending" + }, + style = TextStyle(fontSize = PreviewTheme.FontSize.sm, color = PreviewTheme.Colors.TextColor.secondary), + ) + } + + Row( + modifier = Modifier + .fillMaxWidth() + .semantics { contentDescription = "debug-can-personalize" }, + ) { + Text( + text = "Can Personalize", + style = TextStyle( + fontSize = PreviewTheme.FontSize.sm, + fontWeight = FontWeight.Medium, + color = PreviewTheme.Colors.TextColor.primary, + ), + ) + Spacer(modifier = Modifier.weight(1f)) + Text( + text = if (canPersonalize) "Yes" else "No", + style = TextStyle(fontSize = PreviewTheme.FontSize.sm, color = PreviewTheme.Colors.TextColor.secondary), + ) + } + + Text( + text = "Refresh", + style = TextStyle( + fontSize = PreviewTheme.FontSize.sm, + fontWeight = FontWeight.SemiBold, + color = PreviewTheme.Colors.TextColor.inverse, + ), + modifier = Modifier + .fillMaxWidth() + .background(PreviewTheme.Colors.CP.normal, RoundedCornerShape(PreviewTheme.Radius.md)) + .clickable { scope.launch { client.refreshPreviewState() } } + .padding(vertical = PreviewTheme.Spacing.sm) + .semantics { contentDescription = "preview-refresh-button" }, + ) + } + } +} + +@Composable +private fun OverridesSection( + audienceOverrides: Map, + variantOverrides: Map, + audienceNameMap: Map, + experienceNameMap: Map, + hasOverrides: Boolean, + viewModel: PreviewViewModel, +) { + SectionCard(title = "Overrides", collapsible = true) { + if (hasOverrides) { + Column(verticalArrangement = Arrangement.spacedBy(PreviewTheme.Spacing.md)) { + Text( + text = "${audienceOverrides.size} audience override${if (audienceOverrides.size == 1) "" else "s"}, " + + "${variantOverrides.size} optimization override${if (variantOverrides.size == 1) "" else "s"}", + style = TextStyle(fontSize = PreviewTheme.FontSize.xs, color = PreviewTheme.Colors.TextColor.secondary), + ) + + if (audienceOverrides.isNotEmpty()) { + Column(verticalArrangement = Arrangement.spacedBy(PreviewTheme.Spacing.xs)) { + Text( + text = "Audience Overrides", + style = TextStyle( + fontSize = PreviewTheme.FontSize.sm, + fontWeight = FontWeight.SemiBold, + color = PreviewTheme.Colors.TextColor.primary, + ), + ) + audienceOverrides.entries.sortedBy { audienceNameMap[it.key] ?: it.key }.forEach { (id, qualified) -> + ListItemRow( + label = audienceNameMap[id] ?: id, + value = if (qualified) "Activated" else "Deactivated", + action = Triple("Reset", ActionButtonVariant.RESET) { + viewModel.resetAudienceOverride(id) + }, + actionAccessibilityID = "reset-audience-$id", + ) + } + } + } + + if (variantOverrides.isNotEmpty()) { + Column(verticalArrangement = Arrangement.spacedBy(PreviewTheme.Spacing.xs)) { + Text( + text = "Optimization Overrides", + style = TextStyle( + fontSize = PreviewTheme.FontSize.sm, + fontWeight = FontWeight.SemiBold, + color = PreviewTheme.Colors.TextColor.primary, + ), + ) + variantOverrides.entries.sortedBy { experienceNameMap[it.key] ?: it.key }.forEach { (expId, variant) -> + ListItemRow( + label = experienceNameMap[expId] ?: expId, + value = if (variant == 0) "Baseline" else "Variant $variant", + action = Triple("Reset", ActionButtonVariant.RESET) { + viewModel.resetVariantOverride(expId) + }, + actionAccessibilityID = "reset-variant-$expId", + ) + } + } + } + } + } else { + Text( + text = "No active overrides", + style = TextStyle(fontSize = PreviewTheme.FontSize.sm, color = PreviewTheme.Colors.TextColor.muted), + modifier = Modifier + .fillMaxWidth() + .padding(vertical = PreviewTheme.Spacing.lg), + ) + } + } +} + +@Composable +private fun PanelFooter(hasOverrides: Boolean, onResetClick: () -> Unit) { + Column( + modifier = Modifier + .fillMaxWidth() + .background(PreviewTheme.Colors.Background.primary), + ) { + HorizontalDivider() + Text( + text = "Reset to Actual State", + style = TextStyle( + fontSize = PreviewTheme.FontSize.sm, + fontWeight = FontWeight.SemiBold, + color = PreviewTheme.Colors.TextColor.inverse, + ), + modifier = Modifier + .fillMaxWidth() + .padding(PreviewTheme.Spacing.lg) + .alpha(if (hasOverrides) 1f else PreviewTheme.Opacity.disabled) + .background(PreviewTheme.Colors.Action.destructive, RoundedCornerShape(PreviewTheme.Radius.md)) + .clickable(enabled = hasOverrides, onClick = onResetClick) + .padding(vertical = PreviewTheme.Spacing.md) + .semantics { contentDescription = "reset-all-overrides" }, + ) + } +} + +@Suppress("UNCHECKED_CAST") +private fun jsonValueToMap(value: JSONValue): Map? { + return when (value) { + is JSONValue.Object -> value.value.mapValues { jsonValueToAny(it.value) } + else -> null + } +} + +private fun jsonValueToAny(value: JSONValue): Any { + return when (value) { + is JSONValue.String -> value.value + is JSONValue.Number -> value.value + is JSONValue.Bool -> value.value + is JSONValue.Array -> value.value.map { jsonValueToAny(it) } + is JSONValue.Object -> value.value.mapValues { jsonValueToAny(it.value) } + JSONValue.Null -> "null" + } +} + +private fun stringValue(value: Any?): String { + if (value == null) return "nil" + return when (value) { + is String -> value + is Boolean -> if (value) "true" else "false" + is Number -> value.toString() + else -> value.toString() + } +} diff --git a/packages/android/ContentfulOptimization/src/main/kotlin/com/contentful/optimization/preview/PreviewPanelOverlay.kt b/packages/android/ContentfulOptimization/src/main/kotlin/com/contentful/optimization/preview/PreviewPanelOverlay.kt new file mode 100644 index 00000000..e3cffde9 --- /dev/null +++ b/packages/android/ContentfulOptimization/src/main/kotlin/com/contentful/optimization/preview/PreviewPanelOverlay.kt @@ -0,0 +1,74 @@ +package com.contentful.optimization.preview + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Settings +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FloatingActionButton +import androidx.compose.material3.Icon +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.rememberModalBottomSheetState +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.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.shadow +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.unit.dp +import com.contentful.optimization.compose.LocalOptimizationClient + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun PreviewPanelOverlay( + contentfulClient: PreviewContentfulClient? = null, + content: @Composable () -> Unit, +) { + val client = LocalOptimizationClient.current + var isOpen by remember { mutableStateOf(false) } + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + + LaunchedEffect(isOpen) { + client.setPreviewPanelOpen(isOpen) + } + + Box(modifier = Modifier.fillMaxSize()) { + content() + + FloatingActionButton( + onClick = { isOpen = true }, + shape = CircleShape, + containerColor = PreviewTheme.Colors.FAB.background, + modifier = Modifier + .align(Alignment.BottomEnd) + .padding(PreviewTheme.Spacing.xxl) + .size(PreviewTheme.FABSize.diameter) + .shadow(8.dp, CircleShape) + .semantics { contentDescription = "preview-panel-fab" }, + ) { + Icon( + imageVector = Icons.Default.Settings, + contentDescription = "Open preview panel", + tint = PreviewTheme.Colors.FAB.icon, + ) + } + } + + if (isOpen) { + ModalBottomSheet( + onDismissRequest = { isOpen = false }, + sheetState = sheetState, + containerColor = PreviewTheme.Colors.Background.secondary, + ) { + PreviewPanelContent(contentfulClient = contentfulClient) + } + } +} diff --git a/packages/android/ContentfulOptimization/src/main/kotlin/com/contentful/optimization/preview/PreviewTheme.kt b/packages/android/ContentfulOptimization/src/main/kotlin/com/contentful/optimization/preview/PreviewTheme.kt new file mode 100644 index 00000000..e16a1021 --- /dev/null +++ b/packages/android/ContentfulOptimization/src/main/kotlin/com/contentful/optimization/preview/PreviewTheme.kt @@ -0,0 +1,99 @@ +package com.contentful.optimization.preview + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp + +object PreviewTheme { + + object Colors { + object Background { + val primary = Color.White + val secondary = Color(0xFFF9FAFB) + val tertiary = Color(0xFFF3F4F6) + val quaternary = Color(0xFFE5E7EB) + } + + object TextColor { + val primary = Color(0xFF111827) + val secondary = Color(0xFF4B5563) + val muted = Color(0xFF9CA3AF) + val inverse = Color.White + } + + object CP { + val normal = Color(0xFF8C2EEA) + val hover = Color(0xFF7E29D3) + val active = Color(0xFF7025BB) + } + + object Action { + val activate = Color(0xFF22C55E) + val deactivate = Color(0xFFEF4444) + val reset = Color(0xFFF59E0B) + val destructive = Color(0xFFEF4444) + } + + object Badge { + val api = Color(0xFF3B82F6) + val override_ = Color(0xFFF59E0B) + val manual = Color(0xFF22C55E) + val info = Color(0xFF6B7280) + val experiment = Color(0xFF8B5CF6) + val personalization = Color(0xFF06B6D4) + } + + object Border { + val primary = Color(0xFFE5E7EB) + val secondary = Color(0xFFD1D5DB) + val focus = CP.normal + } + + object Status { + val qualified = Color(0xFF22C55E) + val active = CP.normal + val inactive = Color(0xFF9CA3AF) + } + + object FAB { + val background = Color(0xFFEADDFF) + val icon = CP.normal + } + } + + object Spacing { + val xs = 4.dp + val sm = 8.dp + val md = 12.dp + val lg = 16.dp + val xl = 20.dp + val xxl = 24.dp + val xxxl = 32.dp + } + + object Radius { + val sm = 4.dp + val md = 6.dp + val lg = 8.dp + val xl = 12.dp + } + + object FontSize { + val xs = 12.sp + val sm = 14.sp + val md = 16.sp + val lg = 18.sp + val xl = 20.sp + val xxl = 24.sp + } + + object FABSize { + val diameter = 56.dp + } + + object Opacity { + const val active = 0.7f + const val disabled = 0.5f + const val muted = 0.6f + } +} diff --git a/packages/android/ContentfulOptimization/src/main/kotlin/com/contentful/optimization/preview/PreviewViewModel.kt b/packages/android/ContentfulOptimization/src/main/kotlin/com/contentful/optimization/preview/PreviewViewModel.kt new file mode 100644 index 00000000..29c9762e --- /dev/null +++ b/packages/android/ContentfulOptimization/src/main/kotlin/com/contentful/optimization/preview/PreviewViewModel.kt @@ -0,0 +1,127 @@ +package com.contentful.optimization.preview + +import android.content.ClipData +import android.content.ClipboardManager +import android.content.Context +import com.contentful.optimization.core.AudienceWithExperiencesDTO +import com.contentful.optimization.core.OptimizationClient +import com.contentful.optimization.core.PreviewModelDTO +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow + +class PreviewViewModel( + val client: OptimizationClient, + private val contentfulClient: PreviewContentfulClient?, + private val applicationContext: Context, +) { + private val _searchQuery = MutableStateFlow("") + val searchQuery: StateFlow = _searchQuery.asStateFlow() + + private val _expandedAudiences = MutableStateFlow>(emptySet()) + val expandedAudiences: StateFlow> = _expandedAudiences.asStateFlow() + + private val _isLoadingDefinitions = MutableStateFlow(false) + val isLoadingDefinitions: StateFlow = _isLoadingDefinitions.asStateFlow() + + private val _definitionsError = MutableStateFlow(null) + val definitionsError: StateFlow = _definitionsError.asStateFlow() + + private var hasLoadedDefinitions = false + + fun setSearchQuery(query: String) { + _searchQuery.value = query + } + + suspend fun loadDefinitions() { + val contentful = contentfulClient ?: return + if (hasLoadedDefinitions) return + + _isLoadingDefinitions.value = true + _definitionsError.value = null + + try { + val (audiences, experiences) = fetchAudienceAndExperienceEntries(contentful) + + @Suppress("UNCHECKED_CAST") + val experienceEntriesWithIncludes = experiences.items.map { item -> + val copy = item.toMutableMap() + copy["includes"] = mapOf("Entry" to experiences.includes.entries) + copy + } + + client.loadDefinitions( + audiences = audiences.items, + experiences = experienceEntriesWithIncludes, + ) + + hasLoadedDefinitions = true + _isLoadingDefinitions.value = false + } catch (e: Exception) { + _definitionsError.value = e.message ?: "Unknown error" + _isLoadingDefinitions.value = false + } + } + + fun filteredAudiences(model: PreviewModelDTO?): List { + val audiences = model?.audiencesWithExperiences ?: emptyList() + val query = _searchQuery.value.lowercase() + if (query.isEmpty()) return audiences + return audiences.filter { dto -> + dto.audience.name.lowercase().contains(query) + || (dto.audience.description?.lowercase()?.contains(query) == true) + || dto.experiences.any { it.name.lowercase().contains(query) } + } + } + + fun allExpanded(audiences: List): Boolean { + return audiences.isNotEmpty() && audiences.all { _expandedAudiences.value.contains(it.audience.id) } + } + + fun toggleExpand(audienceId: String) { + val current = _expandedAudiences.value.toMutableSet() + if (current.contains(audienceId)) current.remove(audienceId) else current.add(audienceId) + _expandedAudiences.value = current + } + + fun toggleExpandAll(audiences: List) { + _expandedAudiences.value = if (allExpanded(audiences)) { + emptySet() + } else { + audiences.map { it.audience.id }.toSet() + } + } + + fun setAudienceOverride( + audienceId: String, + state: AudienceOverrideState, + experienceIds: List, + ) { + when (state) { + AudienceOverrideState.ON -> client.overrideAudience(id = audienceId, qualified = true, experienceIds = experienceIds) + AudienceOverrideState.OFF -> client.overrideAudience(id = audienceId, qualified = false, experienceIds = experienceIds) + AudienceOverrideState.DEFAULT -> client.resetAudienceOverride(id = audienceId) + } + } + + fun setVariantOverride(experienceId: String, variantIndex: Int) { + client.overrideVariant(experienceId = experienceId, variantIndex = variantIndex) + } + + fun resetAudienceOverride(audienceId: String) { + client.resetAudienceOverride(id = audienceId) + } + + fun resetVariantOverride(experienceId: String) { + client.resetVariantOverride(experienceId = experienceId) + } + + fun resetAllOverrides() { + client.resetAllOverrides() + } + + fun copyToClipboard(text: String, label: String) { + val clipboard = applicationContext.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager + clipboard.setPrimaryClip(ClipData.newPlainText(label, text)) + } +} From 88a2ee206671bb61e80c2991696f7cfa178a200e Mon Sep 17 00:00:00 2001 From: Alex Freas Date: Thu, 7 May 2026 15:23:59 +0200 Subject: [PATCH 12/70] Update Android SDK docs to reflect Compose UI layer and preview panel --- packages/android/AGENTS.md | 2 +- .../android/ContentfulOptimization/AGENTS.md | 11 +++++++ packages/android/README.md | 33 ++++++++++++------- 3 files changed, 33 insertions(+), 13 deletions(-) diff --git a/packages/android/AGENTS.md b/packages/android/AGENTS.md index 04f4df2a..33000cd8 100644 --- a/packages/android/AGENTS.md +++ b/packages/android/AGENTS.md @@ -10,7 +10,7 @@ This directory owns native Android package work, including the Kotlin Android li ## Key paths - `ContentfulOptimization/` — Android library module (AAR), public Kotlin API, native runtime, - assets, and tests + Compose UI, preview panel, assets, and tests - `android-zipline-bridge/` — TypeScript bridge compiled to a QuickJS-compatible UMD bundle - `README.md` — package status and public-facing notes diff --git a/packages/android/ContentfulOptimization/AGENTS.md b/packages/android/ContentfulOptimization/AGENTS.md index 9306585e..0009a2ef 100644 --- a/packages/android/ContentfulOptimization/AGENTS.md +++ b/packages/android/ContentfulOptimization/AGENTS.md @@ -17,6 +17,11 @@ public API surface. - `src/main/kotlin/com/contentful/optimization/polyfills/` — native polyfill implementations - `src/main/kotlin/com/contentful/optimization/storage/` — SharedPreferences persistence - `src/main/kotlin/com/contentful/optimization/handlers/` — lifecycle and network handlers +- `src/main/kotlin/com/contentful/optimization/tracking/` — view tracking state machine and metadata +- `src/main/kotlin/com/contentful/optimization/compose/` — Jetpack Compose UI layer + (OptimizationRoot, OptimizedEntry, LazyColumn tracking, screen/click/view tracking) +- `src/main/kotlin/com/contentful/optimization/preview/` — preview panel UI (theme, components, + overlay, ViewModel, Contentful client, Activity) - `src/main/assets/` — JS bridge bundle and polyfill scripts (copied from android-zipline-bridge build) @@ -30,6 +35,12 @@ public API surface. polyfill sources. - Keep bridge call signatures and JSON payload shapes aligned with `android-zipline-bridge/src/index.ts`. +- Keep Compose UI components aligned with iOS SwiftUI views when changing shared tracking or preview + contracts. +- `PreviewPanelActivity` uses static client references for View-based app integration. Keep this + pattern minimal and document the lifecycle implications. +- `ViewTrackingController` uses `positionInRoot()` coordinates. The `ScrollContext.scrollY` is + always 0 because element positions already account for scroll offset. ## Commands diff --git a/packages/android/README.md b/packages/android/README.md index adbede54..4341a296 100644 --- a/packages/android/README.md +++ b/packages/android/README.md @@ -12,8 +12,9 @@ batching, and preview overrides. - Kotlin Android library module under `ContentfulOptimization/` - Zipline (QuickJS) JavaScript engine integration - Shared TypeScript bridge under `android-zipline-bridge/` -- Phases 1-2 (core bridge + public API + infrastructure) are implemented -- Phases 3-5 (Compose UI layer, preview panel, polish + distribution) are planned +- Jetpack Compose UI layer (OptimizationRoot, OptimizedEntry, scroll/view/click tracking) +- Preview panel with audience/experience overrides, variant selection, and Contentful integration +- View-based app support via `PreviewPanelActivity` ## Architecture @@ -26,19 +27,27 @@ The SDK mirrors the iOS SDK architecture: - **`OptimizationClient`** exposes reactive state via `StateFlow` and async operations via `suspend` functions - **`SharedPreferencesStore`** persists SDK state across app launches +- **`ViewTrackingController`** implements the three-phase viewport tracking state machine +- **Compose UI layer** provides `OptimizationRoot`, `OptimizedEntry`, `OptimizationLazyColumn`, + `ScreenTrackingEffect`, and click/view tracking modifiers +- **Preview panel** provides a debug overlay with audience toggles, variant selectors, profile + inspection, and override management ## Key differences from iOS -| Aspect | iOS | Android | -| -------------- | ---------------------- | ---------------------------------- | -| JS engine | JavaScriptCore | Zipline (QuickJS) | -| Threading | Main thread | Dedicated single-thread dispatcher | -| Reactive state | `@Published` / Combine | `StateFlow` / `SharedFlow` | -| Async | `async`/`await` | `suspend` functions | -| Persistence | `UserDefaults` | `SharedPreferences` | -| Lifecycle | `NotificationCenter` | `ProcessLifecycleOwner` | -| Network | `NWPathMonitor` | `ConnectivityManager` | -| HTTP | `URLSession` | `OkHttp` | +| Aspect | iOS | Android | +| -------------- | ---------------------- | ---------------------------------------- | +| JS engine | JavaScriptCore | Zipline (QuickJS) | +| Threading | Main thread | Dedicated single-thread dispatcher | +| Reactive state | `@Published` / Combine | `StateFlow` / `SharedFlow` | +| Async | `async`/`await` | `suspend` functions | +| Persistence | `UserDefaults` | `SharedPreferences` | +| Lifecycle | `NotificationCenter` | `ProcessLifecycleOwner` | +| Network | `NWPathMonitor` | `ConnectivityManager` | +| HTTP | `URLSession` | `OkHttp` | +| UI framework | SwiftUI | Jetpack Compose | +| DI / context | `@EnvironmentObject` | `CompositionLocal` | +| Preview panel | `.sheet` + UIHosting | `ModalBottomSheet` + `ComponentActivity` | ## When to use this directory From b0551ed67f78f5ab9b6a828e4acccb31873f3a50 Mon Sep 17 00:00:00 2001 From: Alex Freas Date: Thu, 7 May 2026 17:10:59 +0200 Subject: [PATCH 13/70] Fix Android SDK compilation errors for Kotlin 2.3 compatibility --- implementations/android-sdk/.gitignore | 3 +++ .../android/ContentfulOptimization/build.gradle.kts | 11 +++++++---- .../optimization/bridge/ZiplineContextManager.kt | 3 ++- .../optimization/core/OptimizationClient.kt | 2 +- .../optimization/preview/PreviewPanelContent.kt | 6 +++--- 5 files changed, 16 insertions(+), 9 deletions(-) create mode 100644 implementations/android-sdk/.gitignore diff --git a/implementations/android-sdk/.gitignore b/implementations/android-sdk/.gitignore new file mode 100644 index 00000000..2dc68b89 --- /dev/null +++ b/implementations/android-sdk/.gitignore @@ -0,0 +1,3 @@ +.gradle/ +app/build/ +local.properties diff --git a/packages/android/ContentfulOptimization/build.gradle.kts b/packages/android/ContentfulOptimization/build.gradle.kts index 02813155..5f4cf597 100644 --- a/packages/android/ContentfulOptimization/build.gradle.kts +++ b/packages/android/ContentfulOptimization/build.gradle.kts @@ -25,17 +25,20 @@ android { targetCompatibility = JavaVersion.VERSION_11 } - kotlinOptions { - jvmTarget = "11" - } buildFeatures { compose = true } } +kotlin { + compilerOptions { + jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_11) + } +} + dependencies { - implementation("app.cash.zipline:zipline-android:1.27.0") + implementation("app.cash.quickjs:quickjs-android:0.9.2") implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.1") implementation("androidx.lifecycle:lifecycle-process:2.8.7") implementation("com.squareup.okhttp3:okhttp:4.12.0") diff --git a/packages/android/ContentfulOptimization/src/main/kotlin/com/contentful/optimization/bridge/ZiplineContextManager.kt b/packages/android/ContentfulOptimization/src/main/kotlin/com/contentful/optimization/bridge/ZiplineContextManager.kt index cfb3ed80..d33c09ba 100644 --- a/packages/android/ContentfulOptimization/src/main/kotlin/com/contentful/optimization/bridge/ZiplineContextManager.kt +++ b/packages/android/ContentfulOptimization/src/main/kotlin/com/contentful/optimization/bridge/ZiplineContextManager.kt @@ -1,11 +1,12 @@ package com.contentful.optimization.bridge import android.content.res.AssetManager -import app.cash.zipline.QuickJs +import app.cash.quickjs.QuickJs import com.contentful.optimization.core.DiagnosticLogger import com.contentful.optimization.core.OptimizationConfig import com.contentful.optimization.core.OptimizationError import com.contentful.optimization.core.PreviewState +import com.contentful.optimization.polyfills.Native import com.contentful.optimization.polyfills.NativeImpl import com.contentful.optimization.polyfills.PolyfillScriptLoader import com.contentful.optimization.polyfills.TimerStore diff --git a/packages/android/ContentfulOptimization/src/main/kotlin/com/contentful/optimization/core/OptimizationClient.kt b/packages/android/ContentfulOptimization/src/main/kotlin/com/contentful/optimization/core/OptimizationClient.kt index 4159ba3e..0062c16f 100644 --- a/packages/android/ContentfulOptimization/src/main/kotlin/com/contentful/optimization/core/OptimizationClient.kt +++ b/packages/android/ContentfulOptimization/src/main/kotlin/com/contentful/optimization/core/OptimizationClient.kt @@ -102,7 +102,7 @@ class OptimizationClient(private val applicationContext: Context) { suspend fun page(properties: Map? = null): Map? { return bridgeCallAsyncJSON("page") { - JSONObject(properties ?: emptyMap()).toString() + JSONObject(properties ?: emptyMap()).toString() } } diff --git a/packages/android/ContentfulOptimization/src/main/kotlin/com/contentful/optimization/preview/PreviewPanelContent.kt b/packages/android/ContentfulOptimization/src/main/kotlin/com/contentful/optimization/preview/PreviewPanelContent.kt index beebd0ae..d826c68f 100644 --- a/packages/android/ContentfulOptimization/src/main/kotlin/com/contentful/optimization/preview/PreviewPanelContent.kt +++ b/packages/android/ContentfulOptimization/src/main/kotlin/com/contentful/optimization/preview/PreviewPanelContent.kt @@ -511,18 +511,18 @@ private fun PanelFooter(hasOverrides: Boolean, onResetClick: () -> Unit) { @Suppress("UNCHECKED_CAST") private fun jsonValueToMap(value: JSONValue): Map? { return when (value) { - is JSONValue.Object -> value.value.mapValues { jsonValueToAny(it.value) } + is JSONValue.Obj -> value.value.mapValues { jsonValueToAny(it.value) } else -> null } } private fun jsonValueToAny(value: JSONValue): Any { return when (value) { - is JSONValue.String -> value.value + is JSONValue.Str -> value.value is JSONValue.Number -> value.value is JSONValue.Bool -> value.value is JSONValue.Array -> value.value.map { jsonValueToAny(it) } - is JSONValue.Object -> value.value.mapValues { jsonValueToAny(it.value) } + is JSONValue.Obj -> value.value.mapValues { jsonValueToAny(it.value) } JSONValue.Null -> "null" } } From ae6e24439cc9c13d44707bd117d012027d77b310 Mon Sep 17 00:00:00 2001 From: Alex Freas Date: Thu, 7 May 2026 17:11:11 +0200 Subject: [PATCH 14/70] Add Android reference implementation Gradle project scaffold --- .../android-sdk/app/build.gradle.kts | 54 ++++ .../app/src/main/AndroidManifest.xml | 18 ++ .../app/src/main/res/values/themes.xml | 4 + implementations/android-sdk/build.gradle.kts | 6 + implementations/android-sdk/gradle.properties | 3 + .../gradle/wrapper/gradle-wrapper.jar | Bin 0 -> 43583 bytes .../gradle/wrapper/gradle-wrapper.properties | 7 + implementations/android-sdk/gradlew | 252 ++++++++++++++++++ implementations/android-sdk/gradlew.bat | 94 +++++++ .../android-sdk/settings.gradle.kts | 22 ++ 10 files changed, 460 insertions(+) create mode 100644 implementations/android-sdk/app/build.gradle.kts create mode 100644 implementations/android-sdk/app/src/main/AndroidManifest.xml create mode 100644 implementations/android-sdk/app/src/main/res/values/themes.xml create mode 100644 implementations/android-sdk/build.gradle.kts create mode 100644 implementations/android-sdk/gradle.properties create mode 100644 implementations/android-sdk/gradle/wrapper/gradle-wrapper.jar create mode 100644 implementations/android-sdk/gradle/wrapper/gradle-wrapper.properties create mode 100755 implementations/android-sdk/gradlew create mode 100644 implementations/android-sdk/gradlew.bat create mode 100644 implementations/android-sdk/settings.gradle.kts diff --git a/implementations/android-sdk/app/build.gradle.kts b/implementations/android-sdk/app/build.gradle.kts new file mode 100644 index 00000000..6853a67b --- /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 = 35 + + 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/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 @@ + + +