diff --git a/build.gradle b/build.gradle index faffe67..382c7fc 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 32c9c37..0588670 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` | `getLocation`, 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 0000000..02cfb40 --- /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.US +} 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 0000000..1e2d327 --- /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 testGetLocationReturnsGeoIP() { + val location = awaitHeadless { callback -> + KetchSdk.getLocation(dataCenter, callback) + } + assertNotNull("Expected location payload", location.location) + assertFalse( + "Expected countryCode from CDN", + location.location?.countryCode.isNullOrBlank(), + ) + } + + @Test + fun testGetBootstrapConfiguration() { + val boot = awaitHeadless { callback -> + KetchSdk.getBootstrapConfiguration(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.getLocation(dataCenter, callback) + } + + val boot = awaitHeadless { callback -> + KetchSdk.getBootstrapConfiguration(ORG_CODE, PROPERTY, dataCenter, callback) + } + + val fullConfig = awaitHeadless { callback -> + KetchSdk.getFullConfiguration( + FullConfigurationRequest( + organizationCode = ORG_CODE, + propertyCode = PROPERTY, + ), + dataCenter, + callback, + ) + } + + val consentConfig = consentConfigFrom(fullConfig, identities) + + val consent = awaitHeadless { callback -> + KetchSdk.getConsent(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/integration-tests/src/androidTest/java/com/ketch/android/integration/tests/ZExplicitContextShowTest.kt b/integration-tests/src/androidTest/java/com/ketch/android/integration/tests/ZExplicitContextShowTest.kt new file mode 100644 index 0000000..b5442c6 --- /dev/null +++ b/integration-tests/src/androidTest/java/com/ketch/android/integration/tests/ZExplicitContextShowTest.kt @@ -0,0 +1,154 @@ +package com.ketch.android.integration.tests + +import androidx.test.core.app.ActivityScenario +import androidx.test.core.app.ApplicationProvider +import androidx.test.espresso.Espresso.pressBackUnconditionally +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import com.ketch.android.Ketch +import com.ketch.android.KetchSdk +import com.ketch.android.data.Consent +import com.ketch.android.data.HideExperienceStatus +import com.ketch.android.data.KetchConfig +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 + +/** + * Covers KD-17712 §D: a [Ketch] instance created from a non-Activity context (e.g. a ViewModel + * holding `applicationContext`, as Kroger does) has neither a seeded Activity nor a tracked one + * until an Activity resumes — `registerActivityLifecycleCallbacks` does not replay past events. + * A no-arg `show*` called in that window fails with "no active Activity" (§C's gap). Passing the + * host explicitly via the new `context` param resolves it deterministically instead. + * + * Each test builds its own [Ketch] instance (rather than reusing the shared [IntegrationTestApp] + * one) so its [com.ketch.android.KetchLifecycleTracker] is guaranteed fresh, independent of + * whatever Activities other tests left resumed. + */ +@RunWith(AndroidJUnit4::class) +class ZExplicitContextShowTest { + + private lateinit var ketch: Ketch + + @Before + fun setUp() { + repeat(3) { + try { + pressBackUnconditionally() + } catch (_: Exception) { + } + } + } + + @After + fun tearDown() { + InstrumentationRegistry.getInstrumentation().runOnMainSync { + ketch.dismissDialog() + } + repeat(3) { + try { + pressBackUnconditionally() + } catch (_: Exception) { + } + } + } + + private fun newAppContextKetch(listener: Ketch.Listener): Ketch = + KetchSdk.create( + context = ApplicationProvider.getApplicationContext(), + organization = IntegrationTestApp.ORG_CODE, + property = IntegrationTestApp.PROPERTY, + environment = IntegrationTestApp.ENVIRONMENT, + listener = listener, + ketchUrl = null, + logLevel = Ketch.LogLevel.DEBUG, + ).also { it.setIdentities(mapOf("aaid" to "test-123")) } + + @Test + fun noArgShowConsent_withNoResumedActivity_reportsNoHostError() { + val outcomeLatch = CountDownLatch(1) + var errorMessage: String? = null + var dialogShown = false + + ketch = newAppContextKetch(object : NoOpListener() { + override fun onError(errMsg: String?) { + errorMessage = errMsg + outcomeLatch.countDown() + } + + override fun onShow() { + dialogShown = true + outcomeLatch.countDown() + } + }) + + // No ActivityScenario is launched here: seedActivity is null (an applicationContext + // isn't a FragmentActivity) and this tracker has never observed a resumed Activity. + InstrumentationRegistry.getInstrumentation().runOnMainSync { + ketch.showConsent() + } + + assertTrue("Expected onError or onShow within 30s", outcomeLatch.await(30, TimeUnit.SECONDS)) + assertFalse("Dialog should not show with no resolvable host", dialogShown) + assertTrue( + "Expected the no-host error message, got: $errorMessage", + errorMessage?.contains("No active Activity") == true + ) + } + + @Test + fun showConsentWithExplicitContext_resolvesHost_withNoTrackedActivity() { + val outcomeLatch = CountDownLatch(1) + var errorMessage: String? = null + var dialogShown = false + + ketch = newAppContextKetch(object : NoOpListener() { + override fun onShow() { + dialogShown = true + outcomeLatch.countDown() + } + + override fun onError(errMsg: String?) { + errorMessage = errMsg + outcomeLatch.countDown() + } + }) + + val scenario = ActivityScenario.launch(MainActivity::class.java) + try { + // Same tracker state as the test above (nothing resumed yet on THIS Ketch's + // tracker) — the only difference is passing the host explicitly. + scenario.onActivity { activity -> + ketch.showConsent(context = activity) + } + + assertTrue("Expected onShow or onError within 30s", outcomeLatch.await(30, TimeUnit.SECONDS)) + assertTrue("Explicit context should resolve the host; got error: $errorMessage", dialogShown) + } finally { + scenario.close() + } + } + + private abstract class NoOpListener : Ketch.Listener { + override fun onShow() {} + override fun onDismiss(status: HideExperienceStatus) {} + override fun onConfigUpdated(config: KetchConfig?) {} + override fun onEnvironmentUpdated(environment: String?) {} + override fun onRegionInfoUpdated(regionInfo: String?) {} + override fun onJurisdictionUpdated(jurisdiction: String?) {} + override fun onIdentitiesUpdated(identities: String?) {} + override fun onConsentUpdated(consent: Consent) {} + override fun onError(errMsg: String?) {} + override fun onUSPrivacyUpdated(values: Map) {} + override fun onTCFUpdated(values: Map) {} + override fun onGPPUpdated(values: Map) {} + override fun onWillShowExperience(type: WillShowExperienceType) {} + override fun onHasShownExperience() {} + } +} 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 0000000..848859e --- /dev/null +++ b/integration-tests/src/androidTest/java/com/ketch/android/integration/tests/ZTriggerFunctionTest.kt @@ -0,0 +1,173 @@ +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.HideExperienceStatus +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(2) + app.setTestMode(object : IntegrationTestApp.TestEventListener { + override fun onWillShowExperience(type: WillShowExperienceType) { + if (type == WillShowExperienceType.ConsentExperience) { + showLatch.countDown() + } + } + + override fun onHasShownExperience() { + showLatch.countDown() + } + }) + onView(withId(R.id.showConsentButton)).perform(click()) + assertTrue("Consent banner should show within 30s", showLatch.await(30, TimeUnit.SECONDS)) + } + + // Dismisses via the tag's own primary button (a real consent submission), which retains the + // WebView warm (see ZWebViewRetentionTest) instead of destroying it like dismissDialog() does. + private fun dismissBannerViaPrimaryButton() { + val dismissLatch = CountDownLatch(1) + app.setTestMode(object : IntegrationTestApp.TestEventListener { + override fun onDismiss(status: HideExperienceStatus) { + dismissLatch.countDown() + } + }) + + val clickLatch = CountDownLatch(1) + scenario.onActivity { activity -> + activity.clickButtonById("ketch-banner-button-primary") { clicked -> + if (clicked) clickLatch.countDown() + } + } + assertTrue("Primary button should be clicked", clickLatch.await(15, TimeUnit.SECONDS)) + assertTrue("Dismiss should fire after button click", dismissLatch.await(15, 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() + dismissBannerViaPrimaryButton() + + 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)) + } + + @Test + fun warmTrigger_clearsDeferredColdTrigger() { + InstrumentationRegistry.getInstrumentation().runOnMainSync { + app.ketch.dismissDialog() + } + Thread.sleep(500) + assertFalse("Precondition: no active WebView before cold trigger", app.hasActiveWebView()) + + InstrumentationRegistry.getInstrumentation().runOnMainSync { + assertTrue("Cold trigger should start WebView load", app.ketch.trigger("integrationTestFunction")) + } + assertTrue("Cold trigger should be deferred while tag loads", app.hasPendingTrigger()) + + InstrumentationRegistry.getInstrumentation().runOnMainSync { + assertTrue("Warm trigger should dispatch on the active WebView", app.ketch.trigger("integrationTestFunction")) + } + assertFalse("Warm trigger should clear the deferred cold trigger", app.hasPendingTrigger()) + } +} diff --git a/integration-tests/src/main/java/com/ketch/android/integration/tests/IntegrationTestApp.kt b/integration-tests/src/main/java/com/ketch/android/integration/tests/IntegrationTestApp.kt index 37342b9..d9b6a53 100644 --- a/integration-tests/src/main/java/com/ketch/android/integration/tests/IntegrationTestApp.kt +++ b/integration-tests/src/main/java/com/ketch/android/integration/tests/IntegrationTestApp.kt @@ -199,6 +199,15 @@ class IntegrationTestApp : Application() { null } + fun hasPendingTrigger(): Boolean = try { + val field = ketch.javaClass.getDeclaredField("pendingTrigger") + field.isAccessible = true + field.get(ketch) != null + } catch (e: Exception) { + Log.e(TAG, "Error reading pendingTrigger: ${e.message}") + false + } + fun hasTapOutsideBridgeMethod(): Boolean = try { val ketchClass = ketch.javaClass val activeWebViewField = ketchClass.getDeclaredField("activeWebView") diff --git a/ketchsdk/build.gradle b/ketchsdk/build.gradle index a89beda..4320de7 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 8f29a2b..08d6967 100644 --- a/ketchsdk/src/main/java/com/ketch/android/Ketch.kt +++ b/ketchsdk/src/main/java/com/ketch/android/Ketch.kt @@ -3,14 +3,26 @@ package com.ketch.android import android.app.Activity import android.app.Application import android.content.Context +import android.content.ContextWrapper 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.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.SubscriptionsRequest +import com.ketch.android.data.SubscriptionsResponse import com.ketch.android.data.WillShowExperienceType import com.ketch.android.ui.KetchDialogFragment import com.ketch.android.ui.KetchWebView @@ -30,8 +42,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 @@ -52,6 +69,11 @@ class Ketch private constructor( } } + // Host explicitly supplied via a show*(context = ...) call. Takes precedence over the + // tracked/seeded host; set at the start of each show* call and cleared once that + // experience is fully dismissed so it can't outlive the call that set it. + private var explicitHost: WeakReference? = null + private var identities: Map = emptyMap() private var language: String? = null private var jurisdiction: String? = null @@ -81,6 +103,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() @@ -150,7 +176,7 @@ class Ketch private constructor( null, emptyList(), null, - ketchUrl, + effectiveKetchUrl, logLevel, age, ageLower, @@ -162,12 +188,104 @@ 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 getLocation(callback: (Result) -> Unit) { + headlessApiClient.getLocation(callback) + } + + suspend fun getLocation(): LocationResponse = headlessApiClient.getLocation() + + /** Minimal config (`GET .../boot.json`). */ + fun getBootstrapConfiguration( + callback: (Result) -> Unit, + ) { + headlessApiClient.getBootstrapConfiguration(orgCode, property, callback) + } + + suspend fun getBootstrapConfiguration(): HeadlessConfiguration = + headlessApiClient.getBootstrapConfiguration(orgCode, property) + + /** Full config with optional env / jurisdiction / language and hash query param. */ + fun getFullConfiguration( + request: FullConfigurationRequest, + callback: (Result) -> Unit, + ) { + headlessApiClient.getFullConfiguration(request, callback) + } + + suspend fun getFullConfiguration(request: FullConfigurationRequest): HeadlessConfiguration = + headlessApiClient.getFullConfiguration(request) + + /** Server consent including `protocols` (`POST .../consent/{org}/get`). */ + fun getConsent( + config: ConsentConfig, + callback: (Result) -> Unit, + ) { + headlessApiClient.getConsent(config, callback) + } + + suspend fun getConsent(config: ConsentConfig): Consent = + headlessApiClient.getConsent(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 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 preferenceQRUrl(request: PreferenceQRRequest): String = + headlessApiClient.preferenceQRUrl(request) + /** * Display the consent, adding the fragment dialog to the given FragmentManager. * + * @param context: Optional host used to resolve the FragmentActivity/FragmentManager for + * this call (e.g. `showConsent(context = LocalContext.current)`). Falls back to the + * tracked foreground Activity when omitted. * @param bottomPadding: Pixels of padding to add to the bottom of the experience */ fun showConsent( + context: Context? = null, shouldRetry: Boolean = false, synchronousPreferences: Boolean = false, bottomPadding: Int = 0, @@ -178,6 +296,8 @@ class Ketch private constructor( return false } + explicitHost = context?.findFragmentActivity()?.let { WeakReference(it) } + val signature = buildLoadSignature(bottomPadding, topPadding) tryWarmWebView(signature, "showConsent")?.let { webView -> webView.showConsentExperience() @@ -185,7 +305,10 @@ class Ketch private constructor( } Log.d(TAG, "Cold WebView load for showConsent") - val webView = prepareColdWebView(shouldRetry, synchronousPreferences, signature) ?: return false + val webView = prepareColdWebView(shouldRetry, synchronousPreferences, signature) ?: run { + explicitHost = null + return false + } webView.load( orgCode, property, @@ -197,7 +320,7 @@ class Ketch private constructor( KetchWebView.ExperienceType.CONSENT, emptyList(), null, - ketchUrl, + effectiveKetchUrl, logLevel, age, ageLower, @@ -212,9 +335,13 @@ class Ketch private constructor( /** * Display the preferences, adding the fragment dialog to the given FragmentManager. * + * @param context: Optional host used to resolve the FragmentActivity/FragmentManager for + * this call (e.g. `showPreferences(context = LocalContext.current)`). Falls back to the + * tracked foreground Activity when omitted. * @param bottomPadding: Pixels of padding to add to the bottom of the experience */ fun showPreferences( + context: Context? = null, shouldRetry: Boolean = false, synchronousPreferences: Boolean = false, bottomPadding: Int = 0, @@ -225,6 +352,8 @@ class Ketch private constructor( return false } + explicitHost = context?.findFragmentActivity()?.let { WeakReference(it) } + val signature = buildLoadSignature(bottomPadding, topPadding) tryWarmWebView(signature, "showPreferences")?.let { webView -> webView.showPreferenceExperience(buildPreferenceOptionsJson()) @@ -232,7 +361,10 @@ class Ketch private constructor( } Log.d(TAG, "Cold WebView load for showPreferences") - val webView = prepareColdWebView(shouldRetry, synchronousPreferences, signature) ?: return false + val webView = prepareColdWebView(shouldRetry, synchronousPreferences, signature) ?: run { + explicitHost = null + return false + } webView.load( orgCode, property, @@ -244,7 +376,7 @@ class Ketch private constructor( KetchWebView.ExperienceType.PREFERENCES, emptyList(), null, - ketchUrl, + effectiveKetchUrl, logLevel, age, ageLower, @@ -261,11 +393,15 @@ class Ketch private constructor( * * @param tabs: list of preferences tab * @param tab: the current tab + * @param context: Optional host used to resolve the FragmentActivity/FragmentManager for + * this call (e.g. `showPreferencesTab(context = LocalContext.current)`). Falls back to + * the tracked foreground Activity when omitted. * @param bottomPadding: Pixels of padding to add to the bottom of the experience */ fun showPreferencesTab( tabs: List, tab: PreferencesTab, + context: Context? = null, shouldRetry: Boolean = false, synchronousPreferences: Boolean = false, bottomPadding: Int = 0, @@ -276,6 +412,8 @@ class Ketch private constructor( return false } + explicitHost = context?.findFragmentActivity()?.let { WeakReference(it) } + val signature = buildLoadSignature(bottomPadding, topPadding) tryWarmWebView(signature, "showPreferencesTab(tab=${tab.name})")?.let { webView -> webView.showPreferenceExperience(buildPreferenceOptionsJson(tabs, tab)) @@ -283,7 +421,10 @@ class Ketch private constructor( } Log.d(TAG, "Cold WebView load for showPreferencesTab(tab=${tab.name})") - val webView = prepareColdWebView(shouldRetry, synchronousPreferences, signature) ?: return false + val webView = prepareColdWebView(shouldRetry, synchronousPreferences, signature) ?: run { + explicitHost = null + return false + } webView.load( orgCode, property, @@ -295,7 +436,7 @@ class Ketch private constructor( KetchWebView.ExperienceType.PREFERENCES, tabs, tab, - ketchUrl, + effectiveKetchUrl, logLevel, age, ageLower, @@ -307,6 +448,65 @@ 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 + * @param context: Optional host used to resolve the FragmentActivity/FragmentManager for + * this call (e.g. `trigger(context = LocalContext.current)`). Falls back to the tracked + * foreground Activity when omitted. + */ + fun trigger(functionName: String, options: Map = emptyMap(), context: Context? = null): Boolean { + val safeName = validateFunctionName(functionName) ?: return false + + if (isShowingExperience) { + Log.d(TAG, "Not triggering '$safeName' as an experience is already being shown") + return false + } + + explicitHost = context?.findFragmentActivity()?.let { WeakReference(it) } + + val optionsJson = JSONObject(options).toString() + val signature = buildLoadSignature(0, 0) + + tryWarmWebView(signature, "trigger($safeName)")?.let { webView -> + // Supersede any deferred cold trigger so onConfigUpdated does not re-fire it. + pendingTrigger = null + webView.trigger(safeName, optionsJson) + return true + } + + Log.d(TAG, "Cold WebView load for trigger($safeName)") + val webView = prepareColdWebView(false, false, signature) ?: run { + explicitHost = null + 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 */ @@ -320,9 +520,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) } } @@ -421,6 +623,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() @@ -438,7 +652,7 @@ class Ketch private constructor( } private fun resolveHost(): FragmentActivity? = - tracker?.current?.get() ?: seedActivity?.get() + explicitHost?.get() ?: tracker?.current?.get() ?: seedActivity?.get() private fun resolveFragmentManager(): FragmentManager? = resolveHost()?.supportFragmentManager ?: seedFragmentManager?.get() @@ -447,6 +661,7 @@ class Ketch private constructor( private fun reportNoHostError() { isShowingExperience = false + explicitHost = null Log.e(TAG, "No active Activity to host the Ketch experience") this@Ketch.listener?.onError("No active Activity to host the Ketch experience") } @@ -542,6 +757,7 @@ class Ketch private constructor( isShowingExperience = false activeDialogFragment = null dialogHost = null + explicitHost = null this@Ketch.listener?.onDismiss(dismissStatus) } @@ -551,6 +767,7 @@ class Ketch private constructor( isShowingExperience = false activeDialogFragment = null dialogHost = null + explicitHost = null if (retain) { activeWebView?.detachFromParent() } else { @@ -562,7 +779,7 @@ class Ketch private constructor( listOf( orgCode, property, - ketchUrl ?: "", + effectiveKetchUrl, environment ?: "", language ?: "", jurisdiction ?: "", @@ -636,6 +853,7 @@ class Ketch private constructor( activeWebView?.kill() activeWebView = null loadedSignature = null + pendingTrigger = null } private fun createWebView(shouldRetry: Boolean = false, synchronousPreferences: Boolean = false): KetchWebView? { @@ -693,6 +911,7 @@ class Ketch private constructor( this@Ketch.listener?.onShow() } else { isShowingExperience = false + explicitHost = null Log.e(TAG, "FragmentManager is destroyed, cannot show dialog") this@Ketch.listener?.onError("FragmentManager is destroyed, cannot show dialog") } @@ -701,6 +920,7 @@ class Ketch private constructor( } } catch (e: Exception) { isShowingExperience = false + explicitHost = null Log.e(TAG, "Error showing dialog: ${e.message}") this@Ketch.listener?.onError("Error showing dialog: ${e.message}") } @@ -727,6 +947,11 @@ class Ketch private constructor( this@Ketch.listener?.onConfigUpdated(config) + pendingTrigger?.let { pending -> + pendingTrigger = null + webView.trigger(pending.functionName, pending.optionsJson) + } + if (!showConsent) { return } @@ -765,7 +990,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 +1014,7 @@ class Ketch private constructor( } else { handleDialogDismissed() } + Log.d(TAG, "onDismiss source=$source status=${status.name}") this@Ketch.listener?.onDismiss(status) } } @@ -827,6 +1053,7 @@ class Ketch private constructor( this@Ketch.listener?.onShow() } else { isShowingExperience = false + explicitHost = null Log.e(TAG, "FragmentManager is destroyed, cannot show dialog") this@Ketch.listener?.onError("FragmentManager is destroyed, cannot show dialog") } @@ -835,6 +1062,7 @@ class Ketch private constructor( } } catch (e: Exception) { isShowingExperience = false + explicitHost = null Log.e(TAG, "Error showing dialog: ${e.message}") this@Ketch.listener?.onError("Error showing dialog: ${e.message}") } @@ -974,6 +1202,7 @@ class Ketch private constructor( environment: String?, listener: Listener?, ketchUrl: String?, + dataCenter: KetchDataCenter = KetchDataCenter.US, logLevel: LogLevel, ) = Ketch( context = context, @@ -984,7 +1213,9 @@ class Ketch private constructor( environment = environment, listener = listener, ketchUrl = ketchUrl, + dataCenter = dataCenter, logLevel = logLevel, + headlessApiClient = HeadlessApiClient(dataCenter), ) fun create( @@ -995,6 +1226,7 @@ class Ketch private constructor( environment: String?, listener: Listener?, ketchUrl: String?, + dataCenter: KetchDataCenter = KetchDataCenter.US, logLevel: LogLevel, ) = Ketch( context = context, @@ -1005,7 +1237,31 @@ 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) + +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) + +// Walks the ContextWrapper chain to find a hosting FragmentActivity. Returns null for a +// pure-Compose ComponentActivity (no FragmentManager) or any other non-FragmentActivity host; +// callers fall back to the tracked/seeded host in that case. +private fun Context.findFragmentActivity(): FragmentActivity? { + var ctx: Context? = this + while (ctx is ContextWrapper) { + if (ctx is FragmentActivity) return ctx + ctx = ctx.baseContext + } + return null +} diff --git a/ketchsdk/src/main/java/com/ketch/android/KetchSdk.kt b/ketchsdk/src/main/java/com/ketch/android/KetchSdk.kt index 299089a..f67b9a6 100644 --- a/ketchsdk/src/main/java/com/ketch/android/KetchSdk.kt +++ b/ketchsdk/src/main/java/com/ketch/android/KetchSdk.kt @@ -2,6 +2,18 @@ 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.HeadlessConfiguration +import com.ketch.android.data.InvokeRightRequest +import com.ketch.android.data.LocationResponse +import com.ketch.android.data.PreferenceQRRequest +import com.ketch.android.data.SubscriptionsRequest +import com.ketch.android.data.SubscriptionsResponse /** * Factory to create the Ketch object. @@ -12,8 +24,8 @@ import androidx.fragment.app.FragmentManager * PROPERTY, * ENVIRONMENT, * listener, - * TEST_URL, - * Ketch.LogLevel.DEBUG + * dataCenter = KetchDataCenter.US, + * logLevel = Ketch.LogLevel.DEBUG * ) **/ object KetchSdk { @@ -26,7 +38,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( @@ -36,6 +49,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( @@ -45,6 +59,7 @@ object KetchSdk { environment = environment, listener = listener, ketchUrl = ketchUrl, + dataCenter = dataCenter, logLevel = logLevel, ) } @@ -58,14 +73,15 @@ 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( 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, ) @@ -77,7 +93,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 +104,87 @@ 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 getLocation( + dataCenter: KetchDataCenter = KetchDataCenter.US, + callback: (Result) -> Unit, + ) { + HeadlessApiClient(dataCenter).getLocation(callback) + } + + /** Minimal config (`GET .../boot.json`). */ + fun getBootstrapConfiguration( + organization: String, + property: String, + dataCenter: KetchDataCenter = KetchDataCenter.US, + callback: (Result) -> Unit, + ) { + HeadlessApiClient(dataCenter).getBootstrapConfiguration(organization, property, callback) + } + + /** Full config with optional env / jurisdiction / language and hash query param. */ + fun getFullConfiguration( + request: FullConfigurationRequest, + dataCenter: KetchDataCenter = KetchDataCenter.US, + callback: (Result) -> Unit, + ) { + HeadlessApiClient(dataCenter).getFullConfiguration(request, callback) + } + + /** Server consent including `protocols` (`POST .../consent/{org}/get`). */ + fun getConsent( + config: ConsentConfig, + dataCenter: KetchDataCenter = KetchDataCenter.US, + callback: (Result) -> Unit, + ) { + HeadlessApiClient(dataCenter).getConsent(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 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 preferenceQRUrl( + request: PreferenceQRRequest, + dataCenter: KetchDataCenter = KetchDataCenter.US, + ): String = HeadlessApiClient(dataCenter).preferenceQRUrl(request) } 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 0000000..7a94847 --- /dev/null +++ b/ketchsdk/src/main/java/com/ketch/android/api/HeadlessApiClient.kt @@ -0,0 +1,399 @@ +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.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.SubscriptionsRequest +import com.ketch.android.data.SubscriptionsResponse +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 +import java.util.concurrent.TimeUnit + +/** + * Native HTTP client mirroring ketch-tag [KetchWebAPI] (web/v3). + */ +class HeadlessApiClient( + dataCenter: KetchDataCenter = KetchDataCenter.US, + baseUrl: String? = null, + 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 getLocation(callback: (Result) -> Unit) { + launchAsync(callback) { getLocation() } + } + + suspend fun getLocation(): LocationResponse = withContext(Dispatchers.IO) { + get("/ip", LocationResponse::class.java) + } + + fun getBootstrapConfiguration( + organization: String, + property: String, + callback: (Result) -> Unit, + ) { + launchAsync(callback) { getBootstrapConfiguration(organization, property) } + } + + suspend fun getBootstrapConfiguration( + organization: String, + property: String, + ): HeadlessConfiguration = withContext(Dispatchers.IO) { + get("/config/$organization/$property/boot.json", HeadlessConfiguration::class.java) + } + + fun getFullConfiguration( + request: FullConfigurationRequest, + callback: (Result) -> Unit, + ) { + launchAsync(callback) { getFullConfiguration(request) } + } + + suspend fun getFullConfiguration(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 getConsent( + config: ConsentConfig, + callback: (Result) -> Unit, + ) { + launchAsync(callback) { getConsent(config) } + } + + suspend fun getConsent(config: ConsentConfig): Consent = withContext(Dispatchers.IO) { + val path = "/consent/${config.organizationCode}/get" + postConsent(path, ConsentConfigPayload.from(config)) + } + + 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 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 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, + ) + } + + /** 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): Consent { + val request = buildConsentPostRequest(path, body) + return executeConsentFetch(request) + } + + 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): 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() + } + val decoded = decodeConsent(body) + if (decoded != null && hasUsableConsentFields(decoded)) { + return decoded + } + return emptyConsent() + } + } 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(): 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) + } + return Consent(purposes = purposes, vendors = update.vendors, protocols = null) + } + + private fun launchAsync( + 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 { + val result = try { + Result.success(block()) + } catch (error: HeadlessException) { + Result.failure(error) + } catch (error: Exception) { + Result.failure(HeadlessException(error.message ?: "Headless API error", error)) + } + withContext(Dispatchers.Main) { callback(result) } + } + } + + private data class ConsentConfigPayload( + val organizationCode: String, + val propertyCode: String, + val environmentCode: String, + val jurisdictionCode: String, + val identities: Map, + val purposes: Map, + ) { + companion object { + fun from(config: ConsentConfig) = ConsentConfigPayload( + organizationCode = config.organizationCode, + propertyCode = config.propertyCode, + environmentCode = config.environmentCode, + jurisdictionCode = config.jurisdictionCode, + identities = config.identities, + purposes = config.purposes, + ) + } + } + + private data class SetConsentPayload( + val organizationCode: String, + val propertyCode: String, + val environmentCode: String, + val identities: Map, + val jurisdictionCode: String, + val migrationOption: ConsentUpdate.MigrationOption, + val purposes: Map, + val vendors: List?, + ) { + companion object { + fun from(update: ConsentUpdate) = SetConsentPayload( + organizationCode = update.organizationCode, + propertyCode = update.propertyCode, + environmentCode = update.environmentCode, + identities = update.identities, + jurisdictionCode = update.jurisdictionCode, + migrationOption = update.migrationOption, + purposes = update.purposes, + vendors = update.vendors, + ) + } + } +} diff --git a/ketchsdk/src/main/java/com/ketch/android/api/KetchDataCenter.kt b/ketchsdk/src/main/java/com/ketch/android/api/KetchDataCenter.kt new file mode 100644 index 0000000..ea17f96 --- /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/HeadlessModels.kt b/ketchsdk/src/main/java/com/ketch/android/data/HeadlessModels.kt new file mode 100644 index 0000000..f7e1d60 --- /dev/null +++ b/ketchsdk/src/main/java/com/ketch/android/data/HeadlessModels.kt @@ -0,0 +1,175 @@ +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 `getLocation()`. */ +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 `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 `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 f09bb03..17e9f9e 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 @@ -158,8 +165,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 @@ -499,7 +507,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() fun onNativeStoragePut(key: String, value: String) {} 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 0000000..9943a91 --- /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/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 0000000..92c2eb6 --- /dev/null +++ b/ketchsdk/src/test/java/com/ketch/android/api/HeadlessApiClientTest.kt @@ -0,0 +1,80 @@ +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_rightsSubscriptions() { + assertEquals( + "https://global.ketchcdn.com/web/v3/rights/switchbitcorp/invoke", + client.buildUrl("/rights/switchbitcorp/invoke"), + ) + 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 0000000..21c5746 --- /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 0000000..cc0d975 --- /dev/null +++ b/ketchsdk/src/test/java/com/ketch/android/api/HeadlessConsentTest.kt @@ -0,0 +1,151 @@ +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 getConsentPropagatesHttpFailure() = runBlocking { + mockWebServer.enqueue(MockResponse().setResponseCode(HttpURLConnection.HTTP_INTERNAL_ERROR)) + + val client = headlessClient() + try { + client.getConsent(sampleConsentConfig()) + fail("Expected getConsent 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 getConsentReturnsEmptyConsentOn200NullBody() = runBlocking { + mockWebServer.enqueue( + MockResponse() + .setResponseCode(HttpURLConnection.HTTP_OK) + .setBody("null") + .addHeader("Content-Type", "application/json"), + ) + + val client = headlessClient() + val consent = client.getConsent(sampleConsentConfig()) + + assertEquals(emptyMap(), consent.purposes) + assertNull(consent.vendors) + assertNull(consent.protocols) + } + + @Test + fun setConsentAcceptsProtocolsOnlyResponse() = runBlocking { + mockWebServer.enqueue( + MockResponse() + .setResponseCode(HttpURLConnection.HTTP_OK) + .setBody("""{"protocols":{"gpp":"DBABLA~BVQqAAAAAAJY.QA"}}""") + .addHeader("Content-Type", "application/json"), + ) + + val client = headlessClient() + val consent = client.setConsent(sampleConsentUpdate()) + + assertNull(consent.purposes) + assertEquals("DBABLA~BVQqAAAAAAJY.QA", consent.protocols?.get("gpp")) + } + + private fun headlessClient(): HeadlessApiClient { + val okHttpClient = OkHttpClient.Builder() + .addInterceptor { chain -> + val original = chain.request() + val redirected = original.newBuilder() + .url( + original.url.newBuilder() + .scheme("http") + .host(mockWebServer.hostName) + .port(mockWebServer.port) + .build(), + ) + .build() + chain.proceed(redirected) + } + .build() + return HeadlessApiClient( + dataCenter = KetchDataCenter.US, + okHttpClient = okHttpClient, + ) + } + + private fun sampleConsentConfig(): ConsentConfig = + ConsentConfig( + organizationCode = "org", + propertyCode = "prop", + environmentCode = "production", + jurisdictionCode = "default", + identities = mapOf("email" to "user@example.com"), + purposes = mapOf( + "analytics" to ConsentConfig.PurposeLegalBasis(legalBasisCode = "consent_optin"), + ), + ) + + private fun sampleConsentUpdate(): ConsentUpdate = + ConsentUpdate( + organizationCode = "org", + propertyCode = "prop", + environmentCode = "production", + identities = mapOf("email" to "user@example.com"), + jurisdictionCode = "default", + migrationOption = ConsentUpdate.MigrationOption.MIGRATE_DEFAULT, + purposes = mapOf( + "analytics" to ConsentUpdate.PurposeAllowedLegalBasis( + allowed = true, + legalBasisCode = "consent_optin", + ), + ), + vendors = null, + protocols = null, + ) +} diff --git a/sample-app-compose/src/main/java/com/ketch/android/sample/compose/KetchSampleApp.kt b/sample-app-compose/src/main/java/com/ketch/android/sample/compose/KetchSampleApp.kt index 778d3a4..7d0139a 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 0f53bfd..a14264e 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 ccb6dc9..fd720e1 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 10dd893..f524a24 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 355ff45..8d741a9 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 0af14a6..10d35ac 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 e425909..a842676 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 @@ + + + + + + + +