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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ object Dependencies {
"com.google.firebase:firebase-messaging:${Versions.FIREBASE_MESSAGING}"
const val googlePlayServicesBase =
"com.google.android.gms:play-services-base:${Versions.GOOGLE_PLAY_SERVICES_BASE}"
const val googlePlayServicesLocation =
"com.google.android.gms:play-services-location:${Versions.GOOGLE_PLAY_SERVICES_LOCATION}"
const val googleServicesPlugin =
"com.google.gms:google-services:${Versions.GOOGLE_SERVICES_PLUGIN}"
const val gradleNexusPublishPlugin =
Expand Down
1 change: 1 addition & 0 deletions buildSrc/src/main/kotlin/io.customer/android/Versions.kt
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ object Versions {
internal const val ROBOLECTRIC = "4.16"
internal const val OKHTTP = "4.12.0"
internal const val RETROFIT = "2.11.0"
internal const val GOOGLE_PLAY_SERVICES_LOCATION = "21.3.0"

// Compose (using latest stable BOM compatible with Kotlin 2.1.21)
internal const val COMPOSE_BOM = "2025.10.00"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,8 @@ import kotlinx.coroutines.test.UnconfinedTestDispatcher
class ScopeProviderStub private constructor(
override val eventBusScope: TestScope,
override val lifecycleListenerScope: TestScope,
override val inAppLifecycleScope: TestScope
override val inAppLifecycleScope: TestScope,
override val locationScope: TestScope
) : ScopeProvider {

@Suppress("FunctionName")
Expand All @@ -18,13 +19,15 @@ class ScopeProviderStub private constructor(
fun Unconfined(): ScopeProviderStub = ScopeProviderStub(
eventBusScope = TestScope(UnconfinedTestDispatcher()),
lifecycleListenerScope = TestScope(UnconfinedTestDispatcher()),
inAppLifecycleScope = TestScope(UnconfinedTestDispatcher())
inAppLifecycleScope = TestScope(UnconfinedTestDispatcher()),
locationScope = TestScope(UnconfinedTestDispatcher())
)

fun Standard(): ScopeProviderStub = ScopeProviderStub(
eventBusScope = TestScope(StandardTestDispatcher()),
lifecycleListenerScope = TestScope(StandardTestDispatcher()),
inAppLifecycleScope = TestScope(StandardTestDispatcher())
inAppLifecycleScope = TestScope(StandardTestDispatcher()),
locationScope = TestScope(StandardTestDispatcher())
)
}
}
2 changes: 2 additions & 0 deletions core/api/core.api
Original file line number Diff line number Diff line change
Expand Up @@ -226,6 +226,7 @@ public abstract interface class io/customer/sdk/core/util/ScopeProvider {
public abstract fun getEventBusScope ()Lkotlinx/coroutines/CoroutineScope;
public abstract fun getInAppLifecycleScope ()Lkotlinx/coroutines/CoroutineScope;
public abstract fun getLifecycleListenerScope ()Lkotlinx/coroutines/CoroutineScope;
public abstract fun getLocationScope ()Lkotlinx/coroutines/CoroutineScope;
}

public final class io/customer/sdk/core/util/SdkDispatchers : io/customer/sdk/core/util/DispatchersProvider {
Expand All @@ -240,6 +241,7 @@ public final class io/customer/sdk/core/util/SdkScopeProvider : io/customer/sdk/
public fun getEventBusScope ()Lkotlinx/coroutines/CoroutineScope;
public fun getInAppLifecycleScope ()Lkotlinx/coroutines/CoroutineScope;
public fun getLifecycleListenerScope ()Lkotlinx/coroutines/CoroutineScope;
public fun getLocationScope ()Lkotlinx/coroutines/CoroutineScope;
}

public abstract class io/customer/sdk/data/model/Region {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
package io.customer.sdk.core.pipeline

import io.customer.base.internal.InternalCustomerIOApi

/**
* Abstraction for sending track events to the data pipeline.
*
* Modules retrieve an implementation via `SDKComponent.getOrNull<DataPipeline>()`
* to send events directly without going through EventBus.
*
* This is an internal SDK contract — not intended for use by host app developers.
*/
@InternalCustomerIOApi
interface DataPipeline {
val isUserIdentified: Boolean
fun track(name: String, properties: Map<String, Any?>)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
package io.customer.sdk.core.pipeline

import io.customer.base.internal.InternalCustomerIOApi

/**
* Hook for modules that participate in the identify event lifecycle.
*
* [getIdentifyContext] returns context entries (String, Number, Boolean)
* added to the identify event's context via `putInContext()`. Return an
* empty map when there is nothing to contribute. These are context-level
* enrichment data (e.g., location coordinates), NOT profile traits.
*
* [resetContext] is called synchronously during `analytics.reset()`
* (clearIdentify flow). Implementations must clear any cached data
* here to prevent stale context from enriching a subsequent identify.
* Full cleanup (persistence, filters) can happen asynchronously via
* EventBus ResetEvent.
*
* This is an internal SDK contract — not intended for use by host app developers.
*/
@InternalCustomerIOApi
interface IdentifyHook {
fun getIdentifyContext(): Map<String, Any>
fun resetContext() {}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
package io.customer.sdk.core.pipeline

import io.customer.base.internal.InternalCustomerIOApi
import io.customer.sdk.core.di.SDKComponent

/**
* Thread-safe registry of [IdentifyHook] instances.
*
* Modules register hooks during initialization. The datapipelines module
* queries all hooks when enriching identify event context and on reset.
*
* Cleared automatically when [SDKComponent.reset] clears singletons.
*
* This is an internal SDK contract — not intended for use by host app developers.
*/
@InternalCustomerIOApi
class IdentifyHookRegistry {
private val hooks = mutableListOf<IdentifyHook>()

@Synchronized
fun register(hook: IdentifyHook) {
if (hook !in hooks) {
hooks.add(hook)
}
}

@Synchronized
fun getAll(): List<IdentifyHook> = hooks.toList()

@Synchronized
fun clear() {
hooks.clear()
}
}

/**
* Singleton accessor for [IdentifyHookRegistry] via [SDKComponent].
*/
@InternalCustomerIOApi
val SDKComponent.identifyHookRegistry: IdentifyHookRegistry
get() = singleton { IdentifyHookRegistry() }
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
package io.customer.sdk.core.util

import android.os.Handler
import android.os.Looper
import io.customer.base.internal.InternalCustomerIOApi

/**
* Abstracts posting work to the main thread.
* Enables testability by allowing tests to mock or replace the implementation.
*/
@InternalCustomerIOApi
interface MainThreadPoster {
fun post(block: () -> Unit)
}

/**
* Default implementation using Android [Handler] with the main [Looper].
*/
@InternalCustomerIOApi
class HandlerMainThreadPoster : MainThreadPoster {
private val handler = Handler(Looper.getMainLooper())

override fun post(block: () -> Unit) {
handler.post(block)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ interface ScopeProvider {
val eventBusScope: CoroutineScope
val lifecycleListenerScope: CoroutineScope
val inAppLifecycleScope: CoroutineScope
val locationScope: CoroutineScope
}

class SdkScopeProvider(private val dispatchers: DispatchersProvider) : ScopeProvider {
Expand All @@ -16,4 +17,6 @@ class SdkScopeProvider(private val dispatchers: DispatchersProvider) : ScopeProv
get() = CoroutineScope(dispatchers.default + SupervisorJob())
override val inAppLifecycleScope: CoroutineScope
get() = CoroutineScope(dispatchers.default + SupervisorJob())
override val locationScope: CoroutineScope
get() = CoroutineScope(dispatchers.default + SupervisorJob())
}
113 changes: 113 additions & 0 deletions core/src/main/kotlin/io/customer/sdk/data/store/PreferenceCrypto.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
package io.customer.sdk.data.store

import android.annotation.SuppressLint
import android.os.Build
import android.security.keystore.KeyGenParameterSpec
import android.security.keystore.KeyProperties
import android.util.Base64
import io.customer.sdk.core.util.Logger
import java.security.KeyStore
import javax.crypto.Cipher
import javax.crypto.KeyGenerator
import javax.crypto.SecretKey
import javax.crypto.spec.GCMParameterSpec

/**
* Encrypts and decrypts strings using an AES-256-GCM key stored in the
* Android Keystore. Falls back to plaintext on API < 23 or when the
* Keystore is unavailable (some OEMs have buggy implementations).
*
* The [MODE_PRIVATE][android.content.Context.MODE_PRIVATE] SharedPreferences
* sandbox remains the baseline protection in all cases.
*/
class PreferenceCrypto(
private val keyAlias: String,
private val logger: Logger
) {
private val isKeystoreAvailable: Boolean = Build.VERSION.SDK_INT >= Build.VERSION_CODES.M

@Volatile
private var cachedKey: SecretKey? = null

@Synchronized
@SuppressLint("NewApi", "InlinedApi")
private fun getOrCreateKey(): SecretKey {
cachedKey?.let { return it }

val keyStore = KeyStore.getInstance(KEYSTORE_PROVIDER).apply { load(null) }
val entry = keyStore.getEntry(keyAlias, null) as? KeyStore.SecretKeyEntry
if (entry != null) {
cachedKey = entry.secretKey
return entry.secretKey
}

val spec = KeyGenParameterSpec.Builder(
keyAlias,
KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT
)
.setBlockModes(KeyProperties.BLOCK_MODE_GCM)
.setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
.setKeySize(256)
.build()

val key = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, KEYSTORE_PROVIDER)
.apply { init(spec) }
.generateKey()
cachedKey = key
return key
}

/**
* Encrypts [plaintext] with AES-256-GCM. Returns a Base64 string
* containing the 12-byte IV prepended to the ciphertext. Falls back
* to returning [plaintext] unchanged if encryption is unavailable.
*/
@SuppressLint("NewApi")
fun encrypt(plaintext: String): String {
if (!isKeystoreAvailable) return plaintext

return try {
val cipher = Cipher.getInstance(TRANSFORMATION)
cipher.init(Cipher.ENCRYPT_MODE, getOrCreateKey())
val ciphertext = cipher.doFinal(plaintext.toByteArray(Charsets.UTF_8))
val combined = cipher.iv + ciphertext
Base64.encodeToString(combined, Base64.NO_WRAP)
} catch (e: Exception) {
logger.debug("Keystore encryption unavailable, storing without encryption: ${e.message}")
plaintext
}
}

/**
* Decrypts an [encoded] Base64 string produced by [encrypt]. If
* decryption fails (e.g. the value was stored as plaintext before
* encryption was enabled, or the Keystore is unavailable), returns
* [encoded] as-is, which handles migration from plaintext transparently.
*/
@SuppressLint("NewApi")
fun decrypt(encoded: String): String {
if (!isKeystoreAvailable) return encoded

return try {
val combined = Base64.decode(encoded, Base64.NO_WRAP)
if (combined.size <= GCM_IV_LENGTH) return encoded

val iv = combined.copyOfRange(0, GCM_IV_LENGTH)
val ciphertext = combined.copyOfRange(GCM_IV_LENGTH, combined.size)
val cipher = Cipher.getInstance(TRANSFORMATION)
cipher.init(Cipher.DECRYPT_MODE, getOrCreateKey(), GCMParameterSpec(GCM_TAG_LENGTH, iv))
String(cipher.doFinal(ciphertext), Charsets.UTF_8)
} catch (e: Exception) {
// Value is likely stored as plaintext from before encryption was
// enabled, or from a Keystore failure during write. Return as-is.
encoded
}
}

companion object {
private const val KEYSTORE_PROVIDER = "AndroidKeyStore"
private const val TRANSFORMATION = "AES/GCM/NoPadding"
private const val GCM_IV_LENGTH = 12
private const val GCM_TAG_LENGTH = 128
}
}
3 changes: 3 additions & 0 deletions core/src/main/kotlin/io/customer/sdk/util/EventNames.kt
Original file line number Diff line number Diff line change
Expand Up @@ -11,4 +11,7 @@ object EventNames {

// Event name fired by AndroidLifecyclePlugin when app enters background
const val APPLICATION_BACKGROUNDED = "Application Backgrounded"

// Event name for location updates tracked by the Location module
const val LOCATION_UPDATE = "Location Update"
}
3 changes: 2 additions & 1 deletion datapipelines/api/datapipelines.api
Original file line number Diff line number Diff line change
Expand Up @@ -130,7 +130,7 @@ public final class io/customer/datapipelines/plugins/StringExtensionsKt {
public static final fun getScreenNameFromActivity (Ljava/lang/String;)Ljava/lang/String;
}

public final class io/customer/sdk/CustomerIO : io/customer/sdk/DataPipelineInstance, io/customer/sdk/core/module/CustomerIOModule {
public final class io/customer/sdk/CustomerIO : io/customer/sdk/DataPipelineInstance, io/customer/sdk/core/module/CustomerIOModule, io/customer/sdk/core/pipeline/DataPipeline {
public static final field Companion Lio/customer/sdk/CustomerIO$Companion;
public synthetic fun <init> (Lio/customer/sdk/core/di/AndroidSDKComponent;Lio/customer/datapipelines/config/DataPipelinesModuleConfig;Lcom/segment/analytics/kotlin/core/Analytics;Lkotlin/jvm/internal/DefaultConstructorMarker;)V
public fun getAnonymousId ()Ljava/lang/String;
Expand All @@ -144,6 +144,7 @@ public final class io/customer/sdk/CustomerIO : io/customer/sdk/DataPipelineInst
public fun initialize ()V
public static final fun initialize (Lio/customer/sdk/CustomerIOConfig;)V
public static final fun instance ()Lio/customer/sdk/CustomerIO;
public fun isUserIdentified ()Z
public fun setDeviceAttributes (Ljava/util/Map;)V
public fun setDeviceAttributesDeprecated (Ljava/util/Map;)V
public fun setProfileAttributes (Ljava/util/Map;)V
Expand Down
9 changes: 9 additions & 0 deletions datapipelines/build.gradle
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import io.customer.android.Configurations
import io.customer.android.Dependencies
import org.jetbrains.kotlin.gradle.tasks.KotlinCompile

plugins {
id 'com.android.library'
Expand Down Expand Up @@ -32,6 +33,14 @@ android {
}
}

tasks.withType(KotlinCompile).all {
kotlinOptions {
freeCompilerArgs += [
'-opt-in=io.customer.base.internal.InternalCustomerIOApi',
]
}
}

dependencies {
api project(":base")
api project(":core")
Expand Down
Loading
Loading