Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
120 changes: 112 additions & 8 deletions ketchsdk/src/main/java/com/ketch/android/Ketch.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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<LocationResponse>) -> Unit) {
headlessApiClient.getLocation(callback)
/** Combined ISO region code (e.g. "US-CA") from GeoIP (`GET /ip`). Cached on this instance. */
fun getRegion(callback: (Result<String?>) -> 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(
Expand All @@ -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<HeadlessConfiguration>) -> 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<String?>) -> 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(
Expand Down Expand Up @@ -513,6 +589,7 @@ class Ketch private constructor(
*/
fun setLanguage(language: String?) {
this.language = language
clearConfigCache()
}

/**
Expand All @@ -522,6 +599,7 @@ class Ketch private constructor(
*/
fun setJurisdiction(jurisdiction: String?) {
this.jurisdiction = jurisdiction
clearConfigCache()
}

/**
Expand All @@ -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
}
}

/**
Expand Down Expand Up @@ -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("|")
Comment thread
ethanpschoen marked this conversation as resolved.
}

private fun buildPreferenceOptionsJson(
tabs: List<PreferencesTab> = emptyList(),
tab: PreferencesTab? = null,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
}

Expand Down
23 changes: 23 additions & 0 deletions ketchsdk/src/main/java/com/ketch/android/data/HeadlessModels.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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<String, String, String>? {
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,
Expand Down
Original file line number Diff line number Diff line change
@@ -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)
}
}
Loading
Loading