diff --git a/ketchsdk/src/main/java/com/ketch/android/Ketch.kt b/ketchsdk/src/main/java/com/ketch/android/Ketch.kt index 848b1cd..8a25c76 100644 --- a/ketchsdk/src/main/java/com/ketch/android/Ketch.kt +++ b/ketchsdk/src/main/java/com/ketch/android/Ketch.kt @@ -23,6 +23,9 @@ 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.data.configPathSegment +import com.ketch.android.data.normalizedHash +import com.ketch.android.data.toRegionCode import com.ketch.android.ui.KetchDialogFragment import com.ketch.android.ui.KetchWebView import org.json.JSONObject @@ -94,6 +97,13 @@ class Ketch private constructor( // Cache key for the config/presentation inputs baked into the boot HTML (excludes show type/tabs) private var loadedSignature: String? = null + // Headless getFullConfiguration() cache — avoids re-fetching when the config URL path is unchanged + private var cachedConfig: HeadlessConfiguration? = null + private var cachedConfigKey: String? = null + + // Headless getLocation() cache — GET /ip takes no path params, so it never needs invalidation + private var cachedLocation: LocationResponse? = null + // When true, the next dialog dismissal retains the WebView instead of destroying it private var retainWebViewOnDismiss = false @@ -185,12 +195,26 @@ class Ketch private constructor( /** 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) + /** Combined ISO region code (e.g. "US-CA") from GeoIP (`GET /ip`). Cached on this instance. */ + fun getRegion(callback: (Result) -> Unit) { + synchronized(lock) { + cachedLocation?.let { + callback(Result.success(it.location?.toRegionCode())) + return + } + } + headlessApiClient.getLocation { result -> + result.onSuccess { location -> synchronized(lock) { cachedLocation = location } } + callback(result.map { it.location?.toRegionCode() }) + } } - suspend fun getLocation(): LocationResponse = headlessApiClient.getLocation() + suspend fun getRegion(): String? { + synchronized(lock) { cachedLocation?.let { return it.location?.toRegionCode() } } + val location = headlessApiClient.getLocation() + synchronized(lock) { cachedLocation = location } + return location.location?.toRegionCode() + } /** Minimal config (`GET .../boot.json`). */ fun getBootstrapConfiguration( @@ -202,16 +226,68 @@ class Ketch private constructor( suspend fun getBootstrapConfiguration(): HeadlessConfiguration = headlessApiClient.getBootstrapConfiguration(orgCode, property) - /** Full config with optional env / jurisdiction / language and hash query param. */ + /** + * Full config with optional env / jurisdiction / language and hash query param. + * + * Cached on this instance, keyed on the request's URL-path-affecting fields. A cache hit + * skips the network call entirely; [setJurisdiction] and [setLanguage] clear the cache since + * they change the config URL path. + */ fun getFullConfiguration( request: FullConfigurationRequest, callback: (Result) -> Unit, ) { - headlessApiClient.getFullConfiguration(request, callback) + val key = buildConfigCacheKey(request) + synchronized(lock) { + if (cachedConfigKey == key) { + cachedConfig?.let { + callback(Result.success(it)) + return + } + } + } + headlessApiClient.getFullConfiguration(request) { result -> + result.onSuccess { config -> synchronized(lock) { cachedConfig = config; cachedConfigKey = key } } + callback(result) + } } - suspend fun getFullConfiguration(request: FullConfigurationRequest): HeadlessConfiguration = - headlessApiClient.getFullConfiguration(request) + suspend fun getFullConfiguration(request: FullConfigurationRequest): HeadlessConfiguration { + val key = buildConfigCacheKey(request) + synchronized(lock) { + if (cachedConfigKey == key) { + cachedConfig?.let { return it } + } + } + val config = headlessApiClient.getFullConfiguration(request) + synchronized(lock) { cachedConfig = config; cachedConfigKey = key } + return config + } + + /** + * Resolved jurisdiction code for this instance's current org/property/environment/ + * jurisdiction/language, e.g. from a prior [setJurisdiction] call. Backed by the same cache + * as [getFullConfiguration]. + */ + fun getJurisdiction(callback: (Result) -> Unit) { + getFullConfiguration(buildJurisdictionConfigRequest()) { result -> + callback(result.map { it.jurisdiction?.code ?: it.jurisdiction?.defaultJurisdictionCode }) + } + } + + suspend fun getJurisdiction(): String? { + val config = getFullConfiguration(buildJurisdictionConfigRequest()) + return config.jurisdiction?.code ?: config.jurisdiction?.defaultJurisdictionCode + } + + private fun buildJurisdictionConfigRequest(): FullConfigurationRequest = + FullConfigurationRequest( + organizationCode = orgCode, + propertyCode = property, + environmentCode = environment, + jurisdictionCode = jurisdiction, + languageCode = language, + ) /** Server consent including `protocols` (`POST .../consent/{org}/get`). */ fun getConsent( @@ -513,6 +589,7 @@ class Ketch private constructor( */ fun setLanguage(language: String?) { this.language = language + clearConfigCache() } /** @@ -522,6 +599,7 @@ class Ketch private constructor( */ fun setJurisdiction(jurisdiction: String?) { this.jurisdiction = jurisdiction + clearConfigCache() } /** @@ -531,6 +609,17 @@ class Ketch private constructor( */ fun setRegion(region: String?) { this.region = region + // Not a config URL path component (see buildConfigCacheKey) — the config cache is + // intentionally left intact. + } + + // Invalidates the getFullConfiguration() cache; called whenever a config URL path + // component (jurisdiction, language) changes on this instance. + private fun clearConfigCache() { + synchronized(lock) { + cachedConfig = null + cachedConfigKey = null + } } /** @@ -758,6 +847,21 @@ class Ketch private constructor( logLevel.name, ).joinToString("|") + // Cache key for getFullConfiguration() — mirrors HeadlessApiClient's path-building exactly + // (blank treated as absent) via configPathSegment()/normalizedHash(), so requests that hit + // the same URL always share a key and requests that hit different URLs never collide. + private fun buildConfigCacheKey(request: FullConfigurationRequest): String { + val (env, jurisdiction, language) = request.configPathSegment() ?: Triple("", "", "") + return listOf( + request.organizationCode, + request.propertyCode, + env, + jurisdiction, + language, + request.normalizedHash() ?: "", + ).joinToString("|") + } + private fun buildPreferenceOptionsJson( tabs: List = emptyList(), tab: PreferencesTab? = null, diff --git a/ketchsdk/src/main/java/com/ketch/android/api/HeadlessApiClient.kt b/ketchsdk/src/main/java/com/ketch/android/api/HeadlessApiClient.kt index 7a94847..3fb168f 100644 --- a/ketchsdk/src/main/java/com/ketch/android/api/HeadlessApiClient.kt +++ b/ketchsdk/src/main/java/com/ketch/android/api/HeadlessApiClient.kt @@ -6,6 +6,8 @@ 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.configPathSegment +import com.ketch.android.data.normalizedHash import com.ketch.android.data.HeadlessException import com.ketch.android.data.InvokeRightRequest import com.ketch.android.data.LocationResponse @@ -88,14 +90,11 @@ class HeadlessApiClient( 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}" + request.configPathSegment()?.let { (env, jurisdiction, language) -> + path += "/$env/$jurisdiction/$language" } path += "/config.json" - val query = request.hash?.let { mapOf("hash" to it) } ?: emptyMap() + val query = request.normalizedHash()?.let { mapOf("hash" to it) } ?: emptyMap() get(path, HeadlessConfiguration::class.java, query) } 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 f7e1d60..f3b5e6e 100644 --- a/ketchsdk/src/main/java/com/ketch/android/data/HeadlessModels.kt +++ b/ketchsdk/src/main/java/com/ketch/android/data/HeadlessModels.kt @@ -22,6 +22,12 @@ data class LocationResponse( @SerializedName("location") val location: IPInfo? = null, ) +/** Combined ISO region code, e.g. "US-CA", or "US" when no subdivision is known. */ +fun IPInfo.toRegionCode(): String? { + val country = countryCode?.takeIf { it.isNotBlank() } ?: return regionCode?.takeIf { it.isNotBlank() } + return regionCode?.takeIf { it.isNotBlank() }?.let { "$country-$it" } ?: country +} + /** Parameters for v3 `getFullConfiguration` (ketch-types `GetFullConfigurationRequest`). */ data class FullConfigurationRequest( val organizationCode: String, @@ -32,6 +38,23 @@ data class FullConfigurationRequest( val hash: String? = null, ) +/** + * The env/jurisdiction/language segment appended to the full-config path, or null if any of the + * three is absent or blank — matching ketch-tag, which treats a blank value the same as unset. + * The single source of truth for this so the actual HTTP path (see [HeadlessApiClient]) and any + * cache keyed on it (see `Ketch.buildConfigCacheKey`) can never disagree about what "the same + * request" means. + */ +fun FullConfigurationRequest.configPathSegment(): Triple? { + val env = environmentCode?.takeIf { it.isNotBlank() } ?: return null + val jurisdiction = jurisdictionCode?.takeIf { it.isNotBlank() } ?: return null + val language = languageCode?.takeIf { it.isNotBlank() } ?: return null + return Triple(env, jurisdiction, language) +} + +/** Non-blank hash, or null — a blank hash is treated the same as an absent one. */ +fun FullConfigurationRequest.normalizedHash(): String? = hash?.takeIf { it.isNotBlank() } + /** Request body for `POST /consent/{org}/get`. */ data class ConsentConfig( val organizationCode: String, diff --git a/ketchsdk/src/test/java/com/ketch/android/api/HeadlessFullConfigurationPathTest.kt b/ketchsdk/src/test/java/com/ketch/android/api/HeadlessFullConfigurationPathTest.kt new file mode 100644 index 0000000..d4ead96 --- /dev/null +++ b/ketchsdk/src/test/java/com/ketch/android/api/HeadlessFullConfigurationPathTest.kt @@ -0,0 +1,116 @@ +package com.ketch.android.api + +import com.ketch.android.data.FullConfigurationRequest +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.Before +import org.junit.Test +import java.net.HttpURLConnection + +/** + * Regression coverage for the actual URL getFullConfiguration() requests: a blank + * environmentCode/jurisdictionCode/languageCode/hash must be treated the same as a null one + * (matching ketch-tag), since Ketch's config cache key relies on this to never disagree with + * the real request the HTTP client makes. + */ +class HeadlessFullConfigurationPathTest { + private lateinit var mockWebServer: MockWebServer + + @Before + fun setUp() { + mockWebServer = MockWebServer() + mockWebServer.start() + mockWebServer.enqueue( + MockResponse() + .setResponseCode(HttpURLConnection.HTTP_OK) + .setBody("{}") + .addHeader("Content-Type", "application/json"), + ) + } + + @After + fun tearDown() { + mockWebServer.shutdown() + } + + @Test + fun allFieldsPresent_includesEnvJurisdictionLanguage() = runBlocking { + client().getFullConfiguration( + FullConfigurationRequest( + organizationCode = "org", + propertyCode = "prop", + environmentCode = "production", + jurisdictionCode = "us-ca", + languageCode = "en-US", + ), + ) + assertEquals("/web/v3/config/org/prop/production/us-ca/en-US/config.json", mockWebServer.takeRequest().path) + } + + @Test + fun nullEnvironment_omitsSegmentEntirely() = runBlocking { + client().getFullConfiguration( + FullConfigurationRequest( + organizationCode = "org", + propertyCode = "prop", + environmentCode = null, + jurisdictionCode = "us-ca", + languageCode = "en-US", + ), + ) + assertEquals("/web/v3/config/org/prop/config.json", mockWebServer.takeRequest().path) + } + + @Test + fun blankEnvironment_treatedSameAsNull_omitsSegmentEntirely() = runBlocking { + client().getFullConfiguration( + FullConfigurationRequest( + organizationCode = "org", + propertyCode = "prop", + environmentCode = "", + jurisdictionCode = "us-ca", + languageCode = "en-US", + ), + ) + assertEquals("/web/v3/config/org/prop/config.json", mockWebServer.takeRequest().path) + } + + @Test + fun blankHash_omitsHashQueryParam() = runBlocking { + client().getFullConfiguration( + FullConfigurationRequest(organizationCode = "org", propertyCode = "prop", hash = ""), + ) + assertEquals("/web/v3/config/org/prop/config.json", mockWebServer.takeRequest().path) + } + + @Test + fun nonBlankHash_includesHashQueryParam() = runBlocking { + client().getFullConfiguration( + FullConfigurationRequest(organizationCode = "org", propertyCode = "prop", hash = "abc123"), + ) + assertEquals("/web/v3/config/org/prop/config.json?hash=abc123", mockWebServer.takeRequest().path) + } + + private fun client(): 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) + } +} diff --git a/ketchsdk/src/test/java/com/ketch/android/data/HeadlessModelsTest.kt b/ketchsdk/src/test/java/com/ketch/android/data/HeadlessModelsTest.kt new file mode 100644 index 0000000..8a65b82 --- /dev/null +++ b/ketchsdk/src/test/java/com/ketch/android/data/HeadlessModelsTest.kt @@ -0,0 +1,92 @@ +package com.ketch.android.data + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class HeadlessModelsTest { + + @Test + fun toRegionCode_countryAndRegion() { + val ipInfo = IPInfo(countryCode = "US", regionCode = "CA") + assertEquals("US-CA", ipInfo.toRegionCode()) + } + + @Test + fun toRegionCode_countryOnly() { + val ipInfo = IPInfo(countryCode = "US", regionCode = null) + assertEquals("US", ipInfo.toRegionCode()) + } + + @Test + fun toRegionCode_countryOnly_blankRegion() { + val ipInfo = IPInfo(countryCode = "US", regionCode = "") + assertEquals("US", ipInfo.toRegionCode()) + } + + @Test + fun toRegionCode_regionOnly_noCountry() { + val ipInfo = IPInfo(countryCode = null, regionCode = "CA") + assertEquals("CA", ipInfo.toRegionCode()) + } + + @Test + fun toRegionCode_allBlank() { + val ipInfo = IPInfo(countryCode = null, regionCode = null) + assertNull(ipInfo.toRegionCode()) + } + + @Test + fun toRegionCode_blankCountry_blankRegion() { + val ipInfo = IPInfo(countryCode = "", regionCode = "") + assertNull(ipInfo.toRegionCode()) + } + + @Test + fun configPathSegment_allPresent() { + val request = FullConfigurationRequest( + organizationCode = "org", + propertyCode = "prop", + environmentCode = "production", + jurisdictionCode = "us-ca", + languageCode = "en-US", + ) + assertEquals(Triple("production", "us-ca", "en-US"), request.configPathSegment()) + } + + @Test + fun configPathSegment_nullEnvironment_isAbsent() { + val request = FullConfigurationRequest( + organizationCode = "org", + propertyCode = "prop", + environmentCode = null, + jurisdictionCode = "us-ca", + languageCode = "en-US", + ) + assertNull(request.configPathSegment()) + } + + @Test + fun configPathSegment_blankEnvironment_treatedSameAsNull() { + val request = FullConfigurationRequest( + organizationCode = "org", + propertyCode = "prop", + environmentCode = "", + jurisdictionCode = "us-ca", + languageCode = "en-US", + ) + assertNull(request.configPathSegment()) + } + + @Test + fun normalizedHash_blankIsNull() { + assertNull( + FullConfigurationRequest(organizationCode = "org", propertyCode = "prop", hash = "").normalizedHash(), + ) + assertEquals( + "abc123", + FullConfigurationRequest(organizationCode = "org", propertyCode = "prop", hash = "abc123") + .normalizedHash(), + ) + } +} 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 f6c5034..3630bea 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 @@ -65,6 +65,7 @@ fun KetchSampleApp( onLogSharedPreferences: () -> Unit, onTriggerFunction: () -> Unit, onGetJurisdiction: () -> Unit, + onGetRegion: () -> Unit, ) { var isDarkMode by rememberSaveable { mutableStateOf(false) } @@ -124,6 +125,13 @@ fun KetchSampleApp( onExecute = onGetJurisdiction, modifier = Modifier.fillMaxWidth(), ) + Spacer(Modifier.height(16.dp)) + ActionCard( + title = "Get Region", + description = "Call the headless API (getRegion) and log the resolved GeoIP region.", + onExecute = onGetRegion, + 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 7cf8cc2..03842ab 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 @@ -10,7 +10,6 @@ import androidx.appcompat.app.AppCompatActivity import androidx.compose.runtime.mutableStateListOf import com.ketch.android.Ketch import com.ketch.android.TriggerName -import com.ketch.android.data.FullConfigurationRequest class MainActivity : AppCompatActivity() { @@ -64,20 +63,29 @@ class MainActivity : AppCompatActivity() { ketch.trigger(TriggerName.CUSTOM, "demoFunction") }, onGetJurisdiction = { - Log.d(TAG, "getFullConfiguration() called") - logEntries.add("getFullConfiguration() called") - val request = FullConfigurationRequest( - organizationCode = ComposeSampleApplication.ORG_CODE, - propertyCode = ComposeSampleApplication.PROPERTY, - ) - ketch.getFullConfiguration(request) { result -> - result.onSuccess { config -> - val message = "Jurisdiction: ${config.jurisdiction?.code ?: "?"} " + - "(default: ${config.jurisdiction?.defaultJurisdictionCode ?: "?"})" + Log.d(TAG, "getJurisdiction() called") + logEntries.add("getJurisdiction() called") + ketch.getJurisdiction { result -> + result.onSuccess { jurisdiction -> + val message = "Jurisdiction: ${jurisdiction ?: "?"}" Log.d(TAG, message) logEntries.add(message) }.onFailure { error -> - Log.e(TAG, "getFullConfiguration() failed", error) + Log.e(TAG, "getJurisdiction() failed", error) + logEntries.add("Headless error: ${error.message}") + } + } + }, + onGetRegion = { + Log.d(TAG, "getRegion() called") + logEntries.add("getRegion() called") + ketch.getRegion { result -> + result.onSuccess { region -> + val message = "Region: ${region ?: "?"}" + Log.d(TAG, message) + logEntries.add(message) + }.onFailure { error -> + Log.e(TAG, "getRegion() failed", error) logEntries.add("Headless error: ${error.message}") } }