From 84b226dbee661d9d32c9432bb20ce0cbda2a6909 Mon Sep 17 00:00:00 2001 From: sydneycollins-cio <149523950+sydneycollins-cio@users.noreply.github.com> Date: Mon, 5 Oct 2026 15:06:44 -0400 Subject: [PATCH 1/9] feat(MBL-2543): reject secret keys and pick region from key Co-Authored-By: Claude Opus 5.5 --- .../io/customer/sdk/core/util/CioApiKey.kt | 28 ++++++++++ .../customer/sdk/core/util/CioApiKeyTest.kt | 56 +++++++++++++++++++ .../io/customer/sdk/CustomerIOBuilder.kt | 5 +- .../customer/sdk/CustomerIOConfigBuilder.kt | 25 ++++++++- .../sdk/CustomerIOConfigBuilderTest.kt | 49 ++++++++++++++++ 5 files changed, 158 insertions(+), 5 deletions(-) create mode 100644 core/src/main/kotlin/io/customer/sdk/core/util/CioApiKey.kt create mode 100644 core/src/test/java/io/customer/sdk/core/util/CioApiKeyTest.kt diff --git a/core/src/main/kotlin/io/customer/sdk/core/util/CioApiKey.kt b/core/src/main/kotlin/io/customer/sdk/core/util/CioApiKey.kt new file mode 100644 index 000000000..1fbe32a8a --- /dev/null +++ b/core/src/main/kotlin/io/customer/sdk/core/util/CioApiKey.kt @@ -0,0 +1,28 @@ +package io.customer.sdk.core.util + +import io.customer.base.internal.InternalCustomerIOApi +import io.customer.sdk.data.model.Region + +/** + * Reads the prefix of a Customer.io API key: `{ak|wk}_{us|eu}_...`. + * `ak_` keys are secret and must stay on servers, `wk_` keys are public and safe to ship in apps. + * Legacy keys have no prefix. The server validates the rest of the key. + */ +@InternalCustomerIOApi +object CioApiKey { + private val prefixRegex = Regex("^(ak|wk)_(us|eu)_") + + fun isSecretKey(key: String): Boolean = kind(key) == "ak" + + fun isPublicKey(key: String): Boolean = kind(key) == "wk" + + /** + * Region encoded in the key prefix, or null for legacy keys. + */ + fun region(key: String): Region? { + val code = prefixRegex.find(key)?.groupValues?.get(2) ?: return null + return Region.getRegion(code) + } + + private fun kind(key: String): String? = prefixRegex.find(key)?.groupValues?.get(1) +} diff --git a/core/src/test/java/io/customer/sdk/core/util/CioApiKeyTest.kt b/core/src/test/java/io/customer/sdk/core/util/CioApiKeyTest.kt new file mode 100644 index 000000000..0b5eacc61 --- /dev/null +++ b/core/src/test/java/io/customer/sdk/core/util/CioApiKeyTest.kt @@ -0,0 +1,56 @@ +package io.customer.sdk.core.util + +import io.customer.commontest.core.JUnit5Test +import io.customer.sdk.data.model.Region +import org.amshove.kluent.shouldBe +import org.amshove.kluent.shouldBeFalse +import org.amshove.kluent.shouldBeNull +import org.amshove.kluent.shouldBeTrue +import org.junit.jupiter.api.Test + +class CioApiKeyTest : JUnit5Test() { + + @Test + fun isSecretKey_givenSecretKey_expectTrue() { + CioApiKey.isSecretKey(SECRET_US_KEY).shouldBeTrue() + CioApiKey.isPublicKey(SECRET_US_KEY).shouldBeFalse() + } + + @Test + fun isPublicKey_givenPublicKey_expectTrue() { + CioApiKey.isPublicKey(PUBLIC_EU_KEY).shouldBeTrue() + CioApiKey.isSecretKey(PUBLIC_EU_KEY).shouldBeFalse() + } + + @Test + fun isPublicKey_givenLegacyKey_expectFalse() { + CioApiKey.isPublicKey(LEGACY_KEY).shouldBeFalse() + CioApiKey.isSecretKey(LEGACY_KEY).shouldBeFalse() + } + + @Test + fun isPublicKey_givenUnknownRegionPrefix_expectFalse() { + CioApiKey.isPublicKey("wk_ap_$KEY_BODY").shouldBeFalse() + CioApiKey.isSecretKey("ak_ap_$KEY_BODY").shouldBeFalse() + } + + @Test + fun region_givenPrefixedKeys_expectRegionFromPrefix() { + CioApiKey.region(PUBLIC_EU_KEY) shouldBe Region.EU + CioApiKey.region(PUBLIC_US_KEY) shouldBe Region.US + CioApiKey.region(SECRET_US_KEY) shouldBe Region.US + } + + @Test + fun region_givenLegacyKey_expectNull() { + CioApiKey.region(LEGACY_KEY).shouldBeNull() + } + + companion object { + private const val KEY_BODY = "0123456789abcdefABCDEF0123456789_a1B2c3" + const val PUBLIC_US_KEY = "wk_us_$KEY_BODY" + const val PUBLIC_EU_KEY = "wk_eu_$KEY_BODY" + const val SECRET_US_KEY = "ak_us_$KEY_BODY" + const val LEGACY_KEY = "4f3b2a1c0d9e8f7a6b5c" + } +} diff --git a/datapipelines/src/main/kotlin/io/customer/sdk/CustomerIOBuilder.kt b/datapipelines/src/main/kotlin/io/customer/sdk/CustomerIOBuilder.kt index 7ac7c812b..4cbda9d35 100644 --- a/datapipelines/src/main/kotlin/io/customer/sdk/CustomerIOBuilder.kt +++ b/datapipelines/src/main/kotlin/io/customer/sdk/CustomerIOBuilder.kt @@ -62,7 +62,7 @@ class CustomerIOBuilder( private var logLevel: CioLogLevel = CustomerIOConfigBuilder.Defaults.logLevel // Host Settings - private var region: Region = CustomerIOConfigBuilder.Defaults.region + private var region: Region? = null private var apiHost: String? = CustomerIOConfigBuilder.Defaults.apiHost private var cdnHost: String? = CustomerIOConfigBuilder.Defaults.cdnHost @@ -204,11 +204,12 @@ class CustomerIOBuilder( fun build(): CustomerIO { // Create CustomerIOConfig from the current builder state + CustomerIOConfigBuilder.requireNotSecretKey(cdpApiKey) val config = CustomerIOConfig( applicationContext = applicationContext, cdpApiKey = cdpApiKey, logLevel = logLevel, - region = region, + region = CustomerIOConfigBuilder.resolveRegion(cdpApiKey, region), apiHost = apiHost, cdnHost = cdnHost, flushAt = flushAt, diff --git a/datapipelines/src/main/kotlin/io/customer/sdk/CustomerIOConfigBuilder.kt b/datapipelines/src/main/kotlin/io/customer/sdk/CustomerIOConfigBuilder.kt index 078f8324f..8b70a1799 100644 --- a/datapipelines/src/main/kotlin/io/customer/sdk/CustomerIOConfigBuilder.kt +++ b/datapipelines/src/main/kotlin/io/customer/sdk/CustomerIOConfigBuilder.kt @@ -7,6 +7,7 @@ import io.customer.sdk.core.di.SDKComponent import io.customer.sdk.core.di.setupAndroidComponent import io.customer.sdk.core.module.CustomerIOModule import io.customer.sdk.core.module.CustomerIOModuleConfig +import io.customer.sdk.core.util.CioApiKey import io.customer.sdk.core.util.CioLogLevel import io.customer.sdk.data.model.Region @@ -47,6 +48,22 @@ class CustomerIOConfigBuilder( val migrationSiteId: String? = null val screenViewUse: ScreenView = ScreenView.All val modules: List> = emptyList() + + /** + * Secret (`ak_`) keys must never ship in apps, so fail fast if one is used. + */ + fun requireNotSecretKey(cdpApiKey: String) { + require(!CioApiKey.isSecretKey(cdpApiKey)) { + "Secret API keys (ak_) can't be used in apps, use your public key in apps." + } + } + + /** + * Region set by the app wins, then the region in the key prefix, then [region]. + */ + fun resolveRegion(cdpApiKey: String, region: Region?): Region { + return region ?: CioApiKey.region(cdpApiKey) ?: Defaults.region + } } init { @@ -57,7 +74,8 @@ class CustomerIOConfigBuilder( private var logLevel: CioLogLevel = Defaults.logLevel // Host Settings - private var region: Region = Defaults.region + // null means the region is picked from the key prefix, falling back to the default region + private var region: Region? = null private var apiHost: String? = Defaults.apiHost private var cdnHost: String? = Defaults.cdnHost @@ -98,7 +116,7 @@ class CustomerIOConfigBuilder( /** * Specifies the workspace region to ensure CDP requests are routed to the correct regional endpoint. - * Default value is [Region.US]. + * Default value is the region in the key prefix (e.g. `wk_eu_` is [Region.EU]), or [Region.US] for keys without one. * Default values for apiHost and cdnHost are determined by the region. * However, if apiHost or cdnHost are manually specified, those values override region-based defaults. */ @@ -212,11 +230,12 @@ class CustomerIOConfigBuilder( * Build the CustomerIOConfig instance with the specified configuration. */ fun build(): CustomerIOConfig { + requireNotSecretKey(cdpApiKey) return CustomerIOConfig( applicationContext = applicationContext, cdpApiKey = cdpApiKey, logLevel = logLevel, - region = region, + region = resolveRegion(cdpApiKey, region), apiHost = apiHost, cdnHost = cdnHost, flushAt = flushAt, diff --git a/datapipelines/src/test/java/io/customer/sdk/CustomerIOConfigBuilderTest.kt b/datapipelines/src/test/java/io/customer/sdk/CustomerIOConfigBuilderTest.kt index 1947191a9..4def51fb4 100644 --- a/datapipelines/src/test/java/io/customer/sdk/CustomerIOConfigBuilderTest.kt +++ b/datapipelines/src/test/java/io/customer/sdk/CustomerIOConfigBuilderTest.kt @@ -20,9 +20,11 @@ import io.customer.sdk.data.model.Region import io.mockk.every import io.mockk.mockk import io.mockk.verifyOrder +import org.amshove.kluent.invoking import org.amshove.kluent.shouldBe import org.amshove.kluent.shouldNotBe import org.amshove.kluent.shouldNotBeEqualTo +import org.amshove.kluent.shouldThrow import org.junit.Before import org.junit.Test import org.junit.runner.RunWith @@ -374,6 +376,49 @@ class CustomerIOConfigBuilderTest : RobolectricTest() { dataPipelinesModuleConfig.cdnHost shouldBe "cdp.customer.io/v1" } + @Test + fun build_givenSecretKey_expectThrows() { + val builder = createCustomerIOConfigBuilder("ak_us_$KEY_BODY") + + invoking { builder.build() } shouldThrow IllegalArgumentException::class + } + + @Test + fun initialize_givenPublicEUKeyWithoutRegion_expectEURegionBasedHosts() { + val config = createCustomerIOConfigBuilder("wk_eu_$KEY_BODY").build() + + CustomerIO.initialize(config) + + val dataPipelinesModuleConfig = CustomerIO.instance().moduleConfig + dataPipelinesModuleConfig.apiHost shouldBe "cdp-eu.customer.io/v1" + dataPipelinesModuleConfig.cdnHost shouldBe "cdp-eu.customer.io/v1" + } + + @Test + fun initialize_givenPublicEUKeyWithRegionUS_expectRegionOverridesKey() { + val config = createCustomerIOConfigBuilder("wk_eu_$KEY_BODY") + .region(Region.US) + .build() + + CustomerIO.initialize(config) + + val dataPipelinesModuleConfig = CustomerIO.instance().moduleConfig + dataPipelinesModuleConfig.apiHost shouldBe "cdp.customer.io/v1" + dataPipelinesModuleConfig.cdnHost shouldBe "cdp.customer.io/v1" + } + + @Test + fun initialize_givenPublicUSKey_expectKeyIsUsedAsIs() { + val givenKey = "wk_us_$KEY_BODY" + val config = createCustomerIOConfigBuilder(givenKey).build() + + CustomerIO.initialize(config) + + val dataPipelinesModuleConfig = CustomerIO.instance().moduleConfig + dataPipelinesModuleConfig.cdpApiKey shouldBe givenKey + dataPipelinesModuleConfig.apiHost shouldBe "cdp.customer.io/v1" + } + @Test fun build_givenFlushPolicies_expectBuilderAcceptsFlushPolicies() { val givenFlushPolicies = emptyList() @@ -401,6 +446,10 @@ class CustomerIOConfigBuilderTest : RobolectricTest() { configDisabled shouldNotBe null } + private companion object { + const val KEY_BODY = "0123456789abcdefABCDEF0123456789_a1B2c3" + } + private fun createCustomerIOConfigBuilder(givenCdpApiKey: String? = null): CustomerIOConfigBuilder = CustomerIOConfigBuilder( applicationContext = applicationMock, cdpApiKey = givenCdpApiKey ?: String.random From 0a579b82513c992de9f43428fe71a0e6702b6d4e Mon Sep 17 00:00:00 2001 From: sydneycollins-cio <149523950+sydneycollins-cio@users.noreply.github.com> Date: Wed, 7 Oct 2026 14:20:29 -0400 Subject: [PATCH 2/9] fix(MBL-2543): log and skip init for any ak_ key instead of throwing Co-Authored-By: Claude Opus 5.5 --- .../main/kotlin/io/customer/sdk/core/util/CioApiKey.kt | 7 ++++++- .../java/io/customer/sdk/core/util/CioApiKeyTest.kt | 7 ++++++- .../src/main/kotlin/io/customer/sdk/CustomerIO.kt | 7 +++++++ .../main/kotlin/io/customer/sdk/CustomerIOBuilder.kt | 4 +++- .../kotlin/io/customer/sdk/CustomerIOConfigBuilder.kt | 10 ---------- .../main/kotlin/io/customer/sdk/DataPipelinesLogger.kt | 8 ++++++++ .../io/customer/sdk/CustomerIOConfigBuilderTest.kt | 8 +++++--- 7 files changed, 35 insertions(+), 16 deletions(-) diff --git a/core/src/main/kotlin/io/customer/sdk/core/util/CioApiKey.kt b/core/src/main/kotlin/io/customer/sdk/core/util/CioApiKey.kt index 1fbe32a8a..e82373b18 100644 --- a/core/src/main/kotlin/io/customer/sdk/core/util/CioApiKey.kt +++ b/core/src/main/kotlin/io/customer/sdk/core/util/CioApiKey.kt @@ -12,7 +12,12 @@ import io.customer.sdk.data.model.Region object CioApiKey { private val prefixRegex = Regex("^(ak|wk)_(us|eu)_") - fun isSecretKey(key: String): Boolean = kind(key) == "ak" + const val SECRET_KEY_ERROR = "Secret (ak_) keys must not be used in apps. Use your public key in apps." + + /** + * Any `ak_` key is secret, even with an unknown region, so it is never sent from an app. + */ + fun isSecretKey(key: String): Boolean = key.startsWith("ak_") fun isPublicKey(key: String): Boolean = kind(key) == "wk" diff --git a/core/src/test/java/io/customer/sdk/core/util/CioApiKeyTest.kt b/core/src/test/java/io/customer/sdk/core/util/CioApiKeyTest.kt index 0b5eacc61..2b3f7242c 100644 --- a/core/src/test/java/io/customer/sdk/core/util/CioApiKeyTest.kt +++ b/core/src/test/java/io/customer/sdk/core/util/CioApiKeyTest.kt @@ -31,7 +31,12 @@ class CioApiKeyTest : JUnit5Test() { @Test fun isPublicKey_givenUnknownRegionPrefix_expectFalse() { CioApiKey.isPublicKey("wk_ap_$KEY_BODY").shouldBeFalse() - CioApiKey.isSecretKey("ak_ap_$KEY_BODY").shouldBeFalse() + } + + @Test + fun isSecretKey_givenAnyAkPrefix_expectTrue() { + CioApiKey.isSecretKey("ak_ap_$KEY_BODY").shouldBeTrue() + CioApiKey.isSecretKey("ak_$KEY_BODY").shouldBeTrue() } @Test diff --git a/datapipelines/src/main/kotlin/io/customer/sdk/CustomerIO.kt b/datapipelines/src/main/kotlin/io/customer/sdk/CustomerIO.kt index 1034f0948..4fa81baad 100644 --- a/datapipelines/src/main/kotlin/io/customer/sdk/CustomerIO.kt +++ b/datapipelines/src/main/kotlin/io/customer/sdk/CustomerIO.kt @@ -34,6 +34,7 @@ import io.customer.sdk.core.di.SDKComponent import io.customer.sdk.core.module.CustomerIOModule import io.customer.sdk.core.pipeline.DataPipeline import io.customer.sdk.core.pipeline.identifyHookRegistry +import io.customer.sdk.core.util.CioApiKey import io.customer.sdk.core.util.CioLogLevel import io.customer.sdk.core.util.Iso8601TimestampFormatter import io.customer.sdk.core.util.Logger @@ -531,6 +532,12 @@ class CustomerIO private constructor( // Update the log level for the SDK SDKComponent.logger.logLevel = config.logLevel + // Secret keys must not ship in apps, so log and skip init instead of crashing the app + if (CioApiKey.isSecretKey(config.cdpApiKey)) { + logger.coreSdkSecretKeyRejected() + return + } + logger.coreSdkInitStart() // Initialize DataPipelinesModule with the provided configuration diff --git a/datapipelines/src/main/kotlin/io/customer/sdk/CustomerIOBuilder.kt b/datapipelines/src/main/kotlin/io/customer/sdk/CustomerIOBuilder.kt index 4cbda9d35..40b2941b0 100644 --- a/datapipelines/src/main/kotlin/io/customer/sdk/CustomerIOBuilder.kt +++ b/datapipelines/src/main/kotlin/io/customer/sdk/CustomerIOBuilder.kt @@ -7,6 +7,7 @@ import io.customer.sdk.core.di.SDKComponent import io.customer.sdk.core.di.setupAndroidComponent import io.customer.sdk.core.module.CustomerIOModule import io.customer.sdk.core.module.CustomerIOModuleConfig +import io.customer.sdk.core.util.CioApiKey import io.customer.sdk.core.util.CioLogLevel import io.customer.sdk.data.model.Region @@ -204,7 +205,8 @@ class CustomerIOBuilder( fun build(): CustomerIO { // Create CustomerIOConfig from the current builder state - CustomerIOConfigBuilder.requireNotSecretKey(cdpApiKey) + // This builder must return an instance, so it can't skip init like CustomerIO.initialize does + require(!CioApiKey.isSecretKey(cdpApiKey)) { CioApiKey.SECRET_KEY_ERROR } val config = CustomerIOConfig( applicationContext = applicationContext, cdpApiKey = cdpApiKey, diff --git a/datapipelines/src/main/kotlin/io/customer/sdk/CustomerIOConfigBuilder.kt b/datapipelines/src/main/kotlin/io/customer/sdk/CustomerIOConfigBuilder.kt index 8b70a1799..713d25185 100644 --- a/datapipelines/src/main/kotlin/io/customer/sdk/CustomerIOConfigBuilder.kt +++ b/datapipelines/src/main/kotlin/io/customer/sdk/CustomerIOConfigBuilder.kt @@ -49,15 +49,6 @@ class CustomerIOConfigBuilder( val screenViewUse: ScreenView = ScreenView.All val modules: List> = emptyList() - /** - * Secret (`ak_`) keys must never ship in apps, so fail fast if one is used. - */ - fun requireNotSecretKey(cdpApiKey: String) { - require(!CioApiKey.isSecretKey(cdpApiKey)) { - "Secret API keys (ak_) can't be used in apps, use your public key in apps." - } - } - /** * Region set by the app wins, then the region in the key prefix, then [region]. */ @@ -230,7 +221,6 @@ class CustomerIOConfigBuilder( * Build the CustomerIOConfig instance with the specified configuration. */ fun build(): CustomerIOConfig { - requireNotSecretKey(cdpApiKey) return CustomerIOConfig( applicationContext = applicationContext, cdpApiKey = cdpApiKey, diff --git a/datapipelines/src/main/kotlin/io/customer/sdk/DataPipelinesLogger.kt b/datapipelines/src/main/kotlin/io/customer/sdk/DataPipelinesLogger.kt index c038ff35f..94c72353d 100644 --- a/datapipelines/src/main/kotlin/io/customer/sdk/DataPipelinesLogger.kt +++ b/datapipelines/src/main/kotlin/io/customer/sdk/DataPipelinesLogger.kt @@ -2,6 +2,7 @@ package io.customer.sdk import io.customer.sdk.core.module.CustomerIOModule import io.customer.sdk.core.module.CustomerIOModuleConfig +import io.customer.sdk.core.util.CioApiKey import io.customer.sdk.core.util.Logger import java.lang.IllegalStateException @@ -27,6 +28,13 @@ internal class DataPipelinesLogger(private val logger: Logger) { ) } + fun coreSdkSecretKeyRejected() { + logger.error( + tag = INIT_TAG, + message = CioApiKey.SECRET_KEY_ERROR + ) + } + fun coreSdkInitSuccess() { logger.info( tag = INIT_TAG, diff --git a/datapipelines/src/test/java/io/customer/sdk/CustomerIOConfigBuilderTest.kt b/datapipelines/src/test/java/io/customer/sdk/CustomerIOConfigBuilderTest.kt index 4def51fb4..a995e80d6 100644 --- a/datapipelines/src/test/java/io/customer/sdk/CustomerIOConfigBuilderTest.kt +++ b/datapipelines/src/test/java/io/customer/sdk/CustomerIOConfigBuilderTest.kt @@ -377,10 +377,12 @@ class CustomerIOConfigBuilderTest : RobolectricTest() { } @Test - fun build_givenSecretKey_expectThrows() { - val builder = createCustomerIOConfigBuilder("ak_us_$KEY_BODY") + fun initialize_givenSecretKey_expectSdkNotInitialized() { + val config = createCustomerIOConfigBuilder("ak_us_$KEY_BODY").build() - invoking { builder.build() } shouldThrow IllegalArgumentException::class + CustomerIO.initialize(config) + + invoking { CustomerIO.instance() } shouldThrow IllegalStateException::class } @Test From 2e724b020e7c56949061c36503e6fe40e49a9544 Mon Sep 17 00:00:00 2001 From: sydneycollins-cio <149523950+sydneycollins-cio@users.noreply.github.com> Date: Thu, 15 Oct 2026 18:09:16 -0400 Subject: [PATCH 3/9] feat(MBL-2604): rename key prefixes to pk_/sk_ and read region from any prefix Co-Authored-By: Claude Opus 5.5 --- .../kotlin/io/customer/sdk/core/util/CioApiKey.kt | 15 ++++++++------- .../io/customer/sdk/core/util/CioApiKeyTest.kt | 15 ++++++++------- .../io/customer/sdk/CustomerIOConfigBuilder.kt | 2 +- .../customer/sdk/CustomerIOConfigBuilderTest.kt | 8 ++++---- 4 files changed, 21 insertions(+), 19 deletions(-) diff --git a/core/src/main/kotlin/io/customer/sdk/core/util/CioApiKey.kt b/core/src/main/kotlin/io/customer/sdk/core/util/CioApiKey.kt index e82373b18..dd191fac2 100644 --- a/core/src/main/kotlin/io/customer/sdk/core/util/CioApiKey.kt +++ b/core/src/main/kotlin/io/customer/sdk/core/util/CioApiKey.kt @@ -4,22 +4,23 @@ import io.customer.base.internal.InternalCustomerIOApi import io.customer.sdk.data.model.Region /** - * Reads the prefix of a Customer.io API key: `{ak|wk}_{us|eu}_...`. - * `ak_` keys are secret and must stay on servers, `wk_` keys are public and safe to ship in apps. + * Reads the prefix of a Customer.io API key: `{sk|pk}_{us|eu}_...`. + * `sk_` keys are secret and must stay on servers, `pk_` keys are public and safe to ship in apps. + * The region is read from any key shaped `xx_{us|eu}_`, so only the kind checks depend on the prefix names. * Legacy keys have no prefix. The server validates the rest of the key. */ @InternalCustomerIOApi object CioApiKey { - private val prefixRegex = Regex("^(ak|wk)_(us|eu)_") + private val prefixRegex = Regex("^([a-z]{2})_(us|eu)_") - const val SECRET_KEY_ERROR = "Secret (ak_) keys must not be used in apps. Use your public key in apps." + const val SECRET_KEY_ERROR = "Secret (sk_) keys must not be used in apps. Use your public key in apps." /** - * Any `ak_` key is secret, even with an unknown region, so it is never sent from an app. + * Any `sk_` key is secret, even with an unknown region, so it is never sent from an app. */ - fun isSecretKey(key: String): Boolean = key.startsWith("ak_") + fun isSecretKey(key: String): Boolean = key.startsWith("sk_") - fun isPublicKey(key: String): Boolean = kind(key) == "wk" + fun isPublicKey(key: String): Boolean = kind(key) == "pk" /** * Region encoded in the key prefix, or null for legacy keys. diff --git a/core/src/test/java/io/customer/sdk/core/util/CioApiKeyTest.kt b/core/src/test/java/io/customer/sdk/core/util/CioApiKeyTest.kt index 2b3f7242c..f332d48a3 100644 --- a/core/src/test/java/io/customer/sdk/core/util/CioApiKeyTest.kt +++ b/core/src/test/java/io/customer/sdk/core/util/CioApiKeyTest.kt @@ -30,13 +30,13 @@ class CioApiKeyTest : JUnit5Test() { @Test fun isPublicKey_givenUnknownRegionPrefix_expectFalse() { - CioApiKey.isPublicKey("wk_ap_$KEY_BODY").shouldBeFalse() + CioApiKey.isPublicKey("pk_ap_$KEY_BODY").shouldBeFalse() } @Test - fun isSecretKey_givenAnyAkPrefix_expectTrue() { - CioApiKey.isSecretKey("ak_ap_$KEY_BODY").shouldBeTrue() - CioApiKey.isSecretKey("ak_$KEY_BODY").shouldBeTrue() + fun isSecretKey_givenAnySkPrefix_expectTrue() { + CioApiKey.isSecretKey("sk_ap_$KEY_BODY").shouldBeTrue() + CioApiKey.isSecretKey("sk_$KEY_BODY").shouldBeTrue() } @Test @@ -44,6 +44,7 @@ class CioApiKeyTest : JUnit5Test() { CioApiKey.region(PUBLIC_EU_KEY) shouldBe Region.EU CioApiKey.region(PUBLIC_US_KEY) shouldBe Region.US CioApiKey.region(SECRET_US_KEY) shouldBe Region.US + CioApiKey.region("xk_eu_$KEY_BODY") shouldBe Region.EU } @Test @@ -53,9 +54,9 @@ class CioApiKeyTest : JUnit5Test() { companion object { private const val KEY_BODY = "0123456789abcdefABCDEF0123456789_a1B2c3" - const val PUBLIC_US_KEY = "wk_us_$KEY_BODY" - const val PUBLIC_EU_KEY = "wk_eu_$KEY_BODY" - const val SECRET_US_KEY = "ak_us_$KEY_BODY" + const val PUBLIC_US_KEY = "pk_us_$KEY_BODY" + const val PUBLIC_EU_KEY = "pk_eu_$KEY_BODY" + const val SECRET_US_KEY = "sk_us_$KEY_BODY" const val LEGACY_KEY = "4f3b2a1c0d9e8f7a6b5c" } } diff --git a/datapipelines/src/main/kotlin/io/customer/sdk/CustomerIOConfigBuilder.kt b/datapipelines/src/main/kotlin/io/customer/sdk/CustomerIOConfigBuilder.kt index 713d25185..f60824288 100644 --- a/datapipelines/src/main/kotlin/io/customer/sdk/CustomerIOConfigBuilder.kt +++ b/datapipelines/src/main/kotlin/io/customer/sdk/CustomerIOConfigBuilder.kt @@ -107,7 +107,7 @@ class CustomerIOConfigBuilder( /** * Specifies the workspace region to ensure CDP requests are routed to the correct regional endpoint. - * Default value is the region in the key prefix (e.g. `wk_eu_` is [Region.EU]), or [Region.US] for keys without one. + * Default value is the region in the key prefix (e.g. `pk_eu_` is [Region.EU]), or [Region.US] for keys without one. * Default values for apiHost and cdnHost are determined by the region. * However, if apiHost or cdnHost are manually specified, those values override region-based defaults. */ diff --git a/datapipelines/src/test/java/io/customer/sdk/CustomerIOConfigBuilderTest.kt b/datapipelines/src/test/java/io/customer/sdk/CustomerIOConfigBuilderTest.kt index a995e80d6..f4fa54847 100644 --- a/datapipelines/src/test/java/io/customer/sdk/CustomerIOConfigBuilderTest.kt +++ b/datapipelines/src/test/java/io/customer/sdk/CustomerIOConfigBuilderTest.kt @@ -378,7 +378,7 @@ class CustomerIOConfigBuilderTest : RobolectricTest() { @Test fun initialize_givenSecretKey_expectSdkNotInitialized() { - val config = createCustomerIOConfigBuilder("ak_us_$KEY_BODY").build() + val config = createCustomerIOConfigBuilder("sk_us_$KEY_BODY").build() CustomerIO.initialize(config) @@ -387,7 +387,7 @@ class CustomerIOConfigBuilderTest : RobolectricTest() { @Test fun initialize_givenPublicEUKeyWithoutRegion_expectEURegionBasedHosts() { - val config = createCustomerIOConfigBuilder("wk_eu_$KEY_BODY").build() + val config = createCustomerIOConfigBuilder("pk_eu_$KEY_BODY").build() CustomerIO.initialize(config) @@ -398,7 +398,7 @@ class CustomerIOConfigBuilderTest : RobolectricTest() { @Test fun initialize_givenPublicEUKeyWithRegionUS_expectRegionOverridesKey() { - val config = createCustomerIOConfigBuilder("wk_eu_$KEY_BODY") + val config = createCustomerIOConfigBuilder("pk_eu_$KEY_BODY") .region(Region.US) .build() @@ -411,7 +411,7 @@ class CustomerIOConfigBuilderTest : RobolectricTest() { @Test fun initialize_givenPublicUSKey_expectKeyIsUsedAsIs() { - val givenKey = "wk_us_$KEY_BODY" + val givenKey = "pk_us_$KEY_BODY" val config = createCustomerIOConfigBuilder(givenKey).build() CustomerIO.initialize(config) From 5a16f3c85045e023828fd56c232f6a8cd54ef7c4 Mon Sep 17 00:00:00 2001 From: sydneycollins-cio <149523950+sydneycollins-cio@users.noreply.github.com> Date: Thu, 15 Oct 2026 18:16:33 -0400 Subject: [PATCH 4/9] feat(MBL-2604): keep treating retired ak_ keys as secret Co-Authored-By: Claude Opus 5.5 --- .../src/main/kotlin/io/customer/sdk/core/util/CioApiKey.kt | 4 ++-- .../test/java/io/customer/sdk/core/util/CioApiKeyTest.kt | 7 +++++++ 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/core/src/main/kotlin/io/customer/sdk/core/util/CioApiKey.kt b/core/src/main/kotlin/io/customer/sdk/core/util/CioApiKey.kt index dd191fac2..7921e40ff 100644 --- a/core/src/main/kotlin/io/customer/sdk/core/util/CioApiKey.kt +++ b/core/src/main/kotlin/io/customer/sdk/core/util/CioApiKey.kt @@ -16,9 +16,9 @@ object CioApiKey { const val SECRET_KEY_ERROR = "Secret (sk_) keys must not be used in apps. Use your public key in apps." /** - * Any `sk_` key is secret, even with an unknown region, so it is never sent from an app. + * Any `sk_` key, or a retired `ak_` secret key, is secret even with an unknown region, so it is never sent from an app. */ - fun isSecretKey(key: String): Boolean = key.startsWith("sk_") + fun isSecretKey(key: String): Boolean = key.startsWith("sk_") || key.startsWith("ak_") fun isPublicKey(key: String): Boolean = kind(key) == "pk" diff --git a/core/src/test/java/io/customer/sdk/core/util/CioApiKeyTest.kt b/core/src/test/java/io/customer/sdk/core/util/CioApiKeyTest.kt index f332d48a3..0537bd3ec 100644 --- a/core/src/test/java/io/customer/sdk/core/util/CioApiKeyTest.kt +++ b/core/src/test/java/io/customer/sdk/core/util/CioApiKeyTest.kt @@ -39,6 +39,13 @@ class CioApiKeyTest : JUnit5Test() { CioApiKey.isSecretKey("sk_$KEY_BODY").shouldBeTrue() } + @Test + fun isSecretKey_givenRetiredAkPrefix_expectTrue() { + CioApiKey.isSecretKey("ak_us_$KEY_BODY").shouldBeTrue() + CioApiKey.isSecretKey("ak_$KEY_BODY").shouldBeTrue() + CioApiKey.isPublicKey("ak_us_$KEY_BODY").shouldBeFalse() + } + @Test fun region_givenPrefixedKeys_expectRegionFromPrefix() { CioApiKey.region(PUBLIC_EU_KEY) shouldBe Region.EU From 7825642f0c302c0df5f3876b43283b185c904648 Mon Sep 17 00:00:00 2001 From: sydneycollins-cio <149523950+sydneycollins-cio@users.noreply.github.com> Date: Mon, 19 Oct 2026 10:34:26 -0400 Subject: [PATCH 5/9] feat(MBL-2604): drop retired ak_ check, only sk_ is secret Co-Authored-By: Claude Opus 5.5 --- .../src/main/kotlin/io/customer/sdk/core/util/CioApiKey.kt | 4 ++-- .../test/java/io/customer/sdk/core/util/CioApiKeyTest.kt | 7 ------- 2 files changed, 2 insertions(+), 9 deletions(-) diff --git a/core/src/main/kotlin/io/customer/sdk/core/util/CioApiKey.kt b/core/src/main/kotlin/io/customer/sdk/core/util/CioApiKey.kt index 7921e40ff..dd191fac2 100644 --- a/core/src/main/kotlin/io/customer/sdk/core/util/CioApiKey.kt +++ b/core/src/main/kotlin/io/customer/sdk/core/util/CioApiKey.kt @@ -16,9 +16,9 @@ object CioApiKey { const val SECRET_KEY_ERROR = "Secret (sk_) keys must not be used in apps. Use your public key in apps." /** - * Any `sk_` key, or a retired `ak_` secret key, is secret even with an unknown region, so it is never sent from an app. + * Any `sk_` key is secret, even with an unknown region, so it is never sent from an app. */ - fun isSecretKey(key: String): Boolean = key.startsWith("sk_") || key.startsWith("ak_") + fun isSecretKey(key: String): Boolean = key.startsWith("sk_") fun isPublicKey(key: String): Boolean = kind(key) == "pk" diff --git a/core/src/test/java/io/customer/sdk/core/util/CioApiKeyTest.kt b/core/src/test/java/io/customer/sdk/core/util/CioApiKeyTest.kt index 0537bd3ec..f332d48a3 100644 --- a/core/src/test/java/io/customer/sdk/core/util/CioApiKeyTest.kt +++ b/core/src/test/java/io/customer/sdk/core/util/CioApiKeyTest.kt @@ -39,13 +39,6 @@ class CioApiKeyTest : JUnit5Test() { CioApiKey.isSecretKey("sk_$KEY_BODY").shouldBeTrue() } - @Test - fun isSecretKey_givenRetiredAkPrefix_expectTrue() { - CioApiKey.isSecretKey("ak_us_$KEY_BODY").shouldBeTrue() - CioApiKey.isSecretKey("ak_$KEY_BODY").shouldBeTrue() - CioApiKey.isPublicKey("ak_us_$KEY_BODY").shouldBeFalse() - } - @Test fun region_givenPrefixedKeys_expectRegionFromPrefix() { CioApiKey.region(PUBLIC_EU_KEY) shouldBe Region.EU From 9796027125602a359389bc58a6d0b6d7118815bc Mon Sep 17 00:00:00 2001 From: sydneycollins-cio <149523950+sydneycollins-cio@users.noreply.github.com> Date: Mon, 19 Oct 2026 12:59:56 -0400 Subject: [PATCH 6/9] fix(MBL-2543): log and run disabled for sk_ keys instead of crashing Co-Authored-By: Claude Opus 5.5 --- .../io/customer/sdk/core/util/CioApiKey.kt | 8 +++--- .../customer/sdk/core/util/CioApiKeyTest.kt | 5 ++-- .../main/kotlin/io/customer/sdk/CustomerIO.kt | 27 ++++++++++++++++++- .../io/customer/sdk/CustomerIOBuilder.kt | 3 --- .../sdk/CustomerIOBuilderTest.kt | 9 +++++++ .../sdk/CustomerIOConfigBuilderTest.kt | 19 +++++++++---- 6 files changed, 55 insertions(+), 16 deletions(-) diff --git a/core/src/main/kotlin/io/customer/sdk/core/util/CioApiKey.kt b/core/src/main/kotlin/io/customer/sdk/core/util/CioApiKey.kt index dd191fac2..1ebc7792e 100644 --- a/core/src/main/kotlin/io/customer/sdk/core/util/CioApiKey.kt +++ b/core/src/main/kotlin/io/customer/sdk/core/util/CioApiKey.kt @@ -11,7 +11,7 @@ import io.customer.sdk.data.model.Region */ @InternalCustomerIOApi object CioApiKey { - private val prefixRegex = Regex("^([a-z]{2})_(us|eu)_") + private val prefixRegex = Regex("^[a-z]{2}_(us|eu)_") const val SECRET_KEY_ERROR = "Secret (sk_) keys must not be used in apps. Use your public key in apps." @@ -20,15 +20,13 @@ object CioApiKey { */ fun isSecretKey(key: String): Boolean = key.startsWith("sk_") - fun isPublicKey(key: String): Boolean = kind(key) == "pk" + fun isPublicKey(key: String): Boolean = key.startsWith("pk_") /** * Region encoded in the key prefix, or null for legacy keys. */ fun region(key: String): Region? { - val code = prefixRegex.find(key)?.groupValues?.get(2) ?: return null + val code = prefixRegex.find(key)?.groupValues?.get(1) ?: return null return Region.getRegion(code) } - - private fun kind(key: String): String? = prefixRegex.find(key)?.groupValues?.get(1) } diff --git a/core/src/test/java/io/customer/sdk/core/util/CioApiKeyTest.kt b/core/src/test/java/io/customer/sdk/core/util/CioApiKeyTest.kt index f332d48a3..d2864e917 100644 --- a/core/src/test/java/io/customer/sdk/core/util/CioApiKeyTest.kt +++ b/core/src/test/java/io/customer/sdk/core/util/CioApiKeyTest.kt @@ -29,8 +29,9 @@ class CioApiKeyTest : JUnit5Test() { } @Test - fun isPublicKey_givenUnknownRegionPrefix_expectFalse() { - CioApiKey.isPublicKey("pk_ap_$KEY_BODY").shouldBeFalse() + fun isPublicKey_givenAnyPkPrefix_expectTrue() { + CioApiKey.isPublicKey("pk_ap_$KEY_BODY").shouldBeTrue() + CioApiKey.isPublicKey("pk_$KEY_BODY").shouldBeTrue() } @Test diff --git a/datapipelines/src/main/kotlin/io/customer/sdk/CustomerIO.kt b/datapipelines/src/main/kotlin/io/customer/sdk/CustomerIO.kt index 4fa81baad..6e9146116 100644 --- a/datapipelines/src/main/kotlin/io/customer/sdk/CustomerIO.kt +++ b/datapipelines/src/main/kotlin/io/customer/sdk/CustomerIO.kt @@ -492,6 +492,9 @@ class CustomerIO private constructor( @Volatile private var instance: CustomerIO? = null + // Placeholder used instead of a rejected secret key, so the secret never leaves the app + private const val DISABLED_CDP_API_KEY = "disabled" + /** * Returns the instance of CustomerIO SDK. * If the instance is not initialized, it will throw an exception. @@ -532,9 +535,10 @@ class CustomerIO private constructor( // Update the log level for the SDK SDKComponent.logger.logLevel = config.logLevel - // Secret keys must not ship in apps, so log and skip init instead of crashing the app + // Secret keys must not ship in apps, so log and set up a disabled SDK instead of crashing the app if (CioApiKey.isSecretKey(config.cdpApiKey)) { logger.coreSdkSecretKeyRejected() + initializeDisabled(androidSDKComponent, config) return } @@ -577,6 +581,27 @@ class CustomerIO private constructor( logger.coreSdkInitSuccess() } + /** + * Sets up an instance that never holds the secret key and ignores calls, so a later + * [instance] call doesn't crash somewhere unrelated. No modules are initialized. + */ + private fun initializeDisabled(androidSDKComponent: AndroidSDKComponent, config: CustomerIOConfig) { + val disabledConfig = DataPipelinesModuleConfig( + cdpApiKey = DISABLED_CDP_API_KEY, + region = config.region, + flushAt = config.flushAt, + flushInterval = config.flushInterval, + flushPolicies = config.flushPolicies, + autoAddCustomerIODestination = false, + trackApplicationLifecycleEvents = false, + autoTrackDeviceAttributes = false, + autoTrackActivityScreens = false, + screenViewUse = config.screenViewUse + ) + createInstance(androidSDKComponent = androidSDKComponent, moduleConfig = disabledConfig) + .analytics.enabled = false + } + /** * Creates and sets new instance of CustomerIO SDK using the provided implementation. * If the instance is already initialized, it will log an error and skip the initialization. diff --git a/datapipelines/src/main/kotlin/io/customer/sdk/CustomerIOBuilder.kt b/datapipelines/src/main/kotlin/io/customer/sdk/CustomerIOBuilder.kt index 40b2941b0..73eb086da 100644 --- a/datapipelines/src/main/kotlin/io/customer/sdk/CustomerIOBuilder.kt +++ b/datapipelines/src/main/kotlin/io/customer/sdk/CustomerIOBuilder.kt @@ -7,7 +7,6 @@ import io.customer.sdk.core.di.SDKComponent import io.customer.sdk.core.di.setupAndroidComponent import io.customer.sdk.core.module.CustomerIOModule import io.customer.sdk.core.module.CustomerIOModuleConfig -import io.customer.sdk.core.util.CioApiKey import io.customer.sdk.core.util.CioLogLevel import io.customer.sdk.data.model.Region @@ -205,8 +204,6 @@ class CustomerIOBuilder( fun build(): CustomerIO { // Create CustomerIOConfig from the current builder state - // This builder must return an instance, so it can't skip init like CustomerIO.initialize does - require(!CioApiKey.isSecretKey(cdpApiKey)) { CioApiKey.SECRET_KEY_ERROR } val config = CustomerIOConfig( applicationContext = applicationContext, cdpApiKey = cdpApiKey, diff --git a/datapipelines/src/test/java/io/customer/datapipelines/sdk/CustomerIOBuilderTest.kt b/datapipelines/src/test/java/io/customer/datapipelines/sdk/CustomerIOBuilderTest.kt index 400483aef..0b89d93f7 100644 --- a/datapipelines/src/test/java/io/customer/datapipelines/sdk/CustomerIOBuilderTest.kt +++ b/datapipelines/src/test/java/io/customer/datapipelines/sdk/CustomerIOBuilderTest.kt @@ -249,6 +249,15 @@ class CustomerIOBuilderTest : RobolectricTest() { dataPipelinesUSModuleConfig.cdnHost shouldBe givenUSCdnHost } + @Test + fun build_givenSecretKey_expectNoCrashAndErrorLogged() { + val sdk = createCustomerIOBuilder("sk_us_0123456789abcdefABCDEF0123456789_a1B2c3").build() + + assertCalledOnce { mockLogger.coreSdkSecretKeyRejected() } + CustomerIO.instance() shouldBe sdk + sdk.track("event") + } + private fun createCustomerIOBuilder(givenCdpApiKey: String? = null): CustomerIOBuilder = CustomerIOBuilder( applicationContext = applicationMock, cdpApiKey = givenCdpApiKey ?: String.random diff --git a/datapipelines/src/test/java/io/customer/sdk/CustomerIOConfigBuilderTest.kt b/datapipelines/src/test/java/io/customer/sdk/CustomerIOConfigBuilderTest.kt index f4fa54847..e9834f56b 100644 --- a/datapipelines/src/test/java/io/customer/sdk/CustomerIOConfigBuilderTest.kt +++ b/datapipelines/src/test/java/io/customer/sdk/CustomerIOConfigBuilderTest.kt @@ -20,11 +20,9 @@ import io.customer.sdk.data.model.Region import io.mockk.every import io.mockk.mockk import io.mockk.verifyOrder -import org.amshove.kluent.invoking import org.amshove.kluent.shouldBe import org.amshove.kluent.shouldNotBe import org.amshove.kluent.shouldNotBeEqualTo -import org.amshove.kluent.shouldThrow import org.junit.Before import org.junit.Test import org.junit.runner.RunWith @@ -377,12 +375,23 @@ class CustomerIOConfigBuilderTest : RobolectricTest() { } @Test - fun initialize_givenSecretKey_expectSdkNotInitialized() { - val config = createCustomerIOConfigBuilder("sk_us_$KEY_BODY").build() + fun initialize_givenSecretKey_expectErrorLoggedAndDisabledInstance() { + val givenSecretKey = "sk_us_$KEY_BODY" + val givenModule = mockGenericModule() + val config = createCustomerIOConfigBuilder(givenSecretKey) + .addCustomerIOModule(givenModule) + .build() CustomerIO.initialize(config) - invoking { CustomerIO.instance() } shouldThrow IllegalStateException::class + assertCalledOnce { mockLogger.coreSdkSecretKeyRejected() } + assertCalledNever { givenModule.initialize() } + val sdk = CustomerIO.instance() + sdk.moduleConfig.cdpApiKey shouldNotBeEqualTo givenSecretKey + sdk.analytics.enabled shouldBe false + // Calls are ignored instead of crashing + sdk.identify("user-id") + sdk.track("event") } @Test From 3d871efe65c0b4cfe527349afbcf115f6c5c3d49 Mon Sep 17 00:00:00 2001 From: sydneycollins-cio <149523950+sydneycollins-cio@users.noreply.github.com> Date: Mon, 19 Oct 2026 13:11:59 -0400 Subject: [PATCH 7/9] fix(MBL-2543): let a valid key set up the SDK after a refused sk_ key Co-Authored-By: Claude Opus 5.5 --- .../main/kotlin/io/customer/sdk/CustomerIO.kt | 14 ++++++++++- .../sdk/CustomerIOConfigBuilderTest.kt | 25 +++++++++++++++++++ 2 files changed, 38 insertions(+), 1 deletion(-) diff --git a/datapipelines/src/main/kotlin/io/customer/sdk/CustomerIO.kt b/datapipelines/src/main/kotlin/io/customer/sdk/CustomerIO.kt index 6e9146116..ee323cbbb 100644 --- a/datapipelines/src/main/kotlin/io/customer/sdk/CustomerIO.kt +++ b/datapipelines/src/main/kotlin/io/customer/sdk/CustomerIO.kt @@ -538,9 +538,11 @@ class CustomerIO private constructor( // Secret keys must not ship in apps, so log and set up a disabled SDK instead of crashing the app if (CioApiKey.isSecretKey(config.cdpApiKey)) { logger.coreSdkSecretKeyRejected() - initializeDisabled(androidSDKComponent, config) + // Never disable an SDK that is already set up + if (instance == null) initializeDisabled(androidSDKComponent, config) return } + releaseDisabledInstance() logger.coreSdkInitStart() @@ -602,6 +604,16 @@ class CustomerIO private constructor( .analytics.enabled = false } + /** + * Drops the disabled instance left by a refused secret key, so a valid key can still set up the SDK. + */ + @Synchronized + private fun releaseDisabledInstance() { + val disabledInstance = instance?.takeIf { it.moduleConfig.cdpApiKey == DISABLED_CDP_API_KEY } ?: return + runCatching { disabledInstance.analytics.shutdown() } + instance = null + } + /** * Creates and sets new instance of CustomerIO SDK using the provided implementation. * If the instance is already initialized, it will log an error and skip the initialization. diff --git a/datapipelines/src/test/java/io/customer/sdk/CustomerIOConfigBuilderTest.kt b/datapipelines/src/test/java/io/customer/sdk/CustomerIOConfigBuilderTest.kt index e9834f56b..13a336984 100644 --- a/datapipelines/src/test/java/io/customer/sdk/CustomerIOConfigBuilderTest.kt +++ b/datapipelines/src/test/java/io/customer/sdk/CustomerIOConfigBuilderTest.kt @@ -394,6 +394,31 @@ class CustomerIOConfigBuilderTest : RobolectricTest() { sdk.track("event") } + @Test + fun initialize_givenPublicKeyAfterSecretKey_expectSdkSetUpWithPublicKey() { + val givenPublicKey = "pk_us_$KEY_BODY" + CustomerIO.initialize(createCustomerIOConfigBuilder("sk_us_$KEY_BODY").build()) + + CustomerIO.initialize(createCustomerIOConfigBuilder(givenPublicKey).build()) + + val sdk = CustomerIO.instance() + sdk.moduleConfig.cdpApiKey shouldBe givenPublicKey + sdk.analytics.enabled shouldBe true + } + + @Test + fun initialize_givenSecretKeyAfterPublicKey_expectSdkStaysEnabled() { + val givenPublicKey = "pk_us_$KEY_BODY" + CustomerIO.initialize(createCustomerIOConfigBuilder(givenPublicKey).build()) + + CustomerIO.initialize(createCustomerIOConfigBuilder("sk_us_$KEY_BODY").build()) + + assertCalledOnce { mockLogger.coreSdkSecretKeyRejected() } + val sdk = CustomerIO.instance() + sdk.moduleConfig.cdpApiKey shouldBe givenPublicKey + sdk.analytics.enabled shouldBe true + } + @Test fun initialize_givenPublicEUKeyWithoutRegion_expectEURegionBasedHosts() { val config = createCustomerIOConfigBuilder("pk_eu_$KEY_BODY").build() From 8ad61d08663a44e5c1b3fd6871355e8a6797cf0e Mon Sep 17 00:00:00 2001 From: sydneycollins-cio <149523950+sydneycollins-cio@users.noreply.github.com> Date: Tue, 20 Oct 2026 14:20:14 -0400 Subject: [PATCH 8/9] fix(MBL-2543): register modules when a refused sk_ key disables the SDK Module accessors like ModuleLocation.instance() no longer throw after a refused key; their calls do nothing because the modules are not set up. Co-Authored-By: Claude Opus 5.5 --- .../main/kotlin/io/customer/sdk/CustomerIO.kt | 6 ++- .../sdk/CustomerIOConfigBuilderTest.kt | 28 +++++++++- .../customer/geofence/ModuleGeofenceTest.kt | 54 +++++++++++++++++++ .../ModuleMessagingInAppTest.kt | 13 +++++ 4 files changed, 99 insertions(+), 2 deletions(-) create mode 100644 geofence/src/test/java/io/customer/geofence/ModuleGeofenceTest.kt diff --git a/datapipelines/src/main/kotlin/io/customer/sdk/CustomerIO.kt b/datapipelines/src/main/kotlin/io/customer/sdk/CustomerIO.kt index ee323cbbb..774f65015 100644 --- a/datapipelines/src/main/kotlin/io/customer/sdk/CustomerIO.kt +++ b/datapipelines/src/main/kotlin/io/customer/sdk/CustomerIO.kt @@ -585,7 +585,8 @@ class CustomerIO private constructor( /** * Sets up an instance that never holds the secret key and ignores calls, so a later - * [instance] call doesn't crash somewhere unrelated. No modules are initialized. + * [instance] call doesn't crash somewhere unrelated. The configured modules are registered + * so their accessors work too, but none are initialized, so their calls do nothing. */ private fun initializeDisabled(androidSDKComponent: AndroidSDKComponent, config: CustomerIOConfig) { val disabledConfig = DataPipelinesModuleConfig( @@ -602,6 +603,7 @@ class CustomerIO private constructor( ) createInstance(androidSDKComponent = androidSDKComponent, moduleConfig = disabledConfig) .analytics.enabled = false + SDKComponent.modules.putAll(config.modules.associateBy { module -> module.moduleName }) } /** @@ -611,6 +613,8 @@ class CustomerIO private constructor( private fun releaseDisabledInstance() { val disabledInstance = instance?.takeIf { it.moduleConfig.cdpApiKey == DISABLED_CDP_API_KEY } ?: return runCatching { disabledInstance.analytics.shutdown() } + // Drop the modules registered while disabled so only the new config's modules get set up + SDKComponent.modules.clear() instance = null } diff --git a/datapipelines/src/test/java/io/customer/sdk/CustomerIOConfigBuilderTest.kt b/datapipelines/src/test/java/io/customer/sdk/CustomerIOConfigBuilderTest.kt index 13a336984..6b5a00dd6 100644 --- a/datapipelines/src/test/java/io/customer/sdk/CustomerIOConfigBuilderTest.kt +++ b/datapipelines/src/test/java/io/customer/sdk/CustomerIOConfigBuilderTest.kt @@ -377,7 +377,9 @@ class CustomerIOConfigBuilderTest : RobolectricTest() { @Test fun initialize_givenSecretKey_expectErrorLoggedAndDisabledInstance() { val givenSecretKey = "sk_us_$KEY_BODY" - val givenModule = mockGenericModule() + val givenModule = mockGenericModule().apply { + every { moduleName } returns String.random + } val config = createCustomerIOConfigBuilder(givenSecretKey) .addCustomerIOModule(givenModule) .build() @@ -386,6 +388,8 @@ class CustomerIOConfigBuilderTest : RobolectricTest() { assertCalledOnce { mockLogger.coreSdkSecretKeyRejected() } assertCalledNever { givenModule.initialize() } + // Registered so module accessors don't crash, but never set up + SDKComponent.modules[givenModule.moduleName] shouldBe givenModule val sdk = CustomerIO.instance() sdk.moduleConfig.cdpApiKey shouldNotBeEqualTo givenSecretKey sdk.analytics.enabled shouldBe false @@ -406,6 +410,28 @@ class CustomerIOConfigBuilderTest : RobolectricTest() { sdk.analytics.enabled shouldBe true } + @Test + fun initialize_givenPublicKeyAfterSecretKey_expectOnlyNewModulesInitialized() { + val givenDisabledModule = mockGenericModule().apply { + every { moduleName } returns String.random + } + val givenModule = mockGenericModule().apply { + every { moduleName } returns String.random + } + CustomerIO.initialize( + createCustomerIOConfigBuilder("sk_us_$KEY_BODY").addCustomerIOModule(givenDisabledModule).build() + ) + + CustomerIO.initialize( + createCustomerIOConfigBuilder("pk_us_$KEY_BODY").addCustomerIOModule(givenModule).build() + ) + + assertCalledNever { givenDisabledModule.initialize() } + assertCalledOnce { givenModule.initialize() } + SDKComponent.modules[givenDisabledModule.moduleName] shouldBe null + SDKComponent.modules[givenModule.moduleName] shouldBe givenModule + } + @Test fun initialize_givenSecretKeyAfterPublicKey_expectSdkStaysEnabled() { val givenPublicKey = "pk_us_$KEY_BODY" diff --git a/geofence/src/test/java/io/customer/geofence/ModuleGeofenceTest.kt b/geofence/src/test/java/io/customer/geofence/ModuleGeofenceTest.kt new file mode 100644 index 000000000..7795036c0 --- /dev/null +++ b/geofence/src/test/java/io/customer/geofence/ModuleGeofenceTest.kt @@ -0,0 +1,54 @@ +package io.customer.geofence + +import io.customer.base.internal.InternalCustomerIOApi +import io.customer.commontest.config.ApplicationArgument +import io.customer.commontest.config.TestConfig +import io.customer.commontest.config.testConfigurationDefault +import io.customer.commontest.core.RobolectricTest +import io.customer.commontest.extensions.attachToSDKComponent +import io.customer.location.ModuleLocation +import io.mockk.mockk +import io.mockk.verify +import org.amshove.kluent.shouldBe +import org.amshove.kluent.shouldBeNull +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class ModuleGeofenceTest : RobolectricTest() { + + private val mockServices = mockk(relaxed = true) + + override fun setup(testConfig: TestConfig) { + super.setup( + testConfigurationDefault { + argument(ApplicationArgument(applicationMock)) + diGraph { + android { + overrideDependency(mockServices) + } + } + } + ) + } + + @OptIn(InternalCustomerIOApi::class) + @Test + fun instance_givenRegisteredButNotInitialized_expectCallsDoNothingInsteadOfCrashing() { + // The SDK registers modules without setting them up when it refuses a secret key + val givenLocation = ModuleLocation().attachToSDKComponent() + val givenGeofence = ModuleGeofence().attachToSDKComponent() + + ModuleLocation.instance() shouldBe givenLocation + ModuleGeofence.instance() shouldBe givenGeofence + + ModuleLocation.instance().locationServices.setLastKnownLocation(37.7749, -122.4194) + ModuleLocation.instance().locationServices.requestLocationUpdate() + ModuleGeofence.instance().refreshFromCurrentLocation() + + verify(exactly = 0) { mockServices.onLocationAcquired(any(), any(), any()) } + + ModuleLocation.instance().locationServices.getLastKnownLocation().shouldBeNull() + } +} diff --git a/messaginginapp/src/test/java/io/customer/messaginginapp/ModuleMessagingInAppTest.kt b/messaginginapp/src/test/java/io/customer/messaginginapp/ModuleMessagingInAppTest.kt index e4eb73eeb..da18dfea1 100644 --- a/messaginginapp/src/test/java/io/customer/messaginginapp/ModuleMessagingInAppTest.kt +++ b/messaginginapp/src/test/java/io/customer/messaginginapp/ModuleMessagingInAppTest.kt @@ -84,6 +84,19 @@ internal class ModuleMessagingInAppTest : JUnitTest() { super.teardown() } + @Test + fun instance_givenRegisteredButNotInitialized_expectCallsDoNothingInsteadOfCrashing() = runTest { + // The SDK registers modules without setting them up when it refuses a secret key + val inbox = ModuleMessagingInApp.instance().inbox() + + assertEquals(emptyList(), inbox.getMessages()) + ModuleMessagingInApp.instance().dismissMessage() + eventBus.publish(Event.UserChangedEvent(userId = String.random, anonymousId = String.random)) + + assertCalledNever { inAppMessagesProviderMock.setUserId(any()) } + assertCalledNever { inAppMessagesProviderMock.fetchInAppMessages() } + } + @Test fun initialize_givenUserChangedWithUserId_expectGistSetsBothIdsAndFetches() { val givenUserId = String.random From 2e7028b3c94e2cc57529a3d5ea6cec5d4eefa900 Mon Sep 17 00:00:00 2001 From: sydneycollins-cio <149523950+sydneycollins-cio@users.noreply.github.com> Date: Wed, 21 Oct 2026 11:21:55 -0400 Subject: [PATCH 9/9] feat(MBL-2543): move analytics storage when switching to a public key (#926) Co-authored-by: Claude Opus 5.5 --- AGENTS.md | 3 +- .../sdk/core/pipeline/DataPipeline.kt | 6 + .../config/DataPipelinesModuleConfig.kt | 2 + .../migration/AnalyticsStorageMigration.kt | 215 +++++++++ .../main/kotlin/io/customer/sdk/CustomerIO.kt | 43 +- .../AnalyticsStorageMigrationTest.kt | 447 ++++++++++++++++++ messaginginapp/api/messaginginapp.api | 2 + .../MessagingInAppModuleConfig.kt | 47 +- .../di/DIGraphMessagingInApp.kt | 5 +- .../gist/data/NetworkUtilities.kt | 14 +- .../model/engine/EngineWebConfiguration.kt | 5 +- .../gist/data/sse/SseService.kt | 17 +- .../gist/presentation/GistSdk.kt | 8 +- .../state/InAppMessageReducer.kt | 1 + .../state/InAppMessagingAction.kt | 2 +- .../state/InAppMessagingState.kt | 2 + .../NotificationInboxAccessibilityLabels.kt | 2 +- .../controller/InAppMessageViewController.kt | 3 +- .../messaginginapp/InAppMessagingStoreTest.kt | 4 +- .../MessagingInAppModuleConfigTest.kt | 22 +- .../ModuleMessagingInAppTest.kt | 4 +- .../gist/data/NetworkUtilitiesTest.kt | 46 +- .../data/listeners/QueueInboxTriggerTest.kt | 3 +- .../gist/data/sse/SseServiceTest.kt | 58 +++ .../presentation/GistModalActivityTest.kt | 6 +- .../gist/presentation/GistSdkPublicKeyTest.kt | 115 +++++ .../inbox/NotificationInboxTest.kt | 4 +- .../ui/InlineInAppMessageIntegrationTest.kt | 8 +- .../ui/MessagingInAppIntegrationTest.kt | 8 +- .../InAppMessageViewControllerTest.kt | 46 ++ ...InlineMessageViewControllerBehaviorTest.kt | 6 +- .../java_layout/sdk/CustomerIORepository.java | 2 +- .../MemoryLeakageInstrumentedTest.kt | 4 +- .../sample/kotlin_compose/MainApplication.kt | 4 +- 34 files changed, 1095 insertions(+), 69 deletions(-) create mode 100644 datapipelines/src/main/kotlin/io/customer/datapipelines/migration/AnalyticsStorageMigration.kt create mode 100644 datapipelines/src/test/java/io/customer/datapipelines/migration/AnalyticsStorageMigrationTest.kt create mode 100644 messaginginapp/src/test/java/io/customer/messaginginapp/gist/presentation/GistSdkPublicKeyTest.kt diff --git a/AGENTS.md b/AGENTS.md index 440d0a29a..633775ca5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -114,8 +114,7 @@ CustomerIOBuilder(application, ).apply { // Optional: Enable in-app messaging by adding siteId and Region addCustomerIOModule( ModuleMessagingInApp( - // If you're in the EU, set Region.EU - MessagingInAppModuleConfig.Builder(, Region.US).build() + MessagingInAppModuleConfig.Builder().build() ) ) diff --git a/core/src/main/kotlin/io/customer/sdk/core/pipeline/DataPipeline.kt b/core/src/main/kotlin/io/customer/sdk/core/pipeline/DataPipeline.kt index 01476fb75..e8b39d5fa 100644 --- a/core/src/main/kotlin/io/customer/sdk/core/pipeline/DataPipeline.kt +++ b/core/src/main/kotlin/io/customer/sdk/core/pipeline/DataPipeline.kt @@ -1,6 +1,7 @@ package io.customer.sdk.core.pipeline import io.customer.base.internal.InternalCustomerIOApi +import io.customer.sdk.data.model.Region /** * Abstraction for sending track events to the data pipeline. @@ -18,5 +19,10 @@ interface DataPipeline { * a consumer may gate on it immediately after identify() without racing async propagation. */ val isUserIdentified: Boolean + + // The API key and region the SDK was set up with, so modules use the same ones + val cdpApiKey: String + val region: Region + fun track(name: String, properties: Map) } diff --git a/datapipelines/src/main/kotlin/io/customer/datapipelines/config/DataPipelinesModuleConfig.kt b/datapipelines/src/main/kotlin/io/customer/datapipelines/config/DataPipelinesModuleConfig.kt index 0e553cf67..26d143dc6 100644 --- a/datapipelines/src/main/kotlin/io/customer/datapipelines/config/DataPipelinesModuleConfig.kt +++ b/datapipelines/src/main/kotlin/io/customer/datapipelines/config/DataPipelinesModuleConfig.kt @@ -31,6 +31,8 @@ class DataPipelinesModuleConfig( // Determines how SDK should handle screen view events val screenViewUse: ScreenView ) : CustomerIOModuleConfig { + // Shared with other modules through DataPipeline so they use the same region + internal val region: Region = region val apiHost: String = apiHostOverride ?: region.apiHost() val cdnHost: String = cdnHostOverride ?: region.cdnHost() diff --git a/datapipelines/src/main/kotlin/io/customer/datapipelines/migration/AnalyticsStorageMigration.kt b/datapipelines/src/main/kotlin/io/customer/datapipelines/migration/AnalyticsStorageMigration.kt new file mode 100644 index 000000000..5aba6d4d6 --- /dev/null +++ b/datapipelines/src/main/kotlin/io/customer/datapipelines/migration/AnalyticsStorageMigration.kt @@ -0,0 +1,215 @@ +package io.customer.datapipelines.migration + +import android.content.Context +import android.content.SharedPreferences +import com.segment.analytics.kotlin.core.Storage +import io.customer.datapipelines.plugins.CUSTOMER_IO_DATA_PIPELINES +import io.customer.sdk.core.util.CioApiKey +import io.customer.sdk.core.util.Logger +import java.io.File +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.jsonObject + +/** + * Segment keeps analytics storage per write key: identity in the `analytics-android-` prefs and + * queued events in `-` files in the shared `segment-disk-queue` folder. When an app + * switches to a public (`pk_`) key, this keeps the user's identity and their unsent events. + * + * Finished batches need nothing: analytics uploads every finished file in the folder, and each one + * carries the key it was written with. Only unfinished batches of this SDK's earlier keys are moved, + * since analytics only ever finishes the current key's batch. + */ +internal class AnalyticsStorageMigration( + private val context: Context, + private val logger: Logger +) { + /** + * Returns the key the SDK ran with before [newKey]. Uses [savedKey] when there is one. SDK versions + * before 4.5.3 didn't save their key, so on a switch to a public key this looks through the keys + * with analytics storage instead. Keys whose saved analytics settings name the Customer.io + * destination are this SDK's, and the most recently used one is taken. Segment's own SDK, which + * an app can run too, never names ours there, so its storage is never taken. If + * none name ours, a single other key is taken, and several are left alone. + */ + fun previousKey(savedKey: String?, newKey: String): String? { + if (!savedKey.isNullOrEmpty() || !CioApiKey.isPublicKey(newKey)) return savedKey + + return runCatching { + val prefsFiles = File(context.applicationInfo.dataDir, PREFS_DIRECTORY).listFiles() + ?.filter { file -> file.name.startsWith(PREFS_PREFIX) && file.name.endsWith(PREFS_SUFFIX) } + .orEmpty() + val keysByLastUse = prefsFiles + .sortedByDescending { file -> file.lastModified() } + .map { file -> file.name.removePrefix(PREFS_PREFIX).removeSuffix(PREFS_SUFFIX) } + .filter { key -> key.isNotEmpty() && key != newKey } + val keysBySettings = keysByLastUse.groupBy { key -> savedSettingsOwner(key) } + val ownKeys = keysBySettings[SettingsOwner.CUSTOMER_IO].orEmpty() + val unknownKeys = keysBySettings[SettingsOwner.UNKNOWN].orEmpty() + when { + ownKeys.isNotEmpty() -> ownKeys.first() + unknownKeys.size == 1 -> unknownKeys.first() + else -> { + if (keysByLastUse.isNotEmpty()) { + logger.info("Found ${keysByLastUse.size} earlier analytics storages and none known to be this SDK's, so none were moved to the new public key") + } + null + } + } + }.onFailure { ex -> + logger.error("Failed to find previous analytics key: ${ex.message}") + }.getOrNull() + } + + // Analytics saves the settings it has for each key, and this SDK always lists its destination there + private fun savedSettingsOwner(key: String): SettingsOwner { + val settings = prefs(key).getString(Storage.Constants.Settings.rawVal, null) ?: return SettingsOwner.UNKNOWN + val integrations = runCatching { Json.parseToJsonElement(settings).jsonObject[INTEGRATIONS]?.jsonObject } + .getOrNull() ?: return SettingsOwner.UNKNOWN + return if (integrations.containsKey(CUSTOMER_IO_DATA_PIPELINES)) SettingsOwner.CUSTOMER_IO else SettingsOwner.OTHER + } + + private enum class SettingsOwner { CUSTOMER_IO, OTHER, UNKNOWN } + + /** + * Copies the previous key's identity, app version and build, and device id to the new key, keeping + * them for the previous key too. Analytics compares the saved build to tell "Application Installed" + * from "Application Updated", so without it every switching user would look newly installed. + * Must run before the Analytics instance for the new key is created, as it reads these then. + */ + fun copyIdentity(previousKey: String?, newKey: String) { + if (!shouldMigrate(previousKey, newKey)) return + + runCatching { + val newPrefs = prefs(newKey) + val previousValues = prefs(previousKey).all + val editor = newPrefs.edit() + // Identity is all or nothing on purpose: if the new key has any of it, all of it is kept, + // since one key's anonymousId with another key's userId would be a different user + if (IDENTITY_KEYS.none { newPrefs.contains(it) }) { + IDENTITY_KEYS.forEach { key -> editor.putValue(key, previousValues[key]) } + } + // App and device values stand alone, so each missing one is filled in + APP_KEYS.filterNot { newPrefs.contains(it) }.forEach { key -> editor.putValue(key, previousValues[key]) } + editor.commit() + }.onFailure { ex -> + logger.error("Failed to copy analytics identity to new public key: ${ex.message}") + } + } + + /** + * Adds the events from the unfinished batches of [previousKey] and other keys known to be this + * SDK's to the new key's queue as they are, keeping their messageId, timestamp and userId, then + * deletes those batches. Includes batches left by an earlier key change that didn't finish. Other + * keys' batches, like those of Segment's own SDK running in the same app, are left alone. Runs on + * [dispatcher]. + */ + fun moveUnfinishedEvents( + previousKey: String?, + newKey: String, + scope: CoroutineScope, + dispatcher: CoroutineDispatcher, + storage: Storage + ) { + if (!shouldMigrate(previousKey, newKey)) return + + scope.launch(dispatcher) { + runCatching { + val eventsDirectory = context.getDir(EVENTS_DIRECTORY, Context.MODE_PRIVATE) + // Analytics only finishes the current key's batch, so any other key's is left over + val unfinishedBatches = eventsDirectory.listFiles() + ?.filter { file -> file.name.endsWith(TMP_SUFFIX) } + ?.filter { file -> + val batchKey = file.name.removeSuffix(TMP_SUFFIX).substringBeforeLast('-') + batchKey != newKey && + (batchKey == previousKey || savedSettingsOwner(batchKey) == SettingsOwner.CUSTOMER_IO) + } + .orEmpty() + unfinishedBatches.forEach { file -> + // Unknown formats are kept rather than deleted without moving their events + val events = completeEvents(file) ?: return@forEach + events.forEach { event -> storage.write(Storage.Constants.Events, event) } + file.delete() + } + }.onFailure { ex -> + logger.error("Failed to move queued analytics events to new public key: ${ex.message}") + } + } + } + + // Only when switching to a public key, legacy key changes behave as before + private fun shouldMigrate(previousKey: String?, newKey: String): Boolean = + CioApiKey.isPublicKey(newKey) && !previousKey.isNullOrEmpty() && previousKey != newKey + + /** + * An unfinished batch is `{"batch":[` then events split by commas, with no closing yet. Returns + * each complete event as written, dropping a last event cut off part way, or null for any other format. + */ + private fun completeEvents(file: File): List? { + val contents = file.readText() + if (!contents.startsWith(BATCH_START)) return null + + val events = mutableListOf() + var depth = 0 + var inString = false + var escaped = false + var eventStart = -1 + for (index in BATCH_START.length until contents.length) { + val char = contents[index] + when { + escaped -> escaped = false + inString && char == '\\' -> escaped = true + char == '"' -> inString = !inString + inString -> Unit + char == '{' -> { + if (depth == 0) eventStart = index + depth++ + } + char == '}' -> { + depth-- + if (depth == 0) events.add(contents.substring(eventStart, index + 1)) + } + } + } + return events + } + + // Kept as the same type, since the legacy build is saved as a number + private fun SharedPreferences.Editor.putValue(key: String, value: Any?) { + when (value) { + is String -> putString(key, value) + is Int -> putInt(key, value) + is Long -> putLong(key, value) + } + } + + private fun prefs(key: String?): SharedPreferences = + context.getSharedPreferences("$PREFS_PREFIX$key", Context.MODE_PRIVATE) + + internal companion object { + // Names used by Segment's AndroidStorageProvider + const val PREFS_PREFIX = "analytics-android-" + const val EVENTS_DIRECTORY = "segment-disk-queue" + private const val PREFS_DIRECTORY = "shared_prefs" + private const val PREFS_SUFFIX = ".xml" + private const val INTEGRATIONS = "integrations" + private const val TMP_SUFFIX = ".tmp" + private const val BATCH_START = "{\"batch\":[" + + private val IDENTITY_KEYS = listOf( + Storage.Constants.AnonymousId.rawVal, + Storage.Constants.UserId.rawVal, + Storage.Constants.Traits.rawVal + ) + + // Read by analytics to tell an install from an update, and to keep the same device id + private val APP_KEYS = listOf( + Storage.Constants.AppVersion.rawVal, + Storage.Constants.AppBuild.rawVal, + Storage.Constants.LegacyAppBuild.rawVal, + Storage.Constants.DeviceId.rawVal + ) + } +} diff --git a/datapipelines/src/main/kotlin/io/customer/sdk/CustomerIO.kt b/datapipelines/src/main/kotlin/io/customer/sdk/CustomerIO.kt index 774f65015..1d14ec593 100644 --- a/datapipelines/src/main/kotlin/io/customer/sdk/CustomerIO.kt +++ b/datapipelines/src/main/kotlin/io/customer/sdk/CustomerIO.kt @@ -18,6 +18,7 @@ import io.customer.datapipelines.extensions.asMap import io.customer.datapipelines.extensions.sanitizeForJson import io.customer.datapipelines.extensions.type import io.customer.datapipelines.extensions.updateAnalyticsConfig +import io.customer.datapipelines.migration.AnalyticsStorageMigration import io.customer.datapipelines.migration.TrackingMigrationProcessor import io.customer.datapipelines.plugins.ApplicationLifecyclePlugin import io.customer.datapipelines.plugins.AutoTrackDeviceAttributesPlugin @@ -39,6 +40,7 @@ import io.customer.sdk.core.util.CioLogLevel import io.customer.sdk.core.util.Iso8601TimestampFormatter import io.customer.sdk.core.util.Logger import io.customer.sdk.data.model.CustomAttributes +import io.customer.sdk.data.model.Region import io.customer.sdk.data.model.Settings import io.customer.sdk.events.TrackMetric import io.customer.sdk.util.EventNames @@ -103,14 +105,33 @@ class CustomerIO private constructor( } } - internal val analytics: Analytics = overrideAnalytics ?: Analytics( - writeKey = moduleConfig.cdpApiKey, - context = androidSDKComponent.applicationContext, - configs = updateAnalyticsConfig( - moduleConfig = moduleConfig, - errorHandler = errorLogger + internal val analytics: Analytics = overrideAnalytics ?: run { + // Keep identity and unsent events when the app switches to a public key. + // Settings still hold the key from the last launch as they're saved in initialize(). + val newKey = moduleConfig.cdpApiKey + val storageMigration = AnalyticsStorageMigration(androidSDKComponent.applicationContext, logger) + val previousKey = storageMigration.previousKey( + savedKey = globalPreferenceStore.getSettings()?.writeKey, + newKey = newKey ) - ) + storageMigration.copyIdentity(previousKey = previousKey, newKey = newKey) + Analytics( + writeKey = newKey, + context = androidSDKComponent.applicationContext, + configs = updateAnalyticsConfig( + moduleConfig = moduleConfig, + errorHandler = errorLogger + ) + ).also { analytics -> + storageMigration.moveUnfinishedEvents( + previousKey = previousKey, + newKey = newKey, + scope = analytics.analyticsScope, + dispatcher = analytics.fileIODispatcher, + storage = analytics.storage + ) + } + } private val contextPlugin: ContextPlugin = ContextPlugin(deviceStore) @@ -396,6 +417,14 @@ class CustomerIO private constructor( override val userId: String? get() = analytics.userId() + @InternalCustomerIOApi + override val cdpApiKey: String + get() = moduleConfig.cdpApiKey + + @InternalCustomerIOApi + override val region: Region + get() = moduleConfig.region + override val isUserIdentified: Boolean // Prefer the caller-thread mirror so this is correct synchronously right after // identify()/clearIdentify(); fall back to analytics for a session restored from diff --git a/datapipelines/src/test/java/io/customer/datapipelines/migration/AnalyticsStorageMigrationTest.kt b/datapipelines/src/test/java/io/customer/datapipelines/migration/AnalyticsStorageMigrationTest.kt new file mode 100644 index 000000000..b1a74170f --- /dev/null +++ b/datapipelines/src/test/java/io/customer/datapipelines/migration/AnalyticsStorageMigrationTest.kt @@ -0,0 +1,447 @@ +package io.customer.datapipelines.migration + +import android.app.Activity +import android.content.Context +import com.segment.analytics.kotlin.android.AndroidStorageImpl +import com.segment.analytics.kotlin.android.AndroidStorageProvider +import com.segment.analytics.kotlin.android.plugins.AndroidLifecyclePlugin +import com.segment.analytics.kotlin.android.utilities.AndroidKVS +import com.segment.analytics.kotlin.core.Analytics +import com.segment.analytics.kotlin.core.Configuration +import com.segment.analytics.kotlin.core.Settings +import com.segment.analytics.kotlin.core.Storage +import com.segment.analytics.kotlin.core.TrackEvent +import com.segment.analytics.kotlin.core.utilities.FileEventStream +import io.customer.commontest.config.testConfigurationDefault +import io.customer.commontest.core.RobolectricTest +import io.customer.datapipelines.testutils.stubs.TestCoroutineConfiguration +import io.customer.datapipelines.testutils.utils.TestRunPlugin +import io.customer.sdk.core.util.Logger +import io.mockk.mockk +import io.mockk.verify +import java.io.File +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.jsonPrimitive +import kotlinx.serialization.json.put +import org.amshove.kluent.shouldBeEqualTo +import org.amshove.kluent.shouldBeFalse +import org.amshove.kluent.shouldBeNull +import org.amshove.kluent.shouldContain +import org.amshove.kluent.shouldNotContain +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import sovran.kotlin.Store + +@RunWith(RobolectricTestRunner::class) +class AnalyticsStorageMigrationTest : RobolectricTest() { + + private lateinit var migration: AnalyticsStorageMigration + private val mockLogger = mockk(relaxed = true) + + @Before + fun setUp() { + super.setup(testConfigurationDefault {}) + migration = AnalyticsStorageMigration(applicationMock, mockLogger) + } + + override fun teardown() { + listOf(LEGACY_KEY, PUBLIC_KEY, OTHER_LEGACY_KEY, SEGMENT_KEY).forEach { key -> + applicationMock.getSharedPreferences("analytics-android-$key", Context.MODE_PRIVATE).edit().clear().commit() + prefsFile(key).delete() + } + eventsDirectory().listFiles()?.forEach { it.deleteRecursively() } + + super.teardown() + } + + private fun eventsDirectory() = applicationMock.getDir("segment-disk-queue", Context.MODE_PRIVATE) + + // Uses Segment's own storage so the test reads and writes the same names the SDK does at runtime + private fun segmentStorage(writeKey: String): Storage = AndroidStorageImpl( + propertiesFile = AndroidKVS(applicationMock.getSharedPreferences("analytics-android-$writeKey", Context.MODE_PRIVATE)), + eventStream = FileEventStream(eventsDirectory()), + store = Store(), + writeKey = writeKey, + fileIndexKey = "segment.events.file.index.$writeKey", + ioDispatcher = Dispatchers.Unconfined + ) + + private fun givenIdentityForKey(writeKey: String) = runBlocking { + segmentStorage(writeKey).apply { + write(Storage.Constants.AnonymousId, ANONYMOUS_ID) + write(Storage.Constants.UserId, USER_ID) + write(Storage.Constants.Traits, TRAITS) + } + } + + private fun eventsFile(name: String) = File(eventsDirectory(), name) + + private fun prefsFile(writeKey: String) = + File(applicationMock.applicationInfo.dataDir, "shared_prefs/analytics-android-$writeKey.xml") + + // Storage as analytics leaves it for a key, with the settings it saved naming these destinations + private fun givenStorageForKey(writeKey: String, destinations: List?, lastUsed: Long) { + val editor = applicationMock.getSharedPreferences("analytics-android-$writeKey", Context.MODE_PRIVATE).edit() + .putString(Storage.Constants.AnonymousId.rawVal, "anonymous-$writeKey") + destinations?.let { + val settings = Settings( + integrations = buildJsonObject { + destinations.forEach { destination -> + if (destination == SEGMENT_DESTINATION) { + put(destination, buildJsonObject { put("apiKey", writeKey) }) + } else { + // Earlier SDK versions add their destination to the settings as `true` + put(destination, JsonPrimitive(true)) + } + } + } + ) + editor.putString(Storage.Constants.Settings.rawVal, Json.encodeToString(Settings.serializer(), settings)) + } + editor.commit() + prefsFile(writeKey).setLastModified(lastUsed) + } + + private fun moveUnfinishedEvents(previousKey: String, newKey: String, newStorage: Storage) = runBlocking { + migration.moveUnfinishedEvents( + previousKey = previousKey, + newKey = newKey, + scope = this, + dispatcher = Dispatchers.Unconfined, + storage = newStorage + ) + } + + // Finishes the new key's batch and returns what it will upload + private fun uploadedEvents(storage: Storage): String = runBlocking { + storage.rollover() + storage.read(Storage.Constants.Events).orEmpty().split(", ").filter { it.isNotEmpty() } + .joinToString { storage.readAsStream(it)!!.bufferedReader().readText() } + } + + // Starts analytics for the key with Segment's own lifecycle plugin and returns the events it tracks on launch + @OptIn(ExperimentalCoroutinesApi::class) + private fun lifecycleEventsOnLaunch(writeKey: String): List { + val configuration = Configuration(writeKey = writeKey, application = applicationMock).apply { + storageProvider = AndroidStorageProvider + trackApplicationLifecycleEvents = true + autoAddSegmentDestination = false + } + val analytics = object : Analytics(configuration, TestCoroutineConfiguration(UnconfinedTestDispatcher())) {} + val lifecyclePlugin = AndroidLifecyclePlugin() + analytics.add(lifecyclePlugin) + val tracks = mutableListOf() + analytics.add(TestRunPlugin { event -> (event as? TrackEvent)?.let(tracks::add) }) + + lifecyclePlugin.onActivityCreated(mockk(), null) + + return tracks + } + + @Test + fun previousKey_givenSavedKey_expectSavedKeyUsed() { + givenStorageForKey(OTHER_LEGACY_KEY, destinations = listOf(CUSTOMER_IO_DESTINATION), lastUsed = 2_000_000L) + + migration.previousKey(savedKey = LEGACY_KEY, newKey = PUBLIC_KEY) shouldBeEqualTo LEGACY_KEY + } + + @Test + fun previousKey_givenSettingsNamingCustomerIODestination_expectThatKey() { + // Before fetching, analytics also lists Segment's destination in this SDK's settings + givenStorageForKey(LEGACY_KEY, destinations = listOf(SEGMENT_DESTINATION, CUSTOMER_IO_DESTINATION), lastUsed = 1_000_000L) + givenStorageForKey(OTHER_LEGACY_KEY, destinations = null, lastUsed = 2_000_000L) + + val previousKey = migration.previousKey(savedKey = null, newKey = PUBLIC_KEY) + migration.copyIdentity(previousKey = previousKey, newKey = PUBLIC_KEY) + + previousKey shouldBeEqualTo LEGACY_KEY + segmentStorage(PUBLIC_KEY).read(Storage.Constants.AnonymousId) shouldBeEqualTo "anonymous-$LEGACY_KEY" + } + + @Test + fun previousKey_givenSeveralKeysNamingCustomerIODestination_expectMostRecentlyUsed() { + givenStorageForKey(LEGACY_KEY, destinations = listOf(CUSTOMER_IO_DESTINATION), lastUsed = 1_000_000L) + givenStorageForKey(OTHER_LEGACY_KEY, destinations = listOf(CUSTOMER_IO_DESTINATION), lastUsed = 3_000_000L) + + migration.previousKey(savedKey = null, newKey = PUBLIC_KEY) shouldBeEqualTo OTHER_LEGACY_KEY + } + + @Test + fun previousKey_givenNoSettingsAndOneOtherKey_expectThatKey() { + givenStorageForKey(LEGACY_KEY, destinations = null, lastUsed = 1_000_000L) + + migration.previousKey(savedKey = null, newKey = PUBLIC_KEY) shouldBeEqualTo LEGACY_KEY + } + + @Test + fun previousKey_givenSeveralUnknownKeys_expectNoneTakenAndLogged() { + givenStorageForKey(LEGACY_KEY, destinations = null, lastUsed = 1_000_000L) + givenStorageForKey(OTHER_LEGACY_KEY, destinations = null, lastUsed = 2_000_000L) + + migration.previousKey(savedKey = null, newKey = PUBLIC_KEY).shouldBeNull() + verify { mockLogger.info(match { it.contains("none were moved") }, any()) } + } + + // Apps can also run Segment's own SDK with the same storage names + @Test + fun previousKey_givenSegmentOwnedStorage_expectItIgnored() { + givenStorageForKey(SEGMENT_KEY, destinations = listOf(SEGMENT_DESTINATION), lastUsed = 3_000_000L) + + migration.previousKey(savedKey = null, newKey = PUBLIC_KEY).shouldBeNull() + + givenStorageForKey(LEGACY_KEY, destinations = listOf(CUSTOMER_IO_DESTINATION), lastUsed = 1_000_000L) + + migration.previousKey(savedKey = null, newKey = PUBLIC_KEY) shouldBeEqualTo LEGACY_KEY + } + + @Test + fun previousKey_givenSwitchToLegacyKey_expectNoneFound() { + givenStorageForKey(LEGACY_KEY, destinations = listOf(CUSTOMER_IO_DESTINATION), lastUsed = 1_000_000L) + + migration.previousKey(savedKey = null, newKey = OTHER_LEGACY_KEY).shouldBeNull() + } + + @Test + fun copyIdentity_givenSwitchToPublicKey_expectIdentityCopiedAndPreviousKept() { + givenIdentityForKey(LEGACY_KEY) + + migration.copyIdentity(previousKey = LEGACY_KEY, newKey = PUBLIC_KEY) + + listOf(PUBLIC_KEY, LEGACY_KEY).forEach { key -> + val storage = segmentStorage(key) + storage.read(Storage.Constants.AnonymousId) shouldBeEqualTo ANONYMOUS_ID + storage.read(Storage.Constants.UserId) shouldBeEqualTo USER_ID + storage.read(Storage.Constants.Traits) shouldBeEqualTo TRAITS + } + } + + @Test + fun copyIdentity_givenPublicKeyAlreadyHasIdentity_expectExistingIdentityKept() = runBlocking { + givenIdentityForKey(LEGACY_KEY) + segmentStorage(PUBLIC_KEY).write(Storage.Constants.AnonymousId, "existing-anonymous-id") + + migration.copyIdentity(previousKey = LEGACY_KEY, newKey = PUBLIC_KEY) + + segmentStorage(PUBLIC_KEY).read(Storage.Constants.AnonymousId) shouldBeEqualTo "existing-anonymous-id" + segmentStorage(PUBLIC_KEY).read(Storage.Constants.UserId).shouldBeNull() + } + + @Test + fun copyIdentity_givenSwitchToPublicKey_expectAppBuildAndDeviceIdCopied() = runBlocking { + segmentStorage(LEGACY_KEY).apply { + write(Storage.Constants.AppVersion, "0.9.0") + write(Storage.Constants.AppBuild, "90") + write(Storage.Constants.DeviceId, "device-id") + } + // Saved as a number by Segment's older SDK + applicationMock.getSharedPreferences("analytics-android-$LEGACY_KEY", Context.MODE_PRIVATE).edit() + .putInt(Storage.Constants.LegacyAppBuild.rawVal, 80).commit() + + migration.copyIdentity(previousKey = LEGACY_KEY, newKey = PUBLIC_KEY) + + val newStorage = segmentStorage(PUBLIC_KEY) + newStorage.read(Storage.Constants.AppVersion) shouldBeEqualTo "0.9.0" + newStorage.read(Storage.Constants.AppBuild) shouldBeEqualTo "90" + newStorage.read(Storage.Constants.LegacyAppBuild) shouldBeEqualTo "80" + newStorage.read(Storage.Constants.DeviceId) shouldBeEqualTo "device-id" + } + + @Test + fun copyIdentity_givenPublicKeyHasOnlyUserId_expectNoIdentityMixedIn() = runBlocking { + givenIdentityForKey(LEGACY_KEY) + segmentStorage(PUBLIC_KEY).write(Storage.Constants.UserId, "existing-user-id") + + migration.copyIdentity(previousKey = LEGACY_KEY, newKey = PUBLIC_KEY) + + val newStorage = segmentStorage(PUBLIC_KEY) + newStorage.read(Storage.Constants.UserId) shouldBeEqualTo "existing-user-id" + newStorage.read(Storage.Constants.AnonymousId).shouldBeNull() + newStorage.read(Storage.Constants.Traits).shouldBeNull() + } + + @Test + fun copyIdentity_givenPublicKeyHasSomeAppValues_expectOnlyMissingOnesFilledIn() = runBlocking { + givenIdentityForKey(LEGACY_KEY) + segmentStorage(LEGACY_KEY).apply { + write(Storage.Constants.AppVersion, "0.9.0") + write(Storage.Constants.AppBuild, "90") + write(Storage.Constants.DeviceId, "device-id") + } + // Left by an earlier run that stopped part way + segmentStorage(PUBLIC_KEY).apply { + write(Storage.Constants.AnonymousId, "existing-anonymous-id") + write(Storage.Constants.DeviceId, "existing-device-id") + } + + migration.copyIdentity(previousKey = LEGACY_KEY, newKey = PUBLIC_KEY) + + val newStorage = segmentStorage(PUBLIC_KEY) + newStorage.read(Storage.Constants.AnonymousId) shouldBeEqualTo "existing-anonymous-id" + newStorage.read(Storage.Constants.UserId).shouldBeNull() + newStorage.read(Storage.Constants.DeviceId) shouldBeEqualTo "existing-device-id" + newStorage.read(Storage.Constants.AppVersion) shouldBeEqualTo "0.9.0" + newStorage.read(Storage.Constants.AppBuild) shouldBeEqualTo "90" + lifecycleEventsOnLaunch(PUBLIC_KEY).map { it.event } shouldBeEqualTo listOf("Application Updated") + } + + @Test + fun copyIdentity_givenSwitchToPublicKeyInUpdatedApp_expectApplicationUpdatedNotInstalled() = runBlocking { + givenIdentityForKey(LEGACY_KEY) + // The app was on an older build when it last ran with the previous key + segmentStorage(LEGACY_KEY).apply { + write(Storage.Constants.AppVersion, "0.9.0") + write(Storage.Constants.AppBuild, "90") + } + + migration.copyIdentity(previousKey = LEGACY_KEY, newKey = PUBLIC_KEY) + val tracks = lifecycleEventsOnLaunch(PUBLIC_KEY) + + tracks.map { it.event } shouldBeEqualTo listOf("Application Updated") + tracks.first().properties["previous_build"]?.jsonPrimitive?.content shouldBeEqualTo "90" + tracks.first().anonymousId shouldBeEqualTo ANONYMOUS_ID + } + + @Test + fun copyIdentity_givenNoCopy_expectApplicationInstalled() { + // Shows the launch check above would report a fresh install without the copy + lifecycleEventsOnLaunch(PUBLIC_KEY).map { it.event } shouldBeEqualTo listOf("Application Installed") + } + + @Test + fun moveUnfinishedEvents_givenFinishedBatches_expectFilesUntouched() { + eventsFile("$LEGACY_KEY-0").writeText(FINISHED_BATCH) + + moveUnfinishedEvents(previousKey = LEGACY_KEY, newKey = PUBLIC_KEY, newStorage = segmentStorage(PUBLIC_KEY)) + + eventsDirectory().list()!!.toList() shouldBeEqualTo listOf("$LEGACY_KEY-0") + eventsFile("$LEGACY_KEY-0").readText() shouldBeEqualTo FINISHED_BATCH + } + + @Test + fun moveUnfinishedEvents_givenUnfinishedBatch_expectEventsAddedAsWrittenAndBatchDeleted() { + eventsFile("$LEGACY_KEY-1.tmp").writeText("""{"batch":[$FIRST_EVENT,$SECOND_EVENT""") + val newStorage = segmentStorage(PUBLIC_KEY) + + moveUnfinishedEvents(previousKey = LEGACY_KEY, newKey = PUBLIC_KEY, newStorage = newStorage) + + eventsFile("$LEGACY_KEY-1.tmp").exists().shouldBeFalse() + uploadedEvents(newStorage).let { uploaded -> + // Original messageId, timestamp and userId are kept + uploaded shouldContain FIRST_EVENT + uploaded shouldContain SECOND_EVENT + uploaded shouldContain "\"writeKey\":\"$PUBLIC_KEY\"" + } + } + + @Test + fun moveUnfinishedEvents_givenCutOffLastEvent_expectItDropped() { + eventsFile("$LEGACY_KEY-1.tmp").writeText("""{"batch":[$FIRST_EVENT,{"messageId":"cut-off","properties":{"no""") + val newStorage = segmentStorage(PUBLIC_KEY) + + moveUnfinishedEvents(previousKey = LEGACY_KEY, newKey = PUBLIC_KEY, newStorage = newStorage) + + eventsFile("$LEGACY_KEY-1.tmp").exists().shouldBeFalse() + uploadedEvents(newStorage).let { uploaded -> + uploaded shouldContain FIRST_EVENT + uploaded shouldNotContain "cut-off" + } + } + + @Test + fun moveUnfinishedEvents_givenBatchLeftByEarlierKeyChange_expectItMovedToo() { + givenStorageForKey(OTHER_LEGACY_KEY, destinations = listOf(CUSTOMER_IO_DESTINATION), lastUsed = 1_000_000L) + eventsFile("$OTHER_LEGACY_KEY-4.tmp").writeText("""{"batch":[$SECOND_EVENT""") + eventsFile("$LEGACY_KEY-1.tmp").writeText("""{"batch":[$FIRST_EVENT""") + val newStorage = segmentStorage(PUBLIC_KEY) + + moveUnfinishedEvents(previousKey = LEGACY_KEY, newKey = PUBLIC_KEY, newStorage = newStorage) + + eventsFile("$OTHER_LEGACY_KEY-4.tmp").exists().shouldBeFalse() + eventsFile("$LEGACY_KEY-1.tmp").exists().shouldBeFalse() + uploadedEvents(newStorage).let { uploaded -> + uploaded shouldContain FIRST_EVENT + uploaded shouldContain SECOND_EVENT + } + } + + // Apps can also run Segment's own SDK, which keeps its open batch in the same folder + @Test + fun moveUnfinishedEvents_givenOtherSdksUnfinishedBatch_expectItUntouched() { + givenStorageForKey(SEGMENT_KEY, destinations = listOf(SEGMENT_DESTINATION), lastUsed = 3_000_000L) + givenStorageForKey(OTHER_LEGACY_KEY, destinations = null, lastUsed = 2_000_000L) + eventsFile("$SEGMENT_KEY-2.tmp").writeText("""{"batch":[$SECOND_EVENT""") + eventsFile("$OTHER_LEGACY_KEY-3.tmp").writeText("""{"batch":[$SECOND_EVENT""") + eventsFile("$LEGACY_KEY-1.tmp").writeText("""{"batch":[$FIRST_EVENT""") + val newStorage = segmentStorage(PUBLIC_KEY) + + moveUnfinishedEvents(previousKey = LEGACY_KEY, newKey = PUBLIC_KEY, newStorage = newStorage) + + eventsFile("$SEGMENT_KEY-2.tmp").readText() shouldBeEqualTo """{"batch":[$SECOND_EVENT""" + eventsFile("$OTHER_LEGACY_KEY-3.tmp").readText() shouldBeEqualTo """{"batch":[$SECOND_EVENT""" + eventsFile("$LEGACY_KEY-1.tmp").exists().shouldBeFalse() + uploadedEvents(newStorage).let { uploaded -> + uploaded shouldContain FIRST_EVENT + uploaded shouldNotContain SECOND_EVENT + } + } + + @Test + fun moveUnfinishedEvents_givenUnknownBatchFormat_expectFileKept() { + eventsFile("$LEGACY_KEY-1.tmp").writeText("not a batch") + + moveUnfinishedEvents(previousKey = LEGACY_KEY, newKey = PUBLIC_KEY, newStorage = segmentStorage(PUBLIC_KEY)) + + eventsFile("$LEGACY_KEY-1.tmp").readText() shouldBeEqualTo "not a batch" + } + + @Test + fun migrate_givenSameKey_expectNothingChanged() { + givenIdentityForKey(LEGACY_KEY) + eventsFile("$PUBLIC_KEY-1.tmp").writeText("""{"batch":[$FIRST_EVENT""") + + migration.copyIdentity(previousKey = PUBLIC_KEY, newKey = PUBLIC_KEY) + moveUnfinishedEvents(previousKey = PUBLIC_KEY, newKey = PUBLIC_KEY, newStorage = segmentStorage(PUBLIC_KEY)) + + segmentStorage(PUBLIC_KEY).read(Storage.Constants.AnonymousId).shouldBeNull() + eventsFile("$PUBLIC_KEY-1.tmp").readText() shouldBeEqualTo """{"batch":[$FIRST_EVENT""" + } + + @Test + fun migrate_givenSwitchBetweenLegacyKeys_expectNothingChanged() { + givenIdentityForKey(LEGACY_KEY) + eventsFile("$LEGACY_KEY-1.tmp").writeText("""{"batch":[$FIRST_EVENT""") + + migration.copyIdentity(previousKey = LEGACY_KEY, newKey = OTHER_LEGACY_KEY) + moveUnfinishedEvents(previousKey = LEGACY_KEY, newKey = OTHER_LEGACY_KEY, newStorage = segmentStorage(OTHER_LEGACY_KEY)) + + segmentStorage(OTHER_LEGACY_KEY).read(Storage.Constants.AnonymousId).shouldBeNull() + eventsFile("$LEGACY_KEY-1.tmp").exists() shouldBeEqualTo true + } + + companion object { + private const val LEGACY_KEY = "4f3b2a1c0d9e8f7a6b5c" + private const val OTHER_LEGACY_KEY = "9a8b7c6d5e4f3a2b1c0d" + private const val SEGMENT_KEY = "SeGmEnTwRiTeKeY0123456789" + private const val SEGMENT_DESTINATION = "Segment.io" + private const val CUSTOMER_IO_DESTINATION = "Customer.io Data Pipelines" + private const val PUBLIC_KEY = "pk_us_0123456789abcdefABCDEF0123456789_a1B2c3" + private const val ANONYMOUS_ID = "anonymous-id" + private const val USER_ID = "user-id" + private const val TRAITS = """{"email":"user@example.com"}""" + private const val FINISHED_BATCH = """{"batch":[{"event":"done"}],"sentAt":"2026-10-19T00:00:00Z","writeKey":"$LEGACY_KEY"}""" + + // Braces and quotes inside strings must not split events + private const val FIRST_EVENT = + """{"messageId":"m-1","timestamp":"2026-10-19T00:00:01Z","userId":"u-1","type":"track","event":"first","properties":{"note":"a } \" {"}}""" + private const val SECOND_EVENT = + """{"messageId":"m-2","timestamp":"2026-10-19T00:00:02Z","userId":"u-1","type":"track","event":"second"}""" + } +} diff --git a/messaginginapp/api/messaginginapp.api b/messaginginapp/api/messaginginapp.api index 0b4600eee..ea9cd46c8 100644 --- a/messaginginapp/api/messaginginapp.api +++ b/messaginginapp/api/messaginginapp.api @@ -9,6 +9,8 @@ public final class io/customer/messaginginapp/MessagingInAppModuleConfig : io/cu } public final class io/customer/messaginginapp/MessagingInAppModuleConfig$Builder : io/customer/sdk/core/module/CustomerIOModuleConfig$Builder { + public fun ()V + public fun (Ljava/lang/String;)V public fun (Ljava/lang/String;Lio/customer/sdk/data/model/Region;)V public fun build ()Lio/customer/messaginginapp/MessagingInAppModuleConfig; public synthetic fun build ()Lio/customer/sdk/core/module/CustomerIOModuleConfig; diff --git a/messaginginapp/src/main/java/io/customer/messaginginapp/MessagingInAppModuleConfig.kt b/messaginginapp/src/main/java/io/customer/messaginginapp/MessagingInAppModuleConfig.kt index 7fe95c1c2..673c6c189 100644 --- a/messaginginapp/src/main/java/io/customer/messaginginapp/MessagingInAppModuleConfig.kt +++ b/messaginginapp/src/main/java/io/customer/messaginginapp/MessagingInAppModuleConfig.kt @@ -4,7 +4,10 @@ import io.customer.messaginginapp.type.ColorScheme import io.customer.messaginginapp.type.InAppEventListener import io.customer.messaginginapp.type.InboxEventListener import io.customer.messaginginapp.type.NotificationInboxAccessibilityLabels +import io.customer.sdk.core.di.SDKComponent import io.customer.sdk.core.module.CustomerIOModuleConfig +import io.customer.sdk.core.pipeline.DataPipeline +import io.customer.sdk.core.util.CioApiKey import io.customer.sdk.data.model.Region /** @@ -12,7 +15,11 @@ import io.customer.sdk.data.model.Region * experience based on the provided configurations */ class MessagingInAppModuleConfig private constructor( + /** + * Empty when not set, which is fine when the SDK is set up with a public (`pk_`) key. + */ val siteId: String, + @Deprecated("Ignored, in-app uses the region the SDK is set up with") val region: Region, val eventListener: InAppEventListener?, val colorScheme: ColorScheme, @@ -23,10 +30,40 @@ class MessagingInAppModuleConfig private constructor( */ val notificationInboxAccessibilityLabels: NotificationInboxAccessibilityLabels ) : CustomerIOModuleConfig { - class Builder( - private val siteId: String, - private val region: Region - ) : CustomerIOModuleConfig.Builder { + // Read from the SDK as it was set up in CustomerIO init, so they always match it + private val dataPipeline: DataPipeline? + get() = SDKComponent.getOrNull() + + /** + * Public (`pk_`) key the SDK is set up with, which in-app uses instead of the site ID, or null. + */ + internal val publicKey: String? + get() = dataPipeline?.cdpApiKey?.takeIf { CioApiKey.isPublicKey(it) } + + internal val dataCenter: String + get() = (dataPipeline?.region ?: Region.US).code + + /** + * Use without a site ID when the SDK is set up with a public (`pk_`) key. + */ + class Builder() : CustomerIOModuleConfig.Builder { + private var siteId: String? = null + + // Only kept for the deprecated [MessagingInAppModuleConfig.region] + private var region: Region = Region.US + + constructor(siteId: String) : this() { + this.siteId = siteId + } + + @Deprecated( + "Ignored, in-app uses the region the SDK is set up with", + ReplaceWith("Builder(siteId)") + ) + constructor(siteId: String, region: Region) : this(siteId) { + this.region = region + } + private var eventListener: InAppEventListener? = null private var colorScheme: ColorScheme = ColorScheme.AUTO private var inboxEventListener: InboxEventListener? = null @@ -65,7 +102,7 @@ class MessagingInAppModuleConfig private constructor( override fun build(): MessagingInAppModuleConfig { return MessagingInAppModuleConfig( - siteId = siteId, + siteId = siteId.orEmpty(), region = region, eventListener = eventListener, colorScheme = colorScheme, diff --git a/messaginginapp/src/main/java/io/customer/messaginginapp/di/DIGraphMessagingInApp.kt b/messaginginapp/src/main/java/io/customer/messaginginapp/di/DIGraphMessagingInApp.kt index 8f4a64222..7a98b529b 100644 --- a/messaginginapp/src/main/java/io/customer/messaginginapp/di/DIGraphMessagingInApp.kt +++ b/messaginginapp/src/main/java/io/customer/messaginginapp/di/DIGraphMessagingInApp.kt @@ -41,8 +41,9 @@ internal val SDKComponent.gistProvider: GistProvider get() = singleton { GistSdk( siteId = inAppModuleConfig.siteId, - dataCenter = inAppModuleConfig.region.code, - colorScheme = inAppModuleConfig.colorScheme + dataCenter = inAppModuleConfig.dataCenter, + colorScheme = inAppModuleConfig.colorScheme, + publicKey = inAppModuleConfig.publicKey ) } diff --git a/messaginginapp/src/main/java/io/customer/messaginginapp/gist/data/NetworkUtilities.kt b/messaginginapp/src/main/java/io/customer/messaginginapp/gist/data/NetworkUtilities.kt index 843ffa638..02b97f903 100644 --- a/messaginginapp/src/main/java/io/customer/messaginginapp/gist/data/NetworkUtilities.kt +++ b/messaginginapp/src/main/java/io/customer/messaginginapp/gist/data/NetworkUtilities.kt @@ -8,6 +8,7 @@ import okhttp3.Request class NetworkUtilities { companion object { internal const val CIO_SITE_ID_HEADER = "X-CIO-Site-Id" + internal const val AUTHORIZATION_HEADER = "Authorization" internal const val USER_TOKEN_HEADER = "X-Gist-Encoded-User-Token" internal const val CIO_DATACENTER_HEADER = "X-CIO-Datacenter" internal const val CIO_CLIENT_PLATFORM = "X-CIO-Client-Platform" @@ -24,6 +25,7 @@ class NetworkUtilities { // SSE query parameters internal const val SSE_SESSION_ID_PARAM = "sessionId" internal const val SSE_SITE_ID_PARAM = "siteId" + internal const val SSE_KEY_PARAM = "key" internal const val SSE_USER_TOKEN_PARAM = "userToken" // SSE configuration @@ -39,14 +41,22 @@ class NetworkUtilities { * @param builder Request.Builder to add headers to * @param state Current in-app messaging state * @param includeUserToken Whether to include user token header (default: true) + * @param includeAuthorization Whether to include the public key Authorization header (default: true) * @return Request.Builder with common headers applied */ internal fun addCommonHeaders( builder: Request.Builder, state: InAppMessagingState, - includeUserToken: Boolean = true + includeUserToken: Boolean = true, + includeAuthorization: Boolean = true ): Request.Builder { - builder.addHeader(CIO_SITE_ID_HEADER, state.siteId) + // The public key identifies the workspace when set, so siteId is only sent without one + if (state.publicKey == null && state.siteId.isNotBlank()) { + builder.addHeader(CIO_SITE_ID_HEADER, state.siteId) + } + if (includeAuthorization) { + state.publicKey?.let { key -> builder.addHeader(AUTHORIZATION_HEADER, "Bearer $key") } + } builder.addHeader(CIO_DATACENTER_HEADER, state.dataCenter) val androidSDKComponent = SDKComponent.android() diff --git a/messaginginapp/src/main/java/io/customer/messaginginapp/gist/data/model/engine/EngineWebConfiguration.kt b/messaginginapp/src/main/java/io/customer/messaginginapp/gist/data/model/engine/EngineWebConfiguration.kt index 1fd819d92..71309e48b 100644 --- a/messaginginapp/src/main/java/io/customer/messaginginapp/gist/data/model/engine/EngineWebConfiguration.kt +++ b/messaginginapp/src/main/java/io/customer/messaginginapp/gist/data/model/engine/EngineWebConfiguration.kt @@ -1,6 +1,7 @@ package io.customer.messaginginapp.gist.data.model.engine internal data class EngineWebConfiguration( + // Empty when no site ID is configured, which is fine when a public key is set val siteId: String, val dataCenter: String, val messageId: String, @@ -9,5 +10,7 @@ internal data class EngineWebConfiguration( val livePreview: Boolean = false, val properties: Map? = null, val customAttributes: Map? = null, - val colorScheme: String? = null + val colorScheme: String? = null, + // Public (pk_) API key, the renderer sends it as `?key=` alongside siteId when set + val key: String? = null ) diff --git a/messaginginapp/src/main/java/io/customer/messaginginapp/gist/data/sse/SseService.kt b/messaginginapp/src/main/java/io/customer/messaginginapp/gist/data/sse/SseService.kt index 21b750354..1214956a7 100644 --- a/messaginginapp/src/main/java/io/customer/messaginginapp/gist/data/sse/SseService.kt +++ b/messaginginapp/src/main/java/io/customer/messaginginapp/gist/data/sse/SseService.kt @@ -160,7 +160,7 @@ internal class SseService( val originalRequest = chain.request() val networkRequest = originalRequest.newBuilder() val currentState = inAppMessagingManager.getCurrentState() - NetworkUtilities.addCommonHeaders(networkRequest, currentState, includeUserToken = false) // SSE uses userToken in URL, not header + NetworkUtilities.addCommonHeaders(networkRequest, currentState, includeUserToken = false, includeAuthorization = false) // SSE uses userToken and key in URL, not header networkRequest.addHeader(NetworkUtilities.SSE_ACCEPT_HEADER, NetworkUtilities.SSE_ACCEPT_VALUE) networkRequest.addHeader(NetworkUtilities.SSE_CACHE_CONTROL_HEADER, NetworkUtilities.SSE_CACHE_CONTROL_VALUE) val finalRequest = networkRequest.build() @@ -174,15 +174,24 @@ internal class SseService( siteId: String ): Request { val encodedUserToken = Base64.encodeToString(userToken.toByteArray(), Base64.NO_WRAP) - val environment = inAppMessagingManager.getCurrentState().environment + val currentState = inAppMessagingManager.getCurrentState() + val environment = currentState.environment - val url = + val urlBuilder = environment .getSseApiUrl() .toHttpUrl() .newBuilder() .addQueryParameter(NetworkUtilities.SSE_SESSION_ID_PARAM, sessionId) - .addQueryParameter(NetworkUtilities.SSE_SITE_ID_PARAM, siteId) + // The public key identifies the workspace when set, so siteId is only sent without one + val publicKey = currentState.publicKey + if (publicKey != null) { + urlBuilder.addQueryParameter(NetworkUtilities.SSE_KEY_PARAM, publicKey) + } else if (siteId.isNotBlank()) { + urlBuilder.addQueryParameter(NetworkUtilities.SSE_SITE_ID_PARAM, siteId) + } + val url = + urlBuilder .addQueryParameter(NetworkUtilities.SSE_USER_TOKEN_PARAM, encodedUserToken) .build() diff --git a/messaginginapp/src/main/java/io/customer/messaginginapp/gist/presentation/GistSdk.kt b/messaginginapp/src/main/java/io/customer/messaginginapp/gist/presentation/GistSdk.kt index eaf45d688..cf85d9d1e 100644 --- a/messaginginapp/src/main/java/io/customer/messaginginapp/gist/presentation/GistSdk.kt +++ b/messaginginapp/src/main/java/io/customer/messaginginapp/gist/presentation/GistSdk.kt @@ -27,7 +27,8 @@ internal class GistSdk( siteId: String, dataCenter: String, environment: GistEnvironment = GistEnvironment.PROD, - colorScheme: ColorScheme = ColorScheme.AUTO + colorScheme: ColorScheme = ColorScheme.AUTO, + publicKey: String? = null ) : GistProvider { private val inAppMessagingManager = SDKComponent.inAppMessagingManager private val state: InAppMessagingState @@ -42,7 +43,10 @@ internal class GistSdk( private val sseLifecycleManager = SDKComponent.sseLifecycleManager init { - inAppMessagingManager.dispatch(InAppMessagingAction.Initialize(siteId = siteId, dataCenter = dataCenter, environment = environment, colorScheme = colorScheme)) + if (siteId.isBlank() && publicKey == null) { + logger.error("In-app messaging needs a siteId, or the SDK set up with a public (pk_) key") + } + inAppMessagingManager.dispatch(InAppMessagingAction.Initialize(siteId = siteId, dataCenter = dataCenter, environment = environment, colorScheme = colorScheme, publicKey = publicKey)) } override fun reset() { diff --git a/messaginginapp/src/main/java/io/customer/messaginginapp/state/InAppMessageReducer.kt b/messaginginapp/src/main/java/io/customer/messaginginapp/state/InAppMessageReducer.kt index c00ad72d9..0b04504a7 100644 --- a/messaginginapp/src/main/java/io/customer/messaginginapp/state/InAppMessageReducer.kt +++ b/messaginginapp/src/main/java/io/customer/messaginginapp/state/InAppMessageReducer.kt @@ -9,6 +9,7 @@ internal val inAppMessagingReducer: Reducer = { state, acti is InAppMessagingAction.Initialize -> state.copy( siteId = action.siteId, + publicKey = action.publicKey, dataCenter = action.dataCenter, environment = action.environment, colorScheme = action.colorScheme, diff --git a/messaginginapp/src/main/java/io/customer/messaginginapp/state/InAppMessagingAction.kt b/messaginginapp/src/main/java/io/customer/messaginginapp/state/InAppMessagingAction.kt index bb0348ed1..ee1754c21 100644 --- a/messaginginapp/src/main/java/io/customer/messaginginapp/state/InAppMessagingAction.kt +++ b/messaginginapp/src/main/java/io/customer/messaginginapp/state/InAppMessagingAction.kt @@ -8,7 +8,7 @@ import io.customer.messaginginapp.type.ColorScheme import io.customer.messaginginapp.type.InAppMessageError internal sealed class InAppMessagingAction { - data class Initialize(val siteId: String, val dataCenter: String, val environment: GistEnvironment, val colorScheme: ColorScheme = ColorScheme.AUTO) : InAppMessagingAction() + data class Initialize(val siteId: String, val dataCenter: String, val environment: GistEnvironment, val colorScheme: ColorScheme = ColorScheme.AUTO, val publicKey: String? = null) : InAppMessagingAction() data class SetColorScheme(val colorScheme: ColorScheme) : InAppMessagingAction() data class SetPollingInterval(val interval: Long) : InAppMessagingAction() data class SetSseEnabled(val enabled: Boolean) : InAppMessagingAction() diff --git a/messaginginapp/src/main/java/io/customer/messaginginapp/state/InAppMessagingState.kt b/messaginginapp/src/main/java/io/customer/messaginginapp/state/InAppMessagingState.kt index 2317daf77..632301fa3 100644 --- a/messaginginapp/src/main/java/io/customer/messaginginapp/state/InAppMessagingState.kt +++ b/messaginginapp/src/main/java/io/customer/messaginginapp/state/InAppMessagingState.kt @@ -8,6 +8,8 @@ import io.customer.messaginginapp.type.ColorScheme internal data class InAppMessagingState( val siteId: String = "", + // Public (pk_) API key, used instead of siteId to authenticate in-app requests when set + val publicKey: String? = null, val dataCenter: String = "", val environment: GistEnvironment = GistEnvironment.PROD, val colorScheme: ColorScheme = ColorScheme.AUTO, diff --git a/messaginginapp/src/main/java/io/customer/messaginginapp/type/NotificationInboxAccessibilityLabels.kt b/messaginginapp/src/main/java/io/customer/messaginginapp/type/NotificationInboxAccessibilityLabels.kt index c485e723c..9c084da68 100644 --- a/messaginginapp/src/main/java/io/customer/messaginginapp/type/NotificationInboxAccessibilityLabels.kt +++ b/messaginginapp/src/main/java/io/customer/messaginginapp/type/NotificationInboxAccessibilityLabels.kt @@ -14,7 +14,7 @@ package io.customer.messaginginapp.type * `strings.xml`. * * ```kotlin - * MessagingInAppModuleConfig.Builder(siteId, Region.US) + * MessagingInAppModuleConfig.Builder(siteId) * .setNotificationInboxAccessibilityLabels( * NotificationInboxAccessibilityLabels( * bell = context.getString(R.string.inbox_bell), diff --git a/messaginginapp/src/main/java/io/customer/messaginginapp/ui/controller/InAppMessageViewController.kt b/messaginginapp/src/main/java/io/customer/messaginginapp/ui/controller/InAppMessageViewController.kt index 278af2184..7ec48074d 100644 --- a/messaginginapp/src/main/java/io/customer/messaginginapp/ui/controller/InAppMessageViewController.kt +++ b/messaginginapp/src/main/java/io/customer/messaginginapp/ui/controller/InAppMessageViewController.kt @@ -112,7 +112,8 @@ internal abstract class InAppMessageViewController(relaxed = true) + + override fun setup(testConfig: TestConfig) { + super.setup( + testConfigurationDefault { + diGraph { + sdk { + overrideDependency(mockLogger) + // Keep GistSdk from touching real process-lifecycle wiring + overrideDependency(mockk(relaxed = true)) + overrideDependency(mockk(relaxed = true)) + } + } + } + testConfig + ) + } + + override fun teardown() { + SDKComponent.inAppMessagingManager.dispatch(InAppMessagingAction.Reset) + SDKComponent.android().globalPreferenceStore.clearAll() + super.teardown() + } + + // CustomerIO registers itself as the DataPipeline during init, before in-app initializes + private fun givenSdkInitializedWithKey(key: String, region: Region = Region.US) { + val dataPipeline = mockk(relaxed = true) { + every { cdpApiKey } returns key + every { this@mockk.region } returns region + } + SDKComponent.registerDependency { dataPipeline } + } + + @Test + fun gistProvider_givenPublicKeyAndNoSiteId_expectStateHasPublicKey() { + givenSdkInitializedWithKey(PUBLIC_KEY) + ModuleMessagingInApp(MessagingInAppModuleConfig.Builder().build()).attachToSDKComponent() + + SDKComponent.gistProvider + + val state = SDKComponent.inAppMessagingManager.getCurrentState() + state.publicKey shouldBeEqualTo PUBLIC_KEY + state.siteId shouldBeEqualTo "" + verify(exactly = 0) { mockLogger.error(any(), any(), any()) } + } + + @Test + fun gistProvider_givenLegacyKeyAndNoSiteId_expectErrorLogged() { + givenSdkInitializedWithKey(LEGACY_KEY) + ModuleMessagingInApp(MessagingInAppModuleConfig.Builder().build()).attachToSDKComponent() + + SDKComponent.gistProvider + + verify { mockLogger.error("In-app messaging needs a siteId, or the SDK set up with a public (pk_) key", any(), any()) } + } + + @Test + fun gistProvider_givenLegacyKeyAndSiteId_expectNoPublicKey() { + givenSdkInitializedWithKey(LEGACY_KEY) + ModuleMessagingInApp(MessagingInAppModuleConfig.Builder("site-id").build()).attachToSDKComponent() + + SDKComponent.gistProvider + + val state = SDKComponent.inAppMessagingManager.getCurrentState() + state.publicKey.shouldBeNull() + state.siteId shouldBeEqualTo "site-id" + } + + @Test + fun gistProvider_givenStaleSavedSettings_expectKeyAndRegionFromSdkInit() { + // Settings saved by an earlier launch must not decide the key or region + SDKComponent.android().globalPreferenceStore.saveSettings(Settings(writeKey = LEGACY_KEY, apiHost = "cdp.customer.io/v1")) + givenSdkInitializedWithKey(PUBLIC_EU_KEY, Region.EU) + ModuleMessagingInApp(MessagingInAppModuleConfig.Builder().build()).attachToSDKComponent() + + SDKComponent.gistProvider + + val state = SDKComponent.inAppMessagingManager.getCurrentState() + state.publicKey shouldBeEqualTo PUBLIC_EU_KEY + state.dataCenter shouldBeEqualTo "eu" + } + + private companion object { + const val PUBLIC_KEY = "pk_us_0123456789abcdefABCDEF0123456789_a1B2c3" + const val PUBLIC_EU_KEY = "pk_eu_0123456789abcdefABCDEF0123456789_a1B2c3" + const val LEGACY_KEY = "4f3b2a1c0d9e8f7a6b5c" + } +} diff --git a/messaginginapp/src/test/java/io/customer/messaginginapp/inbox/NotificationInboxTest.kt b/messaginginapp/src/test/java/io/customer/messaginginapp/inbox/NotificationInboxTest.kt index 8089810c1..7d70621cf 100644 --- a/messaginginapp/src/test/java/io/customer/messaginginapp/inbox/NotificationInboxTest.kt +++ b/messaginginapp/src/test/java/io/customer/messaginginapp/inbox/NotificationInboxTest.kt @@ -25,7 +25,6 @@ import io.customer.sdk.communication.EventBus import io.customer.sdk.core.di.SDKComponent import io.customer.sdk.core.util.DispatchersProvider import io.customer.sdk.core.util.ScopeProvider -import io.customer.sdk.data.model.Region import io.customer.sdk.events.Metric import io.mockk.Runs import io.mockk.every @@ -71,8 +70,7 @@ class NotificationInboxTest : IntegrationTest() { ) module = ModuleMessagingInApp( config = MessagingInAppModuleConfig.Builder( - siteId = TestConstants.Keys.SITE_ID, - region = Region.US + siteId = TestConstants.Keys.SITE_ID ).build() ).attachToSDKComponent() manager = SDKComponent.inAppMessagingManager diff --git a/messaginginapp/src/test/java/io/customer/messaginginapp/ui/InlineInAppMessageIntegrationTest.kt b/messaginginapp/src/test/java/io/customer/messaginginapp/ui/InlineInAppMessageIntegrationTest.kt index 7db4eda7e..f2d218525 100644 --- a/messaginginapp/src/test/java/io/customer/messaginginapp/ui/InlineInAppMessageIntegrationTest.kt +++ b/messaginginapp/src/test/java/io/customer/messaginginapp/ui/InlineInAppMessageIntegrationTest.kt @@ -27,7 +27,6 @@ import io.customer.messaginginapp.ui.InAppMessagingIntegrationUtil.testTap import io.customer.sdk.core.di.SDKComponent import io.customer.sdk.core.util.Logger import io.customer.sdk.core.util.ScopeProvider -import io.customer.sdk.data.model.Region import io.customer.sdk.lifecycle.CustomerIOActivityLifecycleCallbacks import io.mockk.Runs import io.mockk.every @@ -43,10 +42,9 @@ import org.junit.jupiter.api.Test class InlineInAppMessageIntegrationTest : JUnitTest() { private val moduleConfig = MessagingInAppModuleConfig.Builder( - siteId = "test-site-id", - region = Region.US + siteId = "test-site-id" ).build() - private val gistDataCenter = moduleConfig.region.code + private val gistDataCenter = moduleConfig.dataCenter private val gistEnvironment = GistEnvironment.LOCAL private val scopeProviderStub = ScopeProviderStub.Standard() @@ -92,7 +90,7 @@ class InlineInAppMessageIntegrationTest : JUnitTest() { messagingManager.dispatch( InAppMessagingAction.Initialize( siteId = moduleConfig.siteId, - dataCenter = moduleConfig.region.code, + dataCenter = moduleConfig.dataCenter, environment = gistEnvironment ) ) diff --git a/messaginginapp/src/test/java/io/customer/messaginginapp/ui/MessagingInAppIntegrationTest.kt b/messaginginapp/src/test/java/io/customer/messaginginapp/ui/MessagingInAppIntegrationTest.kt index 0120e4cec..c358df705 100644 --- a/messaginginapp/src/test/java/io/customer/messaginginapp/ui/MessagingInAppIntegrationTest.kt +++ b/messaginginapp/src/test/java/io/customer/messaginginapp/ui/MessagingInAppIntegrationTest.kt @@ -20,7 +20,6 @@ import io.customer.messaginginapp.testutils.core.JUnitTest import io.customer.sdk.core.di.SDKComponent import io.customer.sdk.core.util.Logger import io.customer.sdk.core.util.ScopeProvider -import io.customer.sdk.data.model.Region import io.customer.sdk.lifecycle.CustomerIOActivityLifecycleCallbacks import io.mockk.Runs import io.mockk.every @@ -36,10 +35,9 @@ import org.junit.jupiter.api.Test class MessagingInAppIntegrationTest : JUnitTest() { private val moduleConfig = MessagingInAppModuleConfig.Builder( - siteId = "test-site-id", - region = Region.US + siteId = "test-site-id" ).build() - private val gistDataCenter = moduleConfig.region.code + private val gistDataCenter = moduleConfig.dataCenter private val gistEnvironment = GistEnvironment.LOCAL private val scopeProviderStub = ScopeProviderStub.Standard() @@ -80,7 +78,7 @@ class MessagingInAppIntegrationTest : JUnitTest() { messagingManager.dispatch( InAppMessagingAction.Initialize( siteId = moduleConfig.siteId, - dataCenter = moduleConfig.region.code, + dataCenter = moduleConfig.dataCenter, environment = gistEnvironment ) ) diff --git a/messaginginapp/src/test/java/io/customer/messaginginapp/ui/controller/InAppMessageViewControllerTest.kt b/messaginginapp/src/test/java/io/customer/messaginginapp/ui/controller/InAppMessageViewControllerTest.kt index 909c33131..0f20fb838 100644 --- a/messaginginapp/src/test/java/io/customer/messaginginapp/ui/controller/InAppMessageViewControllerTest.kt +++ b/messaginginapp/src/test/java/io/customer/messaginginapp/ui/controller/InAppMessageViewControllerTest.kt @@ -31,6 +31,7 @@ import io.customer.sdk.core.di.SDKComponent import io.mockk.clearMocks import io.mockk.every import io.mockk.mockk +import io.mockk.slot import io.mockk.spyk import io.mockk.verify import io.mockk.verifyOrder @@ -478,6 +479,51 @@ class InAppMessageViewControllerTest : JUnitTest() { assertCalledOnce { engineWebViewDelegate.setup(expectedConfig) } } + @Test + fun engineSetup_givenPublicKeyAndSiteId_expectEngineConfiguredWithBoth() { + val givenPublicKey = "pk_us_" + String.random + val givenSiteId = String.random + val givenStoreState = InAppMessagingState( + siteId = givenSiteId, + publicKey = givenPublicKey, + dataCenter = String.random, + environment = GistEnvironment.LOCAL + ) + every { inAppMessagingManager.getCurrentState() } returns givenStoreState + val givenMessage = createInAppMessage() + val engineWebViewDelegate = mockk(relaxed = true) + controller.engineWebViewDelegate = engineWebViewDelegate + + controller.loadMessage(givenMessage) + + val configSlot = slot() + verify(exactly = 1) { engineWebViewDelegate.setup(capture(configSlot)) } + configSlot.captured.key shouldBeEqualTo givenPublicKey + configSlot.captured.siteId shouldBeEqualTo givenSiteId + } + + @Test + fun engineSetup_givenPublicKeyWithoutSiteId_expectEngineConfiguredWithKeyOnly() { + val givenPublicKey = "pk_us_" + String.random + val givenStoreState = InAppMessagingState( + siteId = "", + publicKey = givenPublicKey, + dataCenter = String.random, + environment = GistEnvironment.LOCAL + ) + every { inAppMessagingManager.getCurrentState() } returns givenStoreState + val givenMessage = createInAppMessage() + val engineWebViewDelegate = mockk(relaxed = true) + controller.engineWebViewDelegate = engineWebViewDelegate + + controller.loadMessage(givenMessage) + + val configSlot = slot() + verify(exactly = 1) { engineWebViewDelegate.setup(capture(configSlot)) } + configSlot.captured.key shouldBeEqualTo givenPublicKey + configSlot.captured.siteId shouldBeEqualTo "" + } + @Test fun engineSetup_givenStopCalled_expectEngineStopsLoading() { val engineWebViewDelegate = mockk(relaxed = true) diff --git a/messaginginapp/src/test/java/io/customer/messaginginapp/ui/controller/InlineMessageViewControllerBehaviorTest.kt b/messaginginapp/src/test/java/io/customer/messaginginapp/ui/controller/InlineMessageViewControllerBehaviorTest.kt index 19c427321..c8689ca43 100644 --- a/messaginginapp/src/test/java/io/customer/messaginginapp/ui/controller/InlineMessageViewControllerBehaviorTest.kt +++ b/messaginginapp/src/test/java/io/customer/messaginginapp/ui/controller/InlineMessageViewControllerBehaviorTest.kt @@ -34,7 +34,6 @@ import io.customer.messaginginapp.ui.bridge.InAppPlatformDelegate import io.customer.messaginginapp.ui.bridge.InlineInAppMessageViewCallback import io.customer.sdk.core.di.SDKComponent import io.customer.sdk.core.util.ScopeProvider -import io.customer.sdk.data.model.Region import io.customer.sdk.lifecycle.CustomerIOActivityLifecycleCallbacks import io.mockk.Runs import io.mockk.clearMocks @@ -57,10 +56,9 @@ class InlineMessageViewControllerBehaviorTest : JUnitTest() { private val scopeProviderStub = ScopeProviderStub.Standard() private val moduleConfig = MessagingInAppModuleConfig.Builder( - siteId = TestConstants.Keys.SITE_ID, - region = Region.US + siteId = TestConstants.Keys.SITE_ID ).build() - private val gistDataCenter = moduleConfig.region.code + private val gistDataCenter = moduleConfig.dataCenter private val gistEnvironment = GistEnvironment.LOCAL private val screenDensity = Double.random(1.0, 3.0) diff --git a/samples/java_layout/src/main/java/io/customer/android/sample/java_layout/sdk/CustomerIORepository.java b/samples/java_layout/src/main/java/io/customer/android/sample/java_layout/sdk/CustomerIORepository.java index d4c11d16f..d5a200d00 100644 --- a/samples/java_layout/src/main/java/io/customer/android/sample/java_layout/sdk/CustomerIORepository.java +++ b/samples/java_layout/src/main/java/io/customer/android/sample/java_layout/sdk/CustomerIORepository.java @@ -100,7 +100,7 @@ public void initializeSdk(SampleApplication application) { // Enables in-app messages if (sdkConfig.isInAppMessagingEnabled()) { builder.addCustomerIOModule(new ModuleMessagingInApp( - new MessagingInAppModuleConfig.Builder(sdkConfig.getSiteId(), sdkConfig.getRegion()) + new MessagingInAppModuleConfig.Builder(sdkConfig.getSiteId()) .setEventListener(new InAppMessageEventListener(appGraph.getLogger())) .setInboxEventListener(new SampleInboxEventListener(appGraph.getLogger())) // Visual Notification Inbox TalkBack labels. The SDK ships none of its own (so diff --git a/samples/kotlin_compose/src/androidTest/java/io/customer/android/sample/kotlin_compose/MemoryLeakageInstrumentedTest.kt b/samples/kotlin_compose/src/androidTest/java/io/customer/android/sample/kotlin_compose/MemoryLeakageInstrumentedTest.kt index f1d5314bb..9e507a3a6 100644 --- a/samples/kotlin_compose/src/androidTest/java/io/customer/android/sample/kotlin_compose/MemoryLeakageInstrumentedTest.kt +++ b/samples/kotlin_compose/src/androidTest/java/io/customer/android/sample/kotlin_compose/MemoryLeakageInstrumentedTest.kt @@ -20,7 +20,6 @@ import io.customer.messaginginapp.ModuleMessagingInApp import io.customer.messagingpush.ModuleMessagingPushFCM import io.customer.sdk.CustomerIO import io.customer.sdk.CustomerIOConfigBuilder -import io.customer.sdk.data.model.Region import kotlinx.coroutines.flow.first import kotlinx.coroutines.runBlocking import leakcanary.AppWatcher @@ -66,8 +65,7 @@ class MemoryLeakageInstrumentedTest { .addCustomerIOModule( ModuleMessagingInApp( config = MessagingInAppModuleConfig.Builder( - siteId = configuration.siteId, - region = Region.US + siteId = configuration.siteId ).setEventListener(InAppMessageEventListener()).build() ) ) diff --git a/samples/kotlin_compose/src/main/java/io/customer/android/sample/kotlin_compose/MainApplication.kt b/samples/kotlin_compose/src/main/java/io/customer/android/sample/kotlin_compose/MainApplication.kt index e6f1795e6..2ad188ef1 100644 --- a/samples/kotlin_compose/src/main/java/io/customer/android/sample/kotlin_compose/MainApplication.kt +++ b/samples/kotlin_compose/src/main/java/io/customer/android/sample/kotlin_compose/MainApplication.kt @@ -13,7 +13,6 @@ import io.customer.messaginginapp.type.NotificationInboxAccessibilityLabels import io.customer.messagingpush.ModuleMessagingPushFCM import io.customer.sdk.CustomerIO import io.customer.sdk.CustomerIOConfigBuilder -import io.customer.sdk.data.model.Region import kotlinx.coroutines.flow.first import kotlinx.coroutines.runBlocking @@ -35,8 +34,7 @@ class MainApplication : Application() { .addCustomerIOModule( ModuleMessagingInApp( config = MessagingInAppModuleConfig.Builder( - siteId = configuration.siteId, - region = Region.US + siteId = configuration.siteId ).setEventListener(InAppMessageEventListener()) .setInboxEventListener(SampleInboxEventListener()) // Visual Notification Inbox TalkBack labels. The SDK ships none of its own (so no