diff --git a/.cursor/rules/development-workflow.mdc b/.cursor/rules/development-workflow.mdc index 9a999671..834af7e7 100644 --- a/.cursor/rules/development-workflow.mdc +++ b/.cursor/rules/development-workflow.mdc @@ -2,13 +2,30 @@ alwaysApply: true --- -# Development Workflow +# Development Workflow — Ketch Android SDK ## Prerequisites -- JDK 17 (JitPack also builds with JDK 17 — keep parity) -- Android SDK with an emulator or device on API 26+ (API 28+ recommended for the instrumented suite) -- `local.properties` with `sdk.dir` pointing at the Android SDK +- **Android Studio** or Android SDK with an emulator or device on API 26+ (API 28+ recommended for the instrumented suite) +- **JDK 17** (JitPack also builds with JDK 17 — keep parity) +- `local.properties` with `sdk.dir` pointing at the Android SDK; emulator/device with `adb` on `PATH` + +## Sample apps + +| Module | UI stack | Manual QA entry | +| --- | --- | --- | +| `sample-app-compose` | Jetpack Compose | `MainActivity` + `KetchSampleApp` | +| `sample-app-standard` | XML + ViewBinding | `activity_main.xml` + `MainActivity` | +| `integration-tests` | XML (Espresso host) | Automation only; layout reference for dashboard | + +Build and run a sample: + +```bash +bash .cursor/skills/ketch-android-run-sample/scripts/run-sample-app.sh compose local +bash .cursor/skills/ketch-android-run-sample/scripts/run-sample-app.sh standard local +``` + +See `ketch-android-run-sample` skill for prerequisites, remote/local SDK toggle, and manual QA checklist. ## Common Commands @@ -19,6 +36,10 @@ alwaysApply: true | `./gradlew build` | Assemble + unit-test all modules | | `./gradlew test` | Run JVM unit tests (all variants) | | `./gradlew :ketchsdk:testDebugUnitTest` | Run SDK unit tests only | +| `./gradlew :sample-app-compose:assembleDebug` | Build Compose sample | +| `./gradlew :sample-app-standard:assembleDebug` | Build standard sample | +| `bash .cursor/skills/ketch-android-run-sample/scripts/run-sample-app.sh compose local` | Build, install, launch Compose sample (local SDK) | +| `bash .cursor/skills/ketch-android-run-sample/scripts/run-sample-app.sh standard local` | Build, install, launch standard sample (local SDK) | | `./gradlew :integration-tests:connectedDebugAndroidTest` | Run the instrumented Espresso suite on a connected device/emulator | | `./gradlew ktlintCheck` | Kotlin lint check | | `./gradlew ktlintFormat` | Auto-format Kotlin sources | @@ -27,6 +48,39 @@ alwaysApply: true | `./gradlew dokkaHtmlMultiModule` | Generate API docs | | `./run-integration-tests.sh` | Clean build + full instrumented run (checks for a connected device) | +## SDK Health Dashboard (manual QA) + +Both `sample-app-compose` and `sample-app-standard` include an on-screen **SDK Health Dashboard** (pattern from `integration-tests`). + +| Section | What to verify | +| --- | --- | +| **Connection** | Init, status, `ethansch061226` / `website_smart_tag` / `production` | +| **WebView / Experience** | Load state, visibility, dismiss reason (ATT N/A on Android) | +| **Privacy / Consent** | Environment, jurisdiction, region, consent, US Privacy / TCF / GPP | +| **Headless** | **Fetch Location**, **Fetch Bootstrap**, **Cold Start** — inline OK/Error | +| **Event log** | Timestamped callback trace (~50 lines) | + +Manual golden path: **Load** → privacy fields update → **Show Consent** → visibility/dismiss rows change → **Fetch Bootstrap** shows success or error on-screen. + +Defaults: org `ethansch061226`, property `website_smart_tag`, environment `production` (constants in `MainActivity`; editable org/property UI is a future enhancement). + +## Local tag URL overrides (non-prod scripts) + +Use `webResourceUrlOverrides` to redirect exact CDN URLs (e.g. UAT `plugins.js` / `ketch.js`) to local dev servers. This is separate from `dataCenter` / `ketchUrl`, which control the boot.js base path. + +```kotlin +KetchSdk.create( + // ... + webResourceUrlOverrides = mapOf( + "https://cdn.uat.ketchjs.com/ketchtag/stable/v2.12/ketch-sdk.js" to "http://localhost:9000/ketch-sdk.js", + ), +) +// or after create: +ketch.setWebResourceUrlOverrides(overrides) +``` + +Android uses native `shouldInterceptRequest` plus a JS hook in index HTML. Sample apps: set `DevUrlOverrides.ENABLED = true` in `DevUrlOverrides.kt`. + ## Validation Checklist Run before considering any change complete: @@ -34,7 +88,8 @@ Run before considering any change complete: 1. `./gradlew :ketchsdk:assembleDebug` — verify the SDK compiles 2. `./gradlew ktlintCheck` — Kotlin style 3. `./gradlew test` — unit tests -4. `./gradlew :integration-tests:connectedDebugAndroidTest` — instrumented suite (requires a running emulator/device); run this when changing SDK runtime behavior, the lifecycle tracker, or the WebView/dialog flow +4. Build affected sample module(s) +5. `./gradlew :integration-tests:connectedDebugAndroidTest` — instrumented suite (requires a running emulator/device); run this when changing SDK runtime behavior, the lifecycle tracker, the WebView/dialog flow, or headless/listener contracts ## Testing diff --git a/.cursor/skills/ketch-android-run-sample/SKILL.md b/.cursor/skills/ketch-android-run-sample/SKILL.md new file mode 100644 index 00000000..e00c2ffe --- /dev/null +++ b/.cursor/skills/ketch-android-run-sample/SKILL.md @@ -0,0 +1,67 @@ +--- +name: ketch-android-run-sample +description: Configures the in-repo Android sample apps (Compose or standard) for either the published com.ketch.android:ketchsdk Maven artifact or the local :ketchsdk module, boots an emulator when needed, builds, installs, launches, and streams filtered logcat. Use when the user runs /ketch-android-run-sample or wants manual SDK Health Dashboard QA on Android. +--- + +# ketch-android-run-sample + +## Instructions + +When the user invokes **`/ketch-android-run-sample`** (or asks to run the Android sample with production / local SDK): + +1. `cd` to the `ketch-android` repository root. + +2. Run the helper script with a **required** sample variant: + +**Local SDK (default for SDK development)** — sample `build.gradle` uses `project(':ketchsdk')`: + +```bash +bash .cursor/skills/ketch-android-run-sample/scripts/run-sample-app.sh compose local +bash .cursor/skills/ketch-android-run-sample/scripts/run-sample-app.sh standard local +``` + +**Published Maven artifact** — sample depends on `com.ketch.android:ketchsdk` (version from `KETCH_ANDROID_SDK_VERSION` or `3.0`): + +```bash +bash .cursor/skills/ketch-android-run-sample/scripts/run-sample-app.sh compose +bash .cursor/skills/ketch-android-run-sample/scripts/run-sample-app.sh standard +``` + +The script boots an emulator when no `adb` device is connected, runs `./gradlew :sample-app-*:assembleDebug`, installs the APK, launches `MainActivity`, and streams filtered logcat (`Ketch`, sample tag). Stop with `Ctrl-C`. + +## Manual testing basics + +**Needs:** Android Studio or SDK, emulator or USB device, `adb` on PATH, network for CDN/headless steps. + +**Launch:** `bash .cursor/skills/ketch-android-run-sample/scripts/run-sample-app.sh compose local` from the `ketch-android` repo root. + +**In the app:** **SDK Health Dashboard** is the first section in the scroll view. Connection row uses org `ethansch061226`, property `website_smart_tag`, environment `production`. Use dashboard panels and event log — logcat from the script is optional for smoke QA. + +**Smoke flow:** **Load** → privacy rows update → **Show Consent** → visibility/dismiss rows change → **Fetch Bootstrap** under Headless. ATT does not apply on Android. + +## Other options + +```bash +DEVICE_ID=emulator-5554 bash .cursor/skills/ketch-android-run-sample/scripts/run-sample-app.sh compose local +AVD_NAME="Pixel_7_API_34" bash .cursor/skills/ketch-android-run-sample/scripts/run-sample-app.sh standard local +KETCH_ANDROID_SDK_VERSION=3.0 bash .cursor/skills/ketch-android-run-sample/scripts/run-sample-app.sh compose +bash .cursor/skills/ketch-android-run-sample/scripts/run-sample-app.sh compose local --build-only +bash .cursor/skills/ketch-android-run-sample/scripts/run-sample-app.sh standard local --full-system-logs +``` + +## Manual QA checklist (SDK Health Dashboard) + +Verify on-screen panels (no logcat required): + +1. **Connection** — Init/status; connection row shows `ethansch061226 / website_smart_tag / production`. +2. **Load** — Tap **Load** → Load row `loading`; environment/consent fields update from callbacks. +3. **WebView / Experience** — **Show Consent** → visibility/dismiss rows change (ATT N/A on Android). +4. **Privacy / Consent** — Environment, jurisdiction, region, consent, US Privacy / TCF / GPP populate after load. +5. **Headless** — **Fetch Location**, **Fetch Bootstrap**, **Cold Start** show inline OK/Error. +6. **Event log** — Timestamped trace (~50 lines max). + +## Notes + +- `integration-tests` is the Espresso automation host; use **compose** or **standard** samples for manual QA. +- Headless steps require network access to the live Ketch CDN. +- Remote mode needs the pinned Maven artifact to be resolvable from your configured repositories. diff --git a/.cursor/skills/ketch-android-run-sample/scripts/configure-sample-dependency.py b/.cursor/skills/ketch-android-run-sample/scripts/configure-sample-dependency.py new file mode 100755 index 00000000..1aefdccb --- /dev/null +++ b/.cursor/skills/ketch-android-run-sample/scripts/configure-sample-dependency.py @@ -0,0 +1,78 @@ +#!/usr/bin/env python3 +"""Switch sample apps between the local :ketchsdk project and a published Maven artifact.""" + +from __future__ import annotations + +import os +import re +import sys +from pathlib import Path + +GROUP = "com.ketch.android" +ARTIFACT = "ketchsdk" +DEFAULT_VERSION = "3.0" + +SAMPLE_GRADLE_FILES = ( + "sample-app-compose/build.gradle", + "sample-app-standard/build.gradle", +) + +LOCAL_DEP = "implementation project(':ketchsdk')" +REMOTE_DEP_TEMPLATE = "implementation '{group}:{artifact}:{version}'" + + +def remote_dep(version: str) -> str: + return REMOTE_DEP_TEMPLATE.format(group=GROUP, artifact=ARTIFACT, version=version) + + +def configure_file(path: Path, mode: str, version: str) -> bool: + text = path.read_text(encoding="utf-8") + target = LOCAL_DEP if mode == "local" else remote_dep(version) + pattern = re.compile( + r"implementation\s+(?:project\(':ketchsdk'\)|'" + + re.escape(GROUP) + + r":" + + re.escape(ARTIFACT) + + r":[^']+')" + ) + if not pattern.search(text): + raise SystemExit(f"Could not find ketchsdk dependency in {path}") + + new_text, count = pattern.subn(target, text, count=1) + if count != 1: + raise SystemExit(f"Expected one ketchsdk dependency in {path}, replaced {count}") + + if new_text == text: + print(f"Already {mode} in {path.name}") + return False + + path.write_text(new_text, encoding="utf-8") + print(f"Updated {path.name} to {mode} ({target})") + return True + + +def main() -> None: + if len(sys.argv) != 3: + raise SystemExit(f"Usage: {sys.argv[0]} ") + + mode = sys.argv[1] + if mode not in {"local", "remote"}: + raise SystemExit("mode must be local or remote") + + repo_root = Path(sys.argv[2]).resolve() + version = os.environ.get("KETCH_ANDROID_SDK_VERSION", DEFAULT_VERSION) + + changed = False + for rel in SAMPLE_GRADLE_FILES: + path = repo_root / rel + if not path.is_file(): + raise SystemExit(f"Missing {path}") + if configure_file(path, mode, version): + changed = True + + if not changed: + print(f"No changes needed (already {mode})") + + +if __name__ == "__main__": + main() diff --git a/.cursor/skills/ketch-android-run-sample/scripts/run-sample-app.sh b/.cursor/skills/ketch-android-run-sample/scripts/run-sample-app.sh new file mode 100755 index 00000000..bcf673c5 --- /dev/null +++ b/.cursor/skills/ketch-android-run-sample/scripts/run-sample-app.sh @@ -0,0 +1,199 @@ +#!/usr/bin/env bash +set -euo pipefail + +usage() { + cat <<'USAGE' +Usage: run-sample-app.sh [local] [--build-only] [--no-logs] [--full-system-logs] + + compose|standard Required. Which sample app module to build and launch. + (no local) Use the published com.ketch.android:ketchsdk Maven artifact. + local Use the in-repo :ketchsdk project (default for day-to-day SDK work). + +Options: + --build-only Build and install the APK; do not launch or stream logs. + --no-logs Alias for --build-only. + --full-system-logs Stream unfiltered logcat for the sample process. + +Environment: + DEVICE_ID adb device serial (default: first connected emulator/device). + AVD_NAME Emulator AVD to boot when no device is connected. + KETCH_ANDROID_SDK_VERSION + Pin remote Maven version (default: 3.0). + +Builds :sample-app-compose or :sample-app-standard, installs via adb, launches +MainActivity, and streams filtered logcat (Ketch SDK + sample tags) unless --build-only. +USAGE +} + +if [[ $# -lt 1 ]]; then + usage >&2 + exit 2 +fi + +SAMPLE_VARIANT="$1" +shift + +case "$SAMPLE_VARIANT" in + compose|standard) + GRADLE_MODULE=":sample-app-${SAMPLE_VARIANT}" + PACKAGE_ID="com.ketch.android.sample.${SAMPLE_VARIANT}" + SAMPLE_TAG="KetchCompose" + if [[ "$SAMPLE_VARIANT" == "standard" ]]; then + SAMPLE_TAG="KetchSample" + fi + ;; + -h|--help) + usage + exit 0 + ;; + *) + echo "First argument must be compose or standard, got: $SAMPLE_VARIANT" >&2 + usage >&2 + exit 2 + ;; +esac + +PACKAGE_MODE="remote" +if [[ "${1:-}" == "local" ]]; then + PACKAGE_MODE="local" + shift +fi + +build_only=0 +stream_logs=1 +full_system_logs=0 + +while [[ $# -gt 0 ]]; do + case "$1" in + --build-only|--no-logs) + build_only=1 + stream_logs=0 + shift + ;; + --full-system-logs) + full_system_logs=1 + shift + ;; + -h|--help) + usage + exit 0 + ;; + *) + echo "Unknown argument: $1" >&2 + usage >&2 + exit 2 + ;; + esac +done + +require_command() { + if ! command -v "$1" >/dev/null 2>&1; then + echo "Required command not found: $1" >&2 + exit 1 + fi +} + +require_command adb +require_command python3 + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/../../../.." && pwd)" +GRADLEW="$REPO_ROOT/gradlew" +APK_PATH="$REPO_ROOT/sample-app-${SAMPLE_VARIANT}/build/outputs/apk/debug/sample-app-${SAMPLE_VARIANT}-debug.apk" + +if [[ ! -x "$GRADLEW" ]]; then + echo "Gradle wrapper not found: $GRADLEW" >&2 + exit 1 +fi + +python3 "$SCRIPT_DIR/configure-sample-dependency.py" "$PACKAGE_MODE" "$REPO_ROOT" + +adb_device_ready() { + adb devices 2>/dev/null | awk 'NR > 1 && $2 == "device" { print $1; exit }' +} + +boot_emulator_if_needed() { + local serial + serial="$(adb_device_ready)" + if [[ -n "$serial" ]]; then + echo "$serial" + return + fi + + if ! command -v emulator >/dev/null 2>&1; then + echo "No adb device connected and emulator command not on PATH." >&2 + echo "Start an Android emulator in Android Studio, or connect a device." >&2 + exit 1 + fi + + local avd="${AVD_NAME:-}" + if [[ -z "$avd" ]]; then + avd="$(emulator -list-avds 2>/dev/null | head -n 1 || true)" + fi + if [[ -z "$avd" ]]; then + echo "No AVD found. Create an emulator in Android Studio or set AVD_NAME." >&2 + exit 1 + fi + + echo "No device connected. Booting emulator AVD: $avd" + emulator -avd "$avd" -no-snapshot-load >/dev/null 2>&1 & + adb wait-for-device + # Wait until boot completes + adb shell 'while [[ -z $(getprop sys.boot_completed) ]]; do sleep 1; done' 2>/dev/null || sleep 15 + adb_device_ready +} + +DEVICE_ID="${DEVICE_ID:-}" +if [[ -z "$DEVICE_ID" ]]; then + DEVICE_ID="$(boot_emulator_if_needed)" +fi + +echo "Using device: $DEVICE_ID" +adb -s "$DEVICE_ID" devices + +if [[ "$PACKAGE_MODE" == "local" ]]; then + echo "Building $GRADLE_MODULE using local :ketchsdk at $REPO_ROOT" +else + echo "Building $GRADLE_MODULE using Maven com.ketch.android:ketchsdk (${KETCH_ANDROID_SDK_VERSION:-3.0})" +fi + +(cd "$REPO_ROOT" && ./gradlew "${GRADLE_MODULE}:assembleDebug" --quiet) + +if [[ ! -f "$APK_PATH" ]]; then + echo "Built APK not found: $APK_PATH" >&2 + exit 1 +fi + +echo "Installing $APK_PATH" +adb -s "$DEVICE_ID" install -r "$APK_PATH" >/dev/null + +if [[ "$build_only" -eq 1 ]]; then + echo "Build/install complete (--build-only)." + exit 0 +fi + +echo "Launching $PACKAGE_ID/.MainActivity" +adb -s "$DEVICE_ID" shell am force-stop "$PACKAGE_ID" >/dev/null 2>&1 || true +adb -s "$DEVICE_ID" shell am start -n "$PACKAGE_ID/.MainActivity" >/dev/null + +log_stream_pid="" +cleanup() { + if [[ -n "$log_stream_pid" ]] && kill -0 "$log_stream_pid" >/dev/null 2>&1; then + kill "$log_stream_pid" >/dev/null 2>&1 || true + fi +} +trap cleanup EXIT INT TERM + +if [[ "$stream_logs" -eq 1 ]]; then + if [[ "$full_system_logs" -eq 1 ]]; then + echo "Streaming logcat for $PACKAGE_ID (full). Press Ctrl-C to stop." + adb -s "$DEVICE_ID" logcat --pid="$(adb -s "$DEVICE_ID" shell pidof -s "$PACKAGE_ID" 2>/dev/null || true)" & + else + echo "Streaming logcat (Ketch, $SAMPLE_TAG). Press Ctrl-C to stop." + adb -s "$DEVICE_ID" logcat -v brief Ketch:D "$SAMPLE_TAG":D '*:S' & + fi + log_stream_pid="$!" + wait "$log_stream_pid" || true +else + echo "Sample launched. Dashboard is at the top of the app scroll view." +fi diff --git a/README.md b/README.md index 5163e753..6a216378 100644 --- a/README.md +++ b/README.md @@ -210,6 +210,68 @@ In any `FragmentActivity` (for example `MainActivity`), use the shared instance: The same `ketch` instance can be used from other Activities in your app. When the user navigates away while an experience is showing, the SDK automatically dismisses it and reports `onDismiss(HideExperienceStatus.ActivityChanged)`. Rotation and backgrounding do not dismiss the experience. +## Headless API (web/v3, pre-WebView) + +Use native HTTP for cold-start flows **before** loading the WebView—location, config, and consent with `protocols` from the CDN. Contract: [mobile-headless-api.md](https://github.com/ketch-com/ketch-tag/blob/main/docs/design/mobile-headless-api.md). + +Testing: [mobile-headless-api-testing.md](https://github.com/ketch-com/ketch-tag/blob/main/docs/design/mobile-headless-api-testing.md). (App Tracking Transparency is iOS-only — not applicable on Android.) + +Pass `dataCenter` when creating the SDK (`KetchDataCenter.US`, `EU`, or `UAT`). Instance methods use the SDK's data center; static `KetchSdk` methods accept an optional `dataCenter` parameter. + +```kotlin +val ketch = KetchSdk.create( + activity = this, + fragmentManager = supportFragmentManager, + organization = ORG_CODE, + property = PROPERTY, + environment = ENVIRONMENT, + listener = listener, + dataCenter = KetchDataCenter.US, +) + +// Recommended cold-start order +ketch.fetchLocation { result -> /* jurisdiction hint */ } +ketch.fetchBootstrapConfiguration { result -> /* boot.json */ } +ketch.fetchFullConfiguration( + FullConfigurationRequest( + organizationCode = ORG_CODE, + propertyCode = PROPERTY, + environmentCode = ENVIRONMENT, + jurisdictionCode = "us-ca", + languageCode = "en-US", + hash = hashFromBootstrap, + ) +) { result -> /* full config */ } + +ketch.fetchConsent(consentConfig) { result -> + // Consent includes purposes and protocols (GPP, TCF, US Privacy, …) +} +ketch.setConsent(consentUpdate) { result -> + // Server-computed protocols in response; request omits protocols +} + +// Rights, profile, subscriptions +ketch.invokeRight(invokeRightRequest) { } +ketch.getProfile(profileRequest) { } +ketch.putProfile(putProfileRequest) { } +ketch.getSubscriptions(subscriptionsRequest) { } +ketch.setSubscriptions(subscriptionsRequest) { } + +// Subscriptions config, QR URL, telemetry +ketch.fetchSubscriptionsConfiguration(subConfigRequest) { } +val qrUrl = ketch.preferenceQRUrl( + PreferenceQRRequest( + organizationCode = ORG_CODE, + propertyCode = PROPERTY, + environmentCode = ENVIRONMENT, + imageSize = 1024, + ) +) +ketch.webReport("mychannel", reportRequest) { } +``` + +`getConsent()` on the WebView listener path is unchanged. Headless calls do not require `load()`. + ## Local Development Setup If you're developing or modifying the SDK and want to test your changes with the sample app, you can use Gradle's composite builds feature to link them together. diff --git a/build.gradle b/build.gradle index faffe678..382c7fc6 100644 --- a/build.gradle +++ b/build.gradle @@ -19,6 +19,8 @@ buildscript { 'dokka' : '1.9.20', 'junit' : '4.13.2', 'gson' : '2.13.2', + 'okhttp' : '4.12.0', + 'coroutines' : '1.10.2', 'webkit' : '1.15.0' ] repositories { diff --git a/integration-tests/README.md b/integration-tests/README.md index 32c9c370..1e410239 100644 --- a/integration-tests/README.md +++ b/integration-tests/README.md @@ -1,6 +1,8 @@ # Ketch SDK Integration Tests -This module contains integration tests for the Ketch Android SDK. The tests are designed to validate the SDK's functionality in a real Android environment. +This module contains integration tests for the Ketch Android SDK. The tests validate SDK functionality in a real Android environment. + +**ATT is N/A on Android** — App Tracking Transparency is iOS-only. For ATT testing see [mobile-att-testing.md](../../ketch-tag/docs/design/mobile-att-testing.md#ketch-android). For headless CDN tests see [mobile-headless-api-testing.md](../../ketch-tag/docs/design/mobile-headless-api-testing.md#ketch-android). ## Overview @@ -32,14 +34,31 @@ integration-tests/ - An Android device or emulator running API 28 or higher - The Ketch SDK module (`ketchsdk`) must be built successfully -### Running Tests Locally +### Headless integration tests + +Live CDN round-trip (no WebView): `KetchHeadlessIntegrationTest` uses org `ketch_samples` / property `android`. + +```bash +./gradlew :integration-tests:connectedAndroidTest \ + -Pandroid.testInstrumentationRunnerArguments.class=com.ketch.android.integration.tests.KetchHeadlessIntegrationTest +``` + +### WebView integration tests + +```bash +# From the ketch-android directory +./gradlew :integration-tests:connectedAndroidTest \ + -Pandroid.testInstrumentationRunnerArguments.class=com.ketch.android.integration.tests.KetchSdkIntegrationTest +``` + +## Running Tests Locally #### From Android Studio 1. Open the `ketch-android` project in Android Studio 2. Connect an Android device or start an emulator 3. Navigate to `integration-tests/src/androidTest/java/com/ketch/android/integration/tests/` -4. Right-click on `KetchSdkIntegrationTest` and select "Run 'KetchSdkIntegrationTest'" +4. Right-click on the desired test class and select Run #### From Command Line @@ -57,10 +76,22 @@ make test-integration-class CLASS=com.ketch.android.integration.tests.ZAutoDismi make help ``` +Or with gradlew directly: + +```bash +# All integration tests +./gradlew :integration-tests:connectedAndroidTest +``` + Reports: `integration-tests/build/reports/androidTests/connected/`. ## Test Coverage +| Suite | Class | What it validates | +| ----- | ----- | ----------------- | +| **Headless CDN** | `KetchHeadlessIntegrationTest` | `fetchLocation`, bootstrap, full config, consent get/set | +| **WebView** | `KetchSdkIntegrationTest` | SDK init, UI buttons, WebView experience load | + The current test suite covers: - **SDK Initialization**: Verifies the SDK initializes correctly @@ -77,9 +108,8 @@ The sample app includes: - Buttons to trigger all major SDK methods (`load`, `showConsent`, `showPreferences`, etc.) - **Open Second Activity** button to demonstrate cross-activity display with a shared Ketch instance -- Status display to show current SDK state +- Status display for SDK state and privacy framework values (TCF, US Privacy, GPP) - Real-time updates from SDK callbacks -- Display of privacy framework values (TCF, US Privacy, GPP) ## Configuration @@ -89,76 +119,27 @@ The sample app uses test configuration values: - **Property**: `test_property` - **Environment**: `test` -To test with real values, update the constants in `MainActivity.kt`: - -```kotlin -private const val ORG_CODE = "your_real_org_code" -private const val PROPERTY = "your_real_property" -private const val ENVIRONMENT = "production" -``` +To test with real values, update the constants in `MainActivity.kt`. ## Adding New Tests -To add new integration tests: - -1. Create test methods in `KetchSdkIntegrationTest.kt` +1. Create test methods in the appropriate `*IntegrationTest.kt` class 2. Use Espresso matchers and assertions -3. Follow the existing test patterns -4. Add any necessary helper methods - -Example test structure: - -```kotlin -@Test -fun testNewFeature() { - // Arrange - // Set up test conditions - - // Act - // Perform actions (button clicks, etc.) - - // Assert - // Verify expected outcomes -} -``` - -## Future Enhancements - -Planned improvements: - -- [ ] Tests for dialog display and interaction -- [ ] Network request mocking for controlled testing -- [ ] Performance testing -- [ ] Accessibility testing -- [ ] Tests for different Android versions and devices -- [ ] Integration with CI/CD pipelines +3. Follow existing test patterns +4. Keep headless tests in `KetchHeadlessIntegrationTest`; WebView tests in `KetchSdkIntegrationTest` ## Troubleshooting -Common issues and solutions: - -**Tests fail with "No activities found"** - -- Ensure the sample app builds successfully -- Check that the device/emulator is running - -**SDK initialization errors** - -- Verify the SDK module is included in dependencies -- Check that test configuration values are valid - -**UI tests fail intermittently** - -- Add appropriate wait conditions for async operations -- Ensure tests are running on a clean app state +| Symptom | Likely cause | +| ------- | ------------ | +| Tests fail with "No activities found" | Sample app did not build; emulator not running | +| SDK initialization errors | Invalid test configuration; missing SDK dependency | +| UI tests fail intermittently | Missing wait for async WebView load | ## Dependencies -The integration tests use: - -- **Espresso**: For UI testing -- **AndroidX Test**: For test infrastructure -- **JUnit 4**: For test framework -- **Awaitility**: For async test conditions (if needed) +- **Espresso** — UI testing +- **AndroidX Test** — test infrastructure +- **JUnit 4** — test framework -All dependencies are automatically managed through the `build.gradle` file. +All dependencies are managed through `build.gradle`. diff --git a/integration-tests/src/androidTest/java/com/ketch/android/integration/tests/HeadlessIntegrationSupport.kt b/integration-tests/src/androidTest/java/com/ketch/android/integration/tests/HeadlessIntegrationSupport.kt new file mode 100644 index 00000000..d8045f82 --- /dev/null +++ b/integration-tests/src/androidTest/java/com/ketch/android/integration/tests/HeadlessIntegrationSupport.kt @@ -0,0 +1,68 @@ +package com.ketch.android.integration.tests + +import com.ketch.android.api.KetchDataCenter +import com.ketch.android.data.ConsentConfig +import com.ketch.android.data.HeadlessConfiguration +import java.util.UUID +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit +import org.junit.Assert.assertTrue + +object HeadlessIntegrationSupport { + const val ORG_CODE = "ketch_samples" + const val PROPERTY = "android" + const val ENVIRONMENT = "production" + const val TIMEOUT_SECONDS = 45L + + fun uniqueEmailIdentity(): Map = + mapOf("email" to "headless-${UUID.randomUUID()}@integration.ketch.test") + + fun awaitHeadless(block: (callback: (Result) -> Unit) -> Unit): T { + val latch = CountDownLatch(1) + var result: Result? = null + block { value -> + result = value + latch.countDown() + } + assertTrue( + "Headless call timed out after ${TIMEOUT_SECONDS}s", + latch.await(TIMEOUT_SECONDS, TimeUnit.SECONDS), + ) + return result!!.getOrElse { throw AssertionError("Headless call failed: ${it.message}", it) } + } + + fun consentConfigFrom( + config: HeadlessConfiguration, + identities: Map, + organizationCode: String = ORG_CODE, + propertyCode: String = PROPERTY, + environmentCode: String = ENVIRONMENT, + ): ConsentConfig { + val jurisdiction = config.jurisdiction?.code + ?: config.jurisdiction?.defaultJurisdictionCode + ?: "us" + val purposes = config.purposes + ?.mapNotNull { purpose -> + val code = purpose.code + val legalBasis = purpose.legalBasisCode + if (code != null && legalBasis != null) { + code to ConsentConfig.PurposeLegalBasis(legalBasis) + } else { + null + } + } + ?.toMap() + ?: emptyMap() + require(purposes.isNotEmpty()) { "Configuration returned no purposes" } + return ConsentConfig( + organizationCode = organizationCode, + propertyCode = propertyCode, + environmentCode = environmentCode, + jurisdictionCode = jurisdiction, + identities = identities, + purposes = purposes, + ) + } + + val dataCenter: KetchDataCenter = KetchDataCenter.UAT +} diff --git a/integration-tests/src/androidTest/java/com/ketch/android/integration/tests/KetchHeadlessIntegrationTest.kt b/integration-tests/src/androidTest/java/com/ketch/android/integration/tests/KetchHeadlessIntegrationTest.kt new file mode 100644 index 00000000..2b24172e --- /dev/null +++ b/integration-tests/src/androidTest/java/com/ketch/android/integration/tests/KetchHeadlessIntegrationTest.kt @@ -0,0 +1,110 @@ +package com.ketch.android.integration.tests + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.ketch.android.KetchSdk +import com.ketch.android.data.ConsentUpdate +import com.ketch.android.data.FullConfigurationRequest +import com.ketch.android.integration.tests.HeadlessIntegrationSupport.ORG_CODE +import com.ketch.android.integration.tests.HeadlessIntegrationSupport.PROPERTY +import com.ketch.android.integration.tests.HeadlessIntegrationSupport.ENVIRONMENT +import com.ketch.android.integration.tests.HeadlessIntegrationSupport.awaitHeadless +import com.ketch.android.integration.tests.HeadlessIntegrationSupport.consentConfigFrom +import com.ketch.android.integration.tests.HeadlessIntegrationSupport.dataCenter +import com.ketch.android.integration.tests.HeadlessIntegrationSupport.uniqueEmailIdentity +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith + +/** + * Live CDN headless round-trip tests (web/v3, sandbox org). + * Requires network on emulator/device. Does not use WebView. + */ +@RunWith(AndroidJUnit4::class) +class KetchHeadlessIntegrationTest { + + @Test + fun testFetchLocationReturnsGeoIP() { + val location = awaitHeadless { callback -> + KetchSdk.fetchLocation(dataCenter, callback) + } + assertNotNull("Expected location payload", location.location) + assertFalse( + "Expected countryCode from CDN", + location.location?.countryCode.isNullOrBlank(), + ) + } + + @Test + fun testFetchBootstrapConfiguration() { + val boot = awaitHeadless { callback -> + KetchSdk.fetchBootstrapConfiguration(ORG_CODE, PROPERTY, dataCenter, callback) + } + assertTrue( + "Bootstrap should include experiences metadata", + boot.experiences != null || boot.jurisdiction != null || !boot.purposes.isNullOrEmpty(), + ) + } + + @Test + fun testHeadlessColdStartConsentRoundTrip() { + val identities = uniqueEmailIdentity() + + awaitHeadless { callback -> + KetchSdk.fetchLocation(dataCenter, callback) + } + + val boot = awaitHeadless { callback -> + KetchSdk.fetchBootstrapConfiguration(ORG_CODE, PROPERTY, dataCenter, callback) + } + + val fullConfig = awaitHeadless { callback -> + KetchSdk.fetchFullConfiguration( + FullConfigurationRequest( + organizationCode = ORG_CODE, + propertyCode = PROPERTY, + ), + dataCenter, + callback, + ) + } + + val consentConfig = consentConfigFrom(fullConfig, identities) + + val consent = awaitHeadless { callback -> + KetchSdk.fetchConsent(consentConfig, dataCenter, callback) + } + assertTrue( + "CDN consent get should return protocols and/or purposes", + !consent.protocols.isNullOrEmpty() || !consent.purposes.isNullOrEmpty(), + ) + + val purposeCode = consentConfig.purposes.keys.first() + val legalBasis = consentConfig.purposes[purposeCode]!! + + val update = ConsentUpdate( + organizationCode = ORG_CODE, + propertyCode = PROPERTY, + environmentCode = ENVIRONMENT, + identities = identities, + jurisdictionCode = consentConfig.jurisdictionCode, + migrationOption = ConsentUpdate.MigrationOption.MIGRATE_DEFAULT, + purposes = mapOf( + purposeCode to ConsentUpdate.PurposeAllowedLegalBasis( + allowed = true, + legalBasisCode = legalBasis.legalBasisCode, + ), + ), + ) + + val updated = awaitHeadless { callback -> + KetchSdk.setConsent(update, dataCenter, callback) + } + assertNotNull("setConsent should return purposes", updated.purposes) + assertTrue( + "setConsent should include updated purpose", + updated.purposes!!.containsKey(purposeCode), + ) + } +} diff --git a/ketchsdk/build.gradle b/ketchsdk/build.gradle index a89bedad..4320de71 100644 --- a/ketchsdk/build.gradle +++ b/ketchsdk/build.gradle @@ -65,10 +65,13 @@ dependencies { implementation "androidx.appcompat:appcompat:${versions.appcompat}" implementation "org.jetbrains.kotlin:kotlin-parcelize-runtime:${versions.kotlin}" implementation "com.google.code.gson:gson:${versions.gson}" + implementation "com.squareup.okhttp3:okhttp:${versions.okhttp}" + implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:${versions.coroutines}" implementation "androidx.webkit:webkit:${versions.webkit}" implementation "androidx.preference:preference:1.2.1" testImplementation "junit:junit:${versions.junit}" + testImplementation "com.squareup.okhttp3:mockwebserver:${versions.okhttp}" androidTestImplementation 'androidx.test.ext:junit:1.3.0' androidTestImplementation 'androidx.test.espresso:espresso-core:3.7.0' } diff --git a/ketchsdk/src/main/java/com/ketch/android/Ketch.kt b/ketchsdk/src/main/java/com/ketch/android/Ketch.kt index 8f29a2b7..503caeb6 100644 --- a/ketchsdk/src/main/java/com/ketch/android/Ketch.kt +++ b/ketchsdk/src/main/java/com/ketch/android/Ketch.kt @@ -7,10 +7,27 @@ import android.util.Log import androidx.fragment.app.Fragment import androidx.fragment.app.FragmentActivity import androidx.fragment.app.FragmentManager +import com.ketch.android.api.HeadlessApiClient +import com.ketch.android.api.KetchDataCenter import com.ketch.android.data.Consent +import com.ketch.android.data.ConsentConfig +import com.ketch.android.data.ConsentUpdate import com.ketch.android.data.ContentDisplay +import com.ketch.android.data.FullConfigurationRequest +import com.ketch.android.data.GetProfileRequest +import com.ketch.android.data.GetProfileResponse +import com.ketch.android.data.HeadlessConfiguration import com.ketch.android.data.HideExperienceStatus +import com.ketch.android.data.InvokeRightRequest import com.ketch.android.data.KetchConfig +import com.ketch.android.data.LocationResponse +import com.ketch.android.data.PreferenceQRRequest +import com.ketch.android.data.PutProfileRequest +import com.ketch.android.data.SubscriptionConfiguration +import com.ketch.android.data.SubscriptionConfigurationRequest +import com.ketch.android.data.SubscriptionsRequest +import com.ketch.android.data.SubscriptionsResponse +import com.ketch.android.data.WebReportRequest import com.ketch.android.data.WillShowExperienceType import com.ketch.android.ui.KetchDialogFragment import com.ketch.android.ui.KetchWebView @@ -30,8 +47,14 @@ class Ketch private constructor( private val environment: String?, private val listener: Listener?, private val ketchUrl: String?, - private val logLevel: LogLevel + private val dataCenter: KetchDataCenter, + private val logLevel: LogLevel, + private val headlessApiClient: HeadlessApiClient, + private var webResourceUrlOverrides: Map = emptyMap(), ) { + // Falls back to the CDN region's base URL when no explicit ketchUrl override is provided. + private val effectiveKetchUrl: String = ketchUrl ?: dataCenter.baseUrl + // Use application context for non-UI operations to avoid memory leaks private val context: Context = context.applicationContext @@ -150,18 +173,170 @@ class Ketch private constructor( null, emptyList(), null, - ketchUrl, + effectiveKetchUrl, logLevel, age, ageLower, ageUpper, bottomPadding, topPadding, - cssStyle + cssStyle, + webResourceUrlOverrides, ) return true } + /** CDN region used for headless and WebView API calls. */ + fun getDataCenter(): KetchDataCenter = dataCenter + + /** + * Redirect exact-match WebView resource URLs (e.g. UAT tag scripts) to local dev servers. + * Android uses native [WebViewClient.shouldInterceptRequest]; JS hook is also embedded in index HTML. + */ + fun setWebResourceUrlOverrides(overrides: Map) { + webResourceUrlOverrides = overrides.toMap() + activeWebView?.setWebResourceUrlOverrides(webResourceUrlOverrides) + } + + /** GeoIP / jurisdiction hint (`GET /ip`). */ + fun fetchLocation(callback: (Result) -> Unit) { + headlessApiClient.fetchLocation(callback) + } + + suspend fun fetchLocation(): LocationResponse = headlessApiClient.fetchLocation() + + /** Minimal config (`GET .../boot.json`). */ + fun fetchBootstrapConfiguration( + callback: (Result) -> Unit, + ) { + headlessApiClient.fetchBootstrapConfiguration(orgCode, property, callback) + } + + suspend fun fetchBootstrapConfiguration(): HeadlessConfiguration = + headlessApiClient.fetchBootstrapConfiguration(orgCode, property) + + /** Full config with optional env / jurisdiction / language and hash query param. */ + fun fetchFullConfiguration( + request: FullConfigurationRequest, + callback: (Result) -> Unit, + ) { + headlessApiClient.fetchFullConfiguration(request, callback) + } + + suspend fun fetchFullConfiguration(request: FullConfigurationRequest): HeadlessConfiguration = + headlessApiClient.fetchFullConfiguration(request) + + /** Server consent including `protocols` (`POST .../consent/{org}/get`). */ + fun fetchConsent( + config: ConsentConfig, + callback: (Result) -> Unit, + ) { + headlessApiClient.fetchConsent(config, callback) + } + + suspend fun fetchConsent(config: ConsentConfig): Consent = + headlessApiClient.fetchConsent(config) + + /** Protocol strings only (same endpoint as fetchConsent). */ + fun fetchProtocols( + config: ConsentConfig, + callback: (Result) -> Unit, + ) { + headlessApiClient.fetchProtocols(config, callback) + } + + suspend fun fetchProtocols(config: ConsentConfig): Consent = + headlessApiClient.fetchProtocols(config) + + /** Updates consent; returns server response with computed `protocols`. */ + fun setConsent( + update: ConsentUpdate, + callback: (Result) -> Unit, + ) { + headlessApiClient.setConsent(update.withoutProtocols(), callback) + } + + suspend fun setConsent(update: ConsentUpdate): Consent = + headlessApiClient.setConsent(update.withoutProtocols()) + + /** Invokes a data subject right (`POST .../rights/{org}/invoke`). */ + fun invokeRight( + request: InvokeRightRequest, + callback: (Result) -> Unit, + ) { + headlessApiClient.invokeRight(request, callback) + } + + suspend fun invokeRight(request: InvokeRightRequest) = headlessApiClient.invokeRight(request) + + /** Gets profile preferences (`POST .../profile/{org}/get`). */ + fun getProfile( + request: GetProfileRequest, + callback: (Result) -> Unit, + ) { + headlessApiClient.getProfile(request, callback) + } + + suspend fun getProfile(request: GetProfileRequest): GetProfileResponse = + headlessApiClient.getProfile(request) + + /** Updates profile preferences (`POST .../profile/{org}/put`). */ + fun putProfile( + request: PutProfileRequest, + callback: (Result) -> Unit, + ) { + headlessApiClient.putProfile(request, callback) + } + + suspend fun putProfile(request: PutProfileRequest) = headlessApiClient.putProfile(request) + + /** Gets subscription topics/controls (`POST .../subscriptions/{org}/get`). */ + fun getSubscriptions( + request: SubscriptionsRequest, + callback: (Result) -> Unit, + ) { + headlessApiClient.getSubscriptions(request, callback) + } + + suspend fun getSubscriptions(request: SubscriptionsRequest): SubscriptionsResponse = + headlessApiClient.getSubscriptions(request) + + /** Updates subscription topics/controls (`POST .../subscriptions/{org}/update`). */ + fun setSubscriptions( + request: SubscriptionsRequest, + callback: (Result) -> Unit, + ) { + headlessApiClient.setSubscriptions(request, callback) + } + + suspend fun setSubscriptions(request: SubscriptionsRequest) = + headlessApiClient.setSubscriptions(request) + + fun fetchSubscriptionsConfiguration( + request: SubscriptionConfigurationRequest, + callback: (Result) -> Unit, + ) { + headlessApiClient.fetchSubscriptionsConfiguration(request, callback) + } + + suspend fun fetchSubscriptionsConfiguration( + request: SubscriptionConfigurationRequest, + ): SubscriptionConfiguration = headlessApiClient.fetchSubscriptionsConfiguration(request) + + fun preferenceQRUrl(request: PreferenceQRRequest): String = + headlessApiClient.preferenceQRUrl(request) + + fun webReport( + channel: String, + request: WebReportRequest, + callback: (Result) -> Unit, + ) { + headlessApiClient.webReport(channel, request, callback) + } + + suspend fun webReport(channel: String, request: WebReportRequest) = + headlessApiClient.webReport(channel, request) + /** * Display the consent, adding the fragment dialog to the given FragmentManager. * @@ -197,14 +372,15 @@ class Ketch private constructor( KetchWebView.ExperienceType.CONSENT, emptyList(), null, - ketchUrl, + effectiveKetchUrl, logLevel, age, ageLower, ageUpper, bottomPadding, topPadding, - cssStyle + cssStyle, + webResourceUrlOverrides, ) return true } @@ -244,14 +420,15 @@ class Ketch private constructor( KetchWebView.ExperienceType.PREFERENCES, emptyList(), null, - ketchUrl, + effectiveKetchUrl, logLevel, age, ageLower, ageUpper, bottomPadding, topPadding, - cssStyle + cssStyle, + webResourceUrlOverrides, ) return true } @@ -295,14 +472,15 @@ class Ketch private constructor( KetchWebView.ExperienceType.PREFERENCES, tabs, tab, - ketchUrl, + effectiveKetchUrl, logLevel, age, ageLower, ageUpper, bottomPadding, topPadding, - cssStyle + cssStyle, + webResourceUrlOverrides, ) return true } @@ -320,9 +498,11 @@ class Ketch private constructor( } catch (e: Exception) { Log.e(TAG, "Error dismissing dialog: ${e.message}") } finally { + Log.d(TAG, "onDismiss source=dismissDialog status=None") resetShowingState(HideExperienceStatus.None) } } else if (isShowingExperience || activeWebView != null) { + Log.d(TAG, "onDismiss source=dismissDialog status=None") resetShowingState(HideExperienceStatus.None) } } @@ -654,6 +834,7 @@ class Ketch private constructor( // foreground Activity is tracked (callbacks still resolve; display needs a host). val webViewContext = resolveWebViewContext() val webView = KetchWebView(webViewContext, shouldRetry) + webView.setWebResourceUrlOverrides(webResourceUrlOverrides) // Enable debug mode if (logLevel === LogLevel.DEBUG) { @@ -733,6 +914,10 @@ class Ketch private constructor( showConsentPopup() } + override fun onConfigDebugInfo(configSummary: String, purposesSummary: String) { + this@Ketch.listener?.onConfigDebugInfo(configSummary, purposesSummary) + } + override fun onEnvironmentUpdated(environment: String?) { this@Ketch.listener?.onEnvironmentUpdated(environment) } @@ -765,7 +950,7 @@ class Ketch private constructor( } } - override fun onClose(status: HideExperienceStatus, retainWebView: Boolean) { + override fun onClose(status: HideExperienceStatus, source: String, retainWebView: Boolean) { synchronized(lock) { if (!retainWebView) { activeWebView = null @@ -789,6 +974,7 @@ class Ketch private constructor( } else { handleDialogDismissed() } + Log.d(TAG, "onDismiss source=$source status=${status.name}") this@Ketch.listener?.onDismiss(status) } } @@ -801,6 +987,10 @@ class Ketch private constructor( this@Ketch.listener?.onHasShownExperience() } + override fun onTapOutside() { + // No-op: not yet surfaced on the public Ketch.Listener API. + } + private fun showConsentPopup() { synchronized(lock) { if (isShowingExperience || findDialogFragment() != null) { @@ -908,6 +1098,11 @@ class Ketch private constructor( */ fun onConfigUpdated(config: KetchConfig?) + /** + * Debug summary parsed from raw config.json (purposes, experiences, env). + */ + fun onConfigDebugInfo(configSummary: String, purposesSummary: String) {} + /** * Called when the environment is updated. */ @@ -974,6 +1169,7 @@ class Ketch private constructor( environment: String?, listener: Listener?, ketchUrl: String?, + dataCenter: KetchDataCenter = KetchDataCenter.US, logLevel: LogLevel, ) = Ketch( context = context, @@ -984,7 +1180,9 @@ class Ketch private constructor( environment = environment, listener = listener, ketchUrl = ketchUrl, + dataCenter = dataCenter, logLevel = logLevel, + headlessApiClient = HeadlessApiClient(dataCenter), ) fun create( @@ -995,7 +1193,9 @@ class Ketch private constructor( environment: String?, listener: Listener?, ketchUrl: String?, + dataCenter: KetchDataCenter = KetchDataCenter.US, logLevel: LogLevel, + webResourceUrlOverrides: Map = emptyMap(), ) = Ketch( context = context, seedActivity = context as? FragmentActivity, @@ -1005,7 +1205,13 @@ class Ketch private constructor( environment = environment, listener = listener, ketchUrl = ketchUrl, + dataCenter = dataCenter, logLevel = logLevel, + headlessApiClient = HeadlessApiClient(dataCenter), + webResourceUrlOverrides = webResourceUrlOverrides, ) } } + +private fun ConsentUpdate.withoutProtocols(): ConsentUpdate = + copy(protocols = null) diff --git a/ketchsdk/src/main/java/com/ketch/android/KetchSdk.kt b/ketchsdk/src/main/java/com/ketch/android/KetchSdk.kt index 299089ae..001483ec 100644 --- a/ketchsdk/src/main/java/com/ketch/android/KetchSdk.kt +++ b/ketchsdk/src/main/java/com/ketch/android/KetchSdk.kt @@ -2,6 +2,24 @@ package com.ketch.android import android.content.Context import androidx.fragment.app.FragmentManager +import com.ketch.android.api.HeadlessApiClient +import com.ketch.android.api.KetchDataCenter +import com.ketch.android.data.Consent +import com.ketch.android.data.ConsentConfig +import com.ketch.android.data.ConsentUpdate +import com.ketch.android.data.FullConfigurationRequest +import com.ketch.android.data.GetProfileRequest +import com.ketch.android.data.GetProfileResponse +import com.ketch.android.data.HeadlessConfiguration +import com.ketch.android.data.InvokeRightRequest +import com.ketch.android.data.LocationResponse +import com.ketch.android.data.PreferenceQRRequest +import com.ketch.android.data.PutProfileRequest +import com.ketch.android.data.SubscriptionConfiguration +import com.ketch.android.data.SubscriptionConfigurationRequest +import com.ketch.android.data.SubscriptionsRequest +import com.ketch.android.data.SubscriptionsResponse +import com.ketch.android.data.WebReportRequest /** * Factory to create the Ketch object. @@ -12,8 +30,8 @@ import androidx.fragment.app.FragmentManager * PROPERTY, * ENVIRONMENT, * listener, - * TEST_URL, - * Ketch.LogLevel.DEBUG + * dataCenter = KetchDataCenter.US, + * logLevel = Ketch.LogLevel.DEBUG * ) **/ object KetchSdk { @@ -58,8 +76,10 @@ object KetchSdk { * @param property - the property name * @param environment - the environment name. * @param listener - Ketch.Listener. Optional - * @param ketchUrl - Overrides the ketch url. Optional + * @param ketchUrl - Overrides the ketch url. Optional; defaults to [dataCenter] base URL. + * @param dataCenter - CDN region for headless and WebView API calls. Default US. * @param logLevel - the log level, can be TRACE, DEBUG, INFO, WARN, ERROR. Default is ERROR + * @param webResourceUrlOverrides - Map of exact source URL to local replacement URL for WebView resources (e.g. tag scripts). */ @Deprecated( message = "Ketch now tracks the foreground Activity automatically; the FragmentManager " + @@ -77,7 +97,9 @@ object KetchSdk { environment: String? = null, listener: Ketch.Listener? = null, ketchUrl: String? = null, - logLevel: Ketch.LogLevel = Ketch.LogLevel.ERROR + dataCenter: KetchDataCenter = KetchDataCenter.US, + logLevel: Ketch.LogLevel = Ketch.LogLevel.ERROR, + webResourceUrlOverrides: Map = emptyMap(), ): Ketch { return Ketch.create( context = context, @@ -87,7 +109,130 @@ object KetchSdk { environment = environment, listener = listener, ketchUrl = ketchUrl, - logLevel = logLevel + dataCenter = dataCenter, + logLevel = logLevel, + webResourceUrlOverrides = webResourceUrlOverrides, + ) + } + + // MARK: - Headless API (static, web/v3) + + /** GeoIP / jurisdiction hint (`GET /ip`). */ + fun fetchLocation( + dataCenter: KetchDataCenter = KetchDataCenter.US, + callback: (Result) -> Unit, + ) { + HeadlessApiClient(dataCenter).fetchLocation(callback) + } + + /** Minimal config (`GET .../boot.json`). */ + fun fetchBootstrapConfiguration( + organization: String, + property: String, + dataCenter: KetchDataCenter = KetchDataCenter.US, + callback: (Result) -> Unit, + ) { + HeadlessApiClient(dataCenter).fetchBootstrapConfiguration(organization, property, callback) + } + + /** Full config with optional env / jurisdiction / language and hash query param. */ + fun fetchFullConfiguration( + request: FullConfigurationRequest, + dataCenter: KetchDataCenter = KetchDataCenter.US, + callback: (Result) -> Unit, + ) { + HeadlessApiClient(dataCenter).fetchFullConfiguration(request, callback) + } + + /** Server consent including `protocols` (`POST .../consent/{org}/get`). */ + fun fetchConsent( + config: ConsentConfig, + dataCenter: KetchDataCenter = KetchDataCenter.US, + callback: (Result) -> Unit, + ) { + HeadlessApiClient(dataCenter).fetchConsent(config, callback) + } + + /** Protocol strings only (same endpoint as fetchConsent). */ + fun fetchProtocols( + config: ConsentConfig, + dataCenter: KetchDataCenter = KetchDataCenter.US, + callback: (Result) -> Unit, + ) { + HeadlessApiClient(dataCenter).fetchProtocols(config, callback) + } + + /** Updates consent; returns server response with computed `protocols`. */ + fun setConsent( + update: ConsentUpdate, + dataCenter: KetchDataCenter = KetchDataCenter.US, + callback: (Result) -> Unit, + ) { + HeadlessApiClient(dataCenter).setConsent( + update.copy(protocols = null), + callback, ) } + + fun invokeRight( + request: InvokeRightRequest, + dataCenter: KetchDataCenter = KetchDataCenter.US, + callback: (Result) -> Unit, + ) { + HeadlessApiClient(dataCenter).invokeRight(request, callback) + } + + fun getProfile( + request: GetProfileRequest, + dataCenter: KetchDataCenter = KetchDataCenter.US, + callback: (Result) -> Unit, + ) { + HeadlessApiClient(dataCenter).getProfile(request, callback) + } + + fun putProfile( + request: PutProfileRequest, + dataCenter: KetchDataCenter = KetchDataCenter.US, + callback: (Result) -> Unit, + ) { + HeadlessApiClient(dataCenter).putProfile(request, callback) + } + + fun getSubscriptions( + request: SubscriptionsRequest, + dataCenter: KetchDataCenter = KetchDataCenter.US, + callback: (Result) -> Unit, + ) { + HeadlessApiClient(dataCenter).getSubscriptions(request, callback) + } + + fun setSubscriptions( + request: SubscriptionsRequest, + dataCenter: KetchDataCenter = KetchDataCenter.US, + callback: (Result) -> Unit, + ) { + HeadlessApiClient(dataCenter).setSubscriptions(request, callback) + } + + fun fetchSubscriptionsConfiguration( + request: SubscriptionConfigurationRequest, + dataCenter: KetchDataCenter = KetchDataCenter.US, + callback: (Result) -> Unit, + ) { + HeadlessApiClient(dataCenter).fetchSubscriptionsConfiguration(request, callback) + } + + fun preferenceQRUrl( + request: PreferenceQRRequest, + dataCenter: KetchDataCenter = KetchDataCenter.US, + ): String = HeadlessApiClient(dataCenter).preferenceQRUrl(request) + + fun webReport( + channel: String, + request: WebReportRequest, + dataCenter: KetchDataCenter = KetchDataCenter.US, + callback: (Result) -> Unit, + ) { + HeadlessApiClient(dataCenter).webReport(channel, request, callback) + } } diff --git a/ketchsdk/src/main/java/com/ketch/android/KetchSharedPreferences.kt b/ketchsdk/src/main/java/com/ketch/android/KetchSharedPreferences.kt index 8c0a946f..0a1ce0bd 100644 --- a/ketchsdk/src/main/java/com/ketch/android/KetchSharedPreferences.kt +++ b/ketchsdk/src/main/java/com/ketch/android/KetchSharedPreferences.kt @@ -2,6 +2,7 @@ package com.ketch.android import android.content.Context import android.content.SharedPreferences +import androidx.annotation.VisibleForTesting import androidx.preference.PreferenceManager import android.util.Log import androidx.core.content.edit @@ -43,23 +44,58 @@ object KetchSharedPreferences { * Clear all SharedPreferences keys with the prefixes in PREFIXES_TO_REMOVE */ private fun clearEntriesWithPrefixes() { + val removed = removeValues(PREFIXES_TO_REMOVE) + Log.d(TAG, "Cleared $removed keys while initializing KetchSharedPreferences") + } + + /** + * Retrieve some value from SharedPreferences + */ + fun getSavedValue(key: String): String? = sharedPreferences.getString(key, null) + + /** + * Read a string value, falling back to [defaultValue] when the key is absent. + */ + fun read(key: String, defaultValue: String = ""): String = + sharedPreferences.getString(key, defaultValue) ?: defaultValue + + /** + * Persist a single string value. Backs the `nativeStoragePut` WebView bridge event. + */ + fun write(key: String, value: String) { + sharedPreferences.edit { putString(key, value) } + } + + /** + * Remove a single key. + */ + fun remove(key: String) { + sharedPreferences.edit { remove(key) } + } + + /** + * Remove every stored key whose name begins with one of [prefixes]. Returns the count removed. + */ + fun removeValues(prefixes: List): Int { val keysToRemove = sharedPreferences.all.keys.filter { key -> - PREFIXES_TO_REMOVE.any { key.startsWith(it) } + prefixes.any { key.startsWith(it) } } - - sharedPreferences.edit().apply { + sharedPreferences.edit { keysToRemove.forEach { remove(it) } - apply() } + return keysToRemove.size + } - // Log the result - Log.d(TAG, "Cleared ${keysToRemove.size} keys while initializing KetchSharedPreferences") + @VisibleForTesting + internal fun bindPreferencesForTesting(prefs: SharedPreferences) { + sharedPreferences = prefs + isInitialized = true } - /** - * Retrieve some value from SharedPreferences - */ - fun getSavedValue(key: String): String? = sharedPreferences.getString(key, null) + @VisibleForTesting + internal fun resetForTesting() { + isInitialized = false + } /** * Save a map of values in SharedPreferences, using either apply (async) or commit (sync) diff --git a/ketchsdk/src/main/java/com/ketch/android/NativeStoragePayload.kt b/ketchsdk/src/main/java/com/ketch/android/NativeStoragePayload.kt new file mode 100644 index 00000000..7b447229 --- /dev/null +++ b/ketchsdk/src/main/java/com/ketch/android/NativeStoragePayload.kt @@ -0,0 +1,27 @@ +package com.ketch.android + +import com.google.gson.Gson +import com.google.gson.JsonParseException + +internal data class NativeStoragePutPayload( + val key: String, + val value: String, +) + +private data class NativeStoragePutPayloadDto( + val key: String?, + val value: String?, +) + +internal fun parseNativeStoragePutPayload(json: String): NativeStoragePutPayload? { + return try { + val raw = Gson().fromJson(json, NativeStoragePutPayloadDto::class.java) ?: return null + val key = raw.key?.trim().orEmpty() + if (key.isEmpty()) { + return null + } + NativeStoragePutPayload(key = key, value = raw.value.orEmpty()) + } catch (_: JsonParseException) { + null + } +} diff --git a/ketchsdk/src/main/java/com/ketch/android/api/HeadlessApiClient.kt b/ketchsdk/src/main/java/com/ketch/android/api/HeadlessApiClient.kt new file mode 100644 index 00000000..aebf1beb --- /dev/null +++ b/ketchsdk/src/main/java/com/ketch/android/api/HeadlessApiClient.kt @@ -0,0 +1,452 @@ +package com.ketch.android.api + +import com.google.gson.Gson +import com.ketch.android.data.Consent +import com.ketch.android.data.ConsentConfig +import com.ketch.android.data.ConsentUpdate +import com.ketch.android.data.FullConfigurationRequest +import com.ketch.android.data.GetProfileRequest +import com.ketch.android.data.GetProfileResponse +import com.ketch.android.data.HeadlessConfiguration +import com.ketch.android.data.HeadlessException +import com.ketch.android.data.InvokeRightRequest +import com.ketch.android.data.LocationResponse +import com.ketch.android.data.PreferenceQRRequest +import com.ketch.android.data.PutProfileRequest +import com.ketch.android.data.SubscriptionConfiguration +import com.ketch.android.data.SubscriptionConfigurationRequest +import com.ketch.android.data.SubscriptionsRequest +import com.ketch.android.data.SubscriptionsResponse +import com.ketch.android.data.WebReportRequest +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import okhttp3.HttpUrl.Companion.toHttpUrl +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.RequestBody.Companion.toRequestBody +import java.io.IOException + +/** + * Native HTTP client mirroring ketch-tag [KetchWebAPI] (web/v3). + */ +class HeadlessApiClient( + dataCenter: KetchDataCenter = KetchDataCenter.US, + baseUrl: String? = null, + private val okHttpClient: OkHttpClient = OkHttpClient(), + private val gson: Gson = Gson(), +) { + private val baseUrl = baseUrl ?: dataCenter.baseUrl + private val jsonMediaType = "application/json".toMediaType() + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + + fun fetchLocation(callback: (Result) -> Unit) { + launchAsync(callback) { fetchLocation() } + } + + suspend fun fetchLocation(): LocationResponse = withContext(Dispatchers.IO) { + get("/ip", LocationResponse::class.java) + } + + fun fetchBootstrapConfiguration( + organization: String, + property: String, + callback: (Result) -> Unit, + ) { + launchAsync(callback) { fetchBootstrapConfiguration(organization, property) } + } + + suspend fun fetchBootstrapConfiguration( + organization: String, + property: String, + ): HeadlessConfiguration = withContext(Dispatchers.IO) { + get("/config/$organization/$property/boot.json", HeadlessConfiguration::class.java) + } + + fun fetchFullConfiguration( + request: FullConfigurationRequest, + callback: (Result) -> Unit, + ) { + launchAsync(callback) { fetchFullConfiguration(request) } + } + + suspend fun fetchFullConfiguration(request: FullConfigurationRequest): HeadlessConfiguration = + withContext(Dispatchers.IO) { + var path = "/config/${request.organizationCode}/${request.propertyCode}" + if (request.environmentCode != null && + request.jurisdictionCode != null && + request.languageCode != null + ) { + path += "/${request.environmentCode}/${request.jurisdictionCode}/${request.languageCode}" + } + path += "/config.json" + val query = request.hash?.let { mapOf("hash" to it) } ?: emptyMap() + get(path, HeadlessConfiguration::class.java, query) + } + + fun fetchConsent( + config: ConsentConfig, + callback: (Result) -> Unit, + ) { + launchAsync(callback) { fetchConsent(config) } + } + + suspend fun fetchConsent(config: ConsentConfig): Consent = withContext(Dispatchers.IO) { + val path = "/consent/${config.organizationCode}/get" + postConsent(path, ConsentConfigPayload.from(config), config) + } + + fun fetchProtocols( + config: ConsentConfig, + callback: (Result) -> Unit, + ) { + launchAsync(callback) { fetchProtocols(config) } + } + + suspend fun fetchProtocols(config: ConsentConfig): Consent = withContext(Dispatchers.IO) { + val response = fetchConsent(config) + if (response.protocols.isNullOrEmpty()) { + Consent( + purposes = response.purposes, + vendors = response.vendors, + protocols = null, + ) + } else { + response + } + } + + fun setConsent( + update: ConsentUpdate, + callback: (Result) -> Unit, + ) { + launchAsync(callback) { setConsent(update) } + } + + suspend fun setConsent(update: ConsentUpdate): Consent = withContext(Dispatchers.IO) { + val path = "/consent/${update.organizationCode}/update" + postSetConsent(path, SetConsentPayload.from(update), update) + } + + fun invokeRight( + request: InvokeRightRequest, + callback: (Result) -> Unit, + ) { + launchAsync(callback) { invokeRight(request) } + } + + suspend fun invokeRight(request: InvokeRightRequest): Unit = withContext(Dispatchers.IO) { + postVoid("/rights/${request.organizationCode}/invoke", request) + } + + fun getProfile( + request: GetProfileRequest, + callback: (Result) -> Unit, + ) { + launchAsync(callback) { getProfile(request) } + } + + suspend fun getProfile(request: GetProfileRequest): GetProfileResponse = withContext(Dispatchers.IO) { + post("/profile/${request.organizationCode}/get", request, GetProfileResponse::class.java) + } + + fun putProfile( + request: PutProfileRequest, + callback: (Result) -> Unit, + ) { + launchAsync(callback) { putProfile(request) } + } + + suspend fun putProfile(request: PutProfileRequest): Unit = withContext(Dispatchers.IO) { + postVoid("/profile/${request.organizationCode}/put", request) + } + + fun getSubscriptions( + request: SubscriptionsRequest, + callback: (Result) -> Unit, + ) { + launchAsync(callback) { getSubscriptions(request) } + } + + suspend fun getSubscriptions(request: SubscriptionsRequest): SubscriptionsResponse = + withContext(Dispatchers.IO) { + post("/subscriptions/${request.organizationCode}/get", request, SubscriptionsResponse::class.java) + } + + fun setSubscriptions( + request: SubscriptionsRequest, + callback: (Result) -> Unit, + ) { + launchAsync(callback) { setSubscriptions(request) } + } + + suspend fun setSubscriptions(request: SubscriptionsRequest): Unit = withContext(Dispatchers.IO) { + postVoid("/subscriptions/${request.organizationCode}/update", request) + } + + fun fetchSubscriptionsConfiguration( + request: SubscriptionConfigurationRequest, + callback: (Result) -> Unit, + ) { + launchAsync(callback) { fetchSubscriptionsConfiguration(request) } + } + + suspend fun fetchSubscriptionsConfiguration( + request: SubscriptionConfigurationRequest, + ): SubscriptionConfiguration = withContext(Dispatchers.IO) { + val path = "/config/${request.organizationCode}/${request.propertyCode}/${request.languageCode}/${request.experienceCode}/subscriptions.json" + get(path, SubscriptionConfiguration::class.java) + } + + fun preferenceQRUrl(request: PreferenceQRRequest): String { + val query = linkedMapOf() + request.environmentCode?.let { query["env"] = it } + request.imageSize?.let { query["size"] = it.toString() } + request.path?.let { query["path"] = it } + request.backgroundColor?.let { query["bgcolor"] = it } + request.foregroundColor?.let { query["fgcolor"] = it } + query.putAll(request.parameters) + return buildUrl( + "/qr/${request.organizationCode}/${request.propertyCode}/preferences.png", + query, + ) + } + + fun webReport( + channel: String, + request: WebReportRequest, + callback: (Result) -> Unit, + ) { + launchAsync(callback) { webReport(channel, request) } + } + + suspend fun webReport(channel: String, request: WebReportRequest): Unit = + withContext(Dispatchers.IO) { + postVoid("/report/$channel", request) + } + + /** Builds an absolute CDN URL for unit tests and debugging. */ + fun buildUrl(path: String, query: Map = emptyMap()): String { + val normalized = if (path.startsWith("/")) path else "/$path" + val url = baseUrl.trimEnd('/') + normalized + if (query.isEmpty()) { + return url + } + val httpUrl = url.toHttpUrl().newBuilder() + query.forEach { (key, value) -> httpUrl.addQueryParameter(key, value) } + return httpUrl.build().toString() + } + + private fun get(path: String, type: Class, query: Map = emptyMap()): T { + val url = buildUrl(path, query) + val request = Request.Builder() + .url(url) + .header("Accept", "application/json") + .get() + .build() + return execute(request, type) + } + + private fun execute(request: Request, type: Class): T { + try { + okHttpClient.newCall(request).execute().use { response -> + val body = response.body?.string().orEmpty() + if (!response.isSuccessful) { + throw HeadlessException("HTTP ${response.code} for ${request.url}") + } + if (body.isBlank() || body == "null") { + throw HeadlessException("Empty response for ${request.url}") + } + return gson.fromJson(body, type) + ?: throw HeadlessException("Failed to decode response for ${request.url}") + } + } catch (error: HeadlessException) { + throw error + } catch (error: IOException) { + throw HeadlessException("Network error for ${request.url}", error) + } + } + + private fun postConsent(path: String, body: ConsentConfigPayload, config: ConsentConfig): Consent { + val request = buildConsentPostRequest(path, body) + return executeConsentFetch(request, config) + } + + private fun postSetConsent(path: String, body: SetConsentPayload, fallback: ConsentUpdate): Consent { + val request = buildConsentPostRequest(path, body) + return executeConsentSet(request, fallback) + } + + private fun buildConsentPostRequest(path: String, body: Any): Request { + val json = gson.toJson(body) + return Request.Builder() + .url(buildUrl(path)) + .header("Accept", "application/json") + .header("Content-Type", "application/json") + .post(json.toRequestBody(jsonMediaType)) + .build() + } + + private fun executeConsentFetch(request: Request, config: ConsentConfig): Consent { + try { + okHttpClient.newCall(request).execute().use { response -> + val body = response.body?.string().orEmpty() + if (!response.isSuccessful) { + throw HeadlessException("HTTP ${response.code} for ${request.url}") + } + if (body.isBlank() || body == "null") { + return emptyConsent(config) + } + val decoded = decodeConsent(body) + if (decoded != null && hasUsableConsentFields(decoded)) { + return decoded + } + return emptyConsent(config) + } + } catch (error: HeadlessException) { + throw error + } catch (error: IOException) { + throw HeadlessException("Network error for ${request.url}", error) + } + } + + private fun executeConsentSet(request: Request, fallback: ConsentUpdate): Consent { + try { + okHttpClient.newCall(request).execute().use { response -> + val body = response.body?.string().orEmpty() + if (!response.isSuccessful) { + throw HeadlessException("HTTP ${response.code} for ${request.url}") + } + val decoded = decodeConsent(body) + if (decoded != null && hasUsableConsentFields(decoded)) { + return decoded + } + return consentFromUpdate(fallback) + } + } catch (error: HeadlessException) { + throw error + } catch (error: IOException) { + throw HeadlessException("Network error for ${request.url}", error) + } + } + + private fun decodeConsent(body: String): Consent? = + try { + gson.fromJson(body, Consent::class.java) + } catch (_: Exception) { + null + } + + private fun hasUsableConsentFields(consent: Consent): Boolean { + if (!consent.purposes.isNullOrEmpty()) return true + if (!consent.protocols.isNullOrEmpty()) return true + return false + } + + private fun post(path: String, body: Any, type: Class): T { + val json = gson.toJson(body) + val request = Request.Builder() + .url(buildUrl(path)) + .header("Accept", "application/json") + .header("Content-Type", "application/json") + .post(json.toRequestBody(jsonMediaType)) + .build() + return execute(request, type) + } + + private fun postVoid(path: String, body: Any) { + val json = gson.toJson(body) + val request = Request.Builder() + .url(buildUrl(path)) + .header("Accept", "application/json") + .header("Content-Type", "application/json") + .post(json.toRequestBody(jsonMediaType)) + .build() + try { + okHttpClient.newCall(request).execute().use { response -> + if (!response.isSuccessful) { + throw HeadlessException("HTTP ${response.code} for ${request.url}") + } + } + } catch (error: HeadlessException) { + throw error + } catch (error: IOException) { + throw HeadlessException("Network error for ${request.url}", error) + } + } + + private fun emptyConsent(config: ConsentConfig): Consent = + Consent(purposes = emptyMap(), vendors = null, protocols = null) + + private fun consentFromUpdate(update: ConsentUpdate): Consent { + val purposes = update.purposes.mapValues { (_, basis) -> + basis.allowed.equals("true", ignoreCase = true) + } + return Consent(purposes = purposes, vendors = update.vendors, protocols = null) + } + + private fun launchAsync( + callback: (Result) -> Unit, + block: suspend () -> T, + ) { + scope.launch { + val result = try { + Result.success(block()) + } catch (error: HeadlessException) { + Result.failure(error) + } catch (error: Exception) { + Result.failure(HeadlessException(error.message ?: "Headless API error", error)) + } + withContext(Dispatchers.Main) { + callback(result) + } + } + } + + private data class ConsentConfigPayload( + val organizationCode: String, + val propertyCode: String, + val environmentCode: String, + val jurisdictionCode: String, + val identities: Map, + val purposes: Map, + ) { + companion object { + fun from(config: ConsentConfig) = ConsentConfigPayload( + organizationCode = config.organizationCode, + propertyCode = config.propertyCode, + environmentCode = config.environmentCode, + jurisdictionCode = config.jurisdictionCode, + identities = config.identities, + purposes = config.purposes, + ) + } + } + + private data class SetConsentPayload( + val organizationCode: String, + val propertyCode: String, + val environmentCode: String, + val identities: Map, + val jurisdictionCode: String, + val migrationOption: ConsentUpdate.MigrationOption, + val purposes: Map, + val vendors: List?, + ) { + companion object { + fun from(update: ConsentUpdate) = SetConsentPayload( + organizationCode = update.organizationCode, + propertyCode = update.propertyCode, + environmentCode = update.environmentCode, + identities = update.identities, + jurisdictionCode = update.jurisdictionCode, + migrationOption = update.migrationOption, + purposes = update.purposes, + vendors = update.vendors, + ) + } + } +} diff --git a/ketchsdk/src/main/java/com/ketch/android/api/KetchDataCenter.kt b/ketchsdk/src/main/java/com/ketch/android/api/KetchDataCenter.kt new file mode 100644 index 00000000..ea17f96c --- /dev/null +++ b/ketchsdk/src/main/java/com/ketch/android/api/KetchDataCenter.kt @@ -0,0 +1,10 @@ +package com.ketch.android.api + +/** + * Ketch CDN region for headless and WebView API calls (matches Flutter / React Native maps). + */ +enum class KetchDataCenter(val baseUrl: String) { + US("https://global.ketchcdn.com/web/v3"), + EU("https://eu.ketchcdn.com/web/v3"), + UAT("https://dev.ketchcdn.com/web/v3"), +} diff --git a/ketchsdk/src/main/java/com/ketch/android/data/ConfigDebug.kt b/ketchsdk/src/main/java/com/ketch/android/data/ConfigDebug.kt new file mode 100644 index 00000000..e577a47a --- /dev/null +++ b/ketchsdk/src/main/java/com/ketch/android/data/ConfigDebug.kt @@ -0,0 +1,85 @@ +package com.ketch.android.data + +import com.google.gson.JsonArray +import com.google.gson.JsonElement +import com.google.gson.JsonObject +import com.google.gson.JsonParser + +/** Summarize config.json for logging / sample-app debug UI. */ +fun summarizeConfigJson(configJson: String?): String { + if (configJson.isNullOrBlank()) return "config: empty" + return try { + val root = JsonParser.parseString(configJson) + if (!root.isJsonObject) return "config: not an object" + summarizeConfigElement(root.asJsonObject) + } catch (e: Exception) { + "config parse error: ${e.message}" + } +} + +fun summarizePurposesJson(configJson: String?): String { + if (configJson.isNullOrBlank()) return "purposes: empty" + return try { + val root = JsonParser.parseString(configJson) + if (!root.isJsonObject) return "purposes: n/a" + summarizePurposesElement(root.asJsonObject) + } catch (e: Exception) { + "purposes parse error: ${e.message}" + } +} + +private fun summarizeConfigElement(root: JsonObject): String { + val env = root.get("environment")?.asJsonObject?.get("code")?.asString + val jurisdiction = root.get("jurisdiction")?.asJsonObject?.get("code")?.asString + ?: root.get("policyScope")?.asJsonObject?.get("code")?.asString + val language = root.get("language")?.asString + val experiences = summarizeExperiences(root.get("experiences")) + return buildString { + append("env=").append(env ?: "?") + append(" jurisdiction=").append(jurisdiction ?: "?") + append(" lang=").append(language ?: "?") + append(" ").append(experiences) + } +} + +private fun summarizePurposesElement(root: JsonObject): String { + val canonical = root.getAsJsonObject("canonicalPurposes") + if (canonical != null && canonical.size() > 0) { + val codes = canonical.entrySet().map { it.key }.sorted() + return "canonicalPurposes(${codes.size}): ${codes.joinToString(", ")}" + } + val purposes = root.getAsJsonArray("purposes") + if (purposes != null && purposes.size() > 0) { + val codes = purposes.mapNotNull { element -> + element.asJsonObject.get("code")?.asString + }.sorted() + return "purposes(${codes.size}): ${codes.joinToString(", ")}" + } + return "purposes: none in config.json" +} + +private fun summarizeExperiences(experiences: JsonElement?): String { + if (experiences == null || experiences.isJsonNull) return "experiences=none" + if (!experiences.isJsonObject) return "experiences=unexpected" + val obj = experiences.asJsonObject + val keys = obj.keySet().sorted() + val autoInitiated = obj.getAsJsonObject("autoInitiated") + val layout = autoInitiated?.getAsJsonObject("layout") + val banner = layout?.has("banner") == true + val modal = layout?.has("modal") == true + val pref = layout?.has("preference") == true + return buildString { + append("experiences keys=[${keys.joinToString(",")}]") + if (layout != null) { + append(" layout banner=$banner modal=$modal pref=$pref") + } + } +} + +private fun JsonArray.mapNotNull(transform: (JsonElement) -> String?): List { + val result = mutableListOf() + for (element in this) { + transform(element)?.let { result.add(it) } + } + return result +} diff --git a/ketchsdk/src/main/java/com/ketch/android/data/HeadlessModels.kt b/ketchsdk/src/main/java/com/ketch/android/data/HeadlessModels.kt new file mode 100644 index 00000000..01c1efa8 --- /dev/null +++ b/ketchsdk/src/main/java/com/ketch/android/data/HeadlessModels.kt @@ -0,0 +1,257 @@ +package com.ketch.android.data + +import com.google.gson.annotations.SerializedName + +/** GeoIP details from `GET /ip` (ketch-types `IPInfo`). */ +data class IPInfo( + @SerializedName("ip") val ip: String? = null, + @SerializedName("hostname") val hostname: String? = null, + @SerializedName("continentCode") val continentCode: String? = null, + @SerializedName("continentName") val continentName: String? = null, + @SerializedName("countryCode") val countryCode: String? = null, + @SerializedName("countryName") val countryName: String? = null, + @SerializedName("regionCode") val regionCode: String? = null, + @SerializedName("regionName") val regionName: String? = null, + @SerializedName("city") val city: String? = null, + @SerializedName("postalCode") val postalCode: String? = null, + @SerializedName("timezone") val timezone: String? = null, +) + +/** Response from headless `fetchLocation()`. */ +data class LocationResponse( + @SerializedName("location") val location: IPInfo? = null, +) + +/** Parameters for v3 `getFullConfiguration` (ketch-types `GetFullConfigurationRequest`). */ +data class FullConfigurationRequest( + val organizationCode: String, + val propertyCode: String, + val environmentCode: String? = null, + val jurisdictionCode: String? = null, + val languageCode: String? = null, + val hash: String? = null, +) + +/** Request body for `POST /consent/{org}/get`. */ +data class ConsentConfig( + val organizationCode: String, + val propertyCode: String, + val environmentCode: String, + val jurisdictionCode: String, + val identities: Map, + val purposes: Map, +) { + data class PurposeLegalBasis( + @SerializedName("legalBasisCode") val legalBasisCode: String, + ) +} + +/** Request body for `POST /consent/{org}/update`. */ +data class ConsentUpdate( + val organizationCode: String, + val propertyCode: String, + val environmentCode: String, + val identities: Map, + val jurisdictionCode: String, + val migrationOption: MigrationOption, + val purposes: Map, + val vendors: List? = null, + val protocols: Map? = null, +) { + enum class MigrationOption { + @SerializedName("MIGRATE_DEFAULT") + MIGRATE_DEFAULT, + + @SerializedName("MIGRATE_NEVER") + MIGRATE_NEVER, + + @SerializedName("MIGRATE_FROM_ALLOW") + MIGRATE_FROM_ALLOW, + + @SerializedName("MIGRATE_FROM_DENY") + MIGRATE_FROM_DENY, + + @SerializedName("MIGRATE_ALWAYS") + MIGRATE_ALWAYS, + } + + data class PurposeAllowedLegalBasis( + @SerializedName("allowed") val allowed: String, + @SerializedName("legalBasisCode") val legalBasisCode: String, + ) { + constructor(allowed: Boolean, legalBasisCode: String) : this( + allowed = allowed.toString(), + legalBasisCode = legalBasisCode, + ) + } +} + +/** Full configuration from bootstrap / full-config CDN endpoints. */ +data class HeadlessConfiguration( + @SerializedName("experiences") val experiences: Experiences? = null, + @SerializedName("theme") val theme: KetchTheme? = null, + @SerializedName("rights") val rights: List? = null, + @SerializedName("jurisdiction") val jurisdiction: ConfigurationJurisdiction? = null, + @SerializedName("purposes") val purposes: List? = null, +) + +data class ConfigurationRight( + @SerializedName("code") val code: String? = null, + @SerializedName("name") val name: String? = null, + @SerializedName("description") val description: String? = null, +) + +data class ConfigurationJurisdiction( + @SerializedName("code") val code: String? = null, + @SerializedName("defaultJurisdictionCode") val defaultJurisdictionCode: String? = null, +) + +data class ConfigurationPurpose( + @SerializedName("code") val code: String? = null, + @SerializedName("legalBasisCode") val legalBasisCode: String? = null, +) + +/** ketch-types `DataSubject` */ +data class DataSubject( + @SerializedName("email") val email: String, + @SerializedName("firstName") val firstName: String, + @SerializedName("lastName") val lastName: String, + @SerializedName("country") val country: String? = null, + @SerializedName("stateRegion") val stateRegion: String? = null, + @SerializedName("city") val city: String? = null, + @SerializedName("description") val description: String? = null, + @SerializedName("phone") val phone: String? = null, + @SerializedName("postalCode") val postalCode: String? = null, + @SerializedName("addressLine1") val addressLine1: String? = null, + @SerializedName("addressLine2") val addressLine2: String? = null, +) + +/** ketch-types `InvokeRightRequest` */ +data class InvokeRightRequest( + val organizationCode: String, + val propertyCode: String, + val environmentCode: String, + val identities: Map, + val jurisdictionCode: String, + val rightCode: String, + val user: DataSubject, + val controllerCode: String? = null, + val invokedAt: Long? = null, + val recaptchaToken: String? = null, + val regionCode: String? = null, + val isAuthenticated: Boolean? = null, +) + +/** ketch-types `ProfilePreferencesIdentity` */ +data class ProfilePreferencesIdentity( + @SerializedName("identitySpace") val identitySpace: String, + @SerializedName("identityValue") val identityValue: String, +) + +/** ketch-types `ProfilePreferencesAttribute` */ +data class ProfilePreferencesAttribute( + @SerializedName("attributeCode") val attributeCode: String, + @SerializedName("attributeValue") val attributeValue: String? = null, + @SerializedName("source") val source: String, +) + +/** ketch-types `ProfilePreferencesContext` */ +data class ProfilePreferencesContext( + @SerializedName("source") val source: String, + @SerializedName("updatedAt") val updatedAt: Long? = null, + @SerializedName("configId") val configId: String? = null, +) + +/** ketch-types `GetProfileRequest` */ +data class GetProfileRequest( + val organizationCode: String, + val propertyCode: String, + val jurisdictionCode: String, + val languageCode: String, + val identities: List, + val controllerCode: String? = null, + val environmentCode: String? = null, + @SerializedName("accountID") val accountId: String? = null, + val regionCode: String? = null, +) + +/** ketch-types `GetProfileResponse` */ +data class GetProfileResponse( + val controllerCode: String? = null, + val propertyCode: String? = null, + val environmentCode: String? = null, + val jurisdictionCode: String? = null, + val regionCode: String? = null, + val attributes: List? = null, +) + +/** ketch-types `PutProfileRequest` */ +data class PutProfileRequest( + val organizationCode: String, + val propertyCode: String, + val jurisdictionCode: String, + val languageCode: String, + val identities: List, + val context: ProfilePreferencesContext, + val controllerCode: String? = null, + val environmentCode: String? = null, + val attributes: List? = null, + val accountId: String? = null, + val regionCode: String? = null, +) + +/** ketch-types `GetSubscriptionConfigurationRequest` */ +data class SubscriptionConfigurationRequest( + val organizationCode: String, + val propertyCode: String, + val languageCode: String, + val experienceCode: String, +) + +/** Subset of ketch-types `SubscriptionConfiguration` */ +data class SubscriptionConfiguration( + val identities: Map? = null, + val controls: List>? = null, + val topics: List>? = null, +) + +/** ketch-types `GetPreferenceQRRequest` */ +data class PreferenceQRRequest( + val organizationCode: String, + val propertyCode: String, + val environmentCode: String? = null, + val imageSize: Int? = null, + val path: String? = null, + val backgroundColor: String? = null, + val foregroundColor: String? = null, + val parameters: Map = emptyMap(), +) + +/** ketch-types `WebReportRequest` */ +data class WebReportRequest( + val type: String, + val age: Int, + val url: String, + @SerializedName("user_agent") val userAgent: String, + val body: Map, +) + +/** ketch-types `GetSubscriptionsRequest` / `SetSubscriptionsRequest` */ +data class SubscriptionsRequest( + val organizationCode: String, + val controllerCode: String? = null, + val propertyCode: String? = null, + val environmentCode: String? = null, + val identities: Map? = null, + val topics: Map>? = null, + val controls: Map>? = null, + val collectedAt: Long? = null, + val jurisdictionCode: String? = null, + val regionCode: String? = null, +) + +/** ketch-types `GetSubscriptionsResponse` */ +typealias SubscriptionsResponse = SubscriptionsRequest + +/** Errors from native headless HTTP calls. */ +class HeadlessException(message: String, cause: Throwable? = null) : Exception(message, cause) diff --git a/ketchsdk/src/main/java/com/ketch/android/data/Index.kt b/ketchsdk/src/main/java/com/ketch/android/data/Index.kt index e8988c59..8c05118e 100644 --- a/ketchsdk/src/main/java/com/ketch/android/data/Index.kt +++ b/ketchsdk/src/main/java/com/ketch/android/data/Index.kt @@ -1,5 +1,7 @@ package com.ketch.android.data +import com.google.gson.Gson + /* * https://global.ketchcdn.com/web/v3//config/ketch_samples/android/boot.js?ketch_log=DEBUG * &ketch_lang=en&ketch_jurisdiction=default&ketch_region=US @@ -23,7 +25,8 @@ fun getIndexHtml( ageUpper: Int? = null, bottomPadding: String = "0px", topPadding: String = "0px", - cssStyleOverride: String? = null + cssStyleOverride: String? = null, + webResourceUrlOverrides: Map = emptyMap(), ) = "\n" + " \n" + @@ -128,6 +131,7 @@ fun getIndexHtml( " };\n" + " })(window.console);\n" + "\n" + + webResourceUrlOverridesScript(webResourceUrlOverrides) + " function initKetchTag(parameters) {\n" + " console.log('Ketch Tag is initialization started...');\n" + " // Use parameters to set SDK query params here\n" + @@ -204,3 +208,61 @@ fun getIndexHtml( " \n" + " \n" + "" + +private fun webResourceUrlOverridesScript(overrides: Map): String { + if (overrides.isEmpty()) return "" + val overridesJson = Gson().toJson(overrides) + return webResourceUrlOverridesInstallScriptBody() + + "\n installWebResourceUrlOverrides($overridesJson);\n" +} + +private fun webResourceUrlOverridesInstallScriptBody(): String = + """ + function installWebResourceUrlOverrides(overrides) { + if (!overrides || !Object.keys(overrides).length) return; + function resolveUrl(url) { + if (!url) return url; + if (overrides[url]) return overrides[url]; + var base = url.split('?')[0].split('#')[0]; + if (base !== url && overrides[base]) return overrides[base]; + for (var key in overrides) { + if (!Object.prototype.hasOwnProperty.call(overrides, key)) continue; + if (key === url || key === base) continue; + if (key.charAt(0) === '/' && base.indexOf(key) !== -1) return overrides[key]; + if (key.indexOf('://') !== -1) continue; + if (base.endsWith(key) || base.indexOf('/' + key) !== -1) return overrides[key]; + } + return url; + } + var srcDesc = Object.getOwnPropertyDescriptor(HTMLScriptElement.prototype, 'src'); + if (srcDesc && srcDesc.set) { + var nativeSrcSet = srcDesc.set; + var nativeSrcGet = srcDesc.get; + Object.defineProperty(HTMLScriptElement.prototype, 'src', { + set: function (value) { nativeSrcSet.call(this, resolveUrl(value)); }, + get: nativeSrcGet, + configurable: true, + }); + } + var origSetAttribute = Element.prototype.setAttribute; + Element.prototype.setAttribute = function (name, value) { + if (name === 'src' && this.tagName === 'SCRIPT') { + return origSetAttribute.call(this, name, resolveUrl(value)); + } + return origSetAttribute.call(this, name, value); + }; + if (window.fetch) { + var origFetch = window.fetch.bind(window); + window.fetch = function (input, init) { + if (typeof input === 'string') { + var mapped = resolveUrl(input); + if (mapped !== input) input = mapped; + } else if (input && input.url) { + var mappedUrl = resolveUrl(input.url); + if (mappedUrl !== input.url) input = new Request(mappedUrl, input); + } + return origFetch(input, init); + }; + } + } + """.trimIndent() + "\n" diff --git a/ketchsdk/src/main/java/com/ketch/android/ui/KetchWebView.kt b/ketchsdk/src/main/java/com/ketch/android/ui/KetchWebView.kt index 353600f3..bb5e2b4d 100644 --- a/ketchsdk/src/main/java/com/ketch/android/ui/KetchWebView.kt +++ b/ketchsdk/src/main/java/com/ketch/android/ui/KetchWebView.kt @@ -23,11 +23,15 @@ import com.google.gson.Gson import com.google.gson.GsonBuilder import com.google.gson.JsonParseException import com.ketch.android.Ketch +import com.ketch.android.KetchSharedPreferences +import com.ketch.android.parseNativeStoragePutPayload import com.ketch.android.data.Consent import com.ketch.android.data.ContentDisplay import com.ketch.android.data.HideExperienceStatus import com.ketch.android.data.KetchConfig import com.ketch.android.data.WillShowExperienceType +import com.ketch.android.data.summarizeConfigJson +import com.ketch.android.data.summarizePurposesJson import com.ketch.android.data.getIndexHtml import com.ketch.android.data.parseHideExperienceStatus import com.ketch.android.data.parseWillShowExperienceType @@ -47,8 +51,15 @@ class KetchWebView(context: Context, shouldRetry: Boolean = false) : WebView(con var listener: WebViewListener? = null private val localContentWebViewClient = LocalContentWebViewClient(shouldRetry) + private var webResourceUrlOverrides: Map = emptyMap() + + fun setWebResourceUrlOverrides(overrides: Map) { + webResourceUrlOverrides = overrides + localContentWebViewClient.webResourceUrlOverrides = overrides + } init { + KetchSharedPreferences.initialize(context) setBackgroundColor(Color.TRANSPARENT) webViewClient = localContentWebViewClient @@ -121,6 +132,8 @@ class KetchWebView(context: Context, shouldRetry: Boolean = false) : WebView(con class LocalContentWebViewClient(private var shouldRetry: Boolean = false) : WebViewClientCompat() { + var webResourceUrlOverrides: Map = emptyMap() + // Flag indicating if the webview has finished loading // We use atomic boolean here because we are using it within a coroutine private var isLoaded = AtomicBoolean(false) @@ -140,6 +153,14 @@ class KetchWebView(context: Context, shouldRetry: Boolean = false) : WebView(con return true } + override fun shouldInterceptRequest( + view: WebView, + request: WebResourceRequest, + ): WebResourceResponse? { + WebResourceOverrideHandler.intercept(webResourceUrlOverrides, request)?.let { return it } + return super.shouldInterceptRequest(view, request) + } + override fun onLoadResource(view: WebView?, url: String?) { super.onLoadResource(view, url) Log.d(TAG, "onLoadResource: $url") @@ -155,8 +176,9 @@ class KetchWebView(context: Context, shouldRetry: Boolean = false) : WebView(con } (view as? KetchWebView)?.let { ketchWebView -> + Log.w(TAG, "onDismiss source=rendererCrash status=None") ketchWebView.kill() - ketchWebView.listener?.onClose(HideExperienceStatus.None, retainWebView = false) + ketchWebView.listener?.onClose(HideExperienceStatus.None, source = "rendererCrash", retainWebView = false) } return true @@ -246,8 +268,10 @@ class KetchWebView(context: Context, shouldRetry: Boolean = false) : WebView(con ageUpper: Int?, bottomPadding: Int?, topPadding: Int?, - cssStyle: String? + cssStyle: String?, + webResourceUrlOverrides: Map = emptyMap(), ) { + setWebResourceUrlOverrides(webResourceUrlOverrides) clearCache(true) // Convert padding values to string @@ -280,7 +304,8 @@ class KetchWebView(context: Context, shouldRetry: Boolean = false) : WebView(con ageUpper = ageUpper, bottomPadding = bottomPaddingPx, topPadding = topPaddingPx, - cssStyleOverride = cssStyle + cssStyleOverride = cssStyle, + webResourceUrlOverrides = webResourceUrlOverrides, ) loadDataWithBaseURL("http://localhost", indexHtml, "text/html", "UTF-8", null) @@ -379,7 +404,10 @@ class KetchWebView(context: Context, shouldRetry: Boolean = false) : WebView(con @JavascriptInterface fun onConfigLoaded(configJson: String?) { - Log.d(TAG, "onConfigLoaded: $configJson") + val configSummary = summarizeConfigJson(configJson) + val purposesSummary = summarizePurposesJson(configJson) + Log.d(TAG, "onConfigLoaded summary: $configSummary") + Log.d(TAG, "onConfigLoaded purposes: $purposesSummary") try { val config = GsonBuilder() @@ -389,10 +417,15 @@ class KetchWebView(context: Context, shouldRetry: Boolean = false) : WebView(con .fromJson(configJson, KetchConfig::class.java) Log.d(TAG, "config: $config") runOnMainThread { + ketchWebView.listener?.onConfigDebugInfo(configSummary, purposesSummary) ketchWebView.listener?.onConfigUpdated(config) } } catch (ex: JsonParseException) { Log.e(TAG, ex.message, ex) + runOnMainThread { + ketchWebView.listener?.onConfigDebugInfo(configSummary, purposesSummary) + ketchWebView.listener?.onConfigUpdated(null) + } } } @@ -444,6 +477,28 @@ class KetchWebView(context: Context, shouldRetry: Boolean = false) : WebView(con } } + @JavascriptInterface + fun nativeStoragePut(payloadJson: String?) { + if (payloadJson.isNullOrBlank()) { + Log.e(TAG, "nativeStoragePut: empty payload") + return + } + val payload = parseNativeStoragePutPayload(payloadJson) + if (payload == null) { + Log.e(TAG, "nativeStoragePut: invalid payload") + return + } + try { + KetchSharedPreferences.write(payload.key, payload.value) + Log.d(TAG, "nativeStoragePut: key=${payload.key}") + runOnMainThread { + ketchWebView.listener?.onNativeStoragePut(payload.key, payload.value) + } + } catch (ex: IllegalStateException) { + Log.e(TAG, "nativeStoragePut: ${ex.message}", ex) + } + } + private fun parseIabTcfGpp(json: String): Map? { val gson = GsonBuilder() .create() @@ -467,6 +522,7 @@ class KetchWebView(context: Context, shouldRetry: Boolean = false) : WebView(con fun onTCFUpdated(values: Map) fun onGPPUpdated(values: Map) fun onConfigUpdated(config: KetchConfig?) + fun onConfigDebugInfo(configSummary: String, purposesSummary: String) {} fun onEnvironmentUpdated(environment: String?) fun onRegionInfoUpdated(regionInfo: String?) fun onJurisdictionUpdated(jurisdiction: String?) @@ -474,9 +530,11 @@ class KetchWebView(context: Context, shouldRetry: Boolean = false) : WebView(con fun onConsentUpdated(consent: Consent) fun onError(errMsg: String?) fun changeDialog(display: ContentDisplay) - fun onClose(status: HideExperienceStatus, retainWebView: Boolean = true) + fun onClose(status: HideExperienceStatus, source: String = "hideExperience", retainWebView: Boolean = true) fun onWillShowExperience(experienceType: WillShowExperienceType) fun onHasShownExperience() + fun onTapOutside() + fun onNativeStoragePut(key: String, value: String) {} } internal enum class ExperienceType { diff --git a/ketchsdk/src/main/java/com/ketch/android/ui/WebResourceOverrideHandler.kt b/ketchsdk/src/main/java/com/ketch/android/ui/WebResourceOverrideHandler.kt new file mode 100644 index 00000000..69141821 --- /dev/null +++ b/ketchsdk/src/main/java/com/ketch/android/ui/WebResourceOverrideHandler.kt @@ -0,0 +1,62 @@ +package com.ketch.android.ui + +import android.util.Log +import android.webkit.WebResourceRequest +import android.webkit.WebResourceResponse +import java.net.HttpURLConnection +import java.net.URL + +/** + * Redirects exact-match WebView resource URLs (e.g. UAT tag scripts) to local dev servers. + */ +internal object WebResourceOverrideHandler { + private const val TAG = "KetchWebOverride" + + fun intercept( + overrides: Map, + request: WebResourceRequest?, + ): WebResourceResponse? { + if (overrides.isEmpty() || request == null) return null + val sourceUrl = request.url?.toString() ?: return null + val destinationUrl = WebResourceUrlOverrideResolver.resolve(sourceUrl, overrides) ?: return null + return loadResponse(sourceUrl, destinationUrl) + } + + private fun loadResponse(sourceUrl: String, destinationUrl: String): WebResourceResponse? { + var connection: HttpURLConnection? = null + return try { + connection = URL(destinationUrl).openConnection() as HttpURLConnection + when (val responseCode = connection.responseCode) { + in 200..299 -> { + Log.d(TAG, "Redirected $sourceUrl -> $destinationUrl") + WebResourceResponse( + mimeTypeFor(destinationUrl), + connection.contentEncoding ?: "UTF-8", + connection.inputStream, + ) + } + else -> { + Log.e( + TAG, + "Override destination HTTP $responseCode for $sourceUrl -> $destinationUrl", + ) + connection.errorStream?.close() + connection.disconnect() + connection = null + null + } + } + } catch (e: Exception) { + Log.e(TAG, "Failed override $sourceUrl -> $destinationUrl: ${e.message}", e) + connection?.disconnect() + null + } + } + + private fun mimeTypeFor(url: String): String = when { + url.endsWith(".js", ignoreCase = true) -> "application/javascript" + url.endsWith(".json", ignoreCase = true) -> "application/json" + url.endsWith(".css", ignoreCase = true) -> "text/css" + else -> "text/plain" + } +} diff --git a/ketchsdk/src/main/java/com/ketch/android/ui/WebResourceUrlOverrideResolver.kt b/ketchsdk/src/main/java/com/ketch/android/ui/WebResourceUrlOverrideResolver.kt new file mode 100644 index 00000000..1989a2be --- /dev/null +++ b/ketchsdk/src/main/java/com/ketch/android/ui/WebResourceUrlOverrideResolver.kt @@ -0,0 +1,32 @@ +package com.ketch.android.ui + +/** + * Resolves a WebView resource URL against override rules. + * + * Keys may be: + * - Exact URL (with or without query string) + * - Path fragment (starts with `/`, matched via [String.contains]) + * - Filename suffix (e.g. `ketch-sdk.js`, matched via path ending) + */ +internal object WebResourceUrlOverrideResolver { + fun resolve(sourceUrl: String, overrides: Map): String? { + if (overrides.isEmpty() || sourceUrl.isBlank()) return null + + overrides[sourceUrl]?.let { return it } + + val base = sourceUrl.substringBefore('#').substringBefore('?') + if (base != sourceUrl) { + overrides[base]?.let { return it } + } + + for ((pattern, destination) in overrides) { + if (pattern == sourceUrl || pattern == base) continue + when { + pattern.startsWith("/") && base.contains(pattern) -> return destination + pattern.contains("://") -> Unit + base.endsWith(pattern) || base.contains("/$pattern") -> return destination + } + } + return null + } +} diff --git a/ketchsdk/src/test/java/com/ketch/android/NativeStorageTest.kt b/ketchsdk/src/test/java/com/ketch/android/NativeStorageTest.kt new file mode 100644 index 00000000..0cd9166d --- /dev/null +++ b/ketchsdk/src/test/java/com/ketch/android/NativeStorageTest.kt @@ -0,0 +1,161 @@ +package com.ketch.android + +import android.content.SharedPreferences +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Before +import org.junit.Test + +class NativeStoragePutPayloadTest { + @Test + fun parse_validPayload() { + val payload = parseNativeStoragePutPayload("""{"key":"consent_id","value":"abc-123"}""") + assertEquals(NativeStoragePutPayload("consent_id", "abc-123"), payload) + } + + @Test + fun parse_blankKey_returnsNull() { + assertNull(parseNativeStoragePutPayload("""{"key":" ","value":"x"}""")) + assertNull(parseNativeStoragePutPayload("""{"key":"","value":"x"}""")) + } + + @Test + fun parse_missingKey_returnsNull() { + assertNull(parseNativeStoragePutPayload("""{"value":"x"}""")) + } + + @Test + fun parse_malformedJson_returnsNull() { + assertNull(parseNativeStoragePutPayload("not-json")) + } + + @Test + fun parse_missingValue_defaultsToEmptyString() { + assertEquals(NativeStoragePutPayload("k", ""), parseNativeStoragePutPayload("""{"key":"k"}""")) + } +} + +class KetchSharedPreferencesStorageTest { + private lateinit var prefs: SharedPreferences + + @Before + fun setUp() { + prefs = MemorySharedPreferences() + KetchSharedPreferences.bindPreferencesForTesting(prefs) + } + + @After + fun tearDown() { + KetchSharedPreferences.resetForTesting() + } + + @Test + fun write_thenRead_roundTrip() { + KetchSharedPreferences.write("foo", "bar") + assertEquals("bar", KetchSharedPreferences.read("foo")) + } + + @Test + fun read_missingKey_returnsDefault() { + assertEquals("fallback", KetchSharedPreferences.read("missing", "fallback")) + } + + @Test + fun remove_deletesWrittenKey() { + KetchSharedPreferences.write("gone", "bye") + KetchSharedPreferences.remove("gone") + assertEquals("missing", KetchSharedPreferences.read("gone", "missing")) + } + + @Test + fun removeValues_removesOnlyPrefixMatchingKeys() { + KetchSharedPreferences.write("IABTCF_TCString", "abc") + KetchSharedPreferences.write("IABGPP_HDR_Version", "1") + KetchSharedPreferences.write("keep_me", "safe") + + val removed = KetchSharedPreferences.removeValues(listOf("IABTCF", "IABGPP", "IABUS")) + + assertEquals(2, removed) + assertEquals("missing", KetchSharedPreferences.read("IABTCF_TCString", "missing")) + assertEquals("missing", KetchSharedPreferences.read("IABGPP_HDR_Version", "missing")) + assertEquals("safe", KetchSharedPreferences.read("keep_me")) + } +} + +private class MemorySharedPreferences : SharedPreferences { + private val store = linkedMapOf() + + override fun getAll(): Map = store.toMap() + + override fun getString(key: String, defValue: String?): String? = store[key] ?: defValue + + override fun getStringSet(key: String, defValues: MutableSet?): MutableSet? = null + + override fun getInt(key: String, defValue: Int): Int = defValue + + override fun getLong(key: String, defValue: Long): Long = defValue + + override fun getFloat(key: String, defValue: Float): Float = defValue + + override fun getBoolean(key: String, defValue: Boolean): Boolean = defValue + + override fun contains(key: String): Boolean = store.containsKey(key) + + override fun edit(): SharedPreferences.Editor = EditorImpl() + + override fun registerOnSharedPreferenceChangeListener( + listener: SharedPreferences.OnSharedPreferenceChangeListener?, + ) = Unit + + override fun unregisterOnSharedPreferenceChangeListener( + listener: SharedPreferences.OnSharedPreferenceChangeListener?, + ) = Unit + + private inner class EditorImpl : SharedPreferences.Editor { + private val pending = linkedMapOf() + + override fun putString(key: String?, value: String?): SharedPreferences.Editor = apply { + if (key != null) pending[key] = value + } + + override fun putStringSet( + key: String?, + values: MutableSet?, + ): SharedPreferences.Editor = this + + override fun putInt(key: String?, value: Int): SharedPreferences.Editor = this + + override fun putLong(key: String?, value: Long): SharedPreferences.Editor = this + + override fun putFloat(key: String?, value: Float): SharedPreferences.Editor = this + + override fun putBoolean(key: String?, value: Boolean): SharedPreferences.Editor = this + + override fun remove(key: String?): SharedPreferences.Editor = apply { + if (key != null) pending[key] = null + } + + override fun clear(): SharedPreferences.Editor = apply { pending.clear() } + + override fun commit(): Boolean { + applyPending() + return true + } + + override fun apply() { + applyPending() + } + + private fun applyPending() { + pending.forEach { (key, value) -> + if (value == null) { + store.remove(key) + } else { + store[key] = value + } + } + pending.clear() + } + } +} diff --git a/ketchsdk/src/test/java/com/ketch/android/api/HeadlessApiClientTest.kt b/ketchsdk/src/test/java/com/ketch/android/api/HeadlessApiClientTest.kt new file mode 100644 index 00000000..614cb432 --- /dev/null +++ b/ketchsdk/src/test/java/com/ketch/android/api/HeadlessApiClientTest.kt @@ -0,0 +1,84 @@ +package com.ketch.android.api + +import org.junit.Assert.assertEquals +import org.junit.Test + +class HeadlessApiClientTest { + private val client = HeadlessApiClient(KetchDataCenter.US) + + @Test + fun buildUrl_ip() { + assertEquals( + "https://global.ketchcdn.com/web/v3/ip", + client.buildUrl("/ip"), + ) + } + + @Test + fun buildUrl_bootstrap() { + assertEquals( + "https://global.ketchcdn.com/web/v3/config/acme/prop/boot.json", + client.buildUrl("/config/acme/prop/boot.json"), + ) + } + + @Test + fun buildUrl_fullConfigurationWithHash() { + assertEquals( + "https://global.ketchcdn.com/web/v3/config/acme/prop/prod/us-ca/en-US/config.json?hash=8913461971881236311", + client.buildUrl( + "/config/acme/prop/prod/us-ca/en-US/config.json", + mapOf("hash" to "8913461971881236311"), + ), + ) + } + + @Test + fun buildUrl_euDataCenter() { + val eu = HeadlessApiClient(KetchDataCenter.EU) + assertEquals("https://eu.ketchcdn.com/web/v3/ip", eu.buildUrl("/ip")) + } + + @Test + fun preferenceQRUrl_matchesContractFixture() { + val url = client.preferenceQRUrl( + com.ketch.android.data.PreferenceQRRequest( + organizationCode = "switchbitcorp", + propertyCode = "switchbit", + environmentCode = "production", + imageSize = 1024, + path = "/policy.html", + backgroundColor = "white", + foregroundColor = "black", + parameters = mapOf("foo" to "bar"), + ), + ) + assertEquals( + "https://global.ketchcdn.com/web/v3/qr/switchbitcorp/switchbit/preferences.png?env=production&size=1024&path=%2Fpolicy.html&bgcolor=white&fgcolor=black&foo=bar", + url, + ) + } + + @Test + fun buildUrl_rightsProfileSubscriptions() { + assertEquals( + "https://global.ketchcdn.com/web/v3/rights/switchbitcorp/invoke", + client.buildUrl("/rights/switchbitcorp/invoke"), + ) + assertEquals( + "https://global.ketchcdn.com/web/v3/profile/acme/get", + client.buildUrl("/profile/acme/get"), + ) + assertEquals( + "https://global.ketchcdn.com/web/v3/subscriptions/acme/update", + client.buildUrl("/subscriptions/acme/update"), + ) + } + + @Test + fun ketchDataCenterBaseUrls() { + assertEquals("https://global.ketchcdn.com/web/v3", KetchDataCenter.US.baseUrl) + assertEquals("https://eu.ketchcdn.com/web/v3", KetchDataCenter.EU.baseUrl) + assertEquals("https://dev.ketchcdn.com/web/v3", KetchDataCenter.UAT.baseUrl) + } +} diff --git a/ketchsdk/src/test/java/com/ketch/android/api/HeadlessConsentPayloadTest.kt b/ketchsdk/src/test/java/com/ketch/android/api/HeadlessConsentPayloadTest.kt new file mode 100644 index 00000000..21c57469 --- /dev/null +++ b/ketchsdk/src/test/java/com/ketch/android/api/HeadlessConsentPayloadTest.kt @@ -0,0 +1,94 @@ +package com.ketch.android.api + +import com.google.gson.Gson +import com.ketch.android.data.ConsentConfig +import com.ketch.android.data.ConsentUpdate +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class HeadlessConsentPayloadTest { + private val gson = Gson() + + @Test + fun setConsentPayloadOmitsProtocols() { + val update = ConsentUpdate( + organizationCode = "org", + propertyCode = "prop", + environmentCode = "production", + identities = mapOf("id" to "1"), + jurisdictionCode = "default", + migrationOption = ConsentUpdate.MigrationOption.MIGRATE_DEFAULT, + purposes = mapOf( + "analytics" to ConsentUpdate.PurposeAllowedLegalBasis( + allowed = true, + legalBasisCode = "consent_optin", + ), + ), + vendors = null, + protocols = mapOf("gpp" to "DBABLA~"), + ) + val payload = SetConsentPayloadForTesting.from(update.copy(protocols = null)) + val json = gson.toJsonTree(payload).asJsonObject + assertNull(json.get("protocols")) + assertEquals("org", json.get("organizationCode").asString) + } + + @Test + fun consentConfigPayloadOmitsCachedAt() { + val config = ConsentConfig( + organizationCode = "org", + propertyCode = "prop", + environmentCode = "production", + jurisdictionCode = "default", + identities = emptyMap(), + purposes = emptyMap(), + ) + val json = gson.toJsonTree(ConsentConfigPayloadForTesting.from(config)).asJsonObject + assertNull(json.get("cachedAt")) + } + + private data class SetConsentPayloadForTesting( + val organizationCode: String, + val propertyCode: String, + val environmentCode: String, + val identities: Map, + val jurisdictionCode: String, + val migrationOption: ConsentUpdate.MigrationOption, + val purposes: Map, + val vendors: List?, + ) { + companion object { + fun from(update: ConsentUpdate) = SetConsentPayloadForTesting( + organizationCode = update.organizationCode, + propertyCode = update.propertyCode, + environmentCode = update.environmentCode, + identities = update.identities, + jurisdictionCode = update.jurisdictionCode, + migrationOption = update.migrationOption, + purposes = update.purposes, + vendors = update.vendors, + ) + } + } + + private data class ConsentConfigPayloadForTesting( + val organizationCode: String, + val propertyCode: String, + val environmentCode: String, + val jurisdictionCode: String, + val identities: Map, + val purposes: Map, + ) { + companion object { + fun from(config: ConsentConfig) = ConsentConfigPayloadForTesting( + organizationCode = config.organizationCode, + propertyCode = config.propertyCode, + environmentCode = config.environmentCode, + jurisdictionCode = config.jurisdictionCode, + identities = config.identities, + purposes = config.purposes, + ) + } + } +} diff --git a/ketchsdk/src/test/java/com/ketch/android/api/HeadlessConsentTest.kt b/ketchsdk/src/test/java/com/ketch/android/api/HeadlessConsentTest.kt new file mode 100644 index 00000000..5e8ec4a9 --- /dev/null +++ b/ketchsdk/src/test/java/com/ketch/android/api/HeadlessConsentTest.kt @@ -0,0 +1,168 @@ +package com.ketch.android.api + +import com.ketch.android.data.Consent +import com.ketch.android.data.ConsentConfig +import com.ketch.android.data.ConsentUpdate +import com.ketch.android.data.HeadlessException +import kotlinx.coroutines.runBlocking +import okhttp3.OkHttpClient +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Assert.fail +import org.junit.Before +import org.junit.Test +import java.io.IOException +import java.net.HttpURLConnection + +class HeadlessConsentTest { + private lateinit var mockWebServer: MockWebServer + + @Before + fun setUp() { + mockWebServer = MockWebServer() + mockWebServer.start() + } + + @After + fun tearDown() { + mockWebServer.shutdown() + } + + @Test + fun fetchConsentPropagatesHttpFailure() = runBlocking { + mockWebServer.enqueue(MockResponse().setResponseCode(HttpURLConnection.HTTP_INTERNAL_ERROR)) + + val client = headlessClient() + try { + client.fetchConsent(sampleConsentConfig()) + fail("Expected fetchConsent to fail on HTTP 500") + } catch (error: HeadlessException) { + assertTrue(error.message?.contains("HTTP 500") == true) + } + } + + @Test + fun setConsentPropagatesNetworkFailure() = runBlocking { + val failingClient = OkHttpClient.Builder() + .addInterceptor { throw IOException("not connected to internet") } + .build() + val client = HeadlessApiClient( + dataCenter = KetchDataCenter.US, + okHttpClient = failingClient, + ) + + try { + client.setConsent(sampleConsentUpdate()) + fail("Expected setConsent to fail on network error") + } catch (error: HeadlessException) { + assertTrue(error.message?.contains("Network error") == true) + assertTrue(error.cause != null) + } + } + + @Test + fun fetchConsentReturnsEmptyConsentOn200NullBody() = runBlocking { + mockWebServer.enqueue( + MockResponse() + .setResponseCode(HttpURLConnection.HTTP_OK) + .setBody("null") + .addHeader("Content-Type", "application/json"), + ) + + val client = headlessClient() + val consent = client.fetchConsent(sampleConsentConfig()) + + assertEquals(emptyMap(), consent.purposes) + assertNull(consent.vendors) + assertNull(consent.protocols) + } + + @Test + fun fetchProtocolsPreservesPurposesWhenProtocolsMissing() = runBlocking { + mockWebServer.enqueue( + MockResponse() + .setResponseCode(HttpURLConnection.HTTP_OK) + .setBody("""{"purposes":{"analytics":true,"marketing":false}}""") + .addHeader("Content-Type", "application/json"), + ) + + val client = headlessClient() + val consent = client.fetchProtocols(sampleConsentConfig()) + + assertNull(consent.protocols) + assertEquals(true, consent.purposes?.get("analytics")) + assertEquals(false, consent.purposes?.get("marketing")) + } + + @Test + fun setConsentAcceptsProtocolsOnlyResponse() = runBlocking { + mockWebServer.enqueue( + MockResponse() + .setResponseCode(HttpURLConnection.HTTP_OK) + .setBody("""{"protocols":{"gpp":"DBABLA~BVQqAAAAAAJY.QA"}}""") + .addHeader("Content-Type", "application/json"), + ) + + val client = headlessClient() + val consent = client.setConsent(sampleConsentUpdate()) + + assertNull(consent.purposes) + assertEquals("DBABLA~BVQqAAAAAAJY.QA", consent.protocols?.get("gpp")) + } + + private fun headlessClient(): HeadlessApiClient { + val okHttpClient = OkHttpClient.Builder() + .addInterceptor { chain -> + val original = chain.request() + val redirected = original.newBuilder() + .url( + original.url.newBuilder() + .scheme("http") + .host(mockWebServer.hostName) + .port(mockWebServer.port) + .build(), + ) + .build() + chain.proceed(redirected) + } + .build() + return HeadlessApiClient( + dataCenter = KetchDataCenter.US, + okHttpClient = okHttpClient, + ) + } + + private fun sampleConsentConfig(): ConsentConfig = + ConsentConfig( + organizationCode = "org", + propertyCode = "prop", + environmentCode = "production", + jurisdictionCode = "default", + identities = mapOf("email" to "user@example.com"), + purposes = mapOf( + "analytics" to ConsentConfig.PurposeLegalBasis(legalBasisCode = "consent_optin"), + ), + ) + + private fun sampleConsentUpdate(): ConsentUpdate = + ConsentUpdate( + organizationCode = "org", + propertyCode = "prop", + environmentCode = "production", + identities = mapOf("email" to "user@example.com"), + jurisdictionCode = "default", + migrationOption = ConsentUpdate.MigrationOption.MIGRATE_DEFAULT, + purposes = mapOf( + "analytics" to ConsentUpdate.PurposeAllowedLegalBasis( + allowed = true, + legalBasisCode = "consent_optin", + ), + ), + vendors = null, + protocols = null, + ) +} diff --git a/sample-app-compose/src/main/java/com/ketch/android/sample/compose/ComposeSampleApplication.kt b/sample-app-compose/src/main/java/com/ketch/android/sample/compose/ComposeSampleApplication.kt index b9ddb193..9da2fcb0 100644 --- a/sample-app-compose/src/main/java/com/ketch/android/sample/compose/ComposeSampleApplication.kt +++ b/sample-app-compose/src/main/java/com/ketch/android/sample/compose/ComposeSampleApplication.kt @@ -16,6 +16,14 @@ class ComposeSampleApplication : Application() { var logCallback: ((String) -> Unit)? = null + /** + * Optional richer [Ketch.Listener] that MainActivity plugs in to drive its + * SDK Health Dashboard. Ketch itself only supports a single listener (set at + * construction time), so [sharedListener] below always fires first and then + * forwards every callback here when a delegate is registered. + */ + var dashboardListener: Ketch.Listener? = null + override fun onCreate() { super.onCreate() ketch = KetchSdk.create( @@ -34,71 +42,90 @@ class ComposeSampleApplication : Application() { override fun onShow() { Log.d(TAG, "onShow: Dialog shown") log("onShow: Dialog shown") + dashboardListener?.onShow() } override fun onDismiss(status: HideExperienceStatus) { Log.d(TAG, "onDismiss: status=$status") log("onDismiss: status=$status") + dashboardListener?.onDismiss(status) } override fun onConfigUpdated(config: KetchConfig?) { Log.d(TAG, "onConfigUpdated: $config") log("onConfigUpdated") + dashboardListener?.onConfigUpdated(config) + } + + override fun onConfigDebugInfo(configSummary: String, purposesSummary: String) { + Log.d(TAG, "onConfigDebugInfo: $configSummary / $purposesSummary") + dashboardListener?.onConfigDebugInfo(configSummary, purposesSummary) } override fun onEnvironmentUpdated(environment: String?) { Log.d(TAG, "onEnvironmentUpdated: $environment") log("onEnvironmentUpdated: $environment") + dashboardListener?.onEnvironmentUpdated(environment) } override fun onRegionInfoUpdated(regionInfo: String?) { Log.d(TAG, "onRegionInfoUpdated: $regionInfo") log("onRegionInfoUpdated: $regionInfo") + dashboardListener?.onRegionInfoUpdated(regionInfo) } override fun onJurisdictionUpdated(jurisdiction: String?) { Log.d(TAG, "onJurisdictionUpdated: $jurisdiction") log("onJurisdictionUpdated: $jurisdiction") + dashboardListener?.onJurisdictionUpdated(jurisdiction) } override fun onIdentitiesUpdated(identities: String?) { Log.d(TAG, "onIdentitiesUpdated: $identities") log("onIdentitiesUpdated: $identities") + dashboardListener?.onIdentitiesUpdated(identities) } override fun onConsentUpdated(consent: Consent) { Log.d(TAG, "onConsentUpdated: purposes=${consent.purposes}") log("onConsentUpdated: ${consent.purposes}") + dashboardListener?.onConsentUpdated(consent) } override fun onError(errMsg: String?) { Log.e(TAG, "onError: $errMsg") log("ERROR: $errMsg") + dashboardListener?.onError(errMsg) } override fun onUSPrivacyUpdated(values: Map) { Log.d(TAG, "onUSPrivacyUpdated: $values") log("onUSPrivacyUpdated: ${values["IABUSPrivacy_String"]}") + dashboardListener?.onUSPrivacyUpdated(values) } override fun onTCFUpdated(values: Map) { Log.d(TAG, "onTCFUpdated: $values") log("onTCFUpdated: ${values["IABTCF_TCString"]}") + dashboardListener?.onTCFUpdated(values) } override fun onGPPUpdated(values: Map) { Log.d(TAG, "onGPPUpdated: $values") log("onGPPUpdated: ${values["IABGPP_HDR_GppString"]}") + dashboardListener?.onGPPUpdated(values) } override fun onWillShowExperience(type: WillShowExperienceType) { Log.d(TAG, "onWillShowExperience: $type") log("onWillShowExperience: $type") + dashboardListener?.onWillShowExperience(type) } override fun onHasShownExperience() { Log.d(TAG, "onHasShownExperience") log("onHasShownExperience") + dashboardListener?.onHasShownExperience() } } @@ -108,8 +135,8 @@ class ComposeSampleApplication : Application() { companion object { private const val TAG = "KetchCompose" - const val ORG_CODE = "ketch_samples" - const val PROPERTY = "android" + const val ORG_CODE = "ethansch061226" + const val PROPERTY = "website_smart_tag" const val ENVIRONMENT = "production" } } diff --git a/sample-app-compose/src/main/java/com/ketch/android/sample/compose/DevUrlOverrides.kt b/sample-app-compose/src/main/java/com/ketch/android/sample/compose/DevUrlOverrides.kt new file mode 100644 index 00000000..efd771ae --- /dev/null +++ b/sample-app-compose/src/main/java/com/ketch/android/sample/compose/DevUrlOverrides.kt @@ -0,0 +1,20 @@ +package com.ketch.android.sample.compose + +/** + * Flip [ENABLED] to redirect UAT tag script URLs to local dev servers. + * Android emulator: use [forEmulator] (localhost when using simulator/port-forward). + */ +object DevUrlOverrides { + const val ENABLED = false + + val forEmulator: Map = mapOf( + "https://cdn.uat.ketchjs.com/ketchtag/stable/v2.12/ketch-sdk.js" to "http://localhost:9000/ketch-sdk.js", + "ketch-sdk.js" to "http://localhost:9000/ketch-sdk.js", + ) + + /** Physical device — use host machine LAN IP if localhost is unreachable from device. */ + val forDevice: Map = mapOf( + "https://cdn.uat.ketchjs.com/ketchtag/stable/v2.12/ketch-sdk.js" to "http://localhost:9000/ketch-sdk.js", + "ketch-sdk.js" to "http://localhost:9000/ketch-sdk.js", + ) +} diff --git a/sample-app-compose/src/main/java/com/ketch/android/sample/compose/HeadlessSampleSupport.kt b/sample-app-compose/src/main/java/com/ketch/android/sample/compose/HeadlessSampleSupport.kt new file mode 100644 index 00000000..4935e3e7 --- /dev/null +++ b/sample-app-compose/src/main/java/com/ketch/android/sample/compose/HeadlessSampleSupport.kt @@ -0,0 +1,46 @@ +package com.ketch.android.sample.compose + +import com.ketch.android.api.KetchDataCenter +import com.ketch.android.data.ConsentConfig +import com.ketch.android.data.HeadlessConfiguration +import java.util.UUID + +object HeadlessSampleSupport { + const val ORG_CODE = "ethansch061226" + const val PROPERTY = "website_smart_tag" + const val ENVIRONMENT = "production" + val dataCenter: KetchDataCenter = KetchDataCenter.UAT + + fun uniqueEmailIdentity(): Map = + mapOf("email" to "headless-${UUID.randomUUID()}@integration.ketch.test") + + fun consentConfigFrom( + config: HeadlessConfiguration, + identities: Map, + ): ConsentConfig { + val jurisdiction = config.jurisdiction?.code + ?: config.jurisdiction?.defaultJurisdictionCode + ?: "us" + val purposes = config.purposes + ?.mapNotNull { purpose -> + val code = purpose.code + val legalBasis = purpose.legalBasisCode + if (code != null && legalBasis != null) { + code to ConsentConfig.PurposeLegalBasis(legalBasis) + } else { + null + } + } + ?.toMap() + ?: emptyMap() + require(purposes.isNotEmpty()) { "Configuration returned no purposes" } + return ConsentConfig( + organizationCode = ORG_CODE, + propertyCode = PROPERTY, + environmentCode = ENVIRONMENT, + jurisdictionCode = jurisdiction, + identities = identities, + purposes = purposes, + ) + } +} diff --git a/sample-app-compose/src/main/java/com/ketch/android/sample/compose/KetchSampleApp.kt b/sample-app-compose/src/main/java/com/ketch/android/sample/compose/KetchSampleApp.kt index 778d3a49..a7fccc71 100644 --- a/sample-app-compose/src/main/java/com/ketch/android/sample/compose/KetchSampleApp.kt +++ b/sample-app-compose/src/main/java/com/ketch/android/sample/compose/KetchSampleApp.kt @@ -7,6 +7,8 @@ import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.IntrinsicSize import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer @@ -28,6 +30,7 @@ import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton import androidx.compose.material3.Switch import androidx.compose.material3.SwitchDefaults import androidx.compose.material3.Text @@ -57,9 +60,17 @@ import com.ketch.android.sample.compose.ui.theme.LightToggleTrack @Composable fun KetchSampleApp( - logEntries: List, + dashboard: SampleDashboardState, + connectionSummary: String, + onLoad: () -> Unit, onShowConsent: () -> Unit, onShowPreferences: () -> Unit, + onSetLanguage: () -> Unit, + onSetJurisdiction: () -> Unit, + onSetRegion: () -> Unit, + onHeadlessLocation: () -> Unit, + onHeadlessBootstrap: () -> Unit, + onHeadlessConsent: () -> Unit, onOpenSecondActivity: () -> Unit, onLogSharedPreferences: () -> Unit, ) { @@ -72,28 +83,51 @@ fun KetchSampleApp( .background(MaterialTheme.colorScheme.background) .windowInsetsPadding(WindowInsets.systemBars) ) { - HeaderBar( - isDarkMode = isDarkMode, - onToggleDarkMode = { isDarkMode = it } - ) - - HorizontalDivider( - color = if (isDarkMode) DarkDivider else LightDivider, - thickness = 1.dp - ) - + HeaderBar(isDarkMode = isDarkMode, onToggleDarkMode = { isDarkMode = it }) + HorizontalDivider(color = if (isDarkMode) DarkDivider else LightDivider, thickness = 1.dp) Column( modifier = Modifier .weight(1f) .verticalScroll(rememberScrollState()) .padding(20.dp) ) { - SectionHeader("Experience Functions") + SectionHeader("SDK Health Dashboard") + DashboardRow("Init", dashboard.initState) + DashboardRow("Status", dashboard.statusText) + DashboardRow("Connection", connectionSummary) + DashboardRow("Load", dashboard.loadState) + DashboardRow("Visibility", dashboard.experienceVisibility) + DashboardRow("Dismiss", dashboard.dismissReason) + + Spacer(Modifier.height(12.dp)) + SectionHeader("Privacy / Consent State") + DashboardRow("Environment", dashboard.environment) + DashboardRow("Jurisdiction", dashboard.jurisdiction) + DashboardRow("Region", dashboard.region) + DashboardRow("Consent", dashboard.consent) + DashboardRow("US Privacy", dashboard.usPrivacy) + DashboardRow("TCF", dashboard.tcf) + DashboardRow("GPP", dashboard.gpp) + DashboardRow("Config", dashboard.configSummary) + DashboardRow("Purposes", dashboard.purposesSummary) + + Spacer(Modifier.height(12.dp)) + SectionHeader("Actions") + ActionButtonsRow(onLoad, onShowConsent, onShowPreferences) + ConfigButtonsRow(onSetLanguage, onSetJurisdiction, onSetRegion) + + Spacer(Modifier.height(12.dp)) + SectionHeader("Headless (live CDN)") + DashboardRow("Location", dashboard.headlessLocationResult) + DashboardRow("Bootstrap", dashboard.headlessBootstrapResult) + DashboardRow("Consent", dashboard.headlessConsentResult) + HeadlessButtonsRow(onHeadlessLocation, onHeadlessBootstrap, onHeadlessConsent) + Spacer(Modifier.height(16.dp)) - CardsRow( - onShowConsent = onShowConsent, - onShowPreferences = onShowPreferences - ) + SectionHeader("Experience Functions") + Spacer(Modifier.height(12.dp)) + CardsRow(onShowConsent = onShowConsent, onShowPreferences = onShowPreferences) + Spacer(Modifier.height(24.dp)) SectionHeader("Debug") Spacer(Modifier.height(12.dp)) @@ -104,6 +138,7 @@ fun KetchSampleApp( modifier = Modifier.fillMaxWidth(), executeLabel = "Log Values", ) + Spacer(Modifier.height(24.dp)) SectionHeader("Cross-Activity Demo") Spacer(Modifier.height(12.dp)) @@ -115,27 +150,58 @@ fun KetchSampleApp( ) { Text("Open Second Activity", fontSize = 14.sp, fontWeight = FontWeight.Medium) } - Spacer(Modifier.height(24.dp)) + + Spacer(Modifier.height(16.dp)) SectionHeader("Event Log") - Spacer(Modifier.height(12.dp)) - EventLog( - entries = logEntries, - isDarkMode = isDarkMode - ) + Spacer(Modifier.height(8.dp)) + EventLog(entries = dashboard.eventLog, isDarkMode = isDarkMode) } } } } @Composable -private fun HeaderBar( - isDarkMode: Boolean, - onToggleDarkMode: (Boolean) -> Unit, -) { +private fun DashboardRow(label: String, value: String) { + Row(modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp)) { + Text("$label:", fontSize = 12.sp, modifier = Modifier.width(100.dp)) + Text(value, fontSize = 12.sp, fontFamily = FontFamily.Monospace, modifier = Modifier.weight(1f)) + } +} + +@OptIn(ExperimentalLayoutApi::class) +@Composable +private fun ActionButtonsRow(onLoad: () -> Unit, onShowConsent: () -> Unit, onShowPreferences: () -> Unit) { + FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { + OutlinedButton(onClick = onLoad) { Text("Load") } + OutlinedButton(onClick = onShowConsent) { Text("Show Consent") } + OutlinedButton(onClick = onShowPreferences) { Text("Show Preferences") } + } +} + +@OptIn(ExperimentalLayoutApi::class) +@Composable +private fun ConfigButtonsRow(onLanguage: () -> Unit, onJurisdiction: () -> Unit, onRegion: () -> Unit) { + FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { + OutlinedButton(onClick = onLanguage) { Text("Set Language EN") } + OutlinedButton(onClick = onJurisdiction) { Text("Set Jurisdiction US") } + OutlinedButton(onClick = onRegion) { Text("Set Region CA") } + } +} + +@OptIn(ExperimentalLayoutApi::class) +@Composable +private fun HeadlessButtonsRow(onLocation: () -> Unit, onBootstrap: () -> Unit, onConsent: () -> Unit) { + FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { + OutlinedButton(onClick = onLocation) { Text("Fetch Location") } + OutlinedButton(onClick = onBootstrap) { Text("Fetch Bootstrap") } + OutlinedButton(onClick = onConsent) { Text("Cold Start") } + } +} + +@Composable +private fun HeaderBar(isDarkMode: Boolean, onToggleDarkMode: (Boolean) -> Unit) { Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 20.dp, vertical = 12.dp), + modifier = Modifier.fillMaxWidth().padding(horizontal = 20.dp, vertical = 12.dp), verticalAlignment = Alignment.CenterVertically, ) { Text( @@ -143,10 +209,8 @@ private fun HeaderBar( fontSize = 20.sp, fontWeight = FontWeight.Bold, color = MaterialTheme.colorScheme.onBackground, - modifier = Modifier.weight(1f) + modifier = Modifier.weight(1f), ) - Text("☀️", fontSize = 16.sp) - Spacer(Modifier.width(4.dp)) Switch( checked = isDarkMode, onCheckedChange = onToggleDarkMode, @@ -155,46 +219,24 @@ private fun HeaderBar( uncheckedTrackColor = if (isDarkMode) DarkToggleTrack else LightToggleTrack, checkedThumbColor = KetchPurple, uncheckedThumbColor = KetchPurple, - ) + ), ) - Spacer(Modifier.width(4.dp)) - Text("🌙", fontSize = 16.sp) } } @Composable private fun SectionHeader(title: String) { - Text( - text = "▾ $title", - fontSize = 18.sp, - fontWeight = FontWeight.Bold, - color = KetchPurple, - ) + Text(text = title, fontSize = 18.sp, fontWeight = FontWeight.Bold, color = KetchPurple) } @Composable -private fun CardsRow( - onShowConsent: () -> Unit, - onShowPreferences: () -> Unit, -) { +private fun CardsRow(onShowConsent: () -> Unit, onShowPreferences: () -> Unit) { Row( - modifier = Modifier - .fillMaxWidth() - .height(IntrinsicSize.Max), + modifier = Modifier.fillMaxWidth().height(IntrinsicSize.Max), horizontalArrangement = Arrangement.spacedBy(16.dp), ) { - ActionCard( - title = "Privacy Preference Unknown", - description = "Trigger the consent banner. This triggers automatically for new users.", - onExecute = onShowConsent, - modifier = Modifier.weight(1f), - ) - ActionCard( - title = "Preferences Opened", - description = "Open the Ketch Privacy Center to manage consent preferences.", - onExecute = onShowPreferences, - modifier = Modifier.weight(1f), - ) + ActionCard("Privacy Preference Unknown", "Trigger the consent banner.", onShowConsent, Modifier.weight(1f)) + ActionCard("Preferences Opened", "Open the Privacy Center.", onShowPreferences, Modifier.weight(1f)) } } @@ -208,27 +250,14 @@ private fun ActionCard( ) { Column( modifier = modifier - .border( - BorderStroke(1.dp, MaterialTheme.colorScheme.outline), - RoundedCornerShape(12.dp) - ) + .border(BorderStroke(1.dp, MaterialTheme.colorScheme.outline), RoundedCornerShape(12.dp)) .clip(RoundedCornerShape(12.dp)) .background(MaterialTheme.colorScheme.surface) - .padding(16.dp) + .padding(16.dp), ) { - Text( - text = title, - fontSize = 15.sp, - fontWeight = FontWeight.Bold, - color = MaterialTheme.colorScheme.onSurface, - ) + Text(title, fontSize = 15.sp, fontWeight = FontWeight.Bold) Spacer(Modifier.height(8.dp)) - Text( - text = description, - fontSize = 13.sp, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.weight(1f), - ) + Text(description, fontSize = 13.sp, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.weight(1f)) Spacer(Modifier.height(12.dp)) Button( onClick = onExecute, @@ -244,46 +273,28 @@ private fun ActionCard( } @Composable -private fun EventLog( - entries: List, - isDarkMode: Boolean, -) { +private fun EventLog(entries: List, isDarkMode: Boolean) { val listState = rememberLazyListState() - LaunchedEffect(entries.size) { - if (entries.isNotEmpty()) { - listState.animateScrollToItem(entries.size - 1) - } + if (entries.isNotEmpty()) listState.animateScrollToItem(entries.size - 1) } - Box( modifier = Modifier .fillMaxWidth() - .height(220.dp) - .border( - BorderStroke(1.dp, MaterialTheme.colorScheme.outline), - RoundedCornerShape(8.dp) - ) + .height(180.dp) + .border(BorderStroke(1.dp, MaterialTheme.colorScheme.outline), RoundedCornerShape(8.dp)) .clip(RoundedCornerShape(8.dp)) .background(if (isDarkMode) DarkLogBackground else LightLogBackground) - .padding(12.dp) + .padding(12.dp), ) { if (entries.isEmpty()) { - Text( - text = "Waiting for events...", - fontSize = 11.sp, - fontFamily = FontFamily.Monospace, - color = if (isDarkMode) DarkLogText else LightLogText, - ) + Text("Waiting for events...", fontSize = 11.sp, fontFamily = FontFamily.Monospace, + color = if (isDarkMode) DarkLogText else LightLogText) } else { LazyColumn(state = listState) { items(entries) { entry -> - Text( - text = entry, - fontSize = 11.sp, - fontFamily = FontFamily.Monospace, - color = if (isDarkMode) DarkLogText else LightLogText, - ) + Text(entry, fontSize = 11.sp, fontFamily = FontFamily.Monospace, + color = if (isDarkMode) DarkLogText else LightLogText) } } } diff --git a/sample-app-compose/src/main/java/com/ketch/android/sample/compose/MainActivity.kt b/sample-app-compose/src/main/java/com/ketch/android/sample/compose/MainActivity.kt index 0f53bfda..fddba210 100644 --- a/sample-app-compose/src/main/java/com/ketch/android/sample/compose/MainActivity.kt +++ b/sample-app-compose/src/main/java/com/ketch/android/sample/compose/MainActivity.kt @@ -7,18 +7,132 @@ import android.util.Log import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge import androidx.appcompat.app.AppCompatActivity -import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue import com.ketch.android.Ketch +import com.ketch.android.KetchSdk +import com.ketch.android.data.Consent +import com.ketch.android.data.FullConfigurationRequest +import com.ketch.android.data.HideExperienceStatus +import com.ketch.android.data.KetchConfig +import com.ketch.android.data.WillShowExperienceType class MainActivity : AppCompatActivity() { private lateinit var ketch: Ketch private lateinit var app: ComposeSampleApplication - private val logEntries = mutableStateListOf() + private var dashboard by mutableStateOf(SampleDashboardState()) companion object { private const val TAG = "KetchCompose" private val IAB_PREFERENCE_PREFIXES = listOf("IABTCF", "IABGPP", "IABUS") + private const val ORG_CODE = ComposeSampleApplication.ORG_CODE + private const val PROPERTY = ComposeSampleApplication.PROPERTY + private const val ENVIRONMENT = ComposeSampleApplication.ENVIRONMENT + private const val LANGUAGE = "en" + } + + private val ketchListener = object : Ketch.Listener { + override fun onShow() { + Log.d(TAG, "onShow") + updateDashboard { + it.copy(experienceVisibility = "showing").appendLog("onShow") + } + } + + override fun onDismiss(status: HideExperienceStatus) { + updateDashboard { + it.copy(experienceVisibility = "dismissed", dismissReason = status.toString()) + .appendLog("onDismiss: $status") + } + } + + override fun onConfigUpdated(config: KetchConfig?) { + val display = config?.experiences?.consent?.display?.name ?: "null" + updateDashboard { + it.appendLog("onConfigUpdated: experience display=$display") + } + } + + override fun onConfigDebugInfo(configSummary: String, purposesSummary: String) { + Log.d(TAG, "config: $configSummary") + Log.d(TAG, "purposes: $purposesSummary") + updateDashboard { + it.copy(configSummary = configSummary, purposesSummary = purposesSummary) + .appendLog("config: $configSummary") + .appendLog("purposes: $purposesSummary") + } + } + + override fun onEnvironmentUpdated(environment: String?) { + updateDashboard { + it.copy( + environment = environment ?: "Not set", + loadState = "loaded", + ).appendLog("onEnvironmentUpdated: $environment") + } + } + + override fun onRegionInfoUpdated(regionInfo: String?) { + updateDashboard { + it.copy(region = regionInfo ?: "Not set").appendLog("onRegionInfoUpdated: $regionInfo") + } + } + + override fun onJurisdictionUpdated(jurisdiction: String?) { + updateDashboard { + it.copy(jurisdiction = jurisdiction ?: "Not set").appendLog("onJurisdictionUpdated: $jurisdiction") + } + } + + override fun onIdentitiesUpdated(identities: String?) { + updateDashboard { it.appendLog("onIdentitiesUpdated: $identities") } + } + + override fun onConsentUpdated(consent: Consent) { + updateDashboard { + it.copy(consent = consent.purposes.toString()).appendLog("onConsentUpdated") + } + } + + override fun onError(errMsg: String?) { + updateDashboard { + it.copy(loadState = "error", initState = "Error") + .setStatus("Error: ${errMsg ?: "unknown"}") + } + } + + override fun onUSPrivacyUpdated(values: Map) { + updateDashboard { + it.copy(usPrivacy = values["IABUSPrivacy_String"]?.toString() ?: "Not set") + .appendLog("onUSPrivacyUpdated") + } + } + + override fun onTCFUpdated(values: Map) { + updateDashboard { + it.copy(tcf = values["IABTCF_TCString"]?.toString() ?: "Not set").appendLog("onTCFUpdated") + } + } + + override fun onGPPUpdated(values: Map) { + updateDashboard { + it.copy(gpp = values["IABGPP_HDR_GppString"]?.toString() ?: "Not set").appendLog("onGPPUpdated") + } + } + + override fun onWillShowExperience(type: WillShowExperienceType) { + updateDashboard { + it.copy(experienceVisibility = "will show: $type").appendLog("onWillShowExperience: $type") + } + } + + override fun onHasShownExperience() { + updateDashboard { + it.copy(experienceVisibility = "shown").appendLog("onHasShownExperience") + } + } } override fun onCreate(savedInstanceState: Bundle?) { @@ -27,27 +141,45 @@ class MainActivity : AppCompatActivity() { app = application as ComposeSampleApplication ketch = app.ketch - app.logCallback = { message -> - runOnUiThread { logEntries.add(message) } - } + app.dashboardListener = ketchListener initializeKetch() setContent { KetchSampleApp( - logEntries = logEntries, + dashboard = dashboard, + connectionSummary = "$ORG_CODE / $PROPERTY / $ENVIRONMENT", + onLoad = { + updateDashboard { it.copy(loadState = "loading").setStatus("Load called") } + ketch.load() + }, onShowConsent = { Log.d(TAG, "showConsent() called") - logEntries.add("showConsent() called") + updateDashboard { it.setStatus("showConsent() on ${if (dashboard.loadState == "loaded") "loaded" else "new"} WebView") } ketch.showConsent() }, onShowPreferences = { Log.d(TAG, "showPreferences() called") - logEntries.add("showPreferences() called") + updateDashboard { it.setStatus("showPreferences() called") } ketch.showPreferences() }, + onSetLanguage = { + ketch.setLanguage("EN") + updateDashboard { it.setStatus("Language set to EN") } + }, + onSetJurisdiction = { + ketch.setJurisdiction("US") + updateDashboard { it.setStatus("Jurisdiction set to US") } + }, + onSetRegion = { + ketch.setRegion("California") + updateDashboard { it.setStatus("Region set to California") } + }, + onHeadlessLocation = { runHeadlessLocation() }, + onHeadlessBootstrap = { runHeadlessBootstrap() }, + onHeadlessConsent = { runHeadlessConsent() }, onOpenSecondActivity = { - logEntries.add("Opening SecondActivity") + updateDashboard { it.appendLog("Opening SecondActivity") } startActivity(Intent(this, SecondActivity::class.java)) }, onLogSharedPreferences = { logSharedPreferences() }, @@ -57,21 +189,26 @@ class MainActivity : AppCompatActivity() { override fun onDestroy() { if (isFinishing) { - app.logCallback = null + app.dashboardListener = null } super.onDestroy() } private fun initializeKetch() { - logEntries.add("Ketch initialized in Application") + // Ketch is created once in ComposeSampleApplication and shared across Activities so the + // WebView (and its consent state) survives navigation to/from SecondActivity. + ketch.setWebResourceUrlOverrides(if (DevUrlOverrides.ENABLED) DevUrlOverrides.forEmulator else emptyMap()) + ketch.setIdentities(mapOf("email" to "sample-test@integration.ketch.test")) + ketch.setLanguage("en") + updateDashboard { it.setStatus("Ketch initialized (shared instance)") } ketch.load() - logEntries.add("load() called from MainActivity") + updateDashboard { it.copy(loadState = "loading").appendLog("load() called from MainActivity") } } private fun logSharedPreferences() { fun emit(message: String) { Log.d(TAG, message) - runOnUiThread { logEntries.add(message) } + updateDashboard { it.appendLog(message) } } val tcf = ketch.getTCFTCString() @@ -100,4 +237,99 @@ class MainActivity : AppCompatActivity() { emit(" $key = $value") } } + + private fun updateDashboard(block: (SampleDashboardState) -> SampleDashboardState) { + runOnUiThread { dashboard = block(dashboard) } + } + + private fun runHeadlessLocation() { + updateDashboard { it.copy(headlessLocationResult = "Loading...") } + KetchSdk.fetchLocation(HeadlessSampleSupport.dataCenter) { result -> + val text = result.fold( + onSuccess = { "OK: ${it.location?.countryCode ?: "?"}" }, + onFailure = { "Error: ${it.message}" }, + ) + updateDashboard { it.copy(headlessLocationResult = text).appendLog("headless location: $text") } + } + } + + private fun runHeadlessBootstrap() { + updateDashboard { it.copy(headlessBootstrapResult = "Loading...") } + KetchSdk.fetchBootstrapConfiguration( + ORG_CODE, + PROPERTY, + HeadlessSampleSupport.dataCenter, + ) { result -> + val text = result.fold( + onSuccess = { boot -> + val jurisdiction = boot.jurisdiction?.code ?: boot.jurisdiction?.defaultJurisdictionCode ?: "?" + "OK: jurisdiction=$jurisdiction purposes=${boot.purposes?.size ?: 0}" + }, + onFailure = { "Error: ${it.message}" }, + ) + updateDashboard { it.copy(headlessBootstrapResult = text).appendLog("headless bootstrap: $text") } + } + } + + private fun runHeadlessConsent() { + updateDashboard { it.copy(headlessConsentResult = "Loading...") } + val identities = HeadlessSampleSupport.uniqueEmailIdentity() + KetchSdk.fetchLocation(HeadlessSampleSupport.dataCenter) { _ -> + KetchSdk.fetchBootstrapConfiguration( + ORG_CODE, + PROPERTY, + HeadlessSampleSupport.dataCenter, + ) { bootResult -> + bootResult.onSuccess { boot -> + val jurisdiction = boot.jurisdiction?.code + ?: boot.jurisdiction?.defaultJurisdictionCode + val configUrl = HeadlessSampleSupport.dataCenter.baseUrl + + "/config/$ORG_CODE/$PROPERTY/$ENVIRONMENT/$jurisdiction/$LANGUAGE/config.json" + updateDashboard { it.appendLog("headless config URL: $configUrl") } + KetchSdk.fetchFullConfiguration( + FullConfigurationRequest( + organizationCode = ORG_CODE, + propertyCode = PROPERTY, + environmentCode = ENVIRONMENT, + jurisdictionCode = jurisdiction, + languageCode = LANGUAGE, + ), + HeadlessSampleSupport.dataCenter, + ) { fullResult -> + fullResult.onSuccess { full -> + val purposeCodes = full.purposes?.mapNotNull { it.code } ?: emptyList() + val configSummary = "headless jurisdiction=${full.jurisdiction?.code} purposes=${purposeCodes.size}" + val purposesSummary = if (purposeCodes.isEmpty()) { + "headless purposes: none" + } else { + "headless purposes: ${purposeCodes.joinToString(", ")}" + } + updateDashboard { + it.copy(configSummary = configSummary, purposesSummary = purposesSummary) + .appendLog(configSummary) + .appendLog(purposesSummary) + } + val config = HeadlessSampleSupport.consentConfigFrom(full, identities) + KetchSdk.fetchConsent(config, HeadlessSampleSupport.dataCenter) { consentResult -> + val text = consentResult.fold( + onSuccess = { + val count = it.purposes?.size ?: it.protocols?.size ?: 0 + "OK: $count item(s)" + }, + onFailure = { "Error: ${it.message}" }, + ) + updateDashboard { + it.copy(headlessConsentResult = text).appendLog("headless consent: $text") + } + } + }.onFailure { err -> + updateDashboard { it.copy(headlessConsentResult = "Error: ${err.message}") } + } + } + }.onFailure { err -> + updateDashboard { it.copy(headlessConsentResult = "Error: ${err.message}") } + } + } + } + } } diff --git a/sample-app-compose/src/main/java/com/ketch/android/sample/compose/SampleDashboardState.kt b/sample-app-compose/src/main/java/com/ketch/android/sample/compose/SampleDashboardState.kt new file mode 100644 index 00000000..571658b4 --- /dev/null +++ b/sample-app-compose/src/main/java/com/ketch/android/sample/compose/SampleDashboardState.kt @@ -0,0 +1,38 @@ +package com.ketch.android.sample.compose + +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale + +data class SampleDashboardState( + val initState: String = "Initialized", + val statusText: String = "Ketch initialized", + val loadState: String = "idle", + val experienceVisibility: String = "hidden", + val dismissReason: String = "—", + val environment: String = "Not set", + val jurisdiction: String = "Not set", + val region: String = "Not set", + val consent: String = "Not set", + val usPrivacy: String = "Not set", + val tcf: String = "Not set", + val gpp: String = "Not set", + val configSummary: String = "Not loaded", + val purposesSummary: String = "Not loaded", + val headlessLocationResult: String = "—", + val headlessBootstrapResult: String = "—", + val headlessConsentResult: String = "—", + val eventLog: List = emptyList(), +) { + fun appendLog(message: String): SampleDashboardState { + val line = "[${timestamp()}] $message" + val next = (eventLog + line).takeLast(50) + return copy(eventLog = next) + } + + fun setStatus(message: String): SampleDashboardState = + appendLog(message).copy(statusText = message) + + private fun timestamp(): String = + SimpleDateFormat("HH:mm:ss", Locale.US).format(Date()) +} diff --git a/sample-app-compose/src/main/res/xml/network_security_config.xml b/sample-app-compose/src/main/res/xml/network_security_config.xml index 77ef7453..247653c8 100644 --- a/sample-app-compose/src/main/res/xml/network_security_config.xml +++ b/sample-app-compose/src/main/res/xml/network_security_config.xml @@ -3,6 +3,8 @@ ketchcdn.com global.ketchcdn.com + 10.0.2.2 + localhost diff --git a/sample-app-standard/src/main/java/com/ketch/android/sample/standard/DevUrlOverrides.kt b/sample-app-standard/src/main/java/com/ketch/android/sample/standard/DevUrlOverrides.kt new file mode 100644 index 00000000..190de681 --- /dev/null +++ b/sample-app-standard/src/main/java/com/ketch/android/sample/standard/DevUrlOverrides.kt @@ -0,0 +1,20 @@ +package com.ketch.android.sample.standard + +/** + * Flip [ENABLED] to redirect UAT tag script URLs to local dev servers. + * Android emulator: use [forEmulator] (localhost when using simulator/port-forward). + */ +object DevUrlOverrides { + const val ENABLED = false + + val forEmulator: Map = mapOf( + "https://cdn.uat.ketchjs.com/ketchtag/stable/v2.12/ketch-sdk.js" to "http://localhost:9000/ketch-sdk.js", + "ketch-sdk.js" to "http://localhost:9000/ketch-sdk.js", + ) + + /** Physical device — use host machine LAN IP if localhost is unreachable from device. */ + val forDevice: Map = mapOf( + "https://cdn.uat.ketchjs.com/ketchtag/stable/v2.12/ketch-sdk.js" to "http://localhost:9000/ketch-sdk.js", + "ketch-sdk.js" to "http://localhost:9000/ketch-sdk.js", + ) +} diff --git a/sample-app-standard/src/main/java/com/ketch/android/sample/standard/HeadlessSampleSupport.kt b/sample-app-standard/src/main/java/com/ketch/android/sample/standard/HeadlessSampleSupport.kt new file mode 100644 index 00000000..c8beb434 --- /dev/null +++ b/sample-app-standard/src/main/java/com/ketch/android/sample/standard/HeadlessSampleSupport.kt @@ -0,0 +1,46 @@ +package com.ketch.android.sample.standard + +import com.ketch.android.api.KetchDataCenter +import com.ketch.android.data.ConsentConfig +import com.ketch.android.data.HeadlessConfiguration +import java.util.UUID + +object HeadlessSampleSupport { + const val ORG_CODE = "ethansch061226" + const val PROPERTY = "website_smart_tag" + const val ENVIRONMENT = "production" + val dataCenter: KetchDataCenter = KetchDataCenter.UAT + + fun uniqueEmailIdentity(): Map = + mapOf("email" to "headless-${UUID.randomUUID()}@integration.ketch.test") + + fun consentConfigFrom( + config: HeadlessConfiguration, + identities: Map, + ): ConsentConfig { + val jurisdiction = config.jurisdiction?.code + ?: config.jurisdiction?.defaultJurisdictionCode + ?: "us" + val purposes = config.purposes + ?.mapNotNull { purpose -> + val code = purpose.code + val legalBasis = purpose.legalBasisCode + if (code != null && legalBasis != null) { + code to ConsentConfig.PurposeLegalBasis(legalBasis) + } else { + null + } + } + ?.toMap() + ?: emptyMap() + require(purposes.isNotEmpty()) { "Configuration returned no purposes" } + return ConsentConfig( + organizationCode = ORG_CODE, + propertyCode = PROPERTY, + environmentCode = ENVIRONMENT, + jurisdictionCode = jurisdiction, + identities = identities, + purposes = purposes, + ) + } +} diff --git a/sample-app-standard/src/main/java/com/ketch/android/sample/standard/MainActivity.kt b/sample-app-standard/src/main/java/com/ketch/android/sample/standard/MainActivity.kt index 355ff455..8ea5fb3b 100644 --- a/sample-app-standard/src/main/java/com/ketch/android/sample/standard/MainActivity.kt +++ b/sample-app-standard/src/main/java/com/ketch/android/sample/standard/MainActivity.kt @@ -12,6 +12,12 @@ import androidx.core.view.WindowCompat import androidx.core.view.WindowInsetsCompat import androidx.core.view.updatePadding import com.ketch.android.Ketch +import com.ketch.android.KetchSdk +import com.ketch.android.data.Consent +import com.ketch.android.data.FullConfigurationRequest +import com.ketch.android.data.HideExperienceStatus +import com.ketch.android.data.KetchConfig +import com.ketch.android.data.WillShowExperienceType import com.ketch.android.sample.standard.databinding.ActivityMainBinding class MainActivity : AppCompatActivity() { @@ -19,23 +25,114 @@ class MainActivity : AppCompatActivity() { private lateinit var binding: ActivityMainBinding private lateinit var ketch: Ketch private lateinit var app: SampleApplication + private var dashboard = SampleDashboardState() companion object { private const val TAG = "KetchSample" private val IAB_PREFERENCE_PREFIXES = listOf("IABTCF", "IABGPP", "IABUS") + private const val ORG_CODE = SampleApplication.ORG_CODE + private const val PROPERTY = SampleApplication.PROPERTY + private const val ENVIRONMENT = SampleApplication.ENVIRONMENT + private const val LANGUAGE = "en" + } + + private val ketchListener = object : Ketch.Listener { + override fun onShow() { + updateDashboard { + it.copy(experienceVisibility = "showing").appendLog("onShow") + } + } + + override fun onDismiss(status: HideExperienceStatus) { + updateDashboard { + it.copy(experienceVisibility = "dismissed", dismissReason = status.toString()) + .appendLog("onDismiss: $status") + } + } + + override fun onConfigUpdated(config: KetchConfig?) { + updateDashboard { it.appendLog("onConfigUpdated") } + } + + override fun onEnvironmentUpdated(environment: String?) { + updateDashboard { + it.copy( + environment = environment ?: "Not set", + loadState = "loaded", + ).appendLog("onEnvironmentUpdated: $environment") + } + } + + override fun onRegionInfoUpdated(regionInfo: String?) { + updateDashboard { + it.copy(region = regionInfo ?: "Not set").appendLog("onRegionInfoUpdated: $regionInfo") + } + } + + override fun onJurisdictionUpdated(jurisdiction: String?) { + updateDashboard { + it.copy(jurisdiction = jurisdiction ?: "Not set").appendLog("onJurisdictionUpdated: $jurisdiction") + } + } + + override fun onIdentitiesUpdated(identities: String?) { + updateDashboard { it.appendLog("onIdentitiesUpdated: $identities") } + } + + override fun onConsentUpdated(consent: Consent) { + updateDashboard { + it.copy(consent = consent.purposes.toString()).appendLog("onConsentUpdated") + } + } + + override fun onError(errMsg: String?) { + updateDashboard { + it.copy(loadState = "error", initState = "Error") + .setStatus("Error: ${errMsg ?: "unknown"}") + } + } + + override fun onUSPrivacyUpdated(values: Map) { + updateDashboard { + it.copy(usPrivacy = values["IABUSPrivacy_String"]?.toString() ?: "Not set") + .appendLog("onUSPrivacyUpdated") + } + } + + override fun onTCFUpdated(values: Map) { + updateDashboard { + it.copy(tcf = values["IABTCF_TCString"]?.toString() ?: "Not set").appendLog("onTCFUpdated") + } + } + + override fun onGPPUpdated(values: Map) { + updateDashboard { + it.copy(gpp = values["IABGPP_HDR_GppString"]?.toString() ?: "Not set").appendLog("onGPPUpdated") + } + } + + override fun onWillShowExperience(type: WillShowExperienceType) { + updateDashboard { + it.copy(experienceVisibility = "will show: $type").appendLog("onWillShowExperience: $type") + } + } + + override fun onHasShownExperience() { + updateDashboard { + it.copy(experienceVisibility = "shown").appendLog("onHasShownExperience") + } + } } override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - WindowCompat.setDecorFitsSystemWindows(window, false) - binding = ActivityMainBinding.inflate(layoutInflater) setContentView(binding.root) app = application as SampleApplication ketch = app.ketch - app.logCallback = { message -> appendLog(message) } + app.dashboardListener = ketchListener ViewCompat.setOnApplyWindowInsetsListener(binding.rootLayout) { view, windowInsets -> val insets = windowInsets.getInsets( @@ -54,11 +151,12 @@ class MainActivity : AppCompatActivity() { setupDarkModeToggle() initializeKetch() setupClickListeners() + renderDashboard() } override fun onDestroy() { if (isFinishing) { - app.logCallback = null + app.dashboardListener = null } super.onDestroy() } @@ -66,7 +164,6 @@ class MainActivity : AppCompatActivity() { private fun setupDarkModeToggle() { val isNightMode = AppCompatDelegate.getDefaultNightMode() == AppCompatDelegate.MODE_NIGHT_YES binding.darkModeSwitch.isChecked = isNightMode - binding.darkModeSwitch.setOnCheckedChangeListener { _, isChecked -> AppCompatDelegate.setDefaultNightMode( if (isChecked) AppCompatDelegate.MODE_NIGHT_YES @@ -76,26 +173,56 @@ class MainActivity : AppCompatActivity() { } private fun initializeKetch() { - appendLog("Ketch initialized in Application") + // Ketch is created once in SampleApplication and shared across Activities so the + // WebView (and its consent state) survives navigation to/from SecondActivity. + ketch.setWebResourceUrlOverrides(if (DevUrlOverrides.ENABLED) DevUrlOverrides.forEmulator else emptyMap()) + ketch.setIdentities(mapOf("email" to "sample-test@integration.ketch.test")) + ketch.setLanguage("en") + updateDashboard { it.setStatus("Ketch initialized (shared instance)") } ketch.load() - appendLog("load() called from MainActivity") + updateDashboard { it.copy(loadState = "loading").appendLog("load() called from MainActivity") } } private fun setupClickListeners() { - binding.showConsentButton.setOnClickListener { + val showConsent = { Log.d(TAG, "showConsent() called") - appendLog("showConsent() called") + updateDashboard { it.setStatus("showConsent() called") } ketch.showConsent() } - - binding.showPreferencesButton.setOnClickListener { + val showPreferences = { Log.d(TAG, "showPreferences() called") - appendLog("showPreferences() called") + updateDashboard { it.setStatus("showPreferences() called") } ketch.showPreferences() } + binding.loadButton.setOnClickListener { + updateDashboard { it.copy(loadState = "loading").setStatus("Load called") } + ketch.load() + } + binding.showConsentButton.setOnClickListener { showConsent() } + binding.showPreferencesButton.setOnClickListener { showPreferences() } + binding.showConsentCardButton.setOnClickListener { showConsent() } + binding.showPreferencesCardButton.setOnClickListener { showPreferences() } + + binding.setLanguageButton.setOnClickListener { + ketch.setLanguage("EN") + updateDashboard { it.setStatus("Language set to EN") } + } + binding.setJurisdictionButton.setOnClickListener { + ketch.setJurisdiction("US") + updateDashboard { it.setStatus("Jurisdiction set to US") } + } + binding.setRegionButton.setOnClickListener { + ketch.setRegion("California") + updateDashboard { it.setStatus("Region set to California") } + } + + binding.headlessLocationButton.setOnClickListener { runHeadlessLocation() } + binding.headlessBootstrapButton.setOnClickListener { runHeadlessBootstrap() } + binding.headlessConsentButton.setOnClickListener { runHeadlessConsent() } + binding.openSecondActivityButton.setOnClickListener { - appendLog("Opening SecondActivity") + updateDashboard { it.appendLog("Opening SecondActivity") } startActivity(Intent(this, SecondActivity::class.java)) } @@ -104,10 +231,17 @@ class MainActivity : AppCompatActivity() { } } + private fun updateDashboard(block: (SampleDashboardState) -> SampleDashboardState) { + runOnUiThread { + dashboard = block(dashboard) + renderDashboard() + } + } + private fun logSharedPreferences() { fun emit(message: String) { Log.d(TAG, message) - appendLog(message) + updateDashboard { it.appendLog(message) } } val tcf = ketch.getTCFTCString() @@ -137,13 +271,98 @@ class MainActivity : AppCompatActivity() { } } - private fun appendLog(message: String) { - runOnUiThread { - val current = binding.eventLogText.text.toString() - val prefix = if (current == "Waiting for events...") "" else "$current\n" - binding.eventLogText.text = "$prefix$message" - binding.eventLogScroll.post { - binding.eventLogScroll.fullScroll(ScrollView.FOCUS_DOWN) + private fun renderDashboard() { + binding.initText.text = "Init: ${dashboard.initState}" + binding.statusText.text = "Status: ${dashboard.statusText}" + binding.connectionText.text = "Connection: $ORG_CODE / $PROPERTY / $ENVIRONMENT" + binding.loadStateText.text = "Load: ${dashboard.loadState}" + binding.visibilityText.text = "Visibility: ${dashboard.experienceVisibility}" + binding.dismissText.text = "Dismiss: ${dashboard.dismissReason}" + binding.environmentText.text = "Environment: ${dashboard.environment}" + binding.jurisdictionText.text = "Jurisdiction: ${dashboard.jurisdiction}" + binding.regionText.text = "Region: ${dashboard.region}" + binding.consentText.text = "Consent: ${truncate(dashboard.consent)}" + binding.usPrivacyText.text = "US Privacy: ${truncate(dashboard.usPrivacy)}" + binding.tcfText.text = "TCF: ${truncate(dashboard.tcf)}" + binding.gppText.text = "GPP: ${truncate(dashboard.gpp)}" + binding.headlessLocationText.text = "Location: ${dashboard.headlessLocationResult}" + binding.headlessBootstrapText.text = "Bootstrap: ${dashboard.headlessBootstrapResult}" + binding.headlessConsentText.text = "Consent: ${dashboard.headlessConsentResult}" + + binding.eventLogText.text = if (dashboard.eventLog.isEmpty()) { + "Waiting for events..." + } else { + dashboard.eventLog.joinToString("\n") + } + binding.eventLogScroll.post { + binding.eventLogScroll.fullScroll(ScrollView.FOCUS_DOWN) + } + } + + private fun truncate(value: String, max: Int = 80): String = + if (value.length <= max) value else "${value.take(max)}…" + + private fun runHeadlessLocation() { + updateDashboard { it.copy(headlessLocationResult = "Loading...") } + KetchSdk.fetchLocation(HeadlessSampleSupport.dataCenter) { result -> + val text = result.fold( + onSuccess = { "OK: ${it.location?.countryCode ?: "?"}" }, + onFailure = { "Error: ${it.message}" }, + ) + updateDashboard { it.copy(headlessLocationResult = text).appendLog("headless location: $text") } + } + } + + private fun runHeadlessBootstrap() { + updateDashboard { it.copy(headlessBootstrapResult = "Loading...") } + KetchSdk.fetchBootstrapConfiguration(ORG_CODE, PROPERTY, HeadlessSampleSupport.dataCenter) { result -> + val text = result.fold( + onSuccess = { "OK: ${it.purposes?.size ?: 0} purpose(s)" }, + onFailure = { "Error: ${it.message}" }, + ) + updateDashboard { it.copy(headlessBootstrapResult = text).appendLog("headless bootstrap: $text") } + } + } + + private fun runHeadlessConsent() { + updateDashboard { it.copy(headlessConsentResult = "Loading...") } + val identities = HeadlessSampleSupport.uniqueEmailIdentity() + KetchSdk.fetchLocation(HeadlessSampleSupport.dataCenter) { _ -> + KetchSdk.fetchBootstrapConfiguration(ORG_CODE, PROPERTY, HeadlessSampleSupport.dataCenter) { bootResult -> + bootResult.onSuccess { boot -> + val jurisdiction = boot.jurisdiction?.code + ?: boot.jurisdiction?.defaultJurisdictionCode + KetchSdk.fetchFullConfiguration( + FullConfigurationRequest( + organizationCode = ORG_CODE, + propertyCode = PROPERTY, + environmentCode = ENVIRONMENT, + jurisdictionCode = jurisdiction, + languageCode = LANGUAGE, + ), + HeadlessSampleSupport.dataCenter, + ) { fullResult -> + fullResult.onSuccess { full -> + val config = HeadlessSampleSupport.consentConfigFrom(full, identities) + KetchSdk.fetchConsent(config, HeadlessSampleSupport.dataCenter) { consentResult -> + val text = consentResult.fold( + onSuccess = { + val count = it.purposes?.size ?: it.protocols?.size ?: 0 + "OK: $count item(s)" + }, + onFailure = { "Error: ${it.message}" }, + ) + updateDashboard { + it.copy(headlessConsentResult = text).appendLog("headless consent: $text") + } + } + }.onFailure { err -> + updateDashboard { it.copy(headlessConsentResult = "Error: ${err.message}") } + } + } + }.onFailure { err -> + updateDashboard { it.copy(headlessConsentResult = "Error: ${err.message}") } + } } } } diff --git a/sample-app-standard/src/main/java/com/ketch/android/sample/standard/SampleApplication.kt b/sample-app-standard/src/main/java/com/ketch/android/sample/standard/SampleApplication.kt index 926a6b3c..afc3dccf 100644 --- a/sample-app-standard/src/main/java/com/ketch/android/sample/standard/SampleApplication.kt +++ b/sample-app-standard/src/main/java/com/ketch/android/sample/standard/SampleApplication.kt @@ -16,6 +16,14 @@ class SampleApplication : Application() { var logCallback: ((String) -> Unit)? = null + /** + * Optional richer [Ketch.Listener] that MainActivity plugs in to drive its + * SDK Health Dashboard. Ketch itself only supports a single listener (set at + * construction time), so [sharedListener] below always fires first and then + * forwards every callback here when a delegate is registered. + */ + var dashboardListener: Ketch.Listener? = null + override fun onCreate() { super.onCreate() ketch = KetchSdk.create( @@ -34,71 +42,90 @@ class SampleApplication : Application() { override fun onShow() { Log.d(TAG, "onShow: Dialog shown") log("onShow: Dialog shown") + dashboardListener?.onShow() } override fun onDismiss(status: HideExperienceStatus) { Log.d(TAG, "onDismiss: status=$status") log("onDismiss: status=$status") + dashboardListener?.onDismiss(status) } override fun onConfigUpdated(config: KetchConfig?) { Log.d(TAG, "onConfigUpdated: $config") log("onConfigUpdated") + dashboardListener?.onConfigUpdated(config) + } + + override fun onConfigDebugInfo(configSummary: String, purposesSummary: String) { + Log.d(TAG, "onConfigDebugInfo: $configSummary / $purposesSummary") + dashboardListener?.onConfigDebugInfo(configSummary, purposesSummary) } override fun onEnvironmentUpdated(environment: String?) { Log.d(TAG, "onEnvironmentUpdated: $environment") log("onEnvironmentUpdated: $environment") + dashboardListener?.onEnvironmentUpdated(environment) } override fun onRegionInfoUpdated(regionInfo: String?) { Log.d(TAG, "onRegionInfoUpdated: $regionInfo") log("onRegionInfoUpdated: $regionInfo") + dashboardListener?.onRegionInfoUpdated(regionInfo) } override fun onJurisdictionUpdated(jurisdiction: String?) { Log.d(TAG, "onJurisdictionUpdated: $jurisdiction") log("onJurisdictionUpdated: $jurisdiction") + dashboardListener?.onJurisdictionUpdated(jurisdiction) } override fun onIdentitiesUpdated(identities: String?) { Log.d(TAG, "onIdentitiesUpdated: $identities") log("onIdentitiesUpdated: $identities") + dashboardListener?.onIdentitiesUpdated(identities) } override fun onConsentUpdated(consent: Consent) { Log.d(TAG, "onConsentUpdated: purposes=${consent.purposes}") log("onConsentUpdated: ${consent.purposes}") + dashboardListener?.onConsentUpdated(consent) } override fun onError(errMsg: String?) { Log.e(TAG, "onError: $errMsg") log("ERROR: $errMsg") + dashboardListener?.onError(errMsg) } override fun onUSPrivacyUpdated(values: Map) { Log.d(TAG, "onUSPrivacyUpdated: $values") log("onUSPrivacyUpdated: ${values["IABUSPrivacy_String"]}") + dashboardListener?.onUSPrivacyUpdated(values) } override fun onTCFUpdated(values: Map) { Log.d(TAG, "onTCFUpdated: $values") log("onTCFUpdated: ${values["IABTCF_TCString"]}") + dashboardListener?.onTCFUpdated(values) } override fun onGPPUpdated(values: Map) { Log.d(TAG, "onGPPUpdated: $values") log("onGPPUpdated: ${values["IABGPP_HDR_GppString"]}") + dashboardListener?.onGPPUpdated(values) } override fun onWillShowExperience(type: WillShowExperienceType) { Log.d(TAG, "onWillShowExperience: $type") log("onWillShowExperience: $type") + dashboardListener?.onWillShowExperience(type) } override fun onHasShownExperience() { Log.d(TAG, "onHasShownExperience") log("onHasShownExperience") + dashboardListener?.onHasShownExperience() } } @@ -108,8 +135,8 @@ class SampleApplication : Application() { companion object { private const val TAG = "KetchSample" - const val ORG_CODE = "ketch_samples" - const val PROPERTY = "android" + const val ORG_CODE = "ethansch061226" + const val PROPERTY = "website_smart_tag" const val ENVIRONMENT = "production" } } diff --git a/sample-app-standard/src/main/java/com/ketch/android/sample/standard/SampleDashboardState.kt b/sample-app-standard/src/main/java/com/ketch/android/sample/standard/SampleDashboardState.kt new file mode 100644 index 00000000..e681a4f2 --- /dev/null +++ b/sample-app-standard/src/main/java/com/ketch/android/sample/standard/SampleDashboardState.kt @@ -0,0 +1,35 @@ +package com.ketch.android.sample.standard + +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale + +data class SampleDashboardState( + val initState: String = "Initialized", + val statusText: String = "Ketch initialized", + val loadState: String = "idle", + val experienceVisibility: String = "hidden", + val dismissReason: String = "—", + val environment: String = "Not set", + val jurisdiction: String = "Not set", + val region: String = "Not set", + val consent: String = "Not set", + val usPrivacy: String = "Not set", + val tcf: String = "Not set", + val gpp: String = "Not set", + val headlessLocationResult: String = "—", + val headlessBootstrapResult: String = "—", + val headlessConsentResult: String = "—", + val eventLog: List = emptyList(), +) { + fun appendLog(message: String): SampleDashboardState { + val line = "[${timestamp()}] $message" + return copy(eventLog = (eventLog + line).takeLast(50)) + } + + fun setStatus(message: String): SampleDashboardState = + appendLog(message).copy(statusText = message) + + private fun timestamp(): String = + SimpleDateFormat("HH:mm:ss", Locale.US).format(Date()) +} diff --git a/sample-app-standard/src/main/res/layout/activity_main.xml b/sample-app-standard/src/main/res/layout/activity_main.xml index e4259099..a2d42a55 100644 --- a/sample-app-standard/src/main/res/layout/activity_main.xml +++ b/sample-app-standard/src/main/res/layout/activity_main.xml @@ -9,7 +9,6 @@ android:background="@color/background" tools:context=".MainActivity"> - - - - - + + + + + + + + + + + + + + + + + + + + +