From 30c83c7fab71742a72aeb5022265cced2716e4f0 Mon Sep 17 00:00:00 2001 From: ethanpschoen Date: Thu, 18 Jun 2026 17:35:27 -0700 Subject: [PATCH 01/10] feat(ketchsdk): add headless API client and WebView experience hooks Expose headless bootstrap/config/profile/consent endpoints via HeadlessApiClient, wire Ketch/KetchSdk surface APIs, refresh index HTML init, and add unit plus integration test coverage. --- build.gradle | 2 + integration-tests/README.md | 115 ++--- .../tests/HeadlessIntegrationSupport.kt | 68 +++ .../tests/KetchHeadlessIntegrationTest.kt | 110 +++++ ketchsdk/build.gradle | 3 + .../src/main/java/com/ketch/android/Ketch.kt | 224 ++++++++- .../main/java/com/ketch/android/KetchSdk.kt | 152 +++++- .../ketch/android/api/HeadlessApiClient.kt | 449 ++++++++++++++++++ .../com/ketch/android/api/KetchDataCenter.kt | 10 + .../java/com/ketch/android/data/Events.kt | 1 + .../com/ketch/android/data/HeadlessModels.kt | 257 ++++++++++ .../java/com/ketch/android/ui/KetchWebView.kt | 76 ++- .../android/api/HeadlessApiClientTest.kt | 84 ++++ .../android/api/HeadlessConsentPayloadTest.kt | 94 ++++ .../ketch/android/api/HeadlessConsentTest.kt | 168 +++++++ .../java/com/ketch/android/data/EventsTest.kt | 27 ++ 16 files changed, 1740 insertions(+), 100 deletions(-) create mode 100644 integration-tests/src/androidTest/java/com/ketch/android/integration/tests/HeadlessIntegrationSupport.kt create mode 100644 integration-tests/src/androidTest/java/com/ketch/android/integration/tests/KetchHeadlessIntegrationTest.kt create mode 100644 ketchsdk/src/main/java/com/ketch/android/api/HeadlessApiClient.kt create mode 100644 ketchsdk/src/main/java/com/ketch/android/api/KetchDataCenter.kt create mode 100644 ketchsdk/src/main/java/com/ketch/android/data/HeadlessModels.kt create mode 100644 ketchsdk/src/test/java/com/ketch/android/api/HeadlessApiClientTest.kt create mode 100644 ketchsdk/src/test/java/com/ketch/android/api/HeadlessConsentPayloadTest.kt create mode 100644 ketchsdk/src/test/java/com/ketch/android/api/HeadlessConsentTest.kt create mode 100644 ketchsdk/src/test/java/com/ketch/android/data/EventsTest.kt 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..17148d37 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.InvokeRightRequest +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.HideExperienceStatus import com.ketch.android.data.KetchConfig +import com.ketch.android.data.LocationResponse import com.ketch.android.data.WillShowExperienceType import com.ketch.android.ui.KetchDialogFragment import com.ketch.android.ui.KetchWebView @@ -30,8 +47,13 @@ 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, ) { + // 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,7 +172,7 @@ class Ketch private constructor( null, emptyList(), null, - ketchUrl, + effectiveKetchUrl, logLevel, age, ageLower, @@ -162,6 +184,148 @@ class Ketch private constructor( return true } + /** CDN region used for headless and WebView API calls. */ + fun getDataCenter(): KetchDataCenter = dataCenter + + /** 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. * @@ -181,6 +345,7 @@ class Ketch private constructor( val signature = buildLoadSignature(bottomPadding, topPadding) tryWarmWebView(signature, "showConsent")?.let { webView -> webView.showConsentExperience() + webView.requestShowConsent(forceImmediate = true) return true } @@ -197,7 +362,7 @@ class Ketch private constructor( KetchWebView.ExperienceType.CONSENT, emptyList(), null, - ketchUrl, + effectiveKetchUrl, logLevel, age, ageLower, @@ -206,6 +371,7 @@ class Ketch private constructor( topPadding, cssStyle ) + webView.requestShowConsent(forceImmediate = true) return true } @@ -244,7 +410,7 @@ class Ketch private constructor( KetchWebView.ExperienceType.PREFERENCES, emptyList(), null, - ketchUrl, + effectiveKetchUrl, logLevel, age, ageLower, @@ -295,7 +461,7 @@ class Ketch private constructor( KetchWebView.ExperienceType.PREFERENCES, tabs, tab, - ketchUrl, + effectiveKetchUrl, logLevel, age, ageLower, @@ -320,9 +486,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) } } @@ -429,7 +597,8 @@ class Ketch private constructor( resolveFragmentManager()?.findFragmentByTag(KetchDialogFragment.TAG)?.let { existingFragment -> try { (existingFragment as? KetchDialogFragment)?.dismissAllowingStateLoss() - this@Ketch.listener?.onDismiss(HideExperienceStatus.None) + Log.d(TAG, "onDismiss source=initCleanup status=Close") + this@Ketch.listener?.onDismiss(HideExperienceStatus.Close) } catch (e: Exception) { Log.e(TAG, "Error dismissing existing dialog in init: ${e.message}") } @@ -663,13 +832,24 @@ class Ketch private constructor( webView.listener = object : KetchWebView.WebViewListener { private var config: KetchConfig? = null - private var showConsent: Boolean = false + private var showConsentPending = false + private var allowShowBeforeConfig = false + + override fun requestShowConsent(forceImmediate: Boolean) { + if (forceImmediate) { + allowShowBeforeConfig = true + } + showConsent() + } override fun showConsent() { - if (config == null) { - showConsent = true + if (config == null && !allowShowBeforeConfig) { + Log.d(TAG, "showConsent deferred until config loads") + showConsentPending = true return } + allowShowBeforeConfig = false + showConsentPending = false showConsentPopup() } @@ -724,13 +904,11 @@ class Ketch private constructor( override fun onConfigUpdated(config: KetchConfig?) { this.config = config - this@Ketch.listener?.onConfigUpdated(config) - - if (!showConsent) { - return + if (showConsentPending) { + showConsentPending = false + showConsentPopup() } - showConsentPopup() } override fun onEnvironmentUpdated(environment: String?) { @@ -765,7 +943,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 +967,7 @@ class Ketch private constructor( } else { handleDialogDismissed() } + Log.d(TAG, "onDismiss source=$source status=${status.name}") this@Ketch.listener?.onDismiss(status) } } @@ -820,6 +999,7 @@ class Ketch private constructor( resolveFragmentManager()?.let { fm -> if (!fm.isDestroyed) { + Log.d(TAG, "showConsentPopup: showing dialog") dialog.show(manager = fm) isShowingExperience = true activeDialogFragment = WeakReference(dialog) @@ -835,11 +1015,10 @@ class Ketch private constructor( } } catch (e: Exception) { isShowingExperience = false + showConsentPending = false Log.e(TAG, "Error showing dialog: ${e.message}") this@Ketch.listener?.onError("Error showing dialog: ${e.message}") } - - showConsent = false } } @@ -851,7 +1030,7 @@ class Ketch private constructor( } ContentDisplay.Banner -> { - it.theme?.modal?.container?.backdrop?.disableContentInteractions == true + it.theme?.banner?.container?.backdrop?.disableContentInteractions == true } } } ?: false @@ -974,6 +1153,7 @@ class Ketch private constructor( environment: String?, listener: Listener?, ketchUrl: String?, + dataCenter: KetchDataCenter = KetchDataCenter.US, logLevel: LogLevel, ) = Ketch( context = context, @@ -984,7 +1164,9 @@ class Ketch private constructor( environment = environment, listener = listener, ketchUrl = ketchUrl, + dataCenter = dataCenter, logLevel = logLevel, + headlessApiClient = HeadlessApiClient(dataCenter), ) fun create( @@ -995,6 +1177,7 @@ class Ketch private constructor( environment: String?, listener: Listener?, ketchUrl: String?, + dataCenter: KetchDataCenter = KetchDataCenter.US, logLevel: LogLevel, ) = Ketch( context = context, @@ -1005,7 +1188,12 @@ class Ketch private constructor( environment = environment, listener = listener, ketchUrl = ketchUrl, + dataCenter = dataCenter, logLevel = logLevel, + headlessApiClient = HeadlessApiClient(dataCenter), ) } } + +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..1ff2e138 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,7 +76,8 @@ 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 */ @Deprecated( @@ -77,7 +96,8 @@ 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, ): Ketch { return Ketch.create( context = context, @@ -87,7 +107,129 @@ object KetchSdk { environment = environment, listener = listener, ketchUrl = ketchUrl, - logLevel = logLevel + dataCenter = dataCenter, + logLevel = logLevel, + ) + } + + // 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/api/HeadlessApiClient.kt b/ketchsdk/src/main/java/com/ketch/android/api/HeadlessApiClient.kt new file mode 100644 index 00000000..5b5f4902 --- /dev/null +++ b/ketchsdk/src/main/java/com/ketch/android/api/HeadlessApiClient.kt @@ -0,0 +1,449 @@ +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 { + try { + callback(Result.success(block())) + } catch (error: HeadlessException) { + callback(Result.failure(error)) + } catch (error: Exception) { + callback(Result.failure(HeadlessException(error.message ?: "Headless API error", error))) + } + } + } + + 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/Events.kt b/ketchsdk/src/main/java/com/ketch/android/data/Events.kt index 9ca392b6..5cb3ba71 100644 --- a/ketchsdk/src/main/java/com/ketch/android/data/Events.kt +++ b/ketchsdk/src/main/java/com/ketch/android/data/Events.kt @@ -8,6 +8,7 @@ enum class HideExperienceStatus(val value: String?) { CloseWithoutSettingConsent("closeWithoutSettingConsent"), WillNotShow("willNotShow"), ActivityChanged("activityChanged"), + SetSubscriptions("setSubscriptions"), None(null); companion object { 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/ui/KetchWebView.kt b/ketchsdk/src/main/java/com/ketch/android/ui/KetchWebView.kt index 353600f3..5ae07192 100644 --- a/ketchsdk/src/main/java/com/ketch/android/ui/KetchWebView.kt +++ b/ketchsdk/src/main/java/com/ketch/android/ui/KetchWebView.kt @@ -155,8 +155,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 @@ -280,7 +281,7 @@ class KetchWebView(context: Context, shouldRetry: Boolean = false) : WebView(con ageUpper = ageUpper, bottomPadding = bottomPaddingPx, topPadding = topPaddingPx, - cssStyleOverride = cssStyle + cssStyleOverride = cssStyle, ) loadDataWithBaseURL("http://localhost", indexHtml, "text/html", "UTF-8", null) @@ -292,9 +293,13 @@ class KetchWebView(context: Context, shouldRetry: Boolean = false) : WebView(con fun hideExperience(status: String?) { // Determine the hideExperience event status val parsedStatus = parseHideExperienceStatus(status) - Log.d(TAG, "hideExperience: $status = ${parsedStatus.name}") + if (parsedStatus === HideExperienceStatus.None && !status.isNullOrBlank()) { + Log.w(TAG, "onDismiss source=hideExperience parseFallback rawStatus=$status") + } else { + Log.d(TAG, "onDismiss source=hideExperience status=${parsedStatus.name} rawStatus=$status") + } runOnMainThread { - ketchWebView.listener?.onClose(parsedStatus) + ketchWebView.listener?.onClose(parsedStatus, "hideExperience") } } @@ -348,12 +353,19 @@ class KetchWebView(context: Context, shouldRetry: Boolean = false) : WebView(con @JavascriptInterface fun willShowExperience(type: String?) { val parsedType = parseWillShowExperienceType(type) - Log.d(TAG, "willShowExperience: $type = ${parsedType.name}") + Log.d(TAG, "willShowExperience: raw=$type parsed=${parsedType.name}") runOnMainThread { - if (parsedType === WillShowExperienceType.ConsentExperience) { + if (parsedType === WillShowExperienceType.ConsentExperience || + type?.contains("consent", ignoreCase = true) == true + ) { ketchWebView.listener?.showConsent() - } else { + } else if (parsedType === WillShowExperienceType.PreferenceExperience || + type?.contains("preference", ignoreCase = true) == true + ) { ketchWebView.listener?.showPreferences() + } else { + Log.w(TAG, "willShowExperience: unknown type, defaulting to consent") + ketchWebView.listener?.showConsent() } ketchWebView.listener?.onWillShowExperience(parsedType) } @@ -370,11 +382,41 @@ class KetchWebView(context: Context, shouldRetry: Boolean = false) : WebView(con @JavascriptInterface fun showConsentExperience(showConsentExperience: String?) { Log.d(TAG, "showConsentExperience: $showConsentExperience") + runOnMainThread { + ketchWebView.listener?.showConsent() + } } @JavascriptInterface fun showPreferenceExperience(showPreferenceExperience: String?) { Log.d(TAG, "showPreferenceExperience: $showPreferenceExperience") + runOnMainThread { + ketchWebView.listener?.showPreferences() + } + } + + @JavascriptInterface + fun showExperience(payload: String?) { + Log.d(TAG, "showExperience: $payload") + runOnMainThread { + if (payload?.contains("preference", ignoreCase = true) == true) { + ketchWebView.listener?.showPreferences() + } else { + ketchWebView.listener?.showConsent() + } + } + } + + @JavascriptInterface + fun renderExperience(payload: String?) { + Log.d(TAG, "renderExperience: $payload") + runOnMainThread { + if (payload?.contains("preference", ignoreCase = true) == true) { + ketchWebView.listener?.showPreferences() + } else { + ketchWebView.listener?.showConsent() + } + } } @JavascriptInterface @@ -387,7 +429,7 @@ class KetchWebView(context: Context, shouldRetry: Boolean = false) : WebView(con .setPrettyPrinting() .create() .fromJson(configJson, KetchConfig::class.java) - Log.d(TAG, "config: $config") + Log.d(TAG, "config parsed: experiences=${config?.experiences?.consent?.display}") runOnMainThread { ketchWebView.listener?.onConfigUpdated(config) } @@ -460,9 +502,23 @@ class KetchWebView(context: Context, shouldRetry: Boolean = false) : WebView(con } } + internal fun requestShowConsent(forceImmediate: Boolean = false) { + listener?.requestShowConsent(forceImmediate) + } + + internal fun requestShowPreferences(forceImmediate: Boolean = false) { + listener?.requestShowPreferences(forceImmediate) + } + interface WebViewListener { fun showConsent() fun showPreferences() + fun requestShowConsent(forceImmediate: Boolean = false) { + showConsent() + } + fun requestShowPreferences(forceImmediate: Boolean = false) { + showPreferences() + } fun onUSPrivacyUpdated(values: Map) fun onTCFUpdated(values: Map) fun onGPPUpdated(values: Map) @@ -474,7 +530,7 @@ 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() } @@ -484,7 +540,7 @@ class KetchWebView(context: Context, shouldRetry: Boolean = false) : WebView(con PREFERENCES; fun getUrlParameter(): String = when (this) { - CONSENT -> "cd" + CONSENT -> "consent" PREFERENCES -> "preferences" } } 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/ketchsdk/src/test/java/com/ketch/android/data/EventsTest.kt b/ketchsdk/src/test/java/com/ketch/android/data/EventsTest.kt new file mode 100644 index 00000000..92a1ca9f --- /dev/null +++ b/ketchsdk/src/test/java/com/ketch/android/data/EventsTest.kt @@ -0,0 +1,27 @@ +package com.ketch.android.data + +import org.junit.Assert.assertEquals +import org.junit.Test + +class EventsTest { + + @Test + fun parseHideExperienceStatus_mapsKnownReasons() { + assertEquals(HideExperienceStatus.SetConsent, parseHideExperienceStatus("setConsent")) + assertEquals(HideExperienceStatus.InvokeRight, parseHideExperienceStatus("invokeRight")) + assertEquals(HideExperienceStatus.Close, parseHideExperienceStatus("close")) + assertEquals( + HideExperienceStatus.CloseWithoutSettingConsent, + parseHideExperienceStatus("closeWithoutSettingConsent") + ) + assertEquals(HideExperienceStatus.WillNotShow, parseHideExperienceStatus("willNotShow")) + assertEquals(HideExperienceStatus.SetSubscriptions, parseHideExperienceStatus("setSubscriptions")) + } + + @Test + fun parseHideExperienceStatus_unknownReasonFallsBackToNone() { + assertEquals(HideExperienceStatus.None, parseHideExperienceStatus("unknownReason")) + assertEquals(HideExperienceStatus.None, parseHideExperienceStatus(null)) + assertEquals(HideExperienceStatus.None, parseHideExperienceStatus("")) + } +} From dc09cd58631a9c15cf8329e1a41539401d6119c6 Mon Sep 17 00:00:00 2001 From: ethanpschoen Date: Mon, 22 Jun 2026 14:13:41 -0700 Subject: [PATCH 02/10] fix(ketchsdk): keep headless-sdk PR scoped to headless API only Remove tap-outside dismiss plumbing and index HTML touch handlers from this branch; those belong in run-sample-skills. Branch is off main only (no native storage commits). --- .../src/main/java/com/ketch/android/ui/KetchWebView.kt | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) 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 5ae07192..d6fc3975 100644 --- a/ketchsdk/src/main/java/com/ketch/android/ui/KetchWebView.kt +++ b/ketchsdk/src/main/java/com/ketch/android/ui/KetchWebView.kt @@ -293,13 +293,9 @@ class KetchWebView(context: Context, shouldRetry: Boolean = false) : WebView(con fun hideExperience(status: String?) { // Determine the hideExperience event status val parsedStatus = parseHideExperienceStatus(status) - if (parsedStatus === HideExperienceStatus.None && !status.isNullOrBlank()) { - Log.w(TAG, "onDismiss source=hideExperience parseFallback rawStatus=$status") - } else { - Log.d(TAG, "onDismiss source=hideExperience status=${parsedStatus.name} rawStatus=$status") - } + Log.d(TAG, "hideExperience: $status = ${parsedStatus.name}") runOnMainThread { - ketchWebView.listener?.onClose(parsedStatus, "hideExperience") + ketchWebView.listener?.onClose(parsedStatus) } } From bf00c42e8b4dc8c261341df119d5904e638e394b Mon Sep 17 00:00:00 2001 From: ethanpschoen Date: Mon, 22 Jun 2026 14:29:56 -0700 Subject: [PATCH 03/10] fix(ketchsdk): scope headless PR to REST API only Drop Index.kt, KetchWebView.kt, Events.kt, and WebView flow changes from the headless branch. Keep HeadlessApiClient, models, KetchSdk/Ketch REST methods, data center wiring, and integration tests. --- .../src/main/java/com/ketch/android/Ketch.kt | 42 +++++-------- .../java/com/ketch/android/data/Events.kt | 1 - .../java/com/ketch/android/ui/KetchWebView.kt | 63 ++----------------- .../java/com/ketch/android/data/EventsTest.kt | 27 -------- 4 files changed, 21 insertions(+), 112 deletions(-) delete mode 100644 ketchsdk/src/test/java/com/ketch/android/data/EventsTest.kt diff --git a/ketchsdk/src/main/java/com/ketch/android/Ketch.kt b/ketchsdk/src/main/java/com/ketch/android/Ketch.kt index 17148d37..c7cbd010 100644 --- a/ketchsdk/src/main/java/com/ketch/android/Ketch.kt +++ b/ketchsdk/src/main/java/com/ketch/android/Ketch.kt @@ -17,7 +17,10 @@ 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 @@ -25,9 +28,6 @@ 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.HideExperienceStatus -import com.ketch.android.data.KetchConfig -import com.ketch.android.data.LocationResponse import com.ketch.android.data.WillShowExperienceType import com.ketch.android.ui.KetchDialogFragment import com.ketch.android.ui.KetchWebView @@ -345,7 +345,6 @@ class Ketch private constructor( val signature = buildLoadSignature(bottomPadding, topPadding) tryWarmWebView(signature, "showConsent")?.let { webView -> webView.showConsentExperience() - webView.requestShowConsent(forceImmediate = true) return true } @@ -371,7 +370,6 @@ class Ketch private constructor( topPadding, cssStyle ) - webView.requestShowConsent(forceImmediate = true) return true } @@ -597,8 +595,7 @@ class Ketch private constructor( resolveFragmentManager()?.findFragmentByTag(KetchDialogFragment.TAG)?.let { existingFragment -> try { (existingFragment as? KetchDialogFragment)?.dismissAllowingStateLoss() - Log.d(TAG, "onDismiss source=initCleanup status=Close") - this@Ketch.listener?.onDismiss(HideExperienceStatus.Close) + this@Ketch.listener?.onDismiss(HideExperienceStatus.None) } catch (e: Exception) { Log.e(TAG, "Error dismissing existing dialog in init: ${e.message}") } @@ -832,24 +829,13 @@ class Ketch private constructor( webView.listener = object : KetchWebView.WebViewListener { private var config: KetchConfig? = null - private var showConsentPending = false - private var allowShowBeforeConfig = false - - override fun requestShowConsent(forceImmediate: Boolean) { - if (forceImmediate) { - allowShowBeforeConfig = true - } - showConsent() - } + private var showConsent: Boolean = false override fun showConsent() { - if (config == null && !allowShowBeforeConfig) { - Log.d(TAG, "showConsent deferred until config loads") - showConsentPending = true + if (config == null) { + showConsent = true return } - allowShowBeforeConfig = false - showConsentPending = false showConsentPopup() } @@ -904,11 +890,13 @@ class Ketch private constructor( override fun onConfigUpdated(config: KetchConfig?) { this.config = config + this@Ketch.listener?.onConfigUpdated(config) - if (showConsentPending) { - showConsentPending = false - showConsentPopup() + + if (!showConsent) { + return } + showConsentPopup() } override fun onEnvironmentUpdated(environment: String?) { @@ -999,7 +987,6 @@ class Ketch private constructor( resolveFragmentManager()?.let { fm -> if (!fm.isDestroyed) { - Log.d(TAG, "showConsentPopup: showing dialog") dialog.show(manager = fm) isShowingExperience = true activeDialogFragment = WeakReference(dialog) @@ -1015,10 +1002,11 @@ class Ketch private constructor( } } catch (e: Exception) { isShowingExperience = false - showConsentPending = false Log.e(TAG, "Error showing dialog: ${e.message}") this@Ketch.listener?.onError("Error showing dialog: ${e.message}") } + + showConsent = false } } @@ -1030,7 +1018,7 @@ class Ketch private constructor( } ContentDisplay.Banner -> { - it.theme?.banner?.container?.backdrop?.disableContentInteractions == true + it.theme?.modal?.container?.backdrop?.disableContentInteractions == true } } } ?: false diff --git a/ketchsdk/src/main/java/com/ketch/android/data/Events.kt b/ketchsdk/src/main/java/com/ketch/android/data/Events.kt index 5cb3ba71..9ca392b6 100644 --- a/ketchsdk/src/main/java/com/ketch/android/data/Events.kt +++ b/ketchsdk/src/main/java/com/ketch/android/data/Events.kt @@ -8,7 +8,6 @@ enum class HideExperienceStatus(val value: String?) { CloseWithoutSettingConsent("closeWithoutSettingConsent"), WillNotShow("willNotShow"), ActivityChanged("activityChanged"), - SetSubscriptions("setSubscriptions"), None(null); companion object { 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 d6fc3975..c595f4e7 100644 --- a/ketchsdk/src/main/java/com/ketch/android/ui/KetchWebView.kt +++ b/ketchsdk/src/main/java/com/ketch/android/ui/KetchWebView.kt @@ -281,7 +281,7 @@ class KetchWebView(context: Context, shouldRetry: Boolean = false) : WebView(con ageUpper = ageUpper, bottomPadding = bottomPaddingPx, topPadding = topPaddingPx, - cssStyleOverride = cssStyle, + cssStyleOverride = cssStyle ) loadDataWithBaseURL("http://localhost", indexHtml, "text/html", "UTF-8", null) @@ -349,19 +349,12 @@ class KetchWebView(context: Context, shouldRetry: Boolean = false) : WebView(con @JavascriptInterface fun willShowExperience(type: String?) { val parsedType = parseWillShowExperienceType(type) - Log.d(TAG, "willShowExperience: raw=$type parsed=${parsedType.name}") + Log.d(TAG, "willShowExperience: $type = ${parsedType.name}") runOnMainThread { - if (parsedType === WillShowExperienceType.ConsentExperience || - type?.contains("consent", ignoreCase = true) == true - ) { + if (parsedType === WillShowExperienceType.ConsentExperience) { ketchWebView.listener?.showConsent() - } else if (parsedType === WillShowExperienceType.PreferenceExperience || - type?.contains("preference", ignoreCase = true) == true - ) { - ketchWebView.listener?.showPreferences() } else { - Log.w(TAG, "willShowExperience: unknown type, defaulting to consent") - ketchWebView.listener?.showConsent() + ketchWebView.listener?.showPreferences() } ketchWebView.listener?.onWillShowExperience(parsedType) } @@ -378,41 +371,11 @@ class KetchWebView(context: Context, shouldRetry: Boolean = false) : WebView(con @JavascriptInterface fun showConsentExperience(showConsentExperience: String?) { Log.d(TAG, "showConsentExperience: $showConsentExperience") - runOnMainThread { - ketchWebView.listener?.showConsent() - } } @JavascriptInterface fun showPreferenceExperience(showPreferenceExperience: String?) { Log.d(TAG, "showPreferenceExperience: $showPreferenceExperience") - runOnMainThread { - ketchWebView.listener?.showPreferences() - } - } - - @JavascriptInterface - fun showExperience(payload: String?) { - Log.d(TAG, "showExperience: $payload") - runOnMainThread { - if (payload?.contains("preference", ignoreCase = true) == true) { - ketchWebView.listener?.showPreferences() - } else { - ketchWebView.listener?.showConsent() - } - } - } - - @JavascriptInterface - fun renderExperience(payload: String?) { - Log.d(TAG, "renderExperience: $payload") - runOnMainThread { - if (payload?.contains("preference", ignoreCase = true) == true) { - ketchWebView.listener?.showPreferences() - } else { - ketchWebView.listener?.showConsent() - } - } } @JavascriptInterface @@ -425,7 +388,7 @@ class KetchWebView(context: Context, shouldRetry: Boolean = false) : WebView(con .setPrettyPrinting() .create() .fromJson(configJson, KetchConfig::class.java) - Log.d(TAG, "config parsed: experiences=${config?.experiences?.consent?.display}") + Log.d(TAG, "config: $config") runOnMainThread { ketchWebView.listener?.onConfigUpdated(config) } @@ -498,23 +461,9 @@ class KetchWebView(context: Context, shouldRetry: Boolean = false) : WebView(con } } - internal fun requestShowConsent(forceImmediate: Boolean = false) { - listener?.requestShowConsent(forceImmediate) - } - - internal fun requestShowPreferences(forceImmediate: Boolean = false) { - listener?.requestShowPreferences(forceImmediate) - } - interface WebViewListener { fun showConsent() fun showPreferences() - fun requestShowConsent(forceImmediate: Boolean = false) { - showConsent() - } - fun requestShowPreferences(forceImmediate: Boolean = false) { - showPreferences() - } fun onUSPrivacyUpdated(values: Map) fun onTCFUpdated(values: Map) fun onGPPUpdated(values: Map) @@ -536,7 +485,7 @@ class KetchWebView(context: Context, shouldRetry: Boolean = false) : WebView(con PREFERENCES; fun getUrlParameter(): String = when (this) { - CONSENT -> "consent" + CONSENT -> "cd" PREFERENCES -> "preferences" } } diff --git a/ketchsdk/src/test/java/com/ketch/android/data/EventsTest.kt b/ketchsdk/src/test/java/com/ketch/android/data/EventsTest.kt deleted file mode 100644 index 92a1ca9f..00000000 --- a/ketchsdk/src/test/java/com/ketch/android/data/EventsTest.kt +++ /dev/null @@ -1,27 +0,0 @@ -package com.ketch.android.data - -import org.junit.Assert.assertEquals -import org.junit.Test - -class EventsTest { - - @Test - fun parseHideExperienceStatus_mapsKnownReasons() { - assertEquals(HideExperienceStatus.SetConsent, parseHideExperienceStatus("setConsent")) - assertEquals(HideExperienceStatus.InvokeRight, parseHideExperienceStatus("invokeRight")) - assertEquals(HideExperienceStatus.Close, parseHideExperienceStatus("close")) - assertEquals( - HideExperienceStatus.CloseWithoutSettingConsent, - parseHideExperienceStatus("closeWithoutSettingConsent") - ) - assertEquals(HideExperienceStatus.WillNotShow, parseHideExperienceStatus("willNotShow")) - assertEquals(HideExperienceStatus.SetSubscriptions, parseHideExperienceStatus("setSubscriptions")) - } - - @Test - fun parseHideExperienceStatus_unknownReasonFallsBackToNone() { - assertEquals(HideExperienceStatus.None, parseHideExperienceStatus("unknownReason")) - assertEquals(HideExperienceStatus.None, parseHideExperienceStatus(null)) - assertEquals(HideExperienceStatus.None, parseHideExperienceStatus("")) - } -} From df205289fa51907c4846d8bb8dbd849de1962f73 Mon Sep 17 00:00:00 2001 From: ethanpschoen Date: Mon, 6 Jul 2026 15:31:06 -0700 Subject: [PATCH 04/10] bug review --- .../src/main/java/com/ketch/android/Ketch.kt | 7 ++- .../main/java/com/ketch/android/KetchSdk.kt | 12 +++-- .../ketch/android/api/HeadlessApiClient.kt | 49 ++++++++++++++----- 3 files changed, 51 insertions(+), 17 deletions(-) diff --git a/ketchsdk/src/main/java/com/ketch/android/Ketch.kt b/ketchsdk/src/main/java/com/ketch/android/Ketch.kt index c7cbd010..a9f1cf5a 100644 --- a/ketchsdk/src/main/java/com/ketch/android/Ketch.kt +++ b/ketchsdk/src/main/java/com/ketch/android/Ketch.kt @@ -226,7 +226,10 @@ class Ketch private constructor( suspend fun fetchConsent(config: ConsentConfig): Consent = headlessApiClient.fetchConsent(config) - /** Protocol strings only (same endpoint as fetchConsent). */ + /** + * Same endpoint as [fetchConsent], but drops `protocols` from the result if the server + * didn't return any (purposes/vendors from a plain consent response are still included). + */ fun fetchProtocols( config: ConsentConfig, callback: (Result) -> Unit, @@ -728,7 +731,7 @@ class Ketch private constructor( listOf( orgCode, property, - ketchUrl ?: "", + effectiveKetchUrl, environment ?: "", language ?: "", jurisdiction ?: "", diff --git a/ketchsdk/src/main/java/com/ketch/android/KetchSdk.kt b/ketchsdk/src/main/java/com/ketch/android/KetchSdk.kt index 1ff2e138..44aa7f57 100644 --- a/ketchsdk/src/main/java/com/ketch/android/KetchSdk.kt +++ b/ketchsdk/src/main/java/com/ketch/android/KetchSdk.kt @@ -44,7 +44,8 @@ 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 */ fun create( @@ -54,6 +55,7 @@ object KetchSdk { environment: String? = null, listener: Ketch.Listener? = null, ketchUrl: String? = null, + dataCenter: KetchDataCenter = KetchDataCenter.US, logLevel: Ketch.LogLevel = Ketch.LogLevel.ERROR ): Ketch { return Ketch.create( @@ -63,6 +65,7 @@ object KetchSdk { environment = environment, listener = listener, ketchUrl = ketchUrl, + dataCenter = dataCenter, logLevel = logLevel, ) } @@ -84,7 +87,7 @@ object KetchSdk { message = "Ketch now tracks the foreground Activity automatically; the FragmentManager " + "argument is no longer required. Use create(context, organization, property, ...).", replaceWith = ReplaceWith( - "KetchSdk.create(context, organization, property, environment, listener, ketchUrl, logLevel)" + "KetchSdk.create(context, organization, property, environment, listener, ketchUrl, dataCenter, logLevel)" ), level = DeprecationLevel.WARNING, ) @@ -150,7 +153,10 @@ object KetchSdk { HeadlessApiClient(dataCenter).fetchConsent(config, callback) } - /** Protocol strings only (same endpoint as fetchConsent). */ + /** + * Same endpoint as [fetchConsent], but drops `protocols` from the result if the server + * didn't return any (purposes/vendors from a plain consent response are still included). + */ fun fetchProtocols( config: ConsentConfig, dataCenter: KetchDataCenter = KetchDataCenter.US, diff --git a/ketchsdk/src/main/java/com/ketch/android/api/HeadlessApiClient.kt b/ketchsdk/src/main/java/com/ketch/android/api/HeadlessApiClient.kt index 5b5f4902..217d129c 100644 --- a/ketchsdk/src/main/java/com/ketch/android/api/HeadlessApiClient.kt +++ b/ketchsdk/src/main/java/com/ketch/android/api/HeadlessApiClient.kt @@ -29,6 +29,7 @@ import okhttp3.OkHttpClient import okhttp3.Request import okhttp3.RequestBody.Companion.toRequestBody import java.io.IOException +import java.util.concurrent.TimeUnit /** * Native HTTP client mirroring ketch-tag [KetchWebAPI] (web/v3). @@ -36,13 +37,30 @@ import java.io.IOException class HeadlessApiClient( dataCenter: KetchDataCenter = KetchDataCenter.US, baseUrl: String? = null, - private val okHttpClient: OkHttpClient = OkHttpClient(), + private val okHttpClient: OkHttpClient = defaultOkHttpClient, private val gson: Gson = Gson(), ) { private val baseUrl = baseUrl ?: dataCenter.baseUrl private val jsonMediaType = "application/json".toMediaType() private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + companion object { + private const val TIMEOUT_SECONDS = 30L + + /** + * Shared client across [HeadlessApiClient] instances so per-call helpers (e.g. [com.ketch.android.KetchSdk] + * static methods) reuse connections/threads instead of allocating a fresh pool each call, and so + * requests fail fast on bad networks instead of hanging indefinitely. + */ + private val defaultOkHttpClient: OkHttpClient by lazy { + OkHttpClient.Builder() + .connectTimeout(TIMEOUT_SECONDS, TimeUnit.SECONDS) + .readTimeout(TIMEOUT_SECONDS, TimeUnit.SECONDS) + .writeTimeout(TIMEOUT_SECONDS, TimeUnit.SECONDS) + .build() + } + } + fun fetchLocation(callback: (Result) -> Unit) { launchAsync(callback) { fetchLocation() } } @@ -96,7 +114,7 @@ class HeadlessApiClient( suspend fun fetchConsent(config: ConsentConfig): Consent = withContext(Dispatchers.IO) { val path = "/consent/${config.organizationCode}/get" - postConsent(path, ConsentConfigPayload.from(config), config) + postConsent(path, ConsentConfigPayload.from(config)) } fun fetchProtocols( @@ -270,9 +288,9 @@ class HeadlessApiClient( } } - private fun postConsent(path: String, body: ConsentConfigPayload, config: ConsentConfig): Consent { + private fun postConsent(path: String, body: ConsentConfigPayload): Consent { val request = buildConsentPostRequest(path, body) - return executeConsentFetch(request, config) + return executeConsentFetch(request) } private fun postSetConsent(path: String, body: SetConsentPayload, fallback: ConsentUpdate): Consent { @@ -290,7 +308,7 @@ class HeadlessApiClient( .build() } - private fun executeConsentFetch(request: Request, config: ConsentConfig): Consent { + private fun executeConsentFetch(request: Request): Consent { try { okHttpClient.newCall(request).execute().use { response -> val body = response.body?.string().orEmpty() @@ -298,13 +316,13 @@ class HeadlessApiClient( throw HeadlessException("HTTP ${response.code} for ${request.url}") } if (body.isBlank() || body == "null") { - return emptyConsent(config) + return emptyConsent() } val decoded = decodeConsent(body) if (decoded != null && hasUsableConsentFields(decoded)) { return decoded } - return emptyConsent(config) + return emptyConsent() } } catch (error: HeadlessException) { throw error @@ -378,9 +396,13 @@ class HeadlessApiClient( } } - private fun emptyConsent(config: ConsentConfig): Consent = + private fun emptyConsent(): Consent = Consent(purposes = emptyMap(), vendors = null, protocols = null) + // Fallback used when setConsent's HTTP response is 2xx but the body has no usable purposes/ + // protocols (e.g. empty body). We build a Consent from the values in the request itself, so the + // caller sees the purposes they asked for as "on". This is a guess: we never confirmed the server + // actually stored them, since the response didn't tell us anything. private fun consentFromUpdate(update: ConsentUpdate): Consent { val purposes = update.purposes.mapValues { (_, basis) -> basis.allowed.equals("true", ignoreCase = true) @@ -392,14 +414,17 @@ class HeadlessApiClient( callback: (Result) -> Unit, block: suspend () -> T, ) { + // Network work runs on the IO dispatcher (scope + block's own withContext); the callback is + // delivered on Main to match the rest of the SDK's threading contract (see KetchWebView). scope.launch { - try { - callback(Result.success(block())) + val result = try { + Result.success(block()) } catch (error: HeadlessException) { - callback(Result.failure(error)) + Result.failure(error) } catch (error: Exception) { - callback(Result.failure(HeadlessException(error.message ?: "Headless API error", error))) + Result.failure(HeadlessException(error.message ?: "Headless API error", error)) } + withContext(Dispatchers.Main) { callback(result) } } } From bc16342a3560c03fb3d97d746a3447782793e9eb Mon Sep 17 00:00:00 2001 From: ethanpschoen Date: Mon, 13 Jul 2026 19:35:24 -0700 Subject: [PATCH 05/10] remove extra and rename methods --- integration-tests/README.md | 2 +- .../tests/KetchHeadlessIntegrationTest.kt | 16 +-- .../src/main/java/com/ketch/android/Ketch.kt | 93 +++-------------- .../main/java/com/ketch/android/KetchSdk.kt | 67 ++----------- .../ketch/android/api/HeadlessApiClient.kt | 99 +++---------------- .../com/ketch/android/data/HeadlessModels.kt | 84 +--------------- .../android/api/HeadlessApiClientTest.kt | 6 +- .../ketch/android/api/HeadlessConsentTest.kt | 27 +---- 8 files changed, 51 insertions(+), 343 deletions(-) diff --git a/integration-tests/README.md b/integration-tests/README.md index 1e410239..0588670b 100644 --- a/integration-tests/README.md +++ b/integration-tests/README.md @@ -89,7 +89,7 @@ Reports: `integration-tests/build/reports/androidTests/connected/`. | Suite | Class | What it validates | | ----- | ----- | ----------------- | -| **Headless CDN** | `KetchHeadlessIntegrationTest` | `fetchLocation`, bootstrap, full config, consent get/set | +| **Headless CDN** | `KetchHeadlessIntegrationTest` | `getLocation`, bootstrap, full config, consent get/set | | **WebView** | `KetchSdkIntegrationTest` | SDK init, UI buttons, WebView experience load | The current test suite covers: 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 index 2b24172e..1e2d327d 100644 --- 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 @@ -25,9 +25,9 @@ import org.junit.runner.RunWith class KetchHeadlessIntegrationTest { @Test - fun testFetchLocationReturnsGeoIP() { + fun testGetLocationReturnsGeoIP() { val location = awaitHeadless { callback -> - KetchSdk.fetchLocation(dataCenter, callback) + KetchSdk.getLocation(dataCenter, callback) } assertNotNull("Expected location payload", location.location) assertFalse( @@ -37,9 +37,9 @@ class KetchHeadlessIntegrationTest { } @Test - fun testFetchBootstrapConfiguration() { + fun testGetBootstrapConfiguration() { val boot = awaitHeadless { callback -> - KetchSdk.fetchBootstrapConfiguration(ORG_CODE, PROPERTY, dataCenter, callback) + KetchSdk.getBootstrapConfiguration(ORG_CODE, PROPERTY, dataCenter, callback) } assertTrue( "Bootstrap should include experiences metadata", @@ -52,15 +52,15 @@ class KetchHeadlessIntegrationTest { val identities = uniqueEmailIdentity() awaitHeadless { callback -> - KetchSdk.fetchLocation(dataCenter, callback) + KetchSdk.getLocation(dataCenter, callback) } val boot = awaitHeadless { callback -> - KetchSdk.fetchBootstrapConfiguration(ORG_CODE, PROPERTY, dataCenter, callback) + KetchSdk.getBootstrapConfiguration(ORG_CODE, PROPERTY, dataCenter, callback) } val fullConfig = awaitHeadless { callback -> - KetchSdk.fetchFullConfiguration( + KetchSdk.getFullConfiguration( FullConfigurationRequest( organizationCode = ORG_CODE, propertyCode = PROPERTY, @@ -73,7 +73,7 @@ class KetchHeadlessIntegrationTest { val consentConfig = consentConfigFrom(fullConfig, identities) val consent = awaitHeadless { callback -> - KetchSdk.fetchConsent(consentConfig, dataCenter, callback) + KetchSdk.getConsent(consentConfig, dataCenter, callback) } assertTrue( "CDN consent get should return protocols and/or purposes", diff --git a/ketchsdk/src/main/java/com/ketch/android/Ketch.kt b/ketchsdk/src/main/java/com/ketch/android/Ketch.kt index a9f1cf5a..b1c736d1 100644 --- a/ketchsdk/src/main/java/com/ketch/android/Ketch.kt +++ b/ketchsdk/src/main/java/com/ketch/android/Ketch.kt @@ -14,20 +14,14 @@ 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 @@ -188,57 +182,43 @@ class Ketch private constructor( fun getDataCenter(): KetchDataCenter = dataCenter /** GeoIP / jurisdiction hint (`GET /ip`). */ - fun fetchLocation(callback: (Result) -> Unit) { - headlessApiClient.fetchLocation(callback) + fun getLocation(callback: (Result) -> Unit) { + headlessApiClient.getLocation(callback) } - suspend fun fetchLocation(): LocationResponse = headlessApiClient.fetchLocation() + suspend fun getLocation(): LocationResponse = headlessApiClient.getLocation() /** Minimal config (`GET .../boot.json`). */ - fun fetchBootstrapConfiguration( + fun getBootstrapConfiguration( callback: (Result) -> Unit, ) { - headlessApiClient.fetchBootstrapConfiguration(orgCode, property, callback) + headlessApiClient.getBootstrapConfiguration(orgCode, property, callback) } - suspend fun fetchBootstrapConfiguration(): HeadlessConfiguration = - headlessApiClient.fetchBootstrapConfiguration(orgCode, property) + suspend fun getBootstrapConfiguration(): HeadlessConfiguration = + headlessApiClient.getBootstrapConfiguration(orgCode, property) /** Full config with optional env / jurisdiction / language and hash query param. */ - fun fetchFullConfiguration( + fun getFullConfiguration( request: FullConfigurationRequest, callback: (Result) -> Unit, ) { - headlessApiClient.fetchFullConfiguration(request, callback) + headlessApiClient.getFullConfiguration(request, callback) } - suspend fun fetchFullConfiguration(request: FullConfigurationRequest): HeadlessConfiguration = - headlessApiClient.fetchFullConfiguration(request) + suspend fun getFullConfiguration(request: FullConfigurationRequest): HeadlessConfiguration = + headlessApiClient.getFullConfiguration(request) /** Server consent including `protocols` (`POST .../consent/{org}/get`). */ - fun fetchConsent( + fun getConsent( config: ConsentConfig, callback: (Result) -> Unit, ) { - headlessApiClient.fetchConsent(config, callback) + headlessApiClient.getConsent(config, callback) } - suspend fun fetchConsent(config: ConsentConfig): Consent = - headlessApiClient.fetchConsent(config) - - /** - * Same endpoint as [fetchConsent], but drops `protocols` from the result if the server - * didn't return any (purposes/vendors from a plain consent response are still included). - */ - fun fetchProtocols( - config: ConsentConfig, - callback: (Result) -> Unit, - ) { - headlessApiClient.fetchProtocols(config, callback) - } - - suspend fun fetchProtocols(config: ConsentConfig): Consent = - headlessApiClient.fetchProtocols(config) + suspend fun getConsent(config: ConsentConfig): Consent = + headlessApiClient.getConsent(config) /** Updates consent; returns server response with computed `protocols`. */ fun setConsent( @@ -261,27 +241,6 @@ class Ketch private constructor( 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, @@ -304,31 +263,9 @@ class Ketch private constructor( 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. * diff --git a/ketchsdk/src/main/java/com/ketch/android/KetchSdk.kt b/ketchsdk/src/main/java/com/ketch/android/KetchSdk.kt index 44aa7f57..f67b9a68 100644 --- a/ketchsdk/src/main/java/com/ketch/android/KetchSdk.kt +++ b/ketchsdk/src/main/java/com/ketch/android/KetchSdk.kt @@ -8,18 +8,12 @@ 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. @@ -118,51 +112,39 @@ object KetchSdk { // MARK: - Headless API (static, web/v3) /** GeoIP / jurisdiction hint (`GET /ip`). */ - fun fetchLocation( + fun getLocation( dataCenter: KetchDataCenter = KetchDataCenter.US, callback: (Result) -> Unit, ) { - HeadlessApiClient(dataCenter).fetchLocation(callback) + HeadlessApiClient(dataCenter).getLocation(callback) } /** Minimal config (`GET .../boot.json`). */ - fun fetchBootstrapConfiguration( + fun getBootstrapConfiguration( organization: String, property: String, dataCenter: KetchDataCenter = KetchDataCenter.US, callback: (Result) -> Unit, ) { - HeadlessApiClient(dataCenter).fetchBootstrapConfiguration(organization, property, callback) + HeadlessApiClient(dataCenter).getBootstrapConfiguration(organization, property, callback) } /** Full config with optional env / jurisdiction / language and hash query param. */ - fun fetchFullConfiguration( + fun getFullConfiguration( request: FullConfigurationRequest, dataCenter: KetchDataCenter = KetchDataCenter.US, callback: (Result) -> Unit, ) { - HeadlessApiClient(dataCenter).fetchFullConfiguration(request, callback) + HeadlessApiClient(dataCenter).getFullConfiguration(request, callback) } /** Server consent including `protocols` (`POST .../consent/{org}/get`). */ - fun fetchConsent( + fun getConsent( config: ConsentConfig, dataCenter: KetchDataCenter = KetchDataCenter.US, callback: (Result) -> Unit, ) { - HeadlessApiClient(dataCenter).fetchConsent(config, callback) - } - - /** - * Same endpoint as [fetchConsent], but drops `protocols` from the result if the server - * didn't return any (purposes/vendors from a plain consent response are still included). - */ - fun fetchProtocols( - config: ConsentConfig, - dataCenter: KetchDataCenter = KetchDataCenter.US, - callback: (Result) -> Unit, - ) { - HeadlessApiClient(dataCenter).fetchProtocols(config, callback) + HeadlessApiClient(dataCenter).getConsent(config, callback) } /** Updates consent; returns server response with computed `protocols`. */ @@ -185,22 +167,6 @@ object KetchSdk { 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, @@ -217,25 +183,8 @@ object KetchSdk { 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/api/HeadlessApiClient.kt b/ketchsdk/src/main/java/com/ketch/android/api/HeadlessApiClient.kt index 217d129c..7a948472 100644 --- a/ketchsdk/src/main/java/com/ketch/android/api/HeadlessApiClient.kt +++ b/ketchsdk/src/main/java/com/ketch/android/api/HeadlessApiClient.kt @@ -5,19 +5,13 @@ 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 @@ -61,37 +55,37 @@ class HeadlessApiClient( } } - fun fetchLocation(callback: (Result) -> Unit) { - launchAsync(callback) { fetchLocation() } + fun getLocation(callback: (Result) -> Unit) { + launchAsync(callback) { getLocation() } } - suspend fun fetchLocation(): LocationResponse = withContext(Dispatchers.IO) { + suspend fun getLocation(): LocationResponse = withContext(Dispatchers.IO) { get("/ip", LocationResponse::class.java) } - fun fetchBootstrapConfiguration( + fun getBootstrapConfiguration( organization: String, property: String, callback: (Result) -> Unit, ) { - launchAsync(callback) { fetchBootstrapConfiguration(organization, property) } + launchAsync(callback) { getBootstrapConfiguration(organization, property) } } - suspend fun fetchBootstrapConfiguration( + suspend fun getBootstrapConfiguration( organization: String, property: String, ): HeadlessConfiguration = withContext(Dispatchers.IO) { get("/config/$organization/$property/boot.json", HeadlessConfiguration::class.java) } - fun fetchFullConfiguration( + fun getFullConfiguration( request: FullConfigurationRequest, callback: (Result) -> Unit, ) { - launchAsync(callback) { fetchFullConfiguration(request) } + launchAsync(callback) { getFullConfiguration(request) } } - suspend fun fetchFullConfiguration(request: FullConfigurationRequest): HeadlessConfiguration = + suspend fun getFullConfiguration(request: FullConfigurationRequest): HeadlessConfiguration = withContext(Dispatchers.IO) { var path = "/config/${request.organizationCode}/${request.propertyCode}" if (request.environmentCode != null && @@ -105,38 +99,18 @@ class HeadlessApiClient( get(path, HeadlessConfiguration::class.java, query) } - fun fetchConsent( + fun getConsent( config: ConsentConfig, callback: (Result) -> Unit, ) { - launchAsync(callback) { fetchConsent(config) } + launchAsync(callback) { getConsent(config) } } - suspend fun fetchConsent(config: ConsentConfig): Consent = withContext(Dispatchers.IO) { + suspend fun getConsent(config: ConsentConfig): Consent = withContext(Dispatchers.IO) { val path = "/consent/${config.organizationCode}/get" postConsent(path, ConsentConfigPayload.from(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, @@ -160,28 +134,6 @@ class HeadlessApiClient( 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, @@ -205,20 +157,6 @@ class HeadlessApiClient( 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 } @@ -233,19 +171,6 @@ class HeadlessApiClient( ) } - 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" diff --git a/ketchsdk/src/main/java/com/ketch/android/data/HeadlessModels.kt b/ketchsdk/src/main/java/com/ketch/android/data/HeadlessModels.kt index 01c1efa8..f7e1d606 100644 --- a/ketchsdk/src/main/java/com/ketch/android/data/HeadlessModels.kt +++ b/ketchsdk/src/main/java/com/ketch/android/data/HeadlessModels.kt @@ -17,7 +17,7 @@ data class IPInfo( @SerializedName("timezone") val timezone: String? = null, ) -/** Response from headless `fetchLocation()`. */ +/** Response from headless `getLocation()`. */ data class LocationResponse( @SerializedName("location") val location: IPInfo? = null, ) @@ -142,79 +142,6 @@ data class InvokeRightRequest( 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, @@ -227,15 +154,6 @@ data class PreferenceQRRequest( 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, diff --git a/ketchsdk/src/test/java/com/ketch/android/api/HeadlessApiClientTest.kt b/ketchsdk/src/test/java/com/ketch/android/api/HeadlessApiClientTest.kt index 614cb432..92c2eb66 100644 --- a/ketchsdk/src/test/java/com/ketch/android/api/HeadlessApiClientTest.kt +++ b/ketchsdk/src/test/java/com/ketch/android/api/HeadlessApiClientTest.kt @@ -60,15 +60,11 @@ class HeadlessApiClientTest { } @Test - fun buildUrl_rightsProfileSubscriptions() { + fun buildUrl_rightsSubscriptions() { 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"), diff --git a/ketchsdk/src/test/java/com/ketch/android/api/HeadlessConsentTest.kt b/ketchsdk/src/test/java/com/ketch/android/api/HeadlessConsentTest.kt index 5e8ec4a9..cc0d9751 100644 --- a/ketchsdk/src/test/java/com/ketch/android/api/HeadlessConsentTest.kt +++ b/ketchsdk/src/test/java/com/ketch/android/api/HeadlessConsentTest.kt @@ -33,13 +33,13 @@ class HeadlessConsentTest { } @Test - fun fetchConsentPropagatesHttpFailure() = runBlocking { + fun getConsentPropagatesHttpFailure() = runBlocking { mockWebServer.enqueue(MockResponse().setResponseCode(HttpURLConnection.HTTP_INTERNAL_ERROR)) val client = headlessClient() try { - client.fetchConsent(sampleConsentConfig()) - fail("Expected fetchConsent to fail on HTTP 500") + client.getConsent(sampleConsentConfig()) + fail("Expected getConsent to fail on HTTP 500") } catch (error: HeadlessException) { assertTrue(error.message?.contains("HTTP 500") == true) } @@ -65,7 +65,7 @@ class HeadlessConsentTest { } @Test - fun fetchConsentReturnsEmptyConsentOn200NullBody() = runBlocking { + fun getConsentReturnsEmptyConsentOn200NullBody() = runBlocking { mockWebServer.enqueue( MockResponse() .setResponseCode(HttpURLConnection.HTTP_OK) @@ -74,30 +74,13 @@ class HeadlessConsentTest { ) val client = headlessClient() - val consent = client.fetchConsent(sampleConsentConfig()) + val consent = client.getConsent(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( From ce80490afb0045abd2e6ca91a0403056b61d7056 Mon Sep 17 00:00:00 2001 From: ethanpschoen Date: Tue, 14 Jul 2026 13:12:39 -0700 Subject: [PATCH 06/10] fix(integration-tests): point headless tests at production CDN, not UAT ketch_samples/android is only provisioned on the production CDN (global.ketchcdn.com / KetchDataCenter.US), not the dev/UAT CDN (dev.ketchcdn.com). testGetBootstrapConfiguration and testHeadlessColdStartConsentRoundTrip 404 on org-specific endpoints (/config/ketch_samples/android/boot.json) against UAT, while testGetLocationReturnsGeoIP incidentally passes since /ip carries no org/property path segment. Every other reference to this org/property in the repo (sample apps, WebView-based integration tests, SDK doc comments) already targets the production CDN. Introduced in 30c83c7 with no stated rationale for UAT; verified via git blame and a live re-run against the real CDN (32/34 -> 34/34). --- .../android/integration/tests/HeadlessIntegrationSupport.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 index d8045f82..02cfb403 100644 --- 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 @@ -64,5 +64,5 @@ object HeadlessIntegrationSupport { ) } - val dataCenter: KetchDataCenter = KetchDataCenter.UAT + val dataCenter: KetchDataCenter = KetchDataCenter.US } From ff649d35ef82ac03eee36410b4eaba8cc9c32f8a Mon Sep 17 00:00:00 2001 From: ethanpschoen Date: Tue, 14 Jul 2026 14:53:27 -0700 Subject: [PATCH 07/10] feat(android): add trigger() for onFunction custom-function rules Mirrors the showConsent/showPreferences warm/cold WebView dispatch pattern to fire ketch-tag's onFunction rule trigger (ketch('trigger', 'custom', '', options)). Cold boots defer the trigger command until the tag finishes loading, since a custom function has no boot URL param the way consent/preferences do. - KetchWebView.trigger() / Ketch.trigger() with functionName validation - Unit tests for functionName validation - Integration test covering warm/cold dispatch and invalid-name rejection - Demo button wired into both sample apps --- .../integration/tests/ZTriggerFunctionTest.kt | 128 ++++++++++++++++++ .../src/main/java/com/ketch/android/Ketch.kt | 78 +++++++++++ .../java/com/ketch/android/ui/KetchWebView.kt | 7 + .../ketch/android/TriggerFunctionNameTest.kt | 44 ++++++ .../android/sample/compose/KetchSampleApp.kt | 8 ++ .../android/sample/compose/MainActivity.kt | 5 + .../android/sample/compose/SecondActivity.kt | 4 + .../sample/compose/SecondActivityScreen.kt | 10 ++ .../android/sample/standard/MainActivity.kt | 6 + .../android/sample/standard/SecondActivity.kt | 5 + .../src/main/res/layout/activity_main.xml | 35 +++++ .../src/main/res/layout/activity_second.xml | 8 ++ 12 files changed, 338 insertions(+) create mode 100644 integration-tests/src/androidTest/java/com/ketch/android/integration/tests/ZTriggerFunctionTest.kt create mode 100644 ketchsdk/src/test/java/com/ketch/android/TriggerFunctionNameTest.kt diff --git a/integration-tests/src/androidTest/java/com/ketch/android/integration/tests/ZTriggerFunctionTest.kt b/integration-tests/src/androidTest/java/com/ketch/android/integration/tests/ZTriggerFunctionTest.kt new file mode 100644 index 00000000..0a6a4904 --- /dev/null +++ b/integration-tests/src/androidTest/java/com/ketch/android/integration/tests/ZTriggerFunctionTest.kt @@ -0,0 +1,128 @@ +package com.ketch.android.integration.tests + +import androidx.test.core.app.ActivityScenario +import androidx.test.core.app.ApplicationProvider +import androidx.test.espresso.Espresso.onView +import androidx.test.espresso.action.ViewActions.click +import androidx.test.espresso.matcher.ViewMatchers.withId +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import com.ketch.android.data.WillShowExperienceType +import org.junit.After +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit + +/** + * Verifies [com.ketch.android.Ketch.trigger] (the `onFunction` rule trigger) dispatches on + * both a warm and a cold WebView, and that invalid function names are rejected before + * anything is sent to the tag. + * + * These tests cover dispatch only, not that a configured experience shows — that requires a + * backend `onFunction|` rule on the `ketch_samples`/`android` test property. + * + * Runs after the core suite (Z prefix). + */ +@RunWith(AndroidJUnit4::class) +class ZTriggerFunctionTest { + + private lateinit var app: IntegrationTestApp + private lateinit var scenario: ActivityScenario + + @Before + fun setUp() { + app = ApplicationProvider.getApplicationContext() as IntegrationTestApp + InstrumentationRegistry.getInstrumentation().runOnMainSync { + app.ketch.dismissDialog() + app.ketch.setIdentities(mapOf("aaid" to "test-123")) + app.ketch.setLanguage(null) + } + scenario = ActivityScenario.launch(MainActivity::class.java) + } + + @After + fun tearDown() { + app.clearTestMode() + InstrumentationRegistry.getInstrumentation().runOnMainSync { + app.ketch.dismissDialog() + app.ketch.setIdentities(mapOf("aaid" to "test-123")) + app.ketch.setLanguage(null) + } + scenario.close() + } + + private fun showConsentBannerAndWait() { + val showLatch = CountDownLatch(1) + app.setTestMode(object : IntegrationTestApp.TestEventListener { + override fun onWillShowExperience(type: WillShowExperienceType) { + if (type == WillShowExperienceType.ConsentExperience) { + showLatch.countDown() + } + } + }) + onView(withId(R.id.showConsentButton)).perform(click()) + assertTrue("Consent banner should show within 30s", showLatch.await(30, TimeUnit.SECONDS)) + } + + @Test + fun invalidFunctionName_isRejectedWithoutDispatch() { + var result: Boolean? = null + InstrumentationRegistry.getInstrumentation().runOnMainSync { + result = app.ketch.trigger("not a valid name!") + } + assertFalse("trigger() should reject an invalid functionName", result!!) + } + + @Test + fun warmWebView_dispatchesTriggerWithoutError() { + showConsentBannerAndWait() + + val errorLatch = CountDownLatch(1) + app.setTestMode(object : IntegrationTestApp.TestEventListener { + override fun onError(error: String) { + throw AssertionError("trigger() on a warm WebView raised an error: $error") + } + }) + + var dispatched = false + InstrumentationRegistry.getInstrumentation().runOnMainSync { + dispatched = app.ketch.trigger("integrationTestFunction", mapOf("source" to "androidTest")) + } + assertTrue("trigger() should dispatch on a warm WebView", dispatched) + + // An unmatched custom function is a silent no-op in ketch-tag's rule engine, not an error. + assertFalse("Unexpected onError after warm trigger()", errorLatch.await(3, TimeUnit.SECONDS)) + assertTrue("WebView should remain intact after trigger()", app.hasActiveWebView()) + } + + @Test + fun coldWebView_bootsAndDispatchesDeferredTrigger() { + InstrumentationRegistry.getInstrumentation().runOnMainSync { + app.ketch.dismissDialog() + } + Thread.sleep(500) + assertFalse("Precondition: no active WebView before cold trigger", app.hasActiveWebView()) + + val configLatch = CountDownLatch(1) + app.setTestMode(object : IntegrationTestApp.TestEventListener { + override fun onConfigUpdated() { + configLatch.countDown() + } + + override fun onError(error: String) { + throw AssertionError("trigger() cold boot raised an error: $error") + } + }) + + var dispatched = false + InstrumentationRegistry.getInstrumentation().runOnMainSync { + dispatched = app.ketch.trigger("integrationTestFunction") + } + assertTrue("trigger() should dispatch (cold boot) even with no warm WebView", dispatched) + assertTrue("Cold-booted tag should finish loading within 30s", configLatch.await(30, TimeUnit.SECONDS)) + } +} diff --git a/ketchsdk/src/main/java/com/ketch/android/Ketch.kt b/ketchsdk/src/main/java/com/ketch/android/Ketch.kt index b1c736d1..7acc4b83 100644 --- a/ketchsdk/src/main/java/com/ketch/android/Ketch.kt +++ b/ketchsdk/src/main/java/com/ketch/android/Ketch.kt @@ -97,6 +97,10 @@ class Ketch private constructor( // When true, the next dialog dismissal retains the WebView instead of destroying it private var retainWebViewOnDismiss = false + // Deferred trigger() call, fired once a cold-booted WebView's tag finishes loading + @Volatile + private var pendingTrigger: PendingTrigger? = null + // Lock object for synchronization private val lock = Any() @@ -411,6 +415,55 @@ class Ketch private constructor( return true } + /** + * Fire a custom-function (`onFunction`) rule trigger. If a matching backend rule shows an + * experience, the fragment dialog is displayed automatically. + * + * @param functionName: the custom function name configured on the backend rule + * @param options: optional key/value trigger arguments + */ + fun trigger(functionName: String, options: Map = emptyMap()): Boolean { + val safeName = validateFunctionName(functionName) ?: return false + + if (isShowingExperience) { + Log.d(TAG, "Not triggering '$safeName' as an experience is already being shown") + return false + } + + val optionsJson = JSONObject(options).toString() + val signature = buildLoadSignature(0, 0) + + tryWarmWebView(signature, "trigger($safeName)")?.let { webView -> + webView.trigger(safeName, optionsJson) + return true + } + + Log.d(TAG, "Cold WebView load for trigger($safeName)") + val webView = prepareColdWebView(false, false, signature) ?: return false + pendingTrigger = PendingTrigger(safeName, optionsJson) + webView.load( + orgCode, + property, + language, + jurisdiction, + region, + environment, + identities, + null, + emptyList(), + null, + effectiveKetchUrl, + logLevel, + age, + ageLower, + ageUpper, + 0, + 0, + cssStyle + ) + return true + } + /** * Dismiss the dialog */ @@ -527,6 +580,18 @@ class Ketch private constructor( private fun isWithin1kb(css: String?): Boolean = (css?.toByteArray(Charsets.UTF_8)?.size ?: 0) <= 1024 + private fun validateFunctionName(functionName: String): String? { + if (!isValidTriggerFunctionName(functionName)) { + Log.w( + TAG, + "[Ketch] trigger rejected: functionName must be non-blank and contain only " + + "letters, digits, '_', '-', or '.'", + ) + return null + } + return functionName + } + init { getPreferences() @@ -742,6 +807,7 @@ class Ketch private constructor( activeWebView?.kill() activeWebView = null loadedSignature = null + pendingTrigger = null } private fun createWebView(shouldRetry: Boolean = false, synchronousPreferences: Boolean = false): KetchWebView? { @@ -833,6 +899,11 @@ class Ketch private constructor( this@Ketch.listener?.onConfigUpdated(config) + pendingTrigger?.let { pending -> + pendingTrigger = null + webView.trigger(pending.functionName, pending.optionsJson) + } + if (!showConsent) { return } @@ -1125,3 +1196,10 @@ class Ketch private constructor( private fun ConsentUpdate.withoutProtocols(): ConsentUpdate = copy(protocols = null) + +private data class PendingTrigger(val functionName: String, val optionsJson: String) + +private val TRIGGER_FUNCTION_NAME_REGEX = Regex("^[A-Za-z0-9_.-]+$") + +internal fun isValidTriggerFunctionName(functionName: String): Boolean = + TRIGGER_FUNCTION_NAME_REGEX.matches(functionName) 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 e5a43494..17e9f9e0 100644 --- a/ketchsdk/src/main/java/com/ketch/android/ui/KetchWebView.kt +++ b/ketchsdk/src/main/java/com/ketch/android/ui/KetchWebView.kt @@ -109,6 +109,13 @@ class KetchWebView(context: Context, shouldRetry: Boolean = false) : WebView(con } } + /** Imperatively fire a custom-function (onFunction) rule trigger on an already-booted page. */ + fun trigger(functionName: String, optionsJson: String) { + evaluateJavascript("ketch('trigger', 'custom', '$functionName', $optionsJson)") { result -> + Log.d(TAG, "trigger result: $result") + } + } + /** Number of page loads since this WebView was created (for testing warm re-show). */ val pageLoadCount: Int get() = localContentWebViewClient.pageLoadCount diff --git a/ketchsdk/src/test/java/com/ketch/android/TriggerFunctionNameTest.kt b/ketchsdk/src/test/java/com/ketch/android/TriggerFunctionNameTest.kt new file mode 100644 index 00000000..9943a91c --- /dev/null +++ b/ketchsdk/src/test/java/com/ketch/android/TriggerFunctionNameTest.kt @@ -0,0 +1,44 @@ +package com.ketch.android + +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class TriggerFunctionNameTest { + @Test + fun validNames_areAccepted() { + assertTrue(isValidTriggerFunctionName("managePrivacy")) + assertTrue(isValidTriggerFunctionName("manage_privacy")) + assertTrue(isValidTriggerFunctionName("manage-privacy")) + assertTrue(isValidTriggerFunctionName("manage.privacy.v2")) + assertTrue(isValidTriggerFunctionName("123")) + } + + @Test + fun blankOrEmpty_isRejected() { + assertFalse(isValidTriggerFunctionName("")) + assertFalse(isValidTriggerFunctionName(" ")) + } + + @Test + fun quoteInjection_isRejected() { + // Would otherwise break out of the single-quoted JS literal in + // KetchWebView.trigger(): ketch('trigger', 'custom', '', ...) + assertFalse(isValidTriggerFunctionName("foo'); alert('xss")) + assertFalse(isValidTriggerFunctionName("foo'")) + } + + @Test + fun backslashOrControlChars_areRejected() { + assertFalse(isValidTriggerFunctionName("foo\\bar")) + assertFalse(isValidTriggerFunctionName("foo\nbar")) + assertFalse(isValidTriggerFunctionName("foo\tbar")) + } + + @Test + fun whitespaceOrSpecialChars_areRejected() { + assertFalse(isValidTriggerFunctionName("foo bar")) + assertFalse(isValidTriggerFunctionName("foo/bar")) + assertFalse(isValidTriggerFunctionName("foo|bar")) + } +} 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..7d0139a4 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 @@ -62,6 +62,7 @@ fun KetchSampleApp( onShowPreferences: () -> Unit, onOpenSecondActivity: () -> Unit, onLogSharedPreferences: () -> Unit, + onTriggerFunction: () -> Unit, ) { var isDarkMode by rememberSaveable { mutableStateOf(false) } @@ -94,6 +95,13 @@ fun KetchSampleApp( onShowConsent = onShowConsent, onShowPreferences = onShowPreferences ) + Spacer(Modifier.height(16.dp)) + ActionCard( + title = "Trigger Custom Function", + description = "Fire an onFunction rule trigger by custom function name.", + onExecute = onTriggerFunction, + modifier = Modifier.fillMaxWidth(), + ) Spacer(Modifier.height(24.dp)) SectionHeader("Debug") Spacer(Modifier.height(12.dp)) 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..a14264e3 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 @@ -51,6 +51,11 @@ class MainActivity : AppCompatActivity() { startActivity(Intent(this, SecondActivity::class.java)) }, onLogSharedPreferences = { logSharedPreferences() }, + onTriggerFunction = { + Log.d(TAG, "trigger('demoFunction') called") + logEntries.add("trigger('demoFunction') called") + ketch.trigger("demoFunction") + }, ) } } diff --git a/sample-app-compose/src/main/java/com/ketch/android/sample/compose/SecondActivity.kt b/sample-app-compose/src/main/java/com/ketch/android/sample/compose/SecondActivity.kt index ccb6dc94..fd720e10 100644 --- a/sample-app-compose/src/main/java/com/ketch/android/sample/compose/SecondActivity.kt +++ b/sample-app-compose/src/main/java/com/ketch/android/sample/compose/SecondActivity.kt @@ -33,6 +33,10 @@ class SecondActivity : AppCompatActivity() { logEntries.add("showPreferences() called from SecondActivity") app.ketch.showPreferences() }, + onTriggerFunction = { + logEntries.add("trigger('demoFunction') called from SecondActivity") + app.ketch.trigger("demoFunction") + }, ) } } diff --git a/sample-app-compose/src/main/java/com/ketch/android/sample/compose/SecondActivityScreen.kt b/sample-app-compose/src/main/java/com/ketch/android/sample/compose/SecondActivityScreen.kt index 10dd8931..f524a249 100644 --- a/sample-app-compose/src/main/java/com/ketch/android/sample/compose/SecondActivityScreen.kt +++ b/sample-app-compose/src/main/java/com/ketch/android/sample/compose/SecondActivityScreen.kt @@ -41,6 +41,7 @@ fun SecondActivityScreen( logEntries: List, onShowConsent: () -> Unit, onShowPreferences: () -> Unit, + onTriggerFunction: () -> Unit, ) { Column( modifier = Modifier @@ -81,6 +82,15 @@ fun SecondActivityScreen( ) { Text("Show Preferences") } + Spacer(Modifier.height(8.dp)) + Button( + onClick = onTriggerFunction, + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(8.dp), + colors = ButtonDefaults.buttonColors(containerColor = KetchPurple), + ) { + Text("Trigger Custom Function") + } Spacer(Modifier.height(24.dp)) Text( 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..8d741a9f 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 @@ -94,6 +94,12 @@ class MainActivity : AppCompatActivity() { ketch.showPreferences() } + binding.triggerFunctionButton.setOnClickListener { + Log.d(TAG, "trigger('demoFunction') called") + appendLog("trigger('demoFunction') called") + ketch.trigger("demoFunction") + } + binding.openSecondActivityButton.setOnClickListener { appendLog("Opening SecondActivity") startActivity(Intent(this, SecondActivity::class.java)) diff --git a/sample-app-standard/src/main/java/com/ketch/android/sample/standard/SecondActivity.kt b/sample-app-standard/src/main/java/com/ketch/android/sample/standard/SecondActivity.kt index 0af14a6d..10d35acf 100644 --- a/sample-app-standard/src/main/java/com/ketch/android/sample/standard/SecondActivity.kt +++ b/sample-app-standard/src/main/java/com/ketch/android/sample/standard/SecondActivity.kt @@ -28,6 +28,11 @@ class SecondActivity : AppCompatActivity() { app.ketch.showPreferences() } + binding.triggerFunctionButton.setOnClickListener { + appendLog("trigger('demoFunction') called from SecondActivity") + app.ketch.trigger("demoFunction") + } + appendLog("SecondActivity started (shared Ketch instance)") } 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..a8426763 100644 --- a/sample-app-standard/src/main/res/layout/activity_main.xml +++ b/sample-app-standard/src/main/res/layout/activity_main.xml @@ -167,6 +167,41 @@ + + + + + + + +