diff --git a/AGENTS.md b/AGENTS.md index 2761cc61b..65a0287bc 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/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..1ebc7792e --- /dev/null +++ b/core/src/main/kotlin/io/customer/sdk/core/util/CioApiKey.kt @@ -0,0 +1,32 @@ +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: `{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("^[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." + + /** + * 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_") + + 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(1) ?: return null + return Region.getRegion(code) + } +} 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..d2864e917 --- /dev/null +++ b/core/src/test/java/io/customer/sdk/core/util/CioApiKeyTest.kt @@ -0,0 +1,63 @@ +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_givenAnyPkPrefix_expectTrue() { + CioApiKey.isPublicKey("pk_ap_$KEY_BODY").shouldBeTrue() + CioApiKey.isPublicKey("pk_$KEY_BODY").shouldBeTrue() + } + + @Test + fun isSecretKey_givenAnySkPrefix_expectTrue() { + CioApiKey.isSecretKey("sk_ap_$KEY_BODY").shouldBeTrue() + CioApiKey.isSecretKey("sk_$KEY_BODY").shouldBeTrue() + } + + @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 + CioApiKey.region("xk_eu_$KEY_BODY") shouldBe Region.EU + } + + @Test + fun region_givenLegacyKey_expectNull() { + CioApiKey.region(LEGACY_KEY).shouldBeNull() + } + + companion object { + private const val KEY_BODY = "0123456789abcdefABCDEF0123456789_a1B2c3" + 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/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 86ff89643..caed76c10 100644 --- a/datapipelines/src/main/kotlin/io/customer/sdk/CustomerIO.kt +++ b/datapipelines/src/main/kotlin/io/customer/sdk/CustomerIO.kt @@ -19,6 +19,7 @@ import io.customer.datapipelines.extensions.sanitizeForJson import io.customer.datapipelines.extensions.type import io.customer.datapipelines.extensions.updateAnalyticsConfig import io.customer.datapipelines.extensions.withDeviceTokenType +import io.customer.datapipelines.migration.AnalyticsStorageMigration import io.customer.datapipelines.migration.TrackingMigrationProcessor import io.customer.datapipelines.plugins.ApplicationLifecyclePlugin import io.customer.datapipelines.plugins.AutoTrackDeviceAttributesPlugin @@ -35,11 +36,13 @@ 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 import io.customer.sdk.data.model.CustomAttributes import io.customer.sdk.data.model.DeviceTokenType +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 @@ -104,14 +107,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 + ) + } + } // Seeded from storage so a cold start can delete the stored device (token change, profile // switch, clearIdentify) before the token is registered again. @@ -405,6 +427,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 @@ -527,6 +557,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. @@ -567,6 +600,15 @@ 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 set up a disabled SDK instead of crashing the app + if (CioApiKey.isSecretKey(config.cdpApiKey)) { + logger.coreSdkSecretKeyRejected() + // Never disable an SDK that is already set up + if (instance == null) initializeDisabled(androidSDKComponent, config) + return + } + releaseDisabledInstance() + logger.coreSdkInitStart() // Initialize DataPipelinesModule with the provided configuration @@ -606,6 +648,41 @@ 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. 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( + 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 + SDKComponent.modules.putAll(config.modules.associateBy { module -> module.moduleName }) + } + + /** + * 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() } + // Drop the modules registered while disabled so only the new config's modules get set up + SDKComponent.modules.clear() + 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/main/kotlin/io/customer/sdk/CustomerIOBuilder.kt b/datapipelines/src/main/kotlin/io/customer/sdk/CustomerIOBuilder.kt index 7ac7c812b..73eb086da 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 @@ -208,7 +208,7 @@ class CustomerIOBuilder( 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..f60824288 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,13 @@ class CustomerIOConfigBuilder( val migrationSiteId: String? = null val screenViewUse: ScreenView = ScreenView.All val modules: List> = emptyList() + + /** + * 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 +65,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 +107,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. `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. */ @@ -216,7 +225,7 @@ class CustomerIOConfigBuilder( applicationContext = applicationContext, cdpApiKey = cdpApiKey, logLevel = logLevel, - region = region, + region = resolveRegion(cdpApiKey, region), apiHost = apiHost, cdnHost = cdnHost, flushAt = flushAt, diff --git a/datapipelines/src/main/kotlin/io/customer/sdk/DataPipelinesLogger.kt b/datapipelines/src/main/kotlin/io/customer/sdk/DataPipelinesLogger.kt index f991e1833..f6edae92f 100644 --- a/datapipelines/src/main/kotlin/io/customer/sdk/DataPipelinesLogger.kt +++ b/datapipelines/src/main/kotlin/io/customer/sdk/DataPipelinesLogger.kt @@ -3,6 +3,7 @@ package io.customer.sdk import io.customer.datapipelines.extensions.DEVICE_TOKEN_TYPE_ATTRIBUTE 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 @@ -28,6 +29,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/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/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 1947191a9..6b5a00dd6 100644 --- a/datapipelines/src/test/java/io/customer/sdk/CustomerIOConfigBuilderTest.kt +++ b/datapipelines/src/test/java/io/customer/sdk/CustomerIOConfigBuilderTest.kt @@ -374,6 +374,113 @@ class CustomerIOConfigBuilderTest : RobolectricTest() { dataPipelinesModuleConfig.cdnHost shouldBe "cdp.customer.io/v1" } + @Test + fun initialize_givenSecretKey_expectErrorLoggedAndDisabledInstance() { + val givenSecretKey = "sk_us_$KEY_BODY" + val givenModule = mockGenericModule().apply { + every { moduleName } returns String.random + } + val config = createCustomerIOConfigBuilder(givenSecretKey) + .addCustomerIOModule(givenModule) + .build() + + CustomerIO.initialize(config) + + 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 + // Calls are ignored instead of crashing + sdk.identify("user-id") + 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_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" + 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() + + 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("pk_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 = "pk_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 +508,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 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/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(), 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 diff --git a/messaginginapp/src/test/java/io/customer/messaginginapp/gist/data/NetworkUtilitiesTest.kt b/messaginginapp/src/test/java/io/customer/messaginginapp/gist/data/NetworkUtilitiesTest.kt index 66b3c2f6c..7467bc18f 100644 --- a/messaginginapp/src/test/java/io/customer/messaginginapp/gist/data/NetworkUtilitiesTest.kt +++ b/messaginginapp/src/test/java/io/customer/messaginginapp/gist/data/NetworkUtilitiesTest.kt @@ -23,12 +23,52 @@ class NetworkUtilitiesTest : IntegrationTest() { // test that reuses the constant would pass straight through a rename of its value. private val appIdentifierHeader = "X-CIO-Client-App-Identifier" - private fun buildHeaders(includeUserToken: Boolean = true) = NetworkUtilities.addCommonHeaders( + private fun buildHeaders( + includeUserToken: Boolean = true, + includeAuthorization: Boolean = true, + state: InAppMessagingState = InAppMessagingState(siteId = "site", dataCenter = "us") + ) = NetworkUtilities.addCommonHeaders( builder = Request.Builder().url("https://gist.example.com/api/v4/users"), - state = InAppMessagingState(siteId = "site", dataCenter = "us"), - includeUserToken = includeUserToken + state = state, + includeUserToken = includeUserToken, + includeAuthorization = includeAuthorization ).build().headers + private val publicKeyState = InAppMessagingState(siteId = "", dataCenter = "us", publicKey = "pk_us_key") + + @Test + fun addCommonHeaders_givenSiteIdOnly_expectSiteIdHeaderAndNoAuthorization() { + val headers = buildHeaders() + + headers["X-CIO-Site-Id"] shouldBeEqualTo "site" + headers["Authorization"] shouldBeEqualTo null + } + + @Test + fun addCommonHeaders_givenPublicKeyWithoutSiteId_expectBearerAndNoSiteIdHeader() { + val headers = buildHeaders(state = publicKeyState) + + headers["Authorization"] shouldBeEqualTo "Bearer pk_us_key" + headers["X-CIO-Site-Id"] shouldBeEqualTo null + } + + @Test + fun addCommonHeaders_givenPublicKeyAndSiteId_expectOnlyBearer() { + val headers = buildHeaders(state = publicKeyState.copy(siteId = "site")) + + headers["Authorization"] shouldBeEqualTo "Bearer pk_us_key" + headers["X-CIO-Site-Id"] shouldBeEqualTo null + } + + @Test + fun addCommonHeaders_givenPublicKeyOnSseRequest_expectNoAuthorization() { + // SSE passes the key in the URL instead + val headers = buildHeaders(includeUserToken = false, includeAuthorization = false, state = publicKeyState.copy(siteId = "site")) + + headers["Authorization"] shouldBeEqualTo null + headers["X-CIO-Site-Id"] shouldBeEqualTo null + } + @Test fun addCommonHeaders_givenFetchRequest_expectAppIdentifierIsHostPackageName() { val headers = buildHeaders() diff --git a/messaginginapp/src/test/java/io/customer/messaginginapp/gist/data/listeners/QueueInboxTriggerTest.kt b/messaginginapp/src/test/java/io/customer/messaginginapp/gist/data/listeners/QueueInboxTriggerTest.kt index 9a0873936..63f93a912 100644 --- a/messaginginapp/src/test/java/io/customer/messaginginapp/gist/data/listeners/QueueInboxTriggerTest.kt +++ b/messaginginapp/src/test/java/io/customer/messaginginapp/gist/data/listeners/QueueInboxTriggerTest.kt @@ -75,8 +75,7 @@ class QueueInboxTriggerTest : IntegrationTest() { ModuleMessagingInApp( config = MessagingInAppModuleConfig.Builder( - siteId = TestConstants.Keys.SITE_ID, - region = io.customer.sdk.data.model.Region.US + siteId = TestConstants.Keys.SITE_ID ).build() ).attachToSDKComponent() diff --git a/messaginginapp/src/test/java/io/customer/messaginginapp/gist/data/sse/SseServiceTest.kt b/messaginginapp/src/test/java/io/customer/messaginginapp/gist/data/sse/SseServiceTest.kt index 67cb17d6f..41cfbac2b 100644 --- a/messaginginapp/src/test/java/io/customer/messaginginapp/gist/data/sse/SseServiceTest.kt +++ b/messaginginapp/src/test/java/io/customer/messaginginapp/gist/data/sse/SseServiceTest.kt @@ -141,6 +141,64 @@ class SseServiceTest : IntegrationTest() { } } + @Test + fun testConnectSse_givenPublicKeyAndSiteId_thenSendsOnlyKeyParam() { + runBlocking { + val server = MockWebServer() + server.enqueue( + MockResponse() + .setResponseCode(200) + .setHeader("Content-Type", "text/event-stream") + .setBody("event: connected\ndata: {}\n\n") + ) + server.start() + mockkObject(GistEnvironment.PROD) + + try { + every { GistEnvironment.PROD.getSseApiUrl() } returns + server.url("/api/v3/sse").toString() + + val state = + InAppMessagingState( + siteId = "test-site", + publicKey = "pk_us_key", + dataCenter = "us", + environment = GistEnvironment.PROD, + userId = "test-user", + sessionId = "test-session" + ) + val service = + SseService( + sseLogger = mockk(relaxed = true), + inAppMessagingManager = mockk { + every { getCurrentState() } returns state + } + ) + + withTimeout(5_000) { + service + .connectSse( + sessionId = state.sessionId, + userToken = state.userId.orEmpty(), + siteId = state.siteId + ).take(3) + .toList() + } + + val request = server.takeRequest() + request.requestUrl?.queryParameter("key") shouldBeEqualTo "pk_us_key" + request.requestUrl?.queryParameter("siteId") shouldBeEqualTo null + request.requestUrl?.queryParameter("sessionId") shouldBeEqualTo "test-session" + request.requestUrl?.queryParameter("userToken") shouldBeEqualTo "dGVzdC11c2Vy" + request.headers["Authorization"] shouldBeEqualTo null + request.headers["X-CIO-Site-Id"] shouldBeEqualTo null + } finally { + unmockkObject(GistEnvironment.PROD) + server.shutdown() + } + } + } + @Test fun testConnectSse_whenServerRejectsRequest_thenEmitsClassifiedFailure() { runBlocking { diff --git a/messaginginapp/src/test/java/io/customer/messaginginapp/gist/presentation/GistModalActivityTest.kt b/messaginginapp/src/test/java/io/customer/messaginginapp/gist/presentation/GistModalActivityTest.kt index 9a5db35f8..8a655077f 100644 --- a/messaginginapp/src/test/java/io/customer/messaginginapp/gist/presentation/GistModalActivityTest.kt +++ b/messaginginapp/src/test/java/io/customer/messaginginapp/gist/presentation/GistModalActivityTest.kt @@ -31,7 +31,6 @@ import io.customer.sdk.core.di.SDKComponent import io.customer.sdk.core.util.DispatchersProvider import io.customer.sdk.core.util.Logger import io.customer.sdk.core.util.ScopeProvider -import io.customer.sdk.data.model.Region import io.mockk.coEvery import io.mockk.every import io.mockk.mockk @@ -299,8 +298,7 @@ class GistModalActivityTest : IntegrationTest() { private fun initializeModuleMessagingInApp(): InAppMessagingManager { val moduleConfig = MessagingInAppModuleConfig.Builder( - siteId = "test-site-id", - region = Region.US + siteId = "test-site-id" ).build() ModuleMessagingInApp(config = moduleConfig).attachToSDKComponent() @@ -310,7 +308,7 @@ class GistModalActivityTest : IntegrationTest() { messagingManager.dispatch( InAppMessagingAction.Initialize( siteId = moduleConfig.siteId, - dataCenter = moduleConfig.region.code, + dataCenter = moduleConfig.dataCenter, environment = GistEnvironment.LOCAL ) ).flushCoroutines(scopeProviderStub.inAppLifecycleScope) diff --git a/messaginginapp/src/test/java/io/customer/messaginginapp/gist/presentation/GistSdkPublicKeyTest.kt b/messaginginapp/src/test/java/io/customer/messaginginapp/gist/presentation/GistSdkPublicKeyTest.kt new file mode 100644 index 000000000..0a5437038 --- /dev/null +++ b/messaginginapp/src/test/java/io/customer/messaginginapp/gist/presentation/GistSdkPublicKeyTest.kt @@ -0,0 +1,115 @@ +package io.customer.messaginginapp.gist.presentation + +import io.customer.commontest.config.TestConfig +import io.customer.commontest.config.testConfigurationDefault +import io.customer.commontest.extensions.attachToSDKComponent +import io.customer.messaginginapp.MessagingInAppModuleConfig +import io.customer.messaginginapp.ModuleMessagingInApp +import io.customer.messaginginapp.di.gistProvider +import io.customer.messaginginapp.di.inAppMessagingManager +import io.customer.messaginginapp.state.InAppMessagingAction +import io.customer.messaginginapp.testutils.core.IntegrationTest +import io.customer.sdk.core.di.SDKComponent +import io.customer.sdk.core.pipeline.DataPipeline +import io.customer.sdk.core.util.Logger +import io.customer.sdk.data.model.Region +import io.customer.sdk.data.model.Settings +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import org.amshove.kluent.shouldBeEqualTo +import org.amshove.kluent.shouldBeNull +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class GistSdkPublicKeyTest : IntegrationTest() { + + private val mockLogger = mockk(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