diff --git a/core/api/core.api b/core/api/core.api index cc28582e6..c30c1eb6f 100644 --- a/core/api/core.api +++ b/core/api/core.api @@ -269,6 +269,38 @@ public final class io/customer/sdk/data/model/Region$US : io/customer/sdk/data/m public static final field INSTANCE Lio/customer/sdk/data/model/Region$US; } +public final class io/customer/sdk/data/model/Settings { + public static final field Companion Lio/customer/sdk/data/model/Settings$Companion; + public synthetic fun (ILjava/lang/String;Ljava/lang/String;Lkotlinx/serialization/internal/SerializationConstructorMarker;)V + public fun (Ljava/lang/String;Ljava/lang/String;)V + public final fun component1 ()Ljava/lang/String; + public final fun component2 ()Ljava/lang/String; + public final fun copy (Ljava/lang/String;Ljava/lang/String;)Lio/customer/sdk/data/model/Settings; + public static synthetic fun copy$default (Lio/customer/sdk/data/model/Settings;Ljava/lang/String;Ljava/lang/String;ILjava/lang/Object;)Lio/customer/sdk/data/model/Settings; + public fun equals (Ljava/lang/Object;)Z + public final fun getApiHost ()Ljava/lang/String; + public final fun getWriteKey ()Ljava/lang/String; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; + public static final fun write$Self (Lio/customer/sdk/data/model/Settings;Lkotlinx/serialization/encoding/CompositeEncoder;Lkotlinx/serialization/descriptors/SerialDescriptor;)V +} + +public final class io/customer/sdk/data/model/Settings$$serializer : kotlinx/serialization/internal/GeneratedSerializer { + public static final field INSTANCE Lio/customer/sdk/data/model/Settings$$serializer; + public static final synthetic field descriptor Lkotlinx/serialization/descriptors/SerialDescriptor; + public fun childSerializers ()[Lkotlinx/serialization/KSerializer; + public fun deserialize (Lkotlinx/serialization/encoding/Decoder;)Lio/customer/sdk/data/model/Settings; + public synthetic fun deserialize (Lkotlinx/serialization/encoding/Decoder;)Ljava/lang/Object; + public fun getDescriptor ()Lkotlinx/serialization/descriptors/SerialDescriptor; + public fun serialize (Lkotlinx/serialization/encoding/Encoder;Lio/customer/sdk/data/model/Settings;)V + public synthetic fun serialize (Lkotlinx/serialization/encoding/Encoder;Ljava/lang/Object;)V + public fun typeParametersSerializers ()[Lkotlinx/serialization/KSerializer; +} + +public final class io/customer/sdk/data/model/Settings$Companion { + public final fun serializer ()Lkotlinx/serialization/KSerializer; +} + public final class io/customer/sdk/events/Metric : java/lang/Enum { public static final field Clicked Lio/customer/sdk/events/Metric; public static final field Converted Lio/customer/sdk/events/Metric; diff --git a/core/build.gradle b/core/build.gradle index a816f9a04..a991b7c4a 100644 --- a/core/build.gradle +++ b/core/build.gradle @@ -5,6 +5,7 @@ plugins { id 'com.android.library' id 'kotlin-android' id 'com.twilio.apkscale' + id 'org.jetbrains.kotlin.plugin.serialization' } ext { @@ -38,4 +39,7 @@ dependencies { api project(":base") api Dependencies.androidxCoreKtx implementation Dependencies.coroutinesAndroid + // Use this as API so customers can provide objects serializations without + // needing to add it as a dependency to their app + api(Dependencies.kotlinxSerializationJson) } diff --git a/core/src/main/kotlin/io/customer/sdk/data/model/Settings.kt b/core/src/main/kotlin/io/customer/sdk/data/model/Settings.kt new file mode 100644 index 000000000..a31c4bf9b --- /dev/null +++ b/core/src/main/kotlin/io/customer/sdk/data/model/Settings.kt @@ -0,0 +1,6 @@ +package io.customer.sdk.data.model + +import kotlinx.serialization.Serializable + +@Serializable +data class Settings(val writeKey: String, val apiHost: String) diff --git a/core/src/main/kotlin/io/customer/sdk/data/store/GlobalPreferenceStore.kt b/core/src/main/kotlin/io/customer/sdk/data/store/GlobalPreferenceStore.kt index 1a49e7390..2df58066c 100644 --- a/core/src/main/kotlin/io/customer/sdk/data/store/GlobalPreferenceStore.kt +++ b/core/src/main/kotlin/io/customer/sdk/data/store/GlobalPreferenceStore.kt @@ -2,6 +2,8 @@ package io.customer.sdk.data.store import android.content.Context import androidx.core.content.edit +import io.customer.sdk.data.model.Settings +import kotlinx.serialization.json.Json /** * Store for global preferences that are not tied to a specific api key, user @@ -9,7 +11,9 @@ import androidx.core.content.edit */ interface GlobalPreferenceStore { fun saveDeviceToken(token: String) + fun saveSettings(value: Settings) fun getDeviceToken(): String? + fun getSettings(): Settings? fun removeDeviceToken() fun clear(key: String) fun clearAll() @@ -27,13 +31,27 @@ internal class GlobalPreferenceStoreImpl( putString(KEY_DEVICE_TOKEN, token) } + override fun saveSettings(value: Settings) = prefs.edit { + putString(KEY_CONFIG_SETTINGS, Json.encodeToString(Settings.serializer(), value)) + } + override fun getDeviceToken(): String? = prefs.read { getString(KEY_DEVICE_TOKEN, null) } + override fun getSettings(): Settings? = prefs.read { + runCatching { + Json.decodeFromString( + Settings.serializer(), + getString(KEY_CONFIG_SETTINGS, null) ?: return null + ) + }.getOrNull() + } + override fun removeDeviceToken() = clear(KEY_DEVICE_TOKEN) companion object { private const val KEY_DEVICE_TOKEN = "device_token" + private const val KEY_CONFIG_SETTINGS = "config_settings" } } diff --git a/datapipelines/src/main/kotlin/io/customer/datapipelines/util/EventNames.kt b/core/src/main/kotlin/io/customer/sdk/util/EventNames.kt similarity index 80% rename from datapipelines/src/main/kotlin/io/customer/datapipelines/util/EventNames.kt rename to core/src/main/kotlin/io/customer/sdk/util/EventNames.kt index 3310cb76a..d7778cf92 100644 --- a/datapipelines/src/main/kotlin/io/customer/datapipelines/util/EventNames.kt +++ b/core/src/main/kotlin/io/customer/sdk/util/EventNames.kt @@ -1,10 +1,10 @@ -package io.customer.datapipelines.util +package io.customer.sdk.util /** * Event names to identify specific events in data pipelines so they can be * reflected on Journeys. */ -internal object EventNames { +object EventNames { const val DEVICE_UPDATE = "Device Created or Updated" const val DEVICE_DELETE = "Device Deleted" const val METRIC_DELIVERY = "Report Delivery Event" diff --git a/datapipelines/build.gradle b/datapipelines/build.gradle index 02b68abfd..ce7df8f7b 100644 --- a/datapipelines/build.gradle +++ b/datapipelines/build.gradle @@ -39,7 +39,4 @@ dependencies { implementation(Dependencies.segment) implementation Dependencies.androidxProcessLifecycle - // Use this as API so customers can provide objects serializations without - // needing to add it as a dependency to their app - api(Dependencies.kotlinxSerializationJson) } diff --git a/datapipelines/src/main/kotlin/io/customer/datapipelines/migration/TrackingMigrationProcessor.kt b/datapipelines/src/main/kotlin/io/customer/datapipelines/migration/TrackingMigrationProcessor.kt index c96041ab6..e8fa80dbf 100644 --- a/datapipelines/src/main/kotlin/io/customer/datapipelines/migration/TrackingMigrationProcessor.kt +++ b/datapipelines/src/main/kotlin/io/customer/datapipelines/migration/TrackingMigrationProcessor.kt @@ -10,11 +10,11 @@ import com.segment.analytics.kotlin.core.platform.EnrichmentClosure import com.segment.analytics.kotlin.core.utilities.putAll import com.segment.analytics.kotlin.core.utilities.putInContextUnderKey import io.customer.datapipelines.extensions.toJsonObject -import io.customer.datapipelines.util.EventNames import io.customer.datapipelines.util.SegmentInstantFormatter import io.customer.sdk.CustomerIO import io.customer.sdk.core.di.SDKComponent import io.customer.sdk.core.util.Logger +import io.customer.sdk.util.EventNames import io.customer.tracking.migration.MigrationAssistant import io.customer.tracking.migration.MigrationProcessor import io.customer.tracking.migration.request.MigrationTask diff --git a/datapipelines/src/main/kotlin/io/customer/datapipelines/plugins/AutoTrackDeviceAttributesPlugin.kt b/datapipelines/src/main/kotlin/io/customer/datapipelines/plugins/AutoTrackDeviceAttributesPlugin.kt index 0cbdd8000..40965595b 100644 --- a/datapipelines/src/main/kotlin/io/customer/datapipelines/plugins/AutoTrackDeviceAttributesPlugin.kt +++ b/datapipelines/src/main/kotlin/io/customer/datapipelines/plugins/AutoTrackDeviceAttributesPlugin.kt @@ -5,7 +5,7 @@ import com.segment.analytics.kotlin.core.BaseEvent import com.segment.analytics.kotlin.core.TrackEvent import com.segment.analytics.kotlin.core.platform.Plugin import com.segment.analytics.kotlin.core.utilities.putAll -import io.customer.datapipelines.util.EventNames +import io.customer.sdk.util.EventNames import kotlinx.serialization.json.buildJsonObject import kotlinx.serialization.json.jsonObject diff --git a/datapipelines/src/main/kotlin/io/customer/sdk/CustomerIO.kt b/datapipelines/src/main/kotlin/io/customer/sdk/CustomerIO.kt index ce9bc59b9..4850b10aa 100644 --- a/datapipelines/src/main/kotlin/io/customer/sdk/CustomerIO.kt +++ b/datapipelines/src/main/kotlin/io/customer/sdk/CustomerIO.kt @@ -23,7 +23,6 @@ import io.customer.datapipelines.plugins.ContextPlugin import io.customer.datapipelines.plugins.CustomerIODestination import io.customer.datapipelines.plugins.DataPipelinePublishedEvents import io.customer.datapipelines.plugins.ScreenFilterPlugin -import io.customer.datapipelines.util.EventNames import io.customer.sdk.communication.Event import io.customer.sdk.communication.subscribe import io.customer.sdk.core.di.AndroidSDKComponent @@ -32,7 +31,9 @@ import io.customer.sdk.core.module.CustomerIOModule import io.customer.sdk.core.util.CioLogLevel import io.customer.sdk.core.util.Logger import io.customer.sdk.data.model.CustomAttributes +import io.customer.sdk.data.model.Settings import io.customer.sdk.events.TrackMetric +import io.customer.sdk.util.EventNames import io.customer.tracking.migration.MigrationProcessor import kotlinx.serialization.SerializationStrategy import kotlinx.serialization.serializer @@ -173,6 +174,12 @@ class CustomerIO private constructor( logger.debug("CustomerIO SDK initialized with DataPipelines module.") // Migrate unsent events from previous version migrateTrackingEvents() + + // save settings to storage + analytics.configuration.let { config -> + val settings = Settings(writeKey = config.writeKey, apiHost = config.apiHost) + globalPreferenceStore.saveSettings(settings) + } } override var profileAttributes: CustomAttributes diff --git a/datapipelines/src/test/java/io/customer/datapipelines/DataPipelinesCompatibilityTests.kt b/datapipelines/src/test/java/io/customer/datapipelines/DataPipelinesCompatibilityTests.kt index fb54c2f92..b50e3d9aa 100644 --- a/datapipelines/src/test/java/io/customer/datapipelines/DataPipelinesCompatibilityTests.kt +++ b/datapipelines/src/test/java/io/customer/datapipelines/DataPipelinesCompatibilityTests.kt @@ -14,13 +14,13 @@ import io.customer.datapipelines.testutils.extensions.deviceToken import io.customer.datapipelines.testutils.extensions.encodeToJsonElement import io.customer.datapipelines.testutils.extensions.shouldMatchTo import io.customer.datapipelines.testutils.extensions.toJsonObject -import io.customer.datapipelines.util.EventNames import io.customer.sdk.core.di.SDKComponent import io.customer.sdk.data.model.CustomAttributes import io.customer.sdk.data.store.DeviceStore import io.customer.sdk.data.store.GlobalPreferenceStore import io.customer.sdk.events.Metric import io.customer.sdk.events.TrackMetric +import io.customer.sdk.util.EventNames import io.mockk.every import kotlinx.coroutines.test.runTest import kotlinx.serialization.json.JsonArray diff --git a/datapipelines/src/test/java/io/customer/datapipelines/DataPipelinesInteractionTests.kt b/datapipelines/src/test/java/io/customer/datapipelines/DataPipelinesInteractionTests.kt index d5af7174f..83399720c 100644 --- a/datapipelines/src/test/java/io/customer/datapipelines/DataPipelinesInteractionTests.kt +++ b/datapipelines/src/test/java/io/customer/datapipelines/DataPipelinesInteractionTests.kt @@ -13,11 +13,12 @@ import io.customer.datapipelines.testutils.utils.OutputReaderPlugin import io.customer.datapipelines.testutils.utils.identifyEvents import io.customer.datapipelines.testutils.utils.screenEvents import io.customer.datapipelines.testutils.utils.trackEvents -import io.customer.datapipelines.util.EventNames import io.customer.sdk.core.di.SDKComponent import io.customer.sdk.data.model.CustomAttributes +import io.customer.sdk.data.model.Settings import io.customer.sdk.data.store.DeviceStore import io.customer.sdk.data.store.GlobalPreferenceStore +import io.customer.sdk.util.EventNames import io.mockk.every import kotlinx.serialization.json.buildJsonObject import kotlinx.serialization.json.put @@ -593,6 +594,11 @@ class DataPipelinesInteractionTests : JUnitTest() { deviceRegisterEvent.context.deviceToken shouldBeEqualTo givenToken } + @Test + fun device_givenSDKInitialized_expectSettingsToBeStored() { + assertCalledOnce { globalPreferenceStore.saveSettings(Settings(writeKey = analytics.configuration.writeKey, apiHost = analytics.configuration.apiHost)) } + } + @Test fun device_givenRegisterTokenWhenNoProfileIdentified_expectStoreAndRegisterDeviceForAnonymousProfile() { val givenToken = String.random diff --git a/datapipelines/src/test/java/io/customer/datapipelines/DeviceAttributesTests.kt b/datapipelines/src/test/java/io/customer/datapipelines/DeviceAttributesTests.kt index 6147f79e6..3a9681195 100644 --- a/datapipelines/src/test/java/io/customer/datapipelines/DeviceAttributesTests.kt +++ b/datapipelines/src/test/java/io/customer/datapipelines/DeviceAttributesTests.kt @@ -12,9 +12,9 @@ import io.customer.datapipelines.testutils.extensions.deviceToken import io.customer.datapipelines.testutils.extensions.encodeToJsonValue import io.customer.datapipelines.testutils.utils.OutputReaderPlugin import io.customer.datapipelines.testutils.utils.trackEvents -import io.customer.datapipelines.util.EventNames import io.customer.sdk.core.di.SDKComponent import io.customer.sdk.data.store.GlobalPreferenceStore +import io.customer.sdk.util.EventNames import io.mockk.every import kotlinx.serialization.json.booleanOrNull import kotlinx.serialization.json.intOrNull diff --git a/datapipelines/src/test/java/io/customer/datapipelines/migration/TrackingMigrationProcessorTest.kt b/datapipelines/src/test/java/io/customer/datapipelines/migration/TrackingMigrationProcessorTest.kt index 52ee9170e..abb7f8dc0 100644 --- a/datapipelines/src/test/java/io/customer/datapipelines/migration/TrackingMigrationProcessorTest.kt +++ b/datapipelines/src/test/java/io/customer/datapipelines/migration/TrackingMigrationProcessorTest.kt @@ -18,12 +18,12 @@ import io.customer.datapipelines.testutils.utils.OutputReaderPlugin import io.customer.datapipelines.testutils.utils.identifyEvents import io.customer.datapipelines.testutils.utils.screenEvents import io.customer.datapipelines.testutils.utils.trackEvents -import io.customer.datapipelines.util.EventNames import io.customer.datapipelines.util.SegmentInstantFormatter import io.customer.sdk.core.di.SDKComponent import io.customer.sdk.data.store.GlobalPreferenceStore import io.customer.sdk.events.Metric import io.customer.sdk.events.serializedName +import io.customer.sdk.util.EventNames import io.customer.tracking.migration.MigrationProcessor import io.customer.tracking.migration.request.MigrationTask import io.mockk.every diff --git a/messagingpush/src/main/java/io/customer/messagingpush/PushDeliveryTracker.kt b/messagingpush/src/main/java/io/customer/messagingpush/PushDeliveryTracker.kt new file mode 100644 index 000000000..e0d972182 --- /dev/null +++ b/messagingpush/src/main/java/io/customer/messagingpush/PushDeliveryTracker.kt @@ -0,0 +1,56 @@ +package io.customer.messagingpush + +import io.customer.messagingpush.di.httpClient +import io.customer.messagingpush.network.HttpClient +import io.customer.messagingpush.network.HttpRequestParams +import io.customer.sdk.core.di.SDKComponent +import io.customer.sdk.util.EventNames +import org.json.JSONObject + +internal interface PushDeliveryTracker { + fun trackMetric(token: String, event: String, deliveryId: String, onComplete: ((Result) -> Unit?)? = null) +} + +internal class PushDeliveryTrackerImpl : PushDeliveryTracker { + + private val httpClient: HttpClient + get() = SDKComponent.httpClient + + /** + * Tracks a metric by performing a single POST request with JSON. + * Returns a `Result`. + */ + override fun trackMetric( + token: String, + event: String, + deliveryId: String, + onComplete: ((Result) -> Unit?)? + ) { + val propertiesJson = JSONObject().apply { + put("recipient", token) + put("metric", event.lowercase()) + put("deliveryId", deliveryId) + } + val topLevelJson = JSONObject().apply { + put("anonymousId", deliveryId) + put("properties", propertiesJson) + put("event", EventNames.METRIC_DELIVERY) + } + + val params = HttpRequestParams( + path = "/track", + headers = mapOf( + "Content-Type" to "application/json; charset=utf-8" + ), + body = topLevelJson.toString() + ) + + // Perform request + httpClient.request(params) { result -> + val mappedResult = result.map { /* we only need success/failure */ } + if (onComplete != null) { + onComplete(mappedResult) + } + } + } +} diff --git a/messagingpush/src/main/java/io/customer/messagingpush/di/DiGraphMessagingPush.kt b/messagingpush/src/main/java/io/customer/messagingpush/di/DiGraphMessagingPush.kt index 6f202b4f1..424618425 100644 --- a/messagingpush/src/main/java/io/customer/messagingpush/di/DiGraphMessagingPush.kt +++ b/messagingpush/src/main/java/io/customer/messagingpush/di/DiGraphMessagingPush.kt @@ -5,6 +5,10 @@ package io.customer.messagingpush.di import io.customer.base.internal.InternalCustomerIOApi import io.customer.messagingpush.MessagingPushModuleConfig import io.customer.messagingpush.ModuleMessagingPushFCM +import io.customer.messagingpush.PushDeliveryTracker +import io.customer.messagingpush.PushDeliveryTrackerImpl +import io.customer.messagingpush.network.HttpClient +import io.customer.messagingpush.network.HttpClientImpl import io.customer.messagingpush.processor.PushMessageProcessor import io.customer.messagingpush.processor.PushMessageProcessorImpl import io.customer.messagingpush.provider.DeviceTokenProvider @@ -45,3 +49,9 @@ internal val SDKComponent.pushMessageProcessor: PushMessageProcessor deepLinkUtil = deepLinkUtil ) } + +internal val SDKComponent.httpClient: HttpClient + get() = singleton { HttpClientImpl() } + +internal val SDKComponent.pushDeliveryTracker: PushDeliveryTracker + get() = singleton { PushDeliveryTrackerImpl() } diff --git a/messagingpush/src/main/java/io/customer/messagingpush/network/HTTPClient.kt b/messagingpush/src/main/java/io/customer/messagingpush/network/HTTPClient.kt new file mode 100644 index 000000000..d996b6a7e --- /dev/null +++ b/messagingpush/src/main/java/io/customer/messagingpush/network/HTTPClient.kt @@ -0,0 +1,120 @@ +package io.customer.messagingpush.network + +import android.util.Base64 +import io.customer.sdk.core.di.SDKComponent +import io.customer.sdk.core.util.DispatchersProvider +import io.customer.sdk.data.store.Client +import io.customer.sdk.data.store.GlobalPreferenceStore +import java.io.IOException +import java.net.HttpURLConnection +import java.net.MalformedURLException +import java.net.URL +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch + +internal data class HttpRequestParams( + val path: String, + val headers: Map = emptyMap(), + val body: String? = null +) + +internal interface HttpClient { + /** + * Performs a POST request to [params.url] with [params.headers] and [params.body]. + * + * @param params The request parameters (URL, headers, body). + * @param onComplete Callback invoked with a `Result`: + * - `Result.success(responseBody)` for 2xx response codes + * - `Result.failure(exception)` for network errors or non-2xx codes + */ + fun request(params: HttpRequestParams, onComplete: (Result) -> Unit) +} + +internal class HttpClientImpl : HttpClient { + + private val connectTimeoutMs = 10_000 + private val readTimeoutMs = 10_000 + private val globalPreferenceStore: GlobalPreferenceStore + get() = SDKComponent.android().globalPreferenceStore + private val client: Client + get() = SDKComponent.android().client + private val dispatcher: DispatchersProvider + get() = SDKComponent.dispatchersProvider + + override fun request(params: HttpRequestParams, onComplete: (Result) -> Unit) { + // Launch a coroutine on our IO dispatcher + CoroutineScope(dispatcher.background).launch { + val result = doNetworkRequest(params) + // If you want to call onComplete on the same IO thread, just invoke it here. + // If you prefer to call it on the main thread, do: + // withContext(Dispatchers.Main) { onComplete(result) } + onComplete(result) + } + } + + private fun doNetworkRequest(params: HttpRequestParams): Result { + val settings = globalPreferenceStore.getSettings() ?: return Result.failure(IllegalStateException("Setting not available")) + val apiHost = settings.apiHost + val writeKey = settings.writeKey + + // Ensure we have exactly one slash + val cleanedPath = if (params.path.startsWith("/")) params.path else "/${params.path}" + val urlString = "https://$apiHost$cleanedPath" + + val connection = try { + val urlObj = URL(urlString) + urlObj.openConnection() as HttpURLConnection + } catch (e: MalformedURLException) { + return Result.failure(IOException("Malformed URL: $urlString", e)) + } catch (e: IOException) { + return Result.failure(e) + } + + return try { + // Configure the connection + connection.connectTimeout = connectTimeoutMs + connection.readTimeout = readTimeoutMs + connection.requestMethod = "POST" + connection.setRequestProperty("User-Agent", client.toString()) + + // Authorization: Basic + val base64Value = Base64.encodeToString( + "$writeKey:".toByteArray(Charsets.UTF_8), + Base64.NO_WRAP + ) + connection.setRequestProperty("Authorization", "Basic $base64Value") + + // Additional headers + params.headers.forEach { (key, value) -> + connection.setRequestProperty(key, value) + } + + // Write the body if present + params.body?.let { requestBody -> + connection.doOutput = true + connection.outputStream.use { os -> + os.write(requestBody.toByteArray()) + } + } + + // Execute + val responseCode = connection.responseCode + val inputStream = try { + connection.inputStream + } catch (e: IOException) { + connection.errorStream + } + val responseBody = inputStream?.bufferedReader()?.use { it.readText() } ?: "" + + if (responseCode in 200..299) { + Result.success(responseBody) + } else { + Result.failure(IOException("HTTP $responseCode: $responseBody")) + } + } catch (e: IOException) { + Result.failure(e) + } finally { + connection.disconnect() + } + } +} diff --git a/messagingpush/src/main/java/io/customer/messagingpush/processor/PushMessageProcessorImpl.kt b/messagingpush/src/main/java/io/customer/messagingpush/processor/PushMessageProcessorImpl.kt index c5c9bb2c9..2821f9e66 100644 --- a/messagingpush/src/main/java/io/customer/messagingpush/processor/PushMessageProcessorImpl.kt +++ b/messagingpush/src/main/java/io/customer/messagingpush/processor/PushMessageProcessorImpl.kt @@ -5,13 +5,16 @@ import android.content.Intent import androidx.annotation.VisibleForTesting import androidx.core.app.TaskStackBuilder import io.customer.messagingpush.MessagingPushModuleConfig +import io.customer.messagingpush.PushDeliveryTracker import io.customer.messagingpush.activity.NotificationClickReceiverActivity import io.customer.messagingpush.config.PushClickBehavior import io.customer.messagingpush.data.model.CustomerIOParsedPushPayload +import io.customer.messagingpush.di.pushDeliveryTracker import io.customer.messagingpush.extensions.parcelable import io.customer.messagingpush.util.DeepLinkUtil import io.customer.messagingpush.util.PushTrackingUtil import io.customer.sdk.communication.Event +import io.customer.sdk.core.di.SDKComponent import io.customer.sdk.core.di.SDKComponent.eventBus import io.customer.sdk.core.util.Logger import io.customer.sdk.events.Metric @@ -22,6 +25,9 @@ internal class PushMessageProcessorImpl( private val deepLinkUtil: DeepLinkUtil ) : PushMessageProcessor { + private val pushDeliveryTracker: PushDeliveryTracker + get() = SDKComponent.pushDeliveryTracker + /** * Responsible for storing and updating recent messages in queue atomically. * Once this method is called, the current implementation marks the notification @@ -86,6 +92,14 @@ internal class PushMessageProcessorImpl( private fun trackDeliveredMetrics(deliveryId: String, deliveryToken: String) { // Track delivered event only if auto-tracking is enabled if (moduleConfig.autoTrackPushEvents) { + // Track delivered metrics via http + pushDeliveryTracker.trackMetric( + event = Metric.Delivered.name, + deliveryId = deliveryId, + token = deliveryToken + ) + + // Track delivered metrics via event bus eventBus.publish( Event.TrackPushMetricEvent( event = Metric.Delivered, diff --git a/messagingpush/src/test/java/io/customer/messagingpush/PushDeliveryTrackingTest.kt b/messagingpush/src/test/java/io/customer/messagingpush/PushDeliveryTrackingTest.kt new file mode 100644 index 000000000..06360a125 --- /dev/null +++ b/messagingpush/src/test/java/io/customer/messagingpush/PushDeliveryTrackingTest.kt @@ -0,0 +1,98 @@ +package io.customer.messagingpush + +import io.customer.commontest.config.TestConfig +import io.customer.commontest.config.testConfigurationDefault +import io.customer.messagingpush.network.HttpClient +import io.customer.messagingpush.network.HttpRequestParams +import io.customer.messagingpush.testutils.core.IntegrationTest +import io.mockk.every +import io.mockk.mockk +import io.mockk.slot +import io.mockk.verify +import org.amshove.kluent.shouldBeEqualTo +import org.amshove.kluent.shouldContain +import org.amshove.kluent.shouldNotBe +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class PushDeliveryTrackingTest : IntegrationTest() { + + private val httpClient: HttpClient = mockk(relaxed = true) + private val pushDeliveryTracker = PushDeliveryTrackerImpl() + + override fun setup(testConfig: TestConfig) { + super.setup( + testConfigurationDefault { + diGraph { + sdk { + // Override the httpClient in your DI so the SUT uses this mock. + overrideDependency(httpClient) + } + } + } + ) + } + + @Test + fun trackMetric_givenValidInputs_expectCorrectPathAndSuccessCallback() { + val token = "token123" + val event = "OPENED" + val deliveryId = "delivery_abc" + + val capturedParams = slot() + + every { + httpClient.request(capture(capturedParams), any()) + } answers { + // Invoke the second arg (the callback) with success. + secondArg<(Result) -> Unit>().invoke(Result.success("Success")) + } + + var callbackResult: Result? = null + + pushDeliveryTracker.trackMetric(token, event, deliveryId) { result -> + callbackResult = result + // Return Unit to match the function signature + Unit + } + + // Assert #1: Confirm the correct path. + capturedParams.captured.path shouldBeEqualTo "/track" + + // Assert #2: The body should not be null. + val requestBody = capturedParams.captured.body + requestBody shouldNotBe null + + // Optional substring checks to verify key fields exist (avoid org.json parsing): + requestBody!! shouldContain token + requestBody shouldContain event.lowercase() + requestBody shouldContain deliveryId + + // Assert #3: The callback result is success. + callbackResult!!.isSuccess.shouldBeEqualTo(true) + + // Ensure we only called once + verify(exactly = 1) { httpClient.request(any(), any()) } + } + + @Test + fun trackMetric_givenHttpClientFails_expectCallbackFailure() { + every { + httpClient.request(any(), any()) + } answers { + // Simulate failure + secondArg<(Result) -> Unit>().invoke(Result.failure(Exception("Network error"))) + } + + var callbackResult: Result? = null + + pushDeliveryTracker.trackMetric("token", "OPENED", "deliveryId") { result -> + callbackResult = result + Unit + } + + callbackResult!!.isFailure.shouldBeEqualTo(true) + } +}