diff --git a/core/src/main/kotlin/io/customer/sdk/core/network/CustomerIOHttpClient.kt b/core/src/main/kotlin/io/customer/sdk/core/network/CustomerIOHttpClient.kt index f9adc05d0..1d922ef4a 100644 --- a/core/src/main/kotlin/io/customer/sdk/core/network/CustomerIOHttpClient.kt +++ b/core/src/main/kotlin/io/customer/sdk/core/network/CustomerIOHttpClient.kt @@ -10,23 +10,41 @@ import java.net.HttpURLConnection import java.net.MalformedURLException import java.net.URL +@InternalCustomerIOApi +enum class HttpMethod { GET, POST, PUT, DELETE } + @InternalCustomerIOApi data class HttpRequestParams( val path: String, + val method: HttpMethod = HttpMethod.POST, val headers: Map = emptyMap(), val body: String? = null ) +/** + * IOException carrying the HTTP [statusCode] for non-2xx responses ([statusCode] + * is null for transport-level failures). Lets callers apply status-aware retry + * (e.g. retry 5xx, skip 4xx) without parsing the message. + */ +@InternalCustomerIOApi +class HttpRequestException( + val statusCode: Int?, + message: String, + cause: Throwable? = null +) : IOException(message, cause) + /** HTTP client for Customer.io API calls with SDK authentication. */ @InternalCustomerIOApi interface CustomerIOHttpClient { /** - * Performs a POST request to [params.path] with [params.headers] and [params.body]. + * Performs an HTTP request to [params.path] using [params.method], with + * [params.headers] and [params.body]. * - * @param params The request parameters (path, headers, body). + * @param params The request parameters (path, method, headers, body). * @return A `Result`: * - `Result.success(responseBody)` for 2xx response codes * - `Result.failure(exception)` for network errors or non-2xx codes + * (an [HttpRequestException] for non-2xx, carrying the status code) */ suspend fun request(params: HttpRequestParams): Result } @@ -66,7 +84,7 @@ internal class CustomerIOHttpClientImpl : CustomerIOHttpClient { // Configure the connection connection.connectTimeout = connectTimeoutMs connection.readTimeout = readTimeoutMs - connection.requestMethod = "POST" + connection.requestMethod = params.method.name connection.setRequestProperty("User-Agent", client.toString()) // Authorization: Basic @@ -101,7 +119,7 @@ internal class CustomerIOHttpClientImpl : CustomerIOHttpClient { if (responseCode in 200..299) { Result.success(responseBody) } else { - Result.failure(IOException("HTTP $responseCode: $responseBody")) + Result.failure(HttpRequestException(responseCode, "HTTP $responseCode: $responseBody")) } } catch (e: IOException) { Result.failure(e) 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 2df58066c..ec448b2ac 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 @@ -12,8 +12,10 @@ import kotlinx.serialization.json.Json interface GlobalPreferenceStore { fun saveDeviceToken(token: String) fun saveSettings(value: Settings) + fun saveInstallationId(value: String) fun getDeviceToken(): String? fun getSettings(): Settings? + fun getInstallationId(): String? fun removeDeviceToken() fun clear(key: String) fun clearAll() @@ -35,6 +37,10 @@ internal class GlobalPreferenceStoreImpl( putString(KEY_CONFIG_SETTINGS, Json.encodeToString(Settings.serializer(), value)) } + override fun saveInstallationId(value: String) = prefs.edit { + putString(KEY_INSTALLATION_ID, value) + } + override fun getDeviceToken(): String? = prefs.read { getString(KEY_DEVICE_TOKEN, null) } @@ -48,10 +54,15 @@ internal class GlobalPreferenceStoreImpl( }.getOrNull() } + override fun getInstallationId(): String? = prefs.read { + getString(KEY_INSTALLATION_ID, null) + } + 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" + private const val KEY_INSTALLATION_ID = "installation_id" } } diff --git a/datapipelines/src/main/kotlin/io/customer/datapipelines/plugins/ContextPlugin.kt b/datapipelines/src/main/kotlin/io/customer/datapipelines/plugins/ContextPlugin.kt index 590684c73..0c774b9ca 100644 --- a/datapipelines/src/main/kotlin/io/customer/datapipelines/plugins/ContextPlugin.kt +++ b/datapipelines/src/main/kotlin/io/customer/datapipelines/plugins/ContextPlugin.kt @@ -14,6 +14,7 @@ import io.customer.sdk.data.store.DeviceStore */ internal class ContextPlugin( private val deviceStore: DeviceStore, + private val installationId: String, private val eventProcessor: ContextPluginEventProcessor = DefaultContextPluginEventProcessor() ) : Plugin { override val type: Plugin.Type = Plugin.Type.Before @@ -23,7 +24,7 @@ internal class ContextPlugin( internal var deviceToken: String? = null override fun execute(event: BaseEvent): BaseEvent { - return eventProcessor.execute(event, deviceStore) { deviceToken } + return eventProcessor.execute(event, deviceStore, installationId) { deviceToken } } } @@ -32,7 +33,12 @@ internal class ContextPlugin( * Allows custom logic to be injected for testing or extension. */ internal interface ContextPluginEventProcessor { - fun execute(event: BaseEvent, deviceStore: DeviceStore, deviceTokenProvider: () -> String?): BaseEvent + fun execute( + event: BaseEvent, + deviceStore: DeviceStore, + installationId: String, + deviceTokenProvider: () -> String? + ): BaseEvent } /** @@ -40,7 +46,12 @@ internal interface ContextPluginEventProcessor { * in the context and ensures the device token is added if not already present. */ internal class DefaultContextPluginEventProcessor : ContextPluginEventProcessor { - override fun execute(event: BaseEvent, deviceStore: DeviceStore, deviceTokenProvider: () -> String?): BaseEvent { + override fun execute( + event: BaseEvent, + deviceStore: DeviceStore, + installationId: String, + deviceTokenProvider: () -> String? + ): BaseEvent { // Set user agent in context as it is required by Customer.io Data Pipelines event.putInContext("userAgent", deviceStore.buildUserAgent()) // Remove analytics library information from context as Customer.io @@ -55,6 +66,10 @@ internal class DefaultContextPluginEventProcessor : ContextPluginEventProcessor event.putInContextUnderKey("device", "token", token) } + // Attach installation id to device in context so backend can correlate + // events to a single install across identify/clearIdentify cycles. + event.putInContextUnderKey("device", "installationId", installationId) + return event } } diff --git a/datapipelines/src/main/kotlin/io/customer/sdk/CustomerIO.kt b/datapipelines/src/main/kotlin/io/customer/sdk/CustomerIO.kt index 10fc002df..9f6ffb7b6 100644 --- a/datapipelines/src/main/kotlin/io/customer/sdk/CustomerIO.kt +++ b/datapipelines/src/main/kotlin/io/customer/sdk/CustomerIO.kt @@ -41,6 +41,7 @@ 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 java.util.UUID import kotlinx.serialization.SerializationStrategy import kotlinx.serialization.serializer @@ -109,7 +110,7 @@ class CustomerIO private constructor( ) ) - private val contextPlugin: ContextPlugin = ContextPlugin(deviceStore) + private val contextPlugin: ContextPlugin // Tracks the last userId successfully identified in this SDK session. Used to dedup // back-to-back identify(userId) calls with no traits, which are no-ops server-side. @@ -121,6 +122,14 @@ class CustomerIO private constructor( Analytics.debugLogsEnabled = logger.logLevel == CioLogLevel.DEBUG Analytics.setLogger(segmentLogger) + // Resolve installation id: load if present, otherwise generate once and persist. + // Survives clearIdentify and SDK re-init; only OS app-data clear wipes it. + val installationId = globalPreferenceStore.getInstallationId() + ?: UUID.randomUUID().toString().also { id -> + globalPreferenceStore.saveInstallationId(id) + } + contextPlugin = ContextPlugin(deviceStore = deviceStore, installationId = installationId) + // Add required plugins to analytics instance analytics.add(contextPlugin) diff --git a/datapipelines/src/test/java/io/customer/datapipelines/plugins/ContextPluginBehaviorTest.kt b/datapipelines/src/test/java/io/customer/datapipelines/plugins/ContextPluginBehaviorTest.kt index c35982a6c..c25faaee2 100644 --- a/datapipelines/src/test/java/io/customer/datapipelines/plugins/ContextPluginBehaviorTest.kt +++ b/datapipelines/src/test/java/io/customer/datapipelines/plugins/ContextPluginBehaviorTest.kt @@ -65,7 +65,9 @@ class ContextPluginBehaviorTest : JUnitTest() { @Test fun execute_whenDeviceTokenIsSetFromAnotherThread_thenAddsCorrectTokenToEvent() { val rounds = ROUND_COUNT - val contextPlugin = ContextPlugin(deviceStore) + // #689 added a required `installationId` to ContextPlugin; the deterministic test + // only asserts on deviceToken propagation, so a fixed stub id is sufficient. + val contextPlugin = ContextPlugin(deviceStore, installationId = "test-installation-id") // We exercise contextPlugin.execute(event) in isolation — no need to // attach to analytics. ContextPlugin.execute does not touch the // `analytics` lateinit; attaching it under JUnitTest's StandardTestDispatcher diff --git a/datapipelines/src/test/java/io/customer/datapipelines/plugins/ContextPluginTest.kt b/datapipelines/src/test/java/io/customer/datapipelines/plugins/ContextPluginTest.kt index 1849fa423..c6950c6b8 100644 --- a/datapipelines/src/test/java/io/customer/datapipelines/plugins/ContextPluginTest.kt +++ b/datapipelines/src/test/java/io/customer/datapipelines/plugins/ContextPluginTest.kt @@ -12,14 +12,19 @@ import io.customer.datapipelines.testutils.core.DataPipelinesTestConfig import io.customer.datapipelines.testutils.core.JUnitTest import io.customer.datapipelines.testutils.core.testConfiguration import io.customer.datapipelines.testutils.extensions.deviceToken +import io.customer.datapipelines.testutils.extensions.installationId import io.customer.datapipelines.testutils.utils.OutputReaderPlugin import io.customer.datapipelines.testutils.utils.trackEvents import io.customer.sdk.core.di.SDKComponent import io.customer.sdk.data.store.GlobalPreferenceStore import io.mockk.every +import io.mockk.slot +import io.mockk.verify +import java.util.UUID import org.amshove.kluent.shouldBeEqualTo import org.amshove.kluent.shouldBeNull import org.amshove.kluent.shouldHaveSingleItem +import org.amshove.kluent.shouldNotBeNull import org.junit.jupiter.api.Test class ContextPluginTest : JUnitTest() { @@ -97,6 +102,84 @@ class ContextPluginTest : JUnitTest() { result.event shouldBeEqualTo givenEventName result.context.deviceToken.shouldBeNull() } + + @Test + fun process_givenInstallationIdExists_expectAttachedToDeviceContext() { + val givenInstallationId = UUID.randomUUID().toString() + setupWithConfig( + testConfiguration { + diGraph { + android { + every { globalPreferenceStore.getInstallationId() } returns givenInstallationId + } + } + } + ) + + val givenEventName = String.random + analytics.process(TrackEvent(emptyJsonObject, givenEventName)) + + val result = outputReaderPlugin.trackEvents.shouldHaveSingleItem() + result.event shouldBeEqualTo givenEventName + result.context.installationId shouldBeEqualTo givenInstallationId + } + + @Test + fun process_givenNoInstallationIdStored_expectGeneratedAndPersisted() { + val savedIdSlot = slot() + setupWithConfig( + testConfiguration { + diGraph { + android { + every { globalPreferenceStore.getInstallationId() } returns null + every { globalPreferenceStore.saveInstallationId(capture(savedIdSlot)) } returns Unit + } + } + } + ) + + verify(exactly = 1) { globalPreferenceStore.saveInstallationId(any()) } + val persistedId = savedIdSlot.captured + // Ensure it parses as a valid UUID + UUID.fromString(persistedId).shouldNotBeNull() + + val firstEventName = String.random + analytics.process(TrackEvent(emptyJsonObject, firstEventName)) + val secondEventName = String.random + analytics.process(TrackEvent(emptyJsonObject, secondEventName)) + + val events = outputReaderPlugin.trackEvents + events[0].context.installationId shouldBeEqualTo persistedId + events[1].context.installationId shouldBeEqualTo persistedId + } + + @Test + fun process_acrossClearIdentify_expectInstallationIdUnchanged() { + val givenInstallationId = UUID.randomUUID().toString() + setupWithConfig( + testConfiguration { + diGraph { + android { + every { globalPreferenceStore.getInstallationId() } returns givenInstallationId + } + } + } + ) + + val firstEventName = String.random + analytics.process(TrackEvent(emptyJsonObject, firstEventName)) + val installationIdBefore = outputReaderPlugin.trackEvents.last().context.installationId + outputReaderPlugin.reset() + + sdkInstance.clearIdentify() + + val secondEventName = String.random + analytics.process(TrackEvent(emptyJsonObject, secondEventName)) + val installationIdAfter = outputReaderPlugin.trackEvents.last().context.installationId + + installationIdBefore shouldBeEqualTo givenInstallationId + installationIdAfter shouldBeEqualTo installationIdBefore + } } private class MigrationTokenPlugin : Plugin { diff --git a/datapipelines/src/test/java/io/customer/datapipelines/testutils/core/UnitTestDelegate.kt b/datapipelines/src/test/java/io/customer/datapipelines/testutils/core/UnitTestDelegate.kt index fc54ecc02..342c78489 100644 --- a/datapipelines/src/test/java/io/customer/datapipelines/testutils/core/UnitTestDelegate.kt +++ b/datapipelines/src/test/java/io/customer/datapipelines/testutils/core/UnitTestDelegate.kt @@ -81,6 +81,7 @@ class UnitTestDelegate( androidSDKComponent.overrideDependency(instance) } every { globalPreferenceStore.getDeviceToken() } returns null + every { globalPreferenceStore.getInstallationId() } returns null // Mock device store to avoid reading/writing to device store // Spy on the stub to provide custom implementation for the test val deviceStoreStub = DeviceStoreStub().getDeviceStore(androidSDKComponent.client) diff --git a/datapipelines/src/test/java/io/customer/datapipelines/testutils/extensions/JsonExtensions.kt b/datapipelines/src/test/java/io/customer/datapipelines/testutils/extensions/JsonExtensions.kt index 9010ed04d..e6e525393 100644 --- a/datapipelines/src/test/java/io/customer/datapipelines/testutils/extensions/JsonExtensions.kt +++ b/datapipelines/src/test/java/io/customer/datapipelines/testutils/extensions/JsonExtensions.kt @@ -85,6 +85,9 @@ private fun Json.encode(value: T?): JsonElement = when (value) { internal val JsonObject.deviceToken: String? get() = this.getStringAtPath("device.token") +internal val JsonObject.installationId: String? + get() = this.getStringAtPath("device.installationId") + fun JsonObject.getStringAtPath(path: String): String? { return findAtPath(path).firstOrNull()?.content } diff --git a/messagingpush/api/messagingpush.api b/messagingpush/api/messagingpush.api index 019e24b43..42831a9f3 100644 --- a/messagingpush/api/messagingpush.api +++ b/messagingpush/api/messagingpush.api @@ -22,8 +22,10 @@ public final class io/customer/messagingpush/CustomerIOFirebaseMessagingService$ public final class io/customer/messagingpush/MessagingPushModuleConfig : io/customer/sdk/core/module/CustomerIOModuleConfig { public static final field Companion Lio/customer/messagingpush/MessagingPushModuleConfig$Companion; - public synthetic fun (ZLio/customer/messagingpush/data/communication/CustomerIOPushNotificationCallback;Lio/customer/messagingpush/config/PushClickBehavior;Lkotlin/jvm/internal/DefaultConstructorMarker;)V + public synthetic fun (ZLio/customer/messagingpush/data/communication/CustomerIOPushNotificationCallback;Lio/customer/messagingpush/config/PushClickBehavior;Lio/customer/messagingpush/livenotification/LiveNotificationBranding;Ljava/util/Set;Lkotlin/jvm/internal/DefaultConstructorMarker;)V public final fun getAutoTrackPushEvents ()Z + public final fun getLiveNotificationBranding ()Lio/customer/messagingpush/livenotification/LiveNotificationBranding; + public final fun getLiveNotificationTypes ()Ljava/util/Set; public final fun getNotificationCallback ()Lio/customer/messagingpush/data/communication/CustomerIOPushNotificationCallback; public final fun getPushClickBehavior ()Lio/customer/messagingpush/config/PushClickBehavior; public fun toString ()Ljava/lang/String; @@ -34,6 +36,8 @@ public final class io/customer/messagingpush/MessagingPushModuleConfig$Builder : public fun build ()Lio/customer/messagingpush/MessagingPushModuleConfig; public synthetic fun build ()Lio/customer/sdk/core/module/CustomerIOModuleConfig; public final fun setAutoTrackPushEvents (Z)Lio/customer/messagingpush/MessagingPushModuleConfig$Builder; + public final fun setLiveNotificationBranding (Lio/customer/messagingpush/livenotification/LiveNotificationBranding;)Lio/customer/messagingpush/MessagingPushModuleConfig$Builder; + public final fun setLiveNotificationTypes ([Ljava/lang/String;)Lio/customer/messagingpush/MessagingPushModuleConfig$Builder; public final fun setNotificationCallback (Lio/customer/messagingpush/data/communication/CustomerIOPushNotificationCallback;)Lio/customer/messagingpush/MessagingPushModuleConfig$Builder; public final fun setPushClickBehavior (Lio/customer/messagingpush/config/PushClickBehavior;)Lio/customer/messagingpush/MessagingPushModuleConfig$Builder; } @@ -50,6 +54,8 @@ public final class io/customer/messagingpush/ModuleMessagingPushFCM : io/custome public synthetic fun getModuleConfig ()Lio/customer/sdk/core/module/CustomerIOModuleConfig; public fun getModuleName ()Ljava/lang/String; public fun initialize ()V + public final fun startLiveNotification (Lio/customer/messagingpush/livenotification/LiveNotificationData;)Ljava/lang/String; + public final fun startLiveNotification (Ljava/lang/String;Ljava/util/Map;)Ljava/lang/String; } public final class io/customer/messagingpush/ModuleMessagingPushFCM$Companion { @@ -75,18 +81,21 @@ public final class io/customer/messagingpush/config/PushClickBehavior : java/lan } public abstract interface class io/customer/messagingpush/data/communication/CustomerIOPushNotificationCallback { + public abstract fun createLiveNotification (Lio/customer/messagingpush/data/model/CustomerIOParsedPushPayload;Landroid/content/Context;)Landroid/app/Notification; public abstract fun onNotificationClicked (Lio/customer/messagingpush/data/model/CustomerIOParsedPushPayload;Landroid/content/Context;)Lkotlin/Unit; public abstract fun onNotificationComposed (Lio/customer/messagingpush/data/model/CustomerIOParsedPushPayload;Landroidx/core/app/NotificationCompat$Builder;)V } public final class io/customer/messagingpush/data/communication/CustomerIOPushNotificationCallback$DefaultImpls { + public static fun createLiveNotification (Lio/customer/messagingpush/data/communication/CustomerIOPushNotificationCallback;Lio/customer/messagingpush/data/model/CustomerIOParsedPushPayload;Landroid/content/Context;)Landroid/app/Notification; public static fun onNotificationClicked (Lio/customer/messagingpush/data/communication/CustomerIOPushNotificationCallback;Lio/customer/messagingpush/data/model/CustomerIOParsedPushPayload;Landroid/content/Context;)Lkotlin/Unit; public static fun onNotificationComposed (Lio/customer/messagingpush/data/communication/CustomerIOPushNotificationCallback;Lio/customer/messagingpush/data/model/CustomerIOParsedPushPayload;Landroidx/core/app/NotificationCompat$Builder;)V } public final class io/customer/messagingpush/data/model/CustomerIOParsedPushPayload : android/os/Parcelable { public static final field CREATOR Lio/customer/messagingpush/data/model/CustomerIOParsedPushPayload$CREATOR; - public fun (Landroid/os/Bundle;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;)V + public fun (Landroid/os/Bundle;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;)V + public synthetic fun (Landroid/os/Bundle;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;ILkotlin/jvm/internal/DefaultConstructorMarker;)V public fun (Landroid/os/Parcel;)V public final fun component1 ()Landroid/os/Bundle; public final fun component2 ()Ljava/lang/String; @@ -94,10 +103,12 @@ public final class io/customer/messagingpush/data/model/CustomerIOParsedPushPayl public final fun component4 ()Ljava/lang/String; public final fun component5 ()Ljava/lang/String; public final fun component6 ()Ljava/lang/String; - public final fun copy (Landroid/os/Bundle;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;)Lio/customer/messagingpush/data/model/CustomerIOParsedPushPayload; - public static synthetic fun copy$default (Lio/customer/messagingpush/data/model/CustomerIOParsedPushPayload;Landroid/os/Bundle;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;ILjava/lang/Object;)Lio/customer/messagingpush/data/model/CustomerIOParsedPushPayload; + public final fun component7 ()Ljava/lang/String; + public final fun copy (Landroid/os/Bundle;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;)Lio/customer/messagingpush/data/model/CustomerIOParsedPushPayload; + public static synthetic fun copy$default (Lio/customer/messagingpush/data/model/CustomerIOParsedPushPayload;Landroid/os/Bundle;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;ILjava/lang/Object;)Lio/customer/messagingpush/data/model/CustomerIOParsedPushPayload; public fun describeContents ()I public fun equals (Ljava/lang/Object;)Z + public final fun getActivityId ()Ljava/lang/String; public final fun getBody ()Ljava/lang/String; public final fun getCioDeliveryId ()Ljava/lang/String; public final fun getCioDeliveryToken ()Ljava/lang/String; @@ -120,6 +131,216 @@ public final class io/customer/messagingpush/di/DiGraphMessagingPushKt { public static final fun getPushModuleConfig (Lio/customer/sdk/core/di/SDKComponent;)Lio/customer/messagingpush/MessagingPushModuleConfig; } +public final class io/customer/messagingpush/livenotification/LiveNotificationBranding { + public fun (Ljava/lang/String;ILjava/lang/String;)V + public synthetic fun (Ljava/lang/String;ILjava/lang/String;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun component1 ()Ljava/lang/String; + public final fun component2 ()I + public final fun component3 ()Ljava/lang/String; + public final fun copy (Ljava/lang/String;ILjava/lang/String;)Lio/customer/messagingpush/livenotification/LiveNotificationBranding; + public static synthetic fun copy$default (Lio/customer/messagingpush/livenotification/LiveNotificationBranding;Ljava/lang/String;ILjava/lang/String;ILjava/lang/Object;)Lio/customer/messagingpush/livenotification/LiveNotificationBranding; + public fun equals (Ljava/lang/Object;)Z + public final fun getAccentColor ()I + public final fun getCompanyName ()Ljava/lang/String; + public final fun getLogoDrawableName ()Ljava/lang/String; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public abstract interface class io/customer/messagingpush/livenotification/LiveNotificationData { + public abstract fun fields ()Ljava/util/Map; + public abstract fun getActivityType ()Ljava/lang/String; +} + +public final class io/customer/messagingpush/livenotification/LiveNotificationData$Airport { + public fun (Ljava/lang/String;Ljava/lang/String;)V + public synthetic fun (Ljava/lang/String;Ljava/lang/String;ILkotlin/jvm/internal/DefaultConstructorMarker;)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/messagingpush/livenotification/LiveNotificationData$Airport; + public static synthetic fun copy$default (Lio/customer/messagingpush/livenotification/LiveNotificationData$Airport;Ljava/lang/String;Ljava/lang/String;ILjava/lang/Object;)Lio/customer/messagingpush/livenotification/LiveNotificationData$Airport; + public fun equals (Ljava/lang/Object;)Z + public final fun getCity ()Ljava/lang/String; + public final fun getCode ()Ljava/lang/String; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class io/customer/messagingpush/livenotification/LiveNotificationData$AuctionBid : io/customer/messagingpush/livenotification/LiveNotificationData { + public fun (Ljava/lang/String;Ljava/lang/String;ILjava/lang/String;ZLjava/lang/Long;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;)V + public synthetic fun (Ljava/lang/String;Ljava/lang/String;ILjava/lang/String;ZLjava/lang/Long;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun component1 ()Ljava/lang/String; + public final fun component2 ()Ljava/lang/String; + public final fun component3 ()I + public final fun component4 ()Ljava/lang/String; + public final fun component5 ()Z + public final fun component6 ()Ljava/lang/Long; + public final fun component7 ()Ljava/lang/String; + public final fun component8 ()Ljava/lang/String; + public final fun component9 ()Ljava/lang/String; + public final fun copy (Ljava/lang/String;Ljava/lang/String;ILjava/lang/String;ZLjava/lang/Long;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;)Lio/customer/messagingpush/livenotification/LiveNotificationData$AuctionBid; + public static synthetic fun copy$default (Lio/customer/messagingpush/livenotification/LiveNotificationData$AuctionBid;Ljava/lang/String;Ljava/lang/String;ILjava/lang/String;ZLjava/lang/Long;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;ILjava/lang/Object;)Lio/customer/messagingpush/livenotification/LiveNotificationData$AuctionBid; + public fun equals (Ljava/lang/Object;)Z + public fun fields ()Ljava/util/Map; + public fun getActivityType ()Ljava/lang/String; + public final fun getBidCount ()I + public final fun getCurrencySymbol ()Ljava/lang/String; + public final fun getCurrentBid ()Ljava/lang/String; + public final fun getEndTime ()Ljava/lang/Long; + public final fun getItemImageKey ()Ljava/lang/String; + public final fun getItemTitle ()Ljava/lang/String; + public final fun getStatusMessage ()Ljava/lang/String; + public final fun getUserBidAmount ()Ljava/lang/String; + public fun hashCode ()I + public final fun isUserHighBidder ()Z + public fun toString ()Ljava/lang/String; +} + +public final class io/customer/messagingpush/livenotification/LiveNotificationData$CountdownTimer : io/customer/messagingpush/livenotification/LiveNotificationData { + public fun (Ljava/lang/String;JLjava/lang/String;Ljava/lang/String;Ljava/lang/String;)V + public synthetic fun (Ljava/lang/String;JLjava/lang/String;Ljava/lang/String;Ljava/lang/String;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun component1 ()Ljava/lang/String; + public final fun component2 ()J + public final fun component3 ()Ljava/lang/String; + public final fun component4 ()Ljava/lang/String; + public final fun component5 ()Ljava/lang/String; + public final fun copy (Ljava/lang/String;JLjava/lang/String;Ljava/lang/String;Ljava/lang/String;)Lio/customer/messagingpush/livenotification/LiveNotificationData$CountdownTimer; + public static synthetic fun copy$default (Lio/customer/messagingpush/livenotification/LiveNotificationData$CountdownTimer;Ljava/lang/String;JLjava/lang/String;Ljava/lang/String;Ljava/lang/String;ILjava/lang/Object;)Lio/customer/messagingpush/livenotification/LiveNotificationData$CountdownTimer; + public fun equals (Ljava/lang/Object;)Z + public fun fields ()Ljava/util/Map; + public fun getActivityType ()Ljava/lang/String; + public final fun getExpiredMessage ()Ljava/lang/String; + public final fun getHeroImageKey ()Ljava/lang/String; + public final fun getStatusMessage ()Ljava/lang/String; + public final fun getTargetDate ()J + public final fun getTitle ()Ljava/lang/String; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class io/customer/messagingpush/livenotification/LiveNotificationData$DeliveryTracking : io/customer/messagingpush/livenotification/LiveNotificationData { + public fun (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/Integer;Ljava/lang/Integer;Ljava/lang/Long;)V + public synthetic fun (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/Integer;Ljava/lang/Integer;Ljava/lang/Long;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun component1 ()Ljava/lang/String; + public final fun component2 ()Ljava/lang/String; + public final fun component3 ()Ljava/lang/String; + public final fun component4 ()Ljava/lang/String; + public final fun component5 ()Ljava/lang/String; + public final fun component6 ()Ljava/lang/Integer; + public final fun component7 ()Ljava/lang/Integer; + public final fun component8 ()Ljava/lang/Long; + public final fun copy (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/Integer;Ljava/lang/Integer;Ljava/lang/Long;)Lio/customer/messagingpush/livenotification/LiveNotificationData$DeliveryTracking; + public static synthetic fun copy$default (Lio/customer/messagingpush/livenotification/LiveNotificationData$DeliveryTracking;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/Integer;Ljava/lang/Integer;Ljava/lang/Long;ILjava/lang/Object;)Lio/customer/messagingpush/livenotification/LiveNotificationData$DeliveryTracking; + public fun equals (Ljava/lang/Object;)Z + public fun fields ()Ljava/util/Map; + public fun getActivityType ()Ljava/lang/String; + public final fun getDriverName ()Ljava/lang/String; + public final fun getEstimatedArrival ()Ljava/lang/Long; + public final fun getOrderId ()Ljava/lang/String; + public final fun getRecipientName ()Ljava/lang/String; + public final fun getStatusImageKey ()Ljava/lang/String; + public final fun getStatusMessage ()Ljava/lang/String; + public final fun getStepCurrent ()Ljava/lang/Integer; + public final fun getStepTotal ()Ljava/lang/Integer; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class io/customer/messagingpush/livenotification/LiveNotificationData$FlightStatus : io/customer/messagingpush/livenotification/LiveNotificationData { + public fun (Ljava/lang/String;Lio/customer/messagingpush/livenotification/LiveNotificationData$Airport;Lio/customer/messagingpush/livenotification/LiveNotificationData$Airport;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/Long;Ljava/lang/Long;Ljava/lang/Double;Ljava/lang/Integer;)V + public synthetic fun (Ljava/lang/String;Lio/customer/messagingpush/livenotification/LiveNotificationData$Airport;Lio/customer/messagingpush/livenotification/LiveNotificationData$Airport;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/Long;Ljava/lang/Long;Ljava/lang/Double;Ljava/lang/Integer;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun component1 ()Ljava/lang/String; + public final fun component10 ()Ljava/lang/Integer; + public final fun component2 ()Lio/customer/messagingpush/livenotification/LiveNotificationData$Airport; + public final fun component3 ()Lio/customer/messagingpush/livenotification/LiveNotificationData$Airport; + public final fun component4 ()Ljava/lang/String; + public final fun component5 ()Ljava/lang/String; + public final fun component6 ()Ljava/lang/String; + public final fun component7 ()Ljava/lang/Long; + public final fun component8 ()Ljava/lang/Long; + public final fun component9 ()Ljava/lang/Double; + public final fun copy (Ljava/lang/String;Lio/customer/messagingpush/livenotification/LiveNotificationData$Airport;Lio/customer/messagingpush/livenotification/LiveNotificationData$Airport;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/Long;Ljava/lang/Long;Ljava/lang/Double;Ljava/lang/Integer;)Lio/customer/messagingpush/livenotification/LiveNotificationData$FlightStatus; + public static synthetic fun copy$default (Lio/customer/messagingpush/livenotification/LiveNotificationData$FlightStatus;Ljava/lang/String;Lio/customer/messagingpush/livenotification/LiveNotificationData$Airport;Lio/customer/messagingpush/livenotification/LiveNotificationData$Airport;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/Long;Ljava/lang/Long;Ljava/lang/Double;Ljava/lang/Integer;ILjava/lang/Object;)Lio/customer/messagingpush/livenotification/LiveNotificationData$FlightStatus; + public fun equals (Ljava/lang/Object;)Z + public fun fields ()Ljava/util/Map; + public fun getActivityType ()Ljava/lang/String; + public final fun getDelayMinutes ()Ljava/lang/Integer; + public final fun getDestination ()Lio/customer/messagingpush/livenotification/LiveNotificationData$Airport; + public final fun getEstimatedArrival ()Ljava/lang/Long; + public final fun getFlightNumber ()Ljava/lang/String; + public final fun getGate ()Ljava/lang/String; + public final fun getOrigin ()Lio/customer/messagingpush/livenotification/LiveNotificationData$Airport; + public final fun getProgressFraction ()Ljava/lang/Double; + public final fun getScheduledDeparture ()Ljava/lang/Long; + public final fun getStatusMessage ()Ljava/lang/String; + public final fun getTerminal ()Ljava/lang/String; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class io/customer/messagingpush/livenotification/LiveNotificationData$LiveScore : io/customer/messagingpush/livenotification/LiveNotificationData { + public fun (Lio/customer/messagingpush/livenotification/LiveNotificationData$Team;Lio/customer/messagingpush/livenotification/LiveNotificationData$Team;Ljava/lang/String;IILjava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;)V + public synthetic fun (Lio/customer/messagingpush/livenotification/LiveNotificationData$Team;Lio/customer/messagingpush/livenotification/LiveNotificationData$Team;Ljava/lang/String;IILjava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun component1 ()Lio/customer/messagingpush/livenotification/LiveNotificationData$Team; + public final fun component2 ()Lio/customer/messagingpush/livenotification/LiveNotificationData$Team; + public final fun component3 ()Ljava/lang/String; + public final fun component4 ()I + public final fun component5 ()I + public final fun component6 ()Ljava/lang/String; + public final fun component7 ()Ljava/lang/String; + public final fun component8 ()Ljava/lang/String; + public final fun component9 ()Ljava/lang/String; + public final fun copy (Lio/customer/messagingpush/livenotification/LiveNotificationData$Team;Lio/customer/messagingpush/livenotification/LiveNotificationData$Team;Ljava/lang/String;IILjava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;)Lio/customer/messagingpush/livenotification/LiveNotificationData$LiveScore; + public static synthetic fun copy$default (Lio/customer/messagingpush/livenotification/LiveNotificationData$LiveScore;Lio/customer/messagingpush/livenotification/LiveNotificationData$Team;Lio/customer/messagingpush/livenotification/LiveNotificationData$Team;Ljava/lang/String;IILjava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;ILjava/lang/Object;)Lio/customer/messagingpush/livenotification/LiveNotificationData$LiveScore; + public fun equals (Ljava/lang/Object;)Z + public fun fields ()Ljava/util/Map; + public fun getActivityType ()Ljava/lang/String; + public final fun getAwayScore ()I + public final fun getAwayTeam ()Lio/customer/messagingpush/livenotification/LiveNotificationData$Team; + public final fun getClock ()Ljava/lang/String; + public final fun getHomeScore ()I + public final fun getHomeTeam ()Lio/customer/messagingpush/livenotification/LiveNotificationData$Team; + public final fun getLeagueLogoKey ()Ljava/lang/String; + public final fun getPeriod ()Ljava/lang/String; + public final fun getSport ()Ljava/lang/String; + public final fun getStatusMessage ()Ljava/lang/String; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class io/customer/messagingpush/livenotification/LiveNotificationData$Team { + public fun (Ljava/lang/String;Ljava/lang/String;)V + public synthetic fun (Ljava/lang/String;Ljava/lang/String;ILkotlin/jvm/internal/DefaultConstructorMarker;)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/messagingpush/livenotification/LiveNotificationData$Team; + public static synthetic fun copy$default (Lio/customer/messagingpush/livenotification/LiveNotificationData$Team;Ljava/lang/String;Ljava/lang/String;ILjava/lang/Object;)Lio/customer/messagingpush/livenotification/LiveNotificationData$Team; + public fun equals (Ljava/lang/Object;)Z + public final fun getLogoKey ()Ljava/lang/String; + public final fun getName ()Ljava/lang/String; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class io/customer/messagingpush/livenotification/LiveNotificationDismissReceiver : android/content/BroadcastReceiver { + public static final field Companion Lio/customer/messagingpush/livenotification/LiveNotificationDismissReceiver$Companion; + public static final field EXTRA_ACTIVITY_ID Ljava/lang/String; + public fun ()V + public fun onReceive (Landroid/content/Context;Landroid/content/Intent;)V +} + +public final class io/customer/messagingpush/livenotification/LiveNotificationDismissReceiver$Companion { +} + +public final class io/customer/messagingpush/livenotification/LiveNotificationType { + public static final field AUCTION_BID Ljava/lang/String; + public static final field COUNTDOWN_TIMER Ljava/lang/String; + public static final field DELIVERY_TRACKING Ljava/lang/String; + public static final field FLIGHT_STATUS Ljava/lang/String; + public static final field INSTANCE Lio/customer/messagingpush/livenotification/LiveNotificationType; + public static final field LIVE_SCORE Ljava/lang/String; +} + public final class io/customer/messagingpush/processor/PushMessageProcessor$Companion { public static final field RECENT_MESSAGES_MAX_SIZE I public final fun getRecentMessagesQueue ()Ljava/util/concurrent/LinkedBlockingDeque; diff --git a/messagingpush/src/main/AndroidManifest.xml b/messagingpush/src/main/AndroidManifest.xml index 0a94efbad..2eb445d4d 100644 --- a/messagingpush/src/main/AndroidManifest.xml +++ b/messagingpush/src/main/AndroidManifest.xml @@ -3,6 +3,7 @@ + + diff --git a/messagingpush/src/main/java/io/customer/messagingpush/Api36LiveNotificationBuilder.kt b/messagingpush/src/main/java/io/customer/messagingpush/Api36LiveNotificationBuilder.kt new file mode 100644 index 000000000..479d8bad0 --- /dev/null +++ b/messagingpush/src/main/java/io/customer/messagingpush/Api36LiveNotificationBuilder.kt @@ -0,0 +1,169 @@ +package io.customer.messagingpush + +import android.app.Notification +import android.app.PendingIntent +import android.content.Context +import android.content.pm.PackageManager +import android.graphics.Bitmap +import android.graphics.drawable.Icon +import android.os.Build +import android.os.Bundle +import androidx.annotation.ColorInt +import androidx.annotation.DrawableRes +import androidx.annotation.RequiresApi +import androidx.core.content.ContextCompat +import io.customer.messagingpush.livenotification.template.PointSpec +import io.customer.messagingpush.livenotification.template.SegmentSpec +import io.customer.sdk.core.di.SDKComponent + +/** + * Parameters for building a promoted live notification on API 36+ (BAKLAVA). + * + * When [showProgress] is true, uses [Notification.ProgressStyle] with full + * segmented progress bar support. When false, uses [Notification.BigTextStyle] + * for text-only live updates (sports scores, auction bids, etc.). + * + * Both modes request promoted ongoing status for live update treatment. + */ +internal data class Api36LiveNotificationParams( + val context: Context, + val channelId: String, + val title: String, + val body: String, + val subText: String?, + @DrawableRes val smallIcon: Int, + @ColorInt val accentColor: Int?, + val segments: List, + val points: List, + val progress: Int, + val progressMax: Int, + @DrawableRes val startIconRes: Int?, + @DrawableRes val endIconRes: Int?, + @DrawableRes val trackerIconRes: Int?, + val pendingIntent: PendingIntent?, + val deleteIntent: PendingIntent?, + val countdownUntil: Long?, + val largeIcon: Bitmap?, + val showProgress: Boolean +) + +/** + * Builds promoted live notifications on API 36+ (BAKLAVA). + * + * Uses [Notification.ProgressStyle] for progress-based notifications and + * [Notification.BigTextStyle] for text-only live updates. Both styles are + * valid for promoted live updates per Android documentation. + * + * Requirements for promoted live updates (customer responsibility): + * - App manifest must declare `android.permission.POST_PROMOTED_NOTIFICATIONS` + * - Notification must not be colorized (this builder does not call setColorized) + * - Notification must use an allowed style (ProgressStyle or BigTextStyle) + * - Notification must have a title and be ongoing + */ +internal object Api36LiveNotificationBuilder { + + // Notification.EXTRA_REQUEST_PROMOTED_ONGOING was added in extension SDK 36.1. + // Use the raw string value so we can compile against base API 36. + private const val EXTRA_REQUEST_PROMOTED_ONGOING = "android.requestPromotedOngoing" + private const val POST_PROMOTED_NOTIFICATIONS_PERMISSION = + "android.permission.POST_PROMOTED_NOTIFICATIONS" + + @RequiresApi(Build.VERSION_CODES.BAKLAVA) + fun build(params: Api36LiveNotificationParams): Notification { + val builder = Notification.Builder(params.context, params.channelId) + .setSmallIcon(params.smallIcon) + .setContentTitle(params.title) + .setContentText(params.body) + .setOngoing(true) + .setOnlyAlertOnce(true) + + if (canPostPromotedNotifications(params.context)) { + val extras = Bundle().apply { + putBoolean(EXTRA_REQUEST_PROMOTED_ONGOING, true) + } + builder.addExtras(extras) + } else { + SDKComponent.logger.debug( + "POST_PROMOTED_NOTIFICATIONS not granted; posting as standard ongoing" + ) + } + + if (params.showProgress) { + builder.setCategory(Notification.CATEGORY_PROGRESS) + + val effectiveSegments = if (params.segments.isNotEmpty()) { + params.segments.map { it.toSystem() } + } else { + listOf(Notification.ProgressStyle.Segment(params.progressMax.coerceAtLeast(1))) + } + val maxProgress = effectiveSegments.sumOf { it.length } + val safeProgress = params.progress.coerceIn(0, maxProgress) + + val progressStyle = Notification.ProgressStyle() + .setProgress(safeProgress) + + progressStyle.progressSegments = effectiveSegments + + if (params.points.isNotEmpty()) { + progressStyle.progressPoints = params.points.map { it.toSystem(maxProgress) } + } + + params.startIconRes?.let { res -> + progressStyle.setProgressStartIcon(Icon.createWithResource(params.context, res)) + } + params.endIconRes?.let { res -> + progressStyle.setProgressEndIcon(Icon.createWithResource(params.context, res)) + } + params.trackerIconRes?.let { res -> + progressStyle.setProgressTrackerIcon(Icon.createWithResource(params.context, res)) + } + + builder.style = progressStyle + } else { + builder.setCategory(Notification.CATEGORY_STATUS) + builder.style = Notification.BigTextStyle().bigText(params.body) + } + + // Only start a count-down chronometer to a future instant. A stale/past target + // (e.g. an estimatedArrival that has already elapsed) would render as an + // already-expired live update and the system may suppress the notification entirely. + params.countdownUntil?.takeIf { it > System.currentTimeMillis() }?.let { until -> + builder.setWhen(until) + builder.setUsesChronometer(true) + builder.setChronometerCountDown(true) + builder.setShowWhen(true) + } + + params.largeIcon?.let { bitmap -> + builder.setLargeIcon(Icon.createWithBitmap(bitmap)) + } + + params.subText?.let { builder.setSubText(it) } + params.accentColor?.let { builder.setColor(it) } + params.pendingIntent?.let { builder.setContentIntent(it) } + params.deleteIntent?.let { builder.setDeleteIntent(it) } + + return builder.build() + } + + private fun canPostPromotedNotifications(context: Context): Boolean { + return ContextCompat.checkSelfPermission( + context, + POST_PROMOTED_NOTIFICATIONS_PERMISSION + ) == PackageManager.PERMISSION_GRANTED + } + + @RequiresApi(Build.VERSION_CODES.BAKLAVA) + private fun SegmentSpec.toSystem(): Notification.ProgressStyle.Segment { + val segment = Notification.ProgressStyle.Segment(length.coerceAtLeast(1)) + color?.let { segment.color = it } + return segment + } + + @RequiresApi(Build.VERSION_CODES.BAKLAVA) + private fun PointSpec.toSystem(maxProgress: Int): Notification.ProgressStyle.Point { + val point = Notification.ProgressStyle.Point(position.coerceIn(0, maxProgress)) + color?.let { point.color = it } + return point + } +} diff --git a/messagingpush/src/main/java/io/customer/messagingpush/BasicNotificationBuilder.kt b/messagingpush/src/main/java/io/customer/messagingpush/BasicNotificationBuilder.kt new file mode 100644 index 000000000..5402e5df2 --- /dev/null +++ b/messagingpush/src/main/java/io/customer/messagingpush/BasicNotificationBuilder.kt @@ -0,0 +1,96 @@ +package io.customer.messagingpush + +import android.app.Notification +import android.app.PendingIntent +import android.content.Context +import android.graphics.Bitmap +import android.os.Build +import androidx.annotation.ColorInt +import androidx.annotation.DrawableRes +import androidx.core.app.NotificationCompat + +/** + * Parameters for building a live notification on pre-API 36 devices using + * standard [NotificationCompat] styles. + * + * Supports: + * - Standard linear progress bar (determinate) + * - Accent color for notification chrome + * - Colorized mode (tints the entire notification background) + * - Title, body, and subtext via standard notification fields + * - Countdown timer (countdown direction requires API 24+) + * + * Not supported on this tier (use [Api36LiveNotificationParams] on API 36+): + * - Segmented progress bar with custom segment colors + * - Progress points, start/end/tracker icons + * - Promoted live update status + */ +internal data class BasicNotificationParams( + val context: Context, + val channelId: String, + val title: String, + val body: String, + val subText: String?, + @DrawableRes val smallIcon: Int, + @ColorInt val accentColor: Int?, + val colorized: Boolean, + val progress: Int, + val progressMax: Int, + val pendingIntent: PendingIntent?, + val deleteIntent: PendingIntent?, + val countdownUntil: Long?, + val largeIcon: Bitmap?, + val showProgress: Boolean +) + +/** + * Builds live notifications for pre-API 36 devices using standard + * [NotificationCompat] styles with a native progress bar. + */ +internal object BasicNotificationBuilder { + + fun build(params: BasicNotificationParams): Notification { + val category = if (params.showProgress) { + NotificationCompat.CATEGORY_PROGRESS + } else { + NotificationCompat.CATEGORY_STATUS + } + + val builder = NotificationCompat.Builder(params.context, params.channelId) + .setSmallIcon(params.smallIcon) + .setContentTitle(params.title) + .setContentText(params.body) + .setOngoing(true) + .setOnlyAlertOnce(true) + .setCategory(category) + .setStyle(NotificationCompat.BigTextStyle().bigText(params.body)) + + if (params.showProgress) { + val safeProgress = params.progress.coerceIn(0, params.progressMax) + builder.setProgress(params.progressMax, safeProgress, false) + } + + // Only count down to a future instant; a past target renders as an already-expired + // chronometer and can suppress the notification (see Api36LiveNotificationBuilder). + params.countdownUntil?.takeIf { it > System.currentTimeMillis() }?.let { until -> + builder.setWhen(until) + builder.setUsesChronometer(true) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + builder.setChronometerCountDown(true) + } + builder.setShowWhen(true) + } + + params.largeIcon?.let { builder.setLargeIcon(it) } + + params.subText?.let { builder.setSubText(it) } + params.accentColor?.let { builder.setColor(it) } + if (params.colorized) { + builder.setColorized(true) + } + params.pendingIntent?.let { builder.setContentIntent(it) } + params.deleteIntent?.let { builder.setDeleteIntent(it) } + + return builder.build() + } +} diff --git a/messagingpush/src/main/java/io/customer/messagingpush/CustomerIOPushNotificationHandler.kt b/messagingpush/src/main/java/io/customer/messagingpush/CustomerIOPushNotificationHandler.kt index b1c27295b..705b14ce2 100644 --- a/messagingpush/src/main/java/io/customer/messagingpush/CustomerIOPushNotificationHandler.kt +++ b/messagingpush/src/main/java/io/customer/messagingpush/CustomerIOPushNotificationHandler.kt @@ -5,7 +5,6 @@ import android.app.PendingIntent import android.content.Context import android.content.Intent import android.graphics.Bitmap -import android.graphics.BitmapFactory import android.media.RingtoneManager import android.os.Build import android.os.Bundle @@ -21,16 +20,13 @@ import io.customer.messagingpush.di.pushLogger import io.customer.messagingpush.di.pushModuleConfig import io.customer.messagingpush.extensions.* import io.customer.messagingpush.processor.PushMessageProcessor +import io.customer.messagingpush.util.BitmapDownloader import io.customer.messagingpush.util.NotificationChannelCreator import io.customer.messagingpush.util.PushTrackingUtil.Companion.DELIVERY_ID_KEY import io.customer.messagingpush.util.PushTrackingUtil.Companion.DELIVERY_TOKEN_KEY import io.customer.sdk.core.di.SDKComponent import io.customer.sdk.core.extensions.applicationMetaData -import java.net.URL import kotlin.math.abs -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.runBlocking -import kotlinx.coroutines.withContext /** * Class to handle PushNotification. @@ -117,9 +113,6 @@ internal class CustomerIOPushNotificationHandler( pushLogger.logShowingPushNotification(remoteMessage) val applicationName = context.applicationInfo.loadLabel(context.packageManager).toString() - val requestCode = abs(System.currentTimeMillis().toInt()) - - bundle.putInt(NOTIFICATION_REQUEST_CODE, requestCode) val appMetaData = context.applicationMetaData() @@ -150,6 +143,33 @@ internal class CustomerIOPushNotificationHandler( appMetaData = appMetaData, notificationManager = notificationManager ) + + // Check if this is a live notification + val activityId = bundle.getString(LiveNotificationHandler.ACTIVITY_ID_KEY) + if (activityId != null) { + val liveChannelId = notificationChannelCreator.createLiveNotificationChannelIfNeededAndReturnChannelId( + context = context, + applicationName = applicationName, + appMetaData = appMetaData, + notificationManager = notificationManager + ) + // onNotificationComposed is intentionally not called for live notifications — + // their layout is SDK-controlled and cannot be safely modified by host apps. + LiveNotificationHandler(bundle).handle( + context = context, + deliveryId = deliveryId, + deliveryToken = deliveryToken, + smallIcon = smallIcon, + tintColor = tintColor, + channelId = liveChannelId, + notificationManager = notificationManager + ) + return + } + + val requestCode = abs(System.currentTimeMillis().toInt()) + bundle.putInt(NOTIFICATION_REQUEST_CODE, requestCode) + val defaultSoundUri = RingtoneManager.getDefaultUri(RingtoneManager.TYPE_NOTIFICATION) val notificationBuilder = NotificationCompat.Builder(context, channelId) .setSmallIcon(smallIcon) @@ -226,19 +246,11 @@ internal class CustomerIOPushNotificationHandler( builder: NotificationCompat.Builder, body: String, defaultLargeIcon: Bitmap? = null - ) = runBlocking { + ) { val style = NotificationCompat.BigPictureStyle() .bigLargeIcon(defaultLargeIcon) .setSummaryText(body) - val url = URL(imageUrl) - withContext(Dispatchers.IO) { - try { - val input = url.openStream() - BitmapFactory.decodeStream(input) - } catch (e: Exception) { - null - } - }?.let { bitmap -> + BitmapDownloader.download(imageUrl)?.let { bitmap -> style.bigPicture(bitmap) builder.setLargeIcon(bitmap) builder.setStyle(style) diff --git a/messagingpush/src/main/java/io/customer/messagingpush/LiveNotificationHandler.kt b/messagingpush/src/main/java/io/customer/messagingpush/LiveNotificationHandler.kt new file mode 100644 index 000000000..f6b65482e --- /dev/null +++ b/messagingpush/src/main/java/io/customer/messagingpush/LiveNotificationHandler.kt @@ -0,0 +1,368 @@ +package io.customer.messagingpush + +import android.app.Notification +import android.app.NotificationManager +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import android.content.pm.PackageManager +import android.os.Build +import android.os.Bundle +import android.os.Handler +import android.os.Looper +import androidx.annotation.ColorInt +import androidx.annotation.DrawableRes +import androidx.core.content.ContextCompat +import io.customer.messagingpush.activity.NotificationClickReceiverActivity +import io.customer.messagingpush.data.model.CustomerIOParsedPushPayload +import io.customer.messagingpush.di.liveNotificationStore +import io.customer.messagingpush.di.pushModuleConfig +import io.customer.messagingpush.livenotification.LiveNotificationBranding +import io.customer.messagingpush.livenotification.LiveNotificationDismissReceiver +import io.customer.messagingpush.livenotification.template.TemplateAssets +import io.customer.messagingpush.livenotification.template.TemplateRegistry +import io.customer.messagingpush.livenotification.template.TemplateRenderResult +import io.customer.messagingpush.util.PushTrackingUtil +import io.customer.sdk.core.di.SDKComponent +import java.util.concurrent.ConcurrentHashMap +import org.json.JSONArray +import org.json.JSONException +import org.json.JSONObject + +/** + * Dispatches templated live notifications. + * + * Live notifications are ongoing notifications that can be updated in-place + * (the Android counterpart of iOS Live Activities). Each push declares an + * `activity_type` (one of the closed set in [TemplateRegistry], prefixed with + * `io.customer.liveactivities.`). Unlike iOS, Android does not split static + * `attributes` from dynamic `content-state`: all template fields arrive + * flattened at the envelope top level. Pushes share a stable [ACTIVITY_ID_KEY] + * so successive updates replace the previous notification rather than creating + * new ones. + */ +internal class LiveNotificationHandler( + private val bundle: Bundle +) { + + companion object { + const val ACTIVITY_ID_KEY = "activity_id" + const val EVENT_KEY = "event" + const val ACTIVITY_TYPE_KEY = "activity_type" + const val TIMESTAMP_KEY = "timestamp" + const val DISMISSAL_DATE_KEY = "dismissal_date" + + private const val EVENT_END = "end" + + /** + * Live-notification envelope keys that are never template fields. + * Everything else in the bundle is flattened into the template `data` + * object. + * + * Note: standard-push keys (`title`, `body`, `image`, `link`, …) are + * intentionally NOT reserved here — they are not part of the live + * envelope, and reserving them would shadow legitimate template fields + * of the same name (e.g. CountdownTimer's `title`). + */ + private val RESERVED_KEYS = setOf( + ACTIVITY_ID_KEY, + EVENT_KEY, + ACTIVITY_TYPE_KEY, + TIMESTAMP_KEY, + DISMISSAL_DATE_KEY, + PushTrackingUtil.DELIVERY_ID_KEY, + PushTrackingUtil.DELIVERY_TOKEN_KEY + ) + + // Pending `end` dismissals, keyed by activity_id, so a new event for the same + // activity can cancel a scheduled removal (e.g. when an activity_id is reused). + private val dismissalHandler = Handler(Looper.getMainLooper()) + private val pendingDismissals = ConcurrentHashMap() + } + + fun handle( + context: Context, + deliveryId: String, + deliveryToken: String, + @DrawableRes smallIcon: Int, + @ColorInt tintColor: Int?, + channelId: String, + notificationManager: NotificationManager + ) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && + ContextCompat.checkSelfPermission(context, android.Manifest.permission.POST_NOTIFICATIONS) + != PackageManager.PERMISSION_GRANTED + ) { + SDKComponent.logger.error( + "POST_NOTIFICATIONS permission not granted; live notification will be dropped by the system. " + + "The host app must request this permission on Android 13+." + ) + } + + val activityId = bundle.getString(ACTIVITY_ID_KEY) ?: return + val event = bundle.getString(EVENT_KEY) + if (event == null) { + SDKComponent.logger.error( + "Live notification push for activity '$activityId' is missing '$EVENT_KEY'; dropping." + ) + return + } + + // Live notifications are opt-in: only handle activity types the host app enabled. + val activityType = bundle.getString(ACTIVITY_TYPE_KEY) + if (activityType == null || activityType !in SDKComponent.pushModuleConfig.liveNotificationTypes) { + SDKComponent.logger.debug( + "Live notification type '$activityType' is not enabled; ignoring activity '$activityId'." + ) + return + } + val isEnd = event == EVENT_END + + // Out-of-order / duplicate guard. Android renders FCM data directly, so unlike iOS + // (where APNs/ActivityKit order updates) the SDK must drop stale pushes itself. `end` + // is terminal and bypasses the guard so a stale `end` still cancels the notification. + val store = SDKComponent.liveNotificationStore + val timestamp = bundle.getString(TIMESTAMP_KEY)?.toLongOrNull() + val lastSeen = store.lastTimestamp(activityId) + if (!isEnd && timestamp != null) { + if (lastSeen != null && timestamp <= lastSeen) { + SDKComponent.logger.debug( + "Dropping out-of-order/duplicate live notification for '$activityId' (timestamp $timestamp <= $lastSeen)." + ) + return + } + } + + // A new event for this activity supersedes any scheduled end-dismissal (handles + // activity_id reuse where the delayed cancel would otherwise kill the new notification). + cancelPendingDismissal(activityId) + + // Advance the high-water timestamp for ALL events (incl. `end`) so a later stale + // update — even one arriving after `end` — is dropped by the guard above. Only ever + // move it forward: a stale, out-of-order `end` (which bypasses the guard) must not + // lower the mark, or a later stale update could slip through and resurrect the activity. + if (timestamp != null && (lastSeen == null || timestamp > lastSeen)) { + store.setLastTimestamp(activityId, timestamp) + } + + val template = TemplateRegistry.find(activityType) + val data = extractData(bundle) + val branding = SDKComponent.pushModuleConfig.liveNotificationBranding + val effectiveSmallIcon = resolveSmallIcon(context, branding, smallIcon) + + val result = template?.render( + context = context, + data = data, + branding = branding, + smallIcon = effectiveSmallIcon, + fallbackTintColor = tintColor + ) + + val notifId = activityId.hashCode() and 0x7FFFFFFF + + if (result?.cancelImmediately == true) { + notificationManager.cancel(activityId, notifId) + return + } + + bundle.putInt(CustomerIOPushNotificationHandler.NOTIFICATION_REQUEST_CODE, notifId) + val parsedPayload = CustomerIOParsedPushPayload( + extras = Bundle(bundle), + deepLink = result?.deepLink ?: bundle.getString(CustomerIOPushNotificationHandler.DEEP_LINK_KEY), + cioDeliveryId = deliveryId, + cioDeliveryToken = deliveryToken, + title = result?.title ?: bundle.getString(CustomerIOPushNotificationHandler.TITLE_KEY).orEmpty(), + body = result?.body ?: bundle.getString(CustomerIOPushNotificationHandler.BODY_KEY).orEmpty(), + activityId = activityId + ) + val pendingIntent = createIntentForNotificationClick(context, notifId, parsedPayload) + val deletePendingIntent = createDeleteIntent(context, notifId, activityId) + + // The host app may fully render the notification; otherwise fall back to the + // SDK template. Custom (template-less) types must be rendered by the callback. + val appNotification = SDKComponent.pushModuleConfig.notificationCallback + ?.createLiveNotification(parsedPayload, context) + val notification = appNotification ?: result?.let { + buildSdkNotification(context, channelId, effectiveSmallIcon, it, pendingIntent, deletePendingIntent) + } + + when { + notification != null -> notificationManager.notify(activityId, notifId, notification) + // An `end` with no renderer still falls through to cancel the existing notification. + !isEnd -> { + SDKComponent.logger.error( + "No renderer for live notification type '$activityType': no built-in template and " + + "createLiveNotification returned null; dropping activity '$activityId'." + ) + return + } + } + + if (isEnd) { + scheduleEndDismissal(bundle, notificationManager, activityId, notifId) + } + } + + private fun buildSdkNotification( + context: Context, + channelId: String, + @DrawableRes effectiveSmallIcon: Int, + result: TemplateRenderResult, + pendingIntent: PendingIntent, + deletePendingIntent: PendingIntent + ): Notification = when { + Build.VERSION.SDK_INT >= Build.VERSION_CODES.BAKLAVA -> { + Api36LiveNotificationBuilder.build( + Api36LiveNotificationParams( + context = context, + channelId = channelId, + title = result.title, + body = result.body, + subText = result.subText, + smallIcon = effectiveSmallIcon, + accentColor = result.accentColor, + segments = result.segments, + points = result.points, + progress = result.progress, + progressMax = result.progressMax, + startIconRes = result.startIconRes, + endIconRes = result.endIconRes, + trackerIconRes = result.trackerIconRes, + pendingIntent = pendingIntent, + deleteIntent = deletePendingIntent, + countdownUntil = result.countdownUntil, + largeIcon = result.largeIcon, + showProgress = result.showProgress + ) + ) + } + else -> { + BasicNotificationBuilder.build( + BasicNotificationParams( + context = context, + channelId = channelId, + title = result.title, + body = result.body, + subText = result.subText, + smallIcon = effectiveSmallIcon, + accentColor = result.accentColor, + colorized = result.colorized, + progress = result.progress, + progressMax = result.progressMax, + pendingIntent = pendingIntent, + deleteIntent = deletePendingIntent, + countdownUntil = result.countdownUntil, + largeIcon = result.largeIcon, + showProgress = result.showProgress + ) + ) + } + } + + /** + * On `end`, removes the notification at the server-provided `dismissal_date` + * (epoch ms). When absent, the activity is removed immediately — there is no + * invented default delay. (Best-effort: a long delay does not survive + * process death.) + */ + private fun scheduleEndDismissal( + bundle: Bundle, + notificationManager: NotificationManager, + activityId: String, + notifId: Int + ) { + val dismissAtMs = bundle.getString(DISMISSAL_DATE_KEY)?.toLongOrNull() + if (dismissAtMs == null) { + notificationManager.cancel(activityId, notifId) + return + } + val delayMs = (dismissAtMs - System.currentTimeMillis()).coerceAtLeast(0L) + val task = Runnable { + notificationManager.cancel(activityId, notifId) + pendingDismissals.remove(activityId) + } + pendingDismissals[activityId] = task + dismissalHandler.postDelayed(task, delayMs) + } + + /** Cancels a scheduled end-dismissal for [activityId], if any. */ + private fun cancelPendingDismissal(activityId: String) { + pendingDismissals.remove(activityId)?.let { dismissalHandler.removeCallbacks(it) } + } + + private fun createDeleteIntent( + context: Context, + requestCode: Int, + activityId: String + ): PendingIntent { + val intent = Intent(context, LiveNotificationDismissReceiver::class.java).apply { + putExtra(LiveNotificationDismissReceiver.EXTRA_ACTIVITY_ID, activityId) + } + val flags = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + } else { + PendingIntent.FLAG_UPDATE_CURRENT + } + return PendingIntent.getBroadcast(context, requestCode, intent, flags) + } + + /** + * Collects the flattened template fields from the FCM envelope: every + * top-level bundle key that is not a [RESERVED_KEYS] envelope key. String + * values that look like JSON objects/arrays (e.g. `origin`, `homeTeam`) are + * parsed so templates can read them as nested structures; scalar strings + * are kept verbatim and coerced on read by `JSONObject.optInt`/`optLong`/etc. + */ + private fun extractData(bundle: Bundle): JSONObject { + val data = JSONObject() + for (key in bundle.keySet()) { + if (key in RESERVED_KEYS) continue + val raw = bundle.getString(key) ?: continue + data.put(key, coerceJsonValue(raw)) + } + return data + } + + private fun coerceJsonValue(raw: String): Any { + val trimmed = raw.trim() + return try { + when { + trimmed.startsWith("{") -> JSONObject(trimmed) + trimmed.startsWith("[") -> JSONArray(trimmed) + else -> raw + } + } catch (e: JSONException) { + raw + } + } + + @DrawableRes + private fun resolveSmallIcon( + context: Context, + branding: LiveNotificationBranding?, + fallback: Int + ): Int { + // Reuses TemplateAssets' kebab→snake normalization + drawable lookup. + return TemplateAssets.resolveDrawable(context, branding?.logoDrawableName) ?: fallback + } + + private fun createIntentForNotificationClick( + context: Context, + requestCode: Int, + payload: CustomerIOParsedPushPayload + ): PendingIntent { + val notifyIntent = Intent(context, NotificationClickReceiverActivity::class.java) + notifyIntent.putExtra(NotificationClickReceiverActivity.NOTIFICATION_PAYLOAD_EXTRA, payload) + val flags = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + } else { + PendingIntent.FLAG_UPDATE_CURRENT + } + return PendingIntent.getActivity( + context, + requestCode, + notifyIntent, + flags + ) + } +} diff --git a/messagingpush/src/main/java/io/customer/messagingpush/MessagingPushModuleConfig.kt b/messagingpush/src/main/java/io/customer/messagingpush/MessagingPushModuleConfig.kt index 433b4b76a..c538dbb9e 100644 --- a/messagingpush/src/main/java/io/customer/messagingpush/MessagingPushModuleConfig.kt +++ b/messagingpush/src/main/java/io/customer/messagingpush/MessagingPushModuleConfig.kt @@ -3,6 +3,7 @@ package io.customer.messagingpush import io.customer.messagingpush.config.PushClickBehavior import io.customer.messagingpush.config.PushClickBehavior.ACTIVITY_PREVENT_RESTART import io.customer.messagingpush.data.communication.CustomerIOPushNotificationCallback +import io.customer.messagingpush.livenotification.LiveNotificationBranding import io.customer.sdk.core.module.CustomerIOModuleConfig /** @@ -12,16 +13,22 @@ import io.customer.sdk.core.module.CustomerIOModuleConfig * notifications * @property pushClickBehavior defines the behavior when a push notification * is clicked + * @property liveNotificationBranding app-level branding applied to templated + * live notifications. `null` means templates fall back to FCM metadata values. */ class MessagingPushModuleConfig private constructor( val autoTrackPushEvents: Boolean, val notificationCallback: CustomerIOPushNotificationCallback?, - val pushClickBehavior: PushClickBehavior + val pushClickBehavior: PushClickBehavior, + val liveNotificationBranding: LiveNotificationBranding?, + val liveNotificationTypes: Set ) : CustomerIOModuleConfig { class Builder : CustomerIOModuleConfig.Builder { private var autoTrackPushEvents: Boolean = true private var notificationCallback: CustomerIOPushNotificationCallback? = null private var pushClickBehavior: PushClickBehavior = ACTIVITY_PREVENT_RESTART + private var liveNotificationBranding: LiveNotificationBranding? = null + private var liveNotificationTypes: Set = emptySet() /** * Allows to enable/disable automatic tracking of push events. Auto tracking will generate @@ -56,17 +63,47 @@ class MessagingPushModuleConfig private constructor( return this } + /** + * Sets the app-level branding applied to templated live notifications. + * + * @param liveNotificationBranding branding bundle (company name, accent color, logo). + */ + fun setLiveNotificationBranding(liveNotificationBranding: LiveNotificationBranding): Builder { + this.liveNotificationBranding = liveNotificationBranding + return this + } + + /** + * Enables live notifications for the given activity types. **This is + * required to use live notifications** — until at least one type is + * enabled the feature is a no-op (nothing is registered with Customer.io + * and pushes for non-enabled types are ignored). + * + * Pass built-in types from [io.customer.messagingpush.livenotification.LiveNotificationType] + * (rendered by the SDK's templates) and/or your own custom type strings + * (rendered by [CustomerIOPushNotificationCallback.createLiveNotification], + * which must be provided for custom types). + * + * @param types reverse-DNS activity type identifiers to enable. + */ + fun setLiveNotificationTypes(vararg types: String): Builder { + this.liveNotificationTypes = types.toSet() + return this + } + override fun build(): MessagingPushModuleConfig { return MessagingPushModuleConfig( autoTrackPushEvents = autoTrackPushEvents, notificationCallback = notificationCallback, - pushClickBehavior = pushClickBehavior + pushClickBehavior = pushClickBehavior, + liveNotificationBranding = liveNotificationBranding, + liveNotificationTypes = liveNotificationTypes ) } } override fun toString(): String { - return "MessagingPushModuleConfig(autoTrackPushEvents=$autoTrackPushEvents, notificationCallback=$notificationCallback, pushClickBehavior=$pushClickBehavior)" + return "MessagingPushModuleConfig(autoTrackPushEvents=$autoTrackPushEvents, notificationCallback=$notificationCallback, pushClickBehavior=$pushClickBehavior, liveNotificationBranding=$liveNotificationBranding, liveNotificationTypes=$liveNotificationTypes)" } companion object { diff --git a/messagingpush/src/main/java/io/customer/messagingpush/ModuleMessagingPushFCM.kt b/messagingpush/src/main/java/io/customer/messagingpush/ModuleMessagingPushFCM.kt index c2eb955cd..58168befe 100644 --- a/messagingpush/src/main/java/io/customer/messagingpush/ModuleMessagingPushFCM.kt +++ b/messagingpush/src/main/java/io/customer/messagingpush/ModuleMessagingPushFCM.kt @@ -9,9 +9,12 @@ import androidx.lifecycle.ProcessLifecycleOwner import androidx.work.WorkManager import androidx.work.await import io.customer.messagingpush.di.fcmTokenProvider +import io.customer.messagingpush.di.liveNotificationManager +import io.customer.messagingpush.di.liveNotificationRegistrar import io.customer.messagingpush.di.pendingPushDeliveryStore import io.customer.messagingpush.di.pushLogger import io.customer.messagingpush.di.pushTrackingUtil +import io.customer.messagingpush.livenotification.LiveNotificationData import io.customer.messagingpush.logger.PushNotificationLogger import io.customer.messagingpush.provider.DeviceTokenProvider import io.customer.messagingpush.store.PendingPushDeliveryMetric @@ -23,6 +26,7 @@ import io.customer.sdk.core.module.CustomerIOModule import io.customer.sdk.core.util.DispatchersProvider import io.customer.sdk.data.store.PendingDeliveryStore import io.customer.sdk.events.Metric +import java.util.UUID import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.filter import kotlinx.coroutines.launch @@ -48,11 +52,43 @@ class ModuleMessagingPushFCM @JvmOverloads constructor( get() = MODULE_NAME override fun initialize() { + // Live notifications are opt-in: only wire up registration when the host app + // enabled at least one activity type. Start before requesting the token so the + // registrar observes the resulting RegisterDeviceTokenEvent. + if (moduleConfig.liveNotificationTypes.isNotEmpty()) { + SDKComponent.liveNotificationRegistrar.start() + } getCurrentFcmToken() subscribeToLifecycleEvents() observeProcessForeground() } + /** + * Starts a live notification locally for a built-in template type. The SDK + * generates a unique activity id, renders the notification immediately, and + * registers the instance with Customer.io so the backend can push updates. + * + * @return the generated `activity_id`, used to correlate subsequent updates. + */ + fun startLiveNotification(data: LiveNotificationData): String = + startLiveNotification(data.activityType, data.fields()) + + /** + * Starts a live notification locally for a customer-defined [activityType] + * (one registered via [MessagingPushModuleConfig.Builder.registerLiveNotificationTypes]). + * Custom types have no built-in template, so a + * [io.customer.messagingpush.data.communication.CustomerIOPushNotificationCallback.createLiveNotification] + * must render them. + * + * @param data flattened fields delivered to the renderer. + * @return the generated `activity_id`. + */ + fun startLiveNotification(activityType: String, data: Map): String { + val activityId = UUID.randomUUID().toString() + SDKComponent.liveNotificationManager.start(activityId, activityType, data) + return activityId + } + private fun subscribeToLifecycleEvents() { activityLifecycleCallbacks.subscribe { events -> events diff --git a/messagingpush/src/main/java/io/customer/messagingpush/data/communication/CustomerIOPushNotificationCallback.kt b/messagingpush/src/main/java/io/customer/messagingpush/data/communication/CustomerIOPushNotificationCallback.kt index e0767b6af..1f92caeac 100644 --- a/messagingpush/src/main/java/io/customer/messagingpush/data/communication/CustomerIOPushNotificationCallback.kt +++ b/messagingpush/src/main/java/io/customer/messagingpush/data/communication/CustomerIOPushNotificationCallback.kt @@ -1,5 +1,6 @@ package io.customer.messagingpush.data.communication +import android.app.Notification import android.content.Context import androidx.core.app.NotificationCompat import io.customer.messagingpush.data.model.CustomerIOParsedPushPayload @@ -42,4 +43,28 @@ interface CustomerIOPushNotificationCallback { payload: CustomerIOParsedPushPayload, builder: NotificationCompat.Builder ) = Unit + + /** + * Called for live notifications to let the host app render the notification + * itself. Return a fully-built [Notification] to take complete control of + * its appearance and intents, or null to use the SDK's built-in template. + * + * Required for customer-defined activity types (registered via + * [io.customer.messagingpush.MessagingPushModuleConfig.Builder.registerLiveNotificationTypes]), + * which have no built-in template; if this returns null for such a type, the + * notification is dropped. + * + * The SDK still owns the posting lifecycle: it posts the returned + * notification keyed by `activity_id` (so later updates replace it) and + * cancels it on `end`. It does NOT modify the returned notification, so any + * dismissal reporting is the app's responsibility. + * + * @param payload parsed live-notification payload (activity id + flattened + * fields in [CustomerIOParsedPushPayload.extras]). + * @param context reference to application context. + */ + fun createLiveNotification( + payload: CustomerIOParsedPushPayload, + context: Context + ): Notification? = null } diff --git a/messagingpush/src/main/java/io/customer/messagingpush/data/model/CustomerIOParsedPushPayload.kt b/messagingpush/src/main/java/io/customer/messagingpush/data/model/CustomerIOParsedPushPayload.kt index 3a1bcb21a..7b3774119 100644 --- a/messagingpush/src/main/java/io/customer/messagingpush/data/model/CustomerIOParsedPushPayload.kt +++ b/messagingpush/src/main/java/io/customer/messagingpush/data/model/CustomerIOParsedPushPayload.kt @@ -13,6 +13,7 @@ import android.os.Parcelable * @property cioDeliveryToken Customer.io message delivery token * @property title notification content title text * @property body notification content body text + * @property activityId stable id identifying a live notification (null for standard push) */ data class CustomerIOParsedPushPayload( val extras: Bundle, @@ -20,7 +21,8 @@ data class CustomerIOParsedPushPayload( val cioDeliveryId: String, val cioDeliveryToken: String, val title: String, - val body: String + val body: String, + val activityId: String? = null ) : Parcelable { constructor(parcel: Parcel) : this( extras = parcel.readBundle(Bundle::class.java.classLoader) ?: Bundle(), @@ -28,7 +30,8 @@ data class CustomerIOParsedPushPayload( cioDeliveryId = parcel.readString().orEmpty(), cioDeliveryToken = parcel.readString().orEmpty(), title = parcel.readString().orEmpty(), - body = parcel.readString().orEmpty() + body = parcel.readString().orEmpty(), + activityId = parcel.readString() ) override fun writeToParcel(parcel: Parcel, flags: Int) { @@ -38,6 +41,7 @@ data class CustomerIOParsedPushPayload( parcel.writeString(cioDeliveryToken) parcel.writeString(title) parcel.writeString(body) + parcel.writeString(activityId) } override fun describeContents(): Int { 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 039497636..cddb98da6 100644 --- a/messagingpush/src/main/java/io/customer/messagingpush/di/DiGraphMessagingPush.kt +++ b/messagingpush/src/main/java/io/customer/messagingpush/di/DiGraphMessagingPush.kt @@ -10,6 +10,11 @@ import io.customer.messagingpush.MessagingPushModuleConfig import io.customer.messagingpush.ModuleMessagingPushFCM import io.customer.messagingpush.PushDeliveryTracker import io.customer.messagingpush.PushDeliveryTrackerImpl +import io.customer.messagingpush.livenotification.LiveNotificationLifecycleClient +import io.customer.messagingpush.livenotification.LiveNotificationLifecycleClientImpl +import io.customer.messagingpush.livenotification.LiveNotificationManager +import io.customer.messagingpush.livenotification.LiveNotificationRegistrar +import io.customer.messagingpush.livenotification.LiveNotificationStore import io.customer.messagingpush.logger.PushNotificationLogger import io.customer.messagingpush.processor.PushDeliveryMetricsBackgroundScheduler import io.customer.messagingpush.processor.PushMessageProcessor @@ -86,6 +91,25 @@ internal val SDKComponent.pushMessageProcessor: PushMessageProcessor ) } +internal val SDKComponent.liveNotificationStore: LiveNotificationStore + get() = singleton { LiveNotificationStore(android().applicationContext) } + +internal val SDKComponent.liveNotificationLifecycleClient: LiveNotificationLifecycleClient + get() = singleton { LiveNotificationLifecycleClientImpl() } + +internal val SDKComponent.liveNotificationRegistrar: LiveNotificationRegistrar + get() = singleton { + LiveNotificationRegistrar(liveNotificationLifecycleClient, liveNotificationStore) + } + +internal val SDKComponent.liveNotificationManager: LiveNotificationManager + get() = singleton { + LiveNotificationManager( + lifecycleClient = liveNotificationLifecycleClient, + registrar = liveNotificationRegistrar + ) + } + internal val SDKComponent.pushDeliveryTracker: PushDeliveryTracker get() = singleton { PushDeliveryTrackerImpl() } diff --git a/messagingpush/src/main/java/io/customer/messagingpush/livenotification/LiveNotificationBranding.kt b/messagingpush/src/main/java/io/customer/messagingpush/livenotification/LiveNotificationBranding.kt new file mode 100644 index 000000000..7ea868d3d --- /dev/null +++ b/messagingpush/src/main/java/io/customer/messagingpush/livenotification/LiveNotificationBranding.kt @@ -0,0 +1,26 @@ +package io.customer.messagingpush.livenotification + +import androidx.annotation.ColorInt + +/** + * App-level branding applied to live notifications. + * + * Registered once via [io.customer.messagingpush.MessagingPushModuleConfig.Builder.setLiveNotificationBranding] + * and shared across every templated live notification this app posts. Templates + * may override individual fields (e.g. accent color flips green/red for auction + * winning/outbid state); when they do not, these values are used. + * + * @property companyName Reserved for future templates that need to render a + * company label. Not consumed by any v1 template mapping. + * @property accentColor Default accent color applied via [android.app.Notification.Builder.setColor]. + * @property logoDrawableName Optional drawable resource name (looked up via + * `Context.getDrawableByName`) that overrides the small icon for live + * notifications only. The standard push channel still uses the small icon + * declared in FCM metadata. Hyphens are normalized to underscores before + * lookup so values like `cio-logo` resolve to `R.drawable.cio_logo`. + */ +data class LiveNotificationBranding( + val companyName: String, + @ColorInt val accentColor: Int, + val logoDrawableName: String? = null +) diff --git a/messagingpush/src/main/java/io/customer/messagingpush/livenotification/LiveNotificationData.kt b/messagingpush/src/main/java/io/customer/messagingpush/livenotification/LiveNotificationData.kt new file mode 100644 index 000000000..a0164d970 --- /dev/null +++ b/messagingpush/src/main/java/io/customer/messagingpush/livenotification/LiveNotificationData.kt @@ -0,0 +1,158 @@ +package io.customer.messagingpush.livenotification + +import io.customer.messagingpush.livenotification.template.AirportFields +import io.customer.messagingpush.livenotification.template.AuctionBidFields +import io.customer.messagingpush.livenotification.template.CountdownTimerFields +import io.customer.messagingpush.livenotification.template.DeliveryTrackingFields +import io.customer.messagingpush.livenotification.template.FlightStatusFields +import io.customer.messagingpush.livenotification.template.LiveScoreFields +import io.customer.messagingpush.livenotification.template.TeamFields +import org.json.JSONObject + +/** + * Typed payload for starting a built-in live notification locally via + * `ModuleMessagingPushFCM.startLiveNotification`. Each subtype knows its + * [activityType] and flattens itself into the envelope fields the templates + * read (the same flattened shape the backend delivers). Field names come from + * the shared `*Fields` constants so local-start and push-render stay in sync. + * + * For customer-defined activity types, use the `Map` overload of + * `startLiveNotification` instead. + */ +sealed interface LiveNotificationData { + val activityType: String + + /** Flattened template fields; null values are omitted by the caller. */ + fun fields(): Map + + data class DeliveryTracking( + val orderId: String, + val statusMessage: String, + val recipientName: String? = null, + val driverName: String? = null, + val statusImageKey: String? = null, + val stepCurrent: Int? = null, + val stepTotal: Int? = null, + val estimatedArrival: Long? = null + ) : LiveNotificationData { + override val activityType = LiveNotificationType.DELIVERY_TRACKING + override fun fields() = mapOf( + DeliveryTrackingFields.ORDER_ID to orderId, + DeliveryTrackingFields.STATUS_MESSAGE to statusMessage, + DeliveryTrackingFields.RECIPIENT_NAME to recipientName, + DeliveryTrackingFields.DRIVER_NAME to driverName, + DeliveryTrackingFields.STATUS_IMAGE_KEY to statusImageKey, + DeliveryTrackingFields.STEP_CURRENT to stepCurrent, + DeliveryTrackingFields.STEP_TOTAL to stepTotal, + DeliveryTrackingFields.ESTIMATED_ARRIVAL to estimatedArrival + ) + } + + data class FlightStatus( + val flightNumber: String, + val origin: Airport, + val destination: Airport, + val statusMessage: String, + val gate: String? = null, + val terminal: String? = null, + val scheduledDeparture: Long? = null, + val estimatedArrival: Long? = null, + val progressFraction: Double? = null, + val delayMinutes: Int? = null + ) : LiveNotificationData { + override val activityType = LiveNotificationType.FLIGHT_STATUS + override fun fields() = mapOf( + FlightStatusFields.FLIGHT_NUMBER to flightNumber, + FlightStatusFields.ORIGIN to origin.toJson(), + FlightStatusFields.DESTINATION to destination.toJson(), + FlightStatusFields.STATUS_MESSAGE to statusMessage, + FlightStatusFields.GATE to gate, + FlightStatusFields.TERMINAL to terminal, + FlightStatusFields.SCHEDULED_DEPARTURE to scheduledDeparture, + FlightStatusFields.ESTIMATED_ARRIVAL to estimatedArrival, + FlightStatusFields.PROGRESS_FRACTION to progressFraction, + FlightStatusFields.DELAY_MINUTES to delayMinutes + ) + } + + data class LiveScore( + val homeTeam: Team, + val awayTeam: Team, + val period: String, + val homeScore: Int = 0, + val awayScore: Int = 0, + val clock: String? = null, + val statusMessage: String? = null, + val sport: String? = null, + val leagueLogoKey: String? = null + ) : LiveNotificationData { + override val activityType = LiveNotificationType.LIVE_SCORE + override fun fields() = mapOf( + LiveScoreFields.HOME_TEAM to homeTeam.toJson(), + LiveScoreFields.AWAY_TEAM to awayTeam.toJson(), + LiveScoreFields.PERIOD to period, + LiveScoreFields.HOME_SCORE to homeScore, + LiveScoreFields.AWAY_SCORE to awayScore, + LiveScoreFields.CLOCK to clock, + LiveScoreFields.STATUS_MESSAGE to statusMessage, + LiveScoreFields.SPORT to sport, + LiveScoreFields.LEAGUE_LOGO_KEY to leagueLogoKey + ) + } + + data class CountdownTimer( + val title: String, + val targetDate: Long, + val statusMessage: String, + val expiredMessage: String? = null, + val heroImageKey: String? = null + ) : LiveNotificationData { + override val activityType = LiveNotificationType.COUNTDOWN_TIMER + override fun fields() = mapOf( + CountdownTimerFields.TITLE to title, + CountdownTimerFields.TARGET_DATE to targetDate, + CountdownTimerFields.STATUS_MESSAGE to statusMessage, + CountdownTimerFields.EXPIRED_MESSAGE to expiredMessage, + CountdownTimerFields.HERO_IMAGE_KEY to heroImageKey + ) + } + + data class AuctionBid( + val itemTitle: String, + val currentBid: String, + val bidCount: Int, + val statusMessage: String, + val isUserHighBidder: Boolean, + val endTime: Long? = null, + val userBidAmount: String? = null, + val itemImageKey: String? = null, + val currencySymbol: String? = null + ) : LiveNotificationData { + override val activityType = LiveNotificationType.AUCTION_BID + override fun fields() = mapOf( + AuctionBidFields.ITEM_TITLE to itemTitle, + AuctionBidFields.CURRENT_BID to currentBid, + AuctionBidFields.BID_COUNT to bidCount, + AuctionBidFields.STATUS_MESSAGE to statusMessage, + AuctionBidFields.IS_USER_HIGH_BIDDER to isUserHighBidder, + AuctionBidFields.END_TIME to endTime, + AuctionBidFields.USER_BID_AMOUNT to userBidAmount, + AuctionBidFields.ITEM_IMAGE_KEY to itemImageKey, + AuctionBidFields.CURRENCY_SYMBOL to currencySymbol + ) + } + + /** Airport endpoint for [FlightStatus]. */ + data class Airport(val code: String, val city: String? = null) { + internal fun toJson(): JSONObject = JSONObject().put(AirportFields.CODE, code).apply { + city?.let { put(AirportFields.CITY, it) } + } + } + + /** Team for [LiveScore]. */ + data class Team(val name: String, val logoKey: String? = null) { + internal fun toJson(): JSONObject = JSONObject().put(TeamFields.NAME, name).apply { + logoKey?.let { put(TeamFields.LOGO_KEY, it) } + } + } +} diff --git a/messagingpush/src/main/java/io/customer/messagingpush/livenotification/LiveNotificationDismissReceiver.kt b/messagingpush/src/main/java/io/customer/messagingpush/livenotification/LiveNotificationDismissReceiver.kt new file mode 100644 index 000000000..6d4f17125 --- /dev/null +++ b/messagingpush/src/main/java/io/customer/messagingpush/livenotification/LiveNotificationDismissReceiver.kt @@ -0,0 +1,39 @@ +package io.customer.messagingpush.livenotification + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import io.customer.messagingpush.di.liveNotificationLifecycleClient +import io.customer.sdk.core.di.SDKComponent +import io.customer.sdk.core.di.setupAndroidComponent +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch + +/** + * Receives the delete intent the system fires when the user dismisses a live + * notification, and reports the dismissal to the backend. + * + * Programmatic [android.app.NotificationManager.cancel] does NOT trigger a + * delete intent, so server-driven `end` events (which cancel the notification) + * do not produce a false user-dismissal report. + */ +class LiveNotificationDismissReceiver : BroadcastReceiver() { + + override fun onReceive(context: Context, intent: Intent) { + val activityId = intent.getStringExtra(EXTRA_ACTIVITY_ID) ?: return + SDKComponent.setupAndroidComponent(context = context) + // Keep the receiver alive for the short-lived network call. + val pendingResult = goAsync() + CoroutineScope(SDKComponent.dispatchersProvider.background).launch { + try { + SDKComponent.liveNotificationLifecycleClient.reportDismissed(activityId) + } finally { + pendingResult.finish() + } + } + } + + companion object { + const val EXTRA_ACTIVITY_ID = "io.customer.messagingpush.EXTRA_LIVE_ACTIVITY_ID" + } +} diff --git a/messagingpush/src/main/java/io/customer/messagingpush/livenotification/LiveNotificationLifecycleClient.kt b/messagingpush/src/main/java/io/customer/messagingpush/livenotification/LiveNotificationLifecycleClient.kt new file mode 100644 index 000000000..e9aecffb0 --- /dev/null +++ b/messagingpush/src/main/java/io/customer/messagingpush/livenotification/LiveNotificationLifecycleClient.kt @@ -0,0 +1,121 @@ +package io.customer.messagingpush.livenotification + +import io.customer.sdk.core.di.SDKComponent +import io.customer.sdk.core.di.httpClient +import io.customer.sdk.core.network.CustomerIOHttpClient +import io.customer.sdk.core.network.HttpMethod +import io.customer.sdk.core.network.HttpRequestException +import io.customer.sdk.core.network.HttpRequestParams +import kotlinx.coroutines.delay +import org.json.JSONObject + +/** + * Reports live-notification lifecycle to the Customer.io backend. + * + * Android has no push-to-start token like iOS; [registerForActivityType] + * instead registers the device's FCM token per activity type so the backend + * can push updates for it (`os = android`, `transport = fcm`). + * [reportDismissed] mirrors iOS's terminal-state DELETE, fired when the user + * dismisses the notification. + */ +internal interface LiveNotificationLifecycleClient { + suspend fun registerForActivityType(activityType: String, token: String, userId: String): Result + + /** + * Registers a specific locally-started activity instance so the backend can + * push updates to it. + */ + suspend fun registerInstance(activityId: String, activityType: String, token: String, userId: String): Result + + suspend fun reportDismissed(activityId: String): Result +} + +internal class LiveNotificationLifecycleClientImpl( + private val httpClient: CustomerIOHttpClient = SDKComponent.httpClient +) : LiveNotificationLifecycleClient { + + override suspend fun registerForActivityType( + activityType: String, + token: String, + userId: String + ): Result { + val body = JSONObject().apply { + put("token", token) + put("os", OS_ANDROID) + put("transport", TRANSPORT_FCM) + put("userId", userId) + } + return send( + HttpRequestParams( + path = "/v1/live_activities/registration/$activityType", + method = HttpMethod.PUT, + headers = JSON_HEADERS, + body = body.toString() + ) + ) + } + + override suspend fun registerInstance( + activityId: String, + activityType: String, + token: String, + userId: String + ): Result { + val body = JSONObject().apply { + put("token", token) + put("activity_type", activityType) + put("os", OS_ANDROID) + put("transport", TRANSPORT_FCM) + put("userId", userId) + } + return send( + HttpRequestParams( + path = "/v1/live_activities/$activityId/push_token", + method = HttpMethod.PUT, + headers = JSON_HEADERS, + body = body.toString() + ) + ) + } + + override suspend fun reportDismissed(activityId: String): Result { + return send( + HttpRequestParams( + path = "/v1/live_activities/$activityId", + method = HttpMethod.DELETE, + headers = JSON_HEADERS, + body = "{}" + ) + ) + } + + /** + * Sends [params], retrying transport failures and 5xx responses up to + * [MAX_ATTEMPTS] with linear backoff. 4xx responses are not retried + * (mirrors the iOS lifecycle client policy). + */ + private suspend fun send(params: HttpRequestParams): Result { + var attempt = 0 + while (true) { + attempt++ + val result = httpClient.request(params) + if (result.isSuccess) return Result.success(Unit) + + val error = result.exceptionOrNull() + val statusCode = (error as? HttpRequestException)?.statusCode + val isClientError = statusCode != null && statusCode in 400..499 + if (isClientError || attempt >= MAX_ATTEMPTS) { + return Result.failure(error ?: IllegalStateException("Live notification request failed")) + } + delay(BASE_BACKOFF_MS * attempt) + } + } + + companion object { + private const val OS_ANDROID = "android" + private const val TRANSPORT_FCM = "fcm" + private const val MAX_ATTEMPTS = 3 + private const val BASE_BACKOFF_MS = 500L + private val JSON_HEADERS = mapOf("Content-Type" to "application/json; charset=utf-8") + } +} diff --git a/messagingpush/src/main/java/io/customer/messagingpush/livenotification/LiveNotificationManager.kt b/messagingpush/src/main/java/io/customer/messagingpush/livenotification/LiveNotificationManager.kt new file mode 100644 index 000000000..73be3bfcd --- /dev/null +++ b/messagingpush/src/main/java/io/customer/messagingpush/livenotification/LiveNotificationManager.kt @@ -0,0 +1,102 @@ +package io.customer.messagingpush.livenotification + +import android.app.NotificationManager +import android.content.Context +import android.os.Bundle +import androidx.annotation.ColorInt +import androidx.annotation.DrawableRes +import io.customer.messagingpush.LiveNotificationHandler +import io.customer.messagingpush.extensions.getColorOrNull +import io.customer.messagingpush.extensions.getMetaDataResource +import io.customer.messagingpush.util.NotificationChannelCreator +import io.customer.sdk.core.di.SDKComponent +import io.customer.sdk.core.extensions.applicationMetaData +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch + +/** + * Starts a live notification locally on behalf of the host app: renders it + * immediately through the normal templating path, then registers the instance + * with the backend so Customer.io can push subsequent updates to it. + * + * Rendering goes straight through [LiveNotificationHandler] (not the FCM + * delivery path), so no delivered/opened metric is fabricated for a + * locally-started notification. + */ +internal class LiveNotificationManager( + private val lifecycleClient: LiveNotificationLifecycleClient, + private val registrar: LiveNotificationRegistrar, + private val notificationChannelCreator: NotificationChannelCreator = NotificationChannelCreator() +) { + private val context: Context + get() = SDKComponent.android().applicationContext + + fun start(activityId: String, activityType: String, fields: Map) { + renderLocally(buildBundle(activityId, activityType, fields)) + registerInstance(activityId, activityType) + } + + private fun buildBundle(activityId: String, activityType: String, fields: Map): Bundle = + Bundle().apply { + // Write template fields first so the reserved envelope keys below always + // win if a field key collides with one (e.g. a field named "timestamp"). + for ((key, value) in fields) { + if (value != null) putString(key, value.toString()) + } + putString(LiveNotificationHandler.ACTIVITY_ID_KEY, activityId) + putString(LiveNotificationHandler.EVENT_KEY, EVENT_START) + putString(LiveNotificationHandler.ACTIVITY_TYPE_KEY, activityType) + putString(LiveNotificationHandler.TIMESTAMP_KEY, System.currentTimeMillis().toString()) + } + + private fun renderLocally(bundle: Bundle) { + val ctx = context + val appMetaData = ctx.applicationMetaData() + val applicationName = ctx.applicationInfo.loadLabel(ctx.packageManager).toString() + + @DrawableRes + val smallIcon = appMetaData?.getMetaDataResource(FCM_DEFAULT_ICON) ?: ctx.applicationInfo.icon + + @ColorInt + val tintColor = appMetaData?.getMetaDataResource(FCM_DEFAULT_COLOR)?.let { ctx.getColorOrNull(it) } + + val notificationManager = ctx.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + val channelId = notificationChannelCreator.createLiveNotificationChannelIfNeededAndReturnChannelId( + context = ctx, + applicationName = applicationName, + appMetaData = appMetaData, + notificationManager = notificationManager + ) + + LiveNotificationHandler(bundle).handle( + context = ctx, + deliveryId = "", + deliveryToken = "", + smallIcon = smallIcon, + tintColor = tintColor, + channelId = channelId, + notificationManager = notificationManager + ) + } + + private fun registerInstance(activityId: String, activityType: String) { + // Reuse the token the registrar already tracks (fetched once in ModuleMessagingPushFCM) + // instead of requesting it again. + val token = registrar.currentToken() + if (token == null) { + SDKComponent.logger.debug( + "No FCM token available yet; skipping instance registration for live notification '$activityId'." + ) + return + } + CoroutineScope(SDKComponent.dispatchersProvider.background).launch { + lifecycleClient.registerInstance(activityId, activityType, token, registrar.currentUserId()) + } + } + + companion object { + private const val EVENT_START = "start" + private const val FCM_DEFAULT_ICON = "com.google.firebase.messaging.default_notification_icon" + private const val FCM_DEFAULT_COLOR = "com.google.firebase.messaging.default_notification_color" + } +} diff --git a/messagingpush/src/main/java/io/customer/messagingpush/livenotification/LiveNotificationRegistrar.kt b/messagingpush/src/main/java/io/customer/messagingpush/livenotification/LiveNotificationRegistrar.kt new file mode 100644 index 000000000..664e8f54b --- /dev/null +++ b/messagingpush/src/main/java/io/customer/messagingpush/livenotification/LiveNotificationRegistrar.kt @@ -0,0 +1,76 @@ +package io.customer.messagingpush.livenotification + +import io.customer.messagingpush.di.pushModuleConfig +import io.customer.sdk.communication.Event +import io.customer.sdk.core.di.SDKComponent +import io.customer.sdk.core.di.SDKComponent.eventBus + +/** + * Registers the device's FCM token with the backend for the live-notification + * activity types the host app enabled via + * `MessagingPushModuleConfig.setLiveNotificationTypes` (the Android analogue of + * iOS push-to-start registration). No types enabled ⇒ nothing is registered. + * + * Registration is (re)attempted whenever the device token rotates + * ([Event.RegisterDeviceTokenEvent]) or the user changes + * ([Event.UserChangedEvent]), and is deduped per activity type via + * [LiveNotificationStore] (signature = `token|userId`). Token deletion / reset + * clears the stored signatures so the next token re-registers. + */ +internal class LiveNotificationRegistrar( + private val client: LiveNotificationLifecycleClient, + private val store: LiveNotificationStore +) { + + @Volatile + private var token: String? = null + + @Volatile + private var userId: String = "" + + /** The current FCM token, or null if not yet received. */ + fun currentToken(): String? = token + + /** The current resolved user identity (identified userId, else anonymousId). */ + fun currentUserId(): String = userId + + private val enabledTypes: Set + get() = SDKComponent.pushModuleConfig.liveNotificationTypes + + fun start() { + // Drop dedup entries for activities that ended long ago without an explicit `end`. + store.trimStaleTimestamps() + + eventBus.subscribe(Event.RegisterDeviceTokenEvent::class) { event -> + token = event.token + registerAll() + } + eventBus.subscribe(Event.UserChangedEvent::class) { event -> + userId = event.userId ?: event.anonymousId + registerAll() + } + eventBus.subscribe(Event.DeleteDeviceTokenEvent::class) { + token = null + store.clearRegistrations() + } + eventBus.subscribe(Event.ResetEvent::class) { + store.clearRegistrations() + } + } + + private suspend fun registerAll() { + val currentToken = token ?: return + val signature = "$currentToken|$userId" + for (activityType in enabledTypes) { + if (store.registrationSignature(activityType) == signature) continue + val result = client.registerForActivityType(activityType, currentToken, userId) + if (result.isSuccess) { + store.setRegistrationSignature(activityType, signature) + } else { + SDKComponent.logger.debug( + "Live notification registration failed for '$activityType'; will retry on next token/user change." + ) + } + } + } +} diff --git a/messagingpush/src/main/java/io/customer/messagingpush/livenotification/LiveNotificationStore.kt b/messagingpush/src/main/java/io/customer/messagingpush/livenotification/LiveNotificationStore.kt new file mode 100644 index 000000000..889a7fff5 --- /dev/null +++ b/messagingpush/src/main/java/io/customer/messagingpush/livenotification/LiveNotificationStore.kt @@ -0,0 +1,74 @@ +package io.customer.messagingpush.livenotification + +import android.content.Context +import androidx.core.content.edit +import java.util.concurrent.TimeUnit + +/** + * Persistent state for live notifications, backed by a dedicated + * SharedPreferences file: + * + * - **Registration dedup** (per `activity_type`): the last signature + * (`token|userId`) registered with the backend, so repeated app launches / + * unchanged tokens don't re-POST the registration. + * - **Out-of-order / dedup guard** (per `activity_id`): the last `timestamp` + * seen, so a delayed or duplicate push that is older than one already + * rendered is dropped. Unlike iOS (where APNs/ActivityKit order updates), the + * Android SDK renders FCM data directly and must guard ordering itself. + * + * Timestamp entries are stored with their record time so stale ones (for + * activities that ended long ago without an explicit `end`) can be trimmed on + * app launch. + */ +internal class LiveNotificationStore(context: Context) { + + private val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) + + // --- Registration dedup (per activity_type) --- + + fun registrationSignature(activityType: String): String? = + prefs.getString(REG_PREFIX + activityType, null) + + fun setRegistrationSignature(activityType: String, signature: String) { + prefs.edit { putString(REG_PREFIX + activityType, signature) } + } + + /** Clears all registration signatures, forcing re-registration (e.g. on reset / token deletion). */ + fun clearRegistrations() { + prefs.edit { + prefs.all.keys.filter { it.startsWith(REG_PREFIX) }.forEach { remove(it) } + } + } + + // --- Out-of-order / dedup guard (per activity_id) --- + + /** The last `timestamp` seen for [activityId], or null if none recorded. */ + fun lastTimestamp(activityId: String): Long? = + prefs.getString(TS_PREFIX + activityId, null)?.substringBefore('|')?.toLongOrNull() + + fun setLastTimestamp(activityId: String, timestamp: Long, now: Long = System.currentTimeMillis()) { + prefs.edit { putString(TS_PREFIX + activityId, "$timestamp|$now") } + } + + fun clearTimestamp(activityId: String) { + prefs.edit { remove(TS_PREFIX + activityId) } + } + + /** Removes timestamp entries recorded longer than [ttlMs] ago. Intended to run on app launch. */ + fun trimStaleTimestamps(ttlMs: Long = DEFAULT_TS_TTL_MS, now: Long = System.currentTimeMillis()) { + val staleKeys = prefs.all.entries.filter { (key, value) -> + key.startsWith(TS_PREFIX) && + ((value as? String)?.substringAfter('|', "")?.toLongOrNull()?.let { now - it > ttlMs } ?: true) + }.map { it.key } + if (staleKeys.isNotEmpty()) { + prefs.edit { staleKeys.forEach { remove(it) } } + } + } + + companion object { + private const val PREFS_NAME = "io.customer.messagingpush.live_notifications" + private const val REG_PREFIX = "reg:" + private const val TS_PREFIX = "ts:" + private val DEFAULT_TS_TTL_MS = TimeUnit.DAYS.toMillis(7) + } +} diff --git a/messagingpush/src/main/java/io/customer/messagingpush/livenotification/LiveNotificationType.kt b/messagingpush/src/main/java/io/customer/messagingpush/livenotification/LiveNotificationType.kt new file mode 100644 index 000000000..734691352 --- /dev/null +++ b/messagingpush/src/main/java/io/customer/messagingpush/livenotification/LiveNotificationType.kt @@ -0,0 +1,19 @@ +package io.customer.messagingpush.livenotification + +/** + * Built-in live-notification activity type identifiers (reverse-DNS, matching + * the iOS Live Activity identifiers). + * + * Pass these — and/or your own custom type strings — to + * [io.customer.messagingpush.MessagingPushModuleConfig.Builder.setLiveNotificationTypes] + * to enable live notifications. The feature is a no-op until at least one type + * is enabled: nothing is registered with the backend and pushes for + * non-enabled types are ignored. + */ +object LiveNotificationType { + const val DELIVERY_TRACKING = "io.customer.liveactivities.deliverytracking" + const val FLIGHT_STATUS = "io.customer.liveactivities.flightstatus" + const val LIVE_SCORE = "io.customer.liveactivities.livescore" + const val COUNTDOWN_TIMER = "io.customer.liveactivities.countdowntimer" + const val AUCTION_BID = "io.customer.liveactivities.auctionbid" +} diff --git a/messagingpush/src/main/java/io/customer/messagingpush/livenotification/template/AuctionBidTemplate.kt b/messagingpush/src/main/java/io/customer/messagingpush/livenotification/template/AuctionBidTemplate.kt new file mode 100644 index 000000000..579f99cf3 --- /dev/null +++ b/messagingpush/src/main/java/io/customer/messagingpush/livenotification/template/AuctionBidTemplate.kt @@ -0,0 +1,67 @@ +package io.customer.messagingpush.livenotification.template + +import android.content.Context +import io.customer.messagingpush.livenotification.LiveNotificationBranding +import org.json.JSONObject + +/** + * `auctionbid` template — current bid + winning/outbid state. + * + * Fields: itemTitle (req), itemImageKey (opt), currencySymbol (default `$`), + * currentBid (req, preformatted string), bidCount (req, int), + * endTime (req, epoch ms), statusMessage (req), isUserHighBidder (req, bool), + * userBidAmount (opt, preformatted string). + * + * Strong visual differentiation between winning (green) and outbid (red) states + * is conveyed via accent color + colorized notification on pre-API-36. + */ +internal object AuctionBidTemplate : LiveNotificationTemplate { + + override val name: String = TemplateRegistry.AUCTION_BID + + private const val WINNING_GREEN: Int = -0xc951c1 // #36AE3F + private const val OUTBID_RED: Int = -0x33ccd0 // #CC3330 + + override fun render( + context: Context, + data: JSONObject, + branding: LiveNotificationBranding?, + smallIcon: Int, + fallbackTintColor: Int? + ): TemplateRenderResult { + val itemTitle = data.optString(AuctionBidFields.ITEM_TITLE) + val itemImageKey = data.optStringNonEmpty(AuctionBidFields.ITEM_IMAGE_KEY) + val currencySymbol = data.optStringNonEmpty(AuctionBidFields.CURRENCY_SYMBOL) ?: "$" + val currentBid = data.optString(AuctionBidFields.CURRENT_BID) + val bidCount = data.optInt(AuctionBidFields.BID_COUNT, 0) + val endTime = data.optLong(AuctionBidFields.END_TIME).takeIf { it > 0 } + val statusMessage = data.optString(AuctionBidFields.STATUS_MESSAGE) + val isUserHighBidder = data.optBoolean(AuctionBidFields.IS_USER_HIGH_BIDDER, false) + val userBidAmount = data.optStringNonEmpty(AuctionBidFields.USER_BID_AMOUNT) + + val body = "$statusMessage · $currencySymbol$currentBid" + val subText = userBidAmount + ?.let { "Your bid: $currencySymbol$it · $bidCount bids" } + ?: "$bidCount bids" + val accentColor = if (isUserHighBidder) WINNING_GREEN else OUTBID_RED + + return TemplateRenderResult( + title = itemTitle, + body = body, + subText = subText, + largeIcon = TemplateAssets.resolveBitmap(context, itemImageKey), + accentColor = accentColor, + colorized = true, + showProgress = false, + progress = 0, + progressMax = 0, + segments = emptyList(), + points = emptyList(), + startIconRes = null, + endIconRes = null, + trackerIconRes = null, + countdownUntil = endTime, + deepLink = null + ) + } +} diff --git a/messagingpush/src/main/java/io/customer/messagingpush/livenotification/template/CountdownTimerTemplate.kt b/messagingpush/src/main/java/io/customer/messagingpush/livenotification/template/CountdownTimerTemplate.kt new file mode 100644 index 000000000..8585923bf --- /dev/null +++ b/messagingpush/src/main/java/io/customer/messagingpush/livenotification/template/CountdownTimerTemplate.kt @@ -0,0 +1,57 @@ +package io.customer.messagingpush.livenotification.template + +import android.content.Context +import io.customer.messagingpush.livenotification.LiveNotificationBranding +import org.json.JSONObject + +/** + * `countdowntimer` template — chronometer ticking toward [targetDate]. + * + * Fields: title (req), heroImageKey (opt), targetDate (req, epoch ms — + * extendable across pushes), statusMessage (req, label above timer), + * expiredMessage (opt). Post-target with no expiredMessage means the activity + * should hide; SDK signals this via [TemplateRenderResult.cancelImmediately]. + */ +internal object CountdownTimerTemplate : LiveNotificationTemplate { + + override val name: String = TemplateRegistry.COUNTDOWN_TIMER + + override fun render( + context: Context, + data: JSONObject, + branding: LiveNotificationBranding?, + smallIcon: Int, + fallbackTintColor: Int? + ): TemplateRenderResult { + val title = data.optString(CountdownTimerFields.TITLE) + val heroImageKey = data.optStringNonEmpty(CountdownTimerFields.HERO_IMAGE_KEY) + val targetDate = data.optLong(CountdownTimerFields.TARGET_DATE).takeIf { it > 0 } + val statusMessage = data.optString(CountdownTimerFields.STATUS_MESSAGE) + val expiredMessage = data.optStringNonEmpty(CountdownTimerFields.EXPIRED_MESSAGE) + + val now = System.currentTimeMillis() + val isPostTarget = targetDate != null && now >= targetDate + // Server pushed a post-target state with no message: hide the activity. + val cancelImmediately = isPostTarget && expiredMessage == null + + return TemplateRenderResult( + title = title, + body = if (isPostTarget) expiredMessage.orEmpty() else statusMessage, + subText = null, + largeIcon = if (cancelImmediately) null else TemplateAssets.resolveBitmap(context, heroImageKey), + accentColor = if (cancelImmediately) null else (branding?.accentColor ?: fallbackTintColor), + colorized = false, + showProgress = false, + progress = 0, + progressMax = 0, + segments = emptyList(), + points = emptyList(), + startIconRes = null, + endIconRes = null, + trackerIconRes = null, + countdownUntil = if (isPostTarget) null else targetDate, + deepLink = null, + cancelImmediately = cancelImmediately + ) + } +} diff --git a/messagingpush/src/main/java/io/customer/messagingpush/livenotification/template/DeliveryTrackingTemplate.kt b/messagingpush/src/main/java/io/customer/messagingpush/livenotification/template/DeliveryTrackingTemplate.kt new file mode 100644 index 000000000..b35010aeb --- /dev/null +++ b/messagingpush/src/main/java/io/customer/messagingpush/livenotification/template/DeliveryTrackingTemplate.kt @@ -0,0 +1,63 @@ +package io.customer.messagingpush.livenotification.template + +import android.content.Context +import io.customer.messagingpush.livenotification.LiveNotificationBranding +import org.json.JSONObject + +/** + * `deliverytracking` template — segmented progress bar over delivery stages. + * + * Fields: orderId (req), recipientName (opt), statusMessage (req), + * statusImageKey (opt), stepCurrent/stepTotal (req), estimatedArrival (opt, + * epoch ms), driverName (opt). + */ +internal object DeliveryTrackingTemplate : LiveNotificationTemplate { + + override val name: String = TemplateRegistry.DELIVERY_TRACKING + + override fun render( + context: Context, + data: JSONObject, + branding: LiveNotificationBranding?, + smallIcon: Int, + fallbackTintColor: Int? + ): TemplateRenderResult { + val orderId = data.optString(DeliveryTrackingFields.ORDER_ID) + val recipientName = data.optStringNonEmpty(DeliveryTrackingFields.RECIPIENT_NAME) + val statusMessage = data.optString(DeliveryTrackingFields.STATUS_MESSAGE) + val statusImageKey = data.optStringNonEmpty(DeliveryTrackingFields.STATUS_IMAGE_KEY) + val stepCurrent = data.optInt(DeliveryTrackingFields.STEP_CURRENT, 0) + val stepTotal = data.optInt(DeliveryTrackingFields.STEP_TOTAL, 1).coerceAtLeast(1) + val estimatedArrival = data.optLong(DeliveryTrackingFields.ESTIMATED_ARRIVAL).takeIf { it > 0 } + val driverName = data.optStringNonEmpty(DeliveryTrackingFields.DRIVER_NAME) + + val title = recipientName?.let { "Delivery for $it" } ?: "Order #$orderId" + val subText = when { + driverName != null && orderId.isNotEmpty() -> "Driver: $driverName · Order #$orderId" + driverName != null -> "Driver: $driverName" + orderId.isNotEmpty() -> "Order #$orderId" + else -> null + } + + val segments = List(stepTotal) { SegmentSpec(length = 1) } + + return TemplateRenderResult( + title = title, + body = statusMessage, + subText = subText, + largeIcon = TemplateAssets.resolveBitmap(context, statusImageKey), + accentColor = branding?.accentColor ?: fallbackTintColor, + colorized = false, + showProgress = true, + progress = stepCurrent.coerceIn(0, stepTotal), + progressMax = stepTotal, + segments = segments, + points = emptyList(), + startIconRes = null, + endIconRes = null, + trackerIconRes = null, + countdownUntil = estimatedArrival, + deepLink = null + ) + } +} diff --git a/messagingpush/src/main/java/io/customer/messagingpush/livenotification/template/FlightStatusTemplate.kt b/messagingpush/src/main/java/io/customer/messagingpush/livenotification/template/FlightStatusTemplate.kt new file mode 100644 index 000000000..1cb7668d5 --- /dev/null +++ b/messagingpush/src/main/java/io/customer/messagingpush/livenotification/template/FlightStatusTemplate.kt @@ -0,0 +1,82 @@ +package io.customer.messagingpush.livenotification.template + +import android.content.Context +import io.customer.messagingpush.livenotification.LiveNotificationBranding +import org.json.JSONObject + +/** + * `flightstatus` template — flight progress with optional in-flight progress bar. + * + * Fields: flightNumber, origin{code, city}, destination{code, city}, + * statusMessage (req), gate (opt), terminal (opt), + * scheduledDeparture/estimatedArrival (req, epoch ms), progressFraction (opt, 0–1), + * delayMinutes (opt). + */ +internal object FlightStatusTemplate : LiveNotificationTemplate { + + override val name: String = TemplateRegistry.FLIGHT_STATUS + + private const val DELAY_RED: Int = -0x33ccd0 // #CC3330 + + override fun render( + context: Context, + data: JSONObject, + branding: LiveNotificationBranding?, + smallIcon: Int, + fallbackTintColor: Int? + ): TemplateRenderResult { + val flightNumber = data.optString(FlightStatusFields.FLIGHT_NUMBER) + val origin = data.optJSONObject(FlightStatusFields.ORIGIN) + val destination = data.optJSONObject(FlightStatusFields.DESTINATION) + val originCode = origin?.optString(AirportFields.CODE).orEmpty() + val destinationCode = destination?.optString(AirportFields.CODE).orEmpty() + + val statusMessage = data.optString(FlightStatusFields.STATUS_MESSAGE) + val gate = data.optStringNonEmpty(FlightStatusFields.GATE) + val terminal = data.optStringNonEmpty(FlightStatusFields.TERMINAL) + val scheduledDeparture = data.optLong(FlightStatusFields.SCHEDULED_DEPARTURE).takeIf { it > 0 } + val estimatedArrival = data.optLong(FlightStatusFields.ESTIMATED_ARRIVAL).takeIf { it > 0 } + val progressFractionRaw = + if (data.has(FlightStatusFields.PROGRESS_FRACTION)) data.optDouble(FlightStatusFields.PROGRESS_FRACTION) else Double.NaN + val progressFraction = progressFractionRaw.takeIf { !it.isNaN() } + val delayMinutes = data.optInt(FlightStatusFields.DELAY_MINUTES, 0).takeIf { it > 0 } + + val title = "$flightNumber · $originCode → $destinationCode" + val subText = "Gate ${gate ?: "TBA"} · Terminal ${terminal ?: "TBA"}" + val body = if (delayMinutes != null) { + "$statusMessage · Delayed $delayMinutes min" + } else { + statusMessage + } + + val showProgress = progressFraction != null + val progress = progressFraction + ?.coerceIn(0.0, 1.0) + ?.let { (it * 100).toInt() } + ?: 0 + val countdownUntil = if (showProgress) estimatedArrival else scheduledDeparture + val accentColor = when { + delayMinutes != null -> DELAY_RED + else -> branding?.accentColor ?: fallbackTintColor + } + + return TemplateRenderResult( + title = title, + body = body, + subText = subText, + largeIcon = null, + accentColor = accentColor, + colorized = false, + showProgress = showProgress, + progress = progress, + progressMax = 100, + segments = emptyList(), + points = emptyList(), + startIconRes = null, + endIconRes = null, + trackerIconRes = null, + countdownUntil = countdownUntil, + deepLink = null + ) + } +} diff --git a/messagingpush/src/main/java/io/customer/messagingpush/livenotification/template/JsonExtensions.kt b/messagingpush/src/main/java/io/customer/messagingpush/livenotification/template/JsonExtensions.kt new file mode 100644 index 000000000..0a57da9f7 --- /dev/null +++ b/messagingpush/src/main/java/io/customer/messagingpush/livenotification/template/JsonExtensions.kt @@ -0,0 +1,16 @@ +package io.customer.messagingpush.livenotification.template + +import org.json.JSONObject + +/** + * Returns the string value for [key], or null when the key is absent, holds an + * explicit JSON null, or is empty. + * + * Prefer this over `optString(key).takeIf { it.isNotEmpty() }`: `optString` + * returns the literal string `"null"` for an explicit JSON null, which would + * otherwise slip past an `isNotEmpty()` guard and render as visible text. + */ +internal fun JSONObject.optStringNonEmpty(key: String): String? { + if (isNull(key)) return null + return optString(key).takeIf { it.isNotEmpty() } +} diff --git a/messagingpush/src/main/java/io/customer/messagingpush/livenotification/template/LiveNotificationFields.kt b/messagingpush/src/main/java/io/customer/messagingpush/livenotification/template/LiveNotificationFields.kt new file mode 100644 index 000000000..51a195bdf --- /dev/null +++ b/messagingpush/src/main/java/io/customer/messagingpush/livenotification/template/LiveNotificationFields.kt @@ -0,0 +1,74 @@ +package io.customer.messagingpush.livenotification.template + +/** + * Single source of truth for live-notification field names. Referenced by both + * the push-render path (each `*Template.render` reading the flattened payload) + * and the local-start path (`LiveNotificationData.fields()`), so the two can't + * drift apart. + */ +internal object DeliveryTrackingFields { + const val ORDER_ID = "orderId" + const val RECIPIENT_NAME = "recipientName" + const val STATUS_MESSAGE = "statusMessage" + const val STATUS_IMAGE_KEY = "statusImageKey" + const val STEP_CURRENT = "stepCurrent" + const val STEP_TOTAL = "stepTotal" + const val ESTIMATED_ARRIVAL = "estimatedArrival" + const val DRIVER_NAME = "driverName" +} + +internal object FlightStatusFields { + const val FLIGHT_NUMBER = "flightNumber" + const val ORIGIN = "origin" + const val DESTINATION = "destination" + const val STATUS_MESSAGE = "statusMessage" + const val GATE = "gate" + const val TERMINAL = "terminal" + const val SCHEDULED_DEPARTURE = "scheduledDeparture" + const val ESTIMATED_ARRIVAL = "estimatedArrival" + const val PROGRESS_FRACTION = "progressFraction" + const val DELAY_MINUTES = "delayMinutes" +} + +internal object LiveScoreFields { + const val HOME_TEAM = "homeTeam" + const val AWAY_TEAM = "awayTeam" + const val HOME_SCORE = "homeScore" + const val AWAY_SCORE = "awayScore" + const val PERIOD = "period" + const val CLOCK = "clock" + const val STATUS_MESSAGE = "statusMessage" + const val SPORT = "sport" + const val LEAGUE_LOGO_KEY = "leagueLogoKey" +} + +internal object CountdownTimerFields { + const val TITLE = "title" + const val HERO_IMAGE_KEY = "heroImageKey" + const val TARGET_DATE = "targetDate" + const val STATUS_MESSAGE = "statusMessage" + const val EXPIRED_MESSAGE = "expiredMessage" +} + +internal object AuctionBidFields { + const val ITEM_TITLE = "itemTitle" + const val ITEM_IMAGE_KEY = "itemImageKey" + const val CURRENCY_SYMBOL = "currencySymbol" + const val CURRENT_BID = "currentBid" + const val BID_COUNT = "bidCount" + const val END_TIME = "endTime" + const val STATUS_MESSAGE = "statusMessage" + const val IS_USER_HIGH_BIDDER = "isUserHighBidder" + const val USER_BID_AMOUNT = "userBidAmount" +} + +/** Nested object sub-fields shared by templates that embed them. */ +internal object AirportFields { + const val CODE = "code" + const val CITY = "city" +} + +internal object TeamFields { + const val NAME = "name" + const val LOGO_KEY = "logoKey" +} diff --git a/messagingpush/src/main/java/io/customer/messagingpush/livenotification/template/LiveNotificationTemplate.kt b/messagingpush/src/main/java/io/customer/messagingpush/livenotification/template/LiveNotificationTemplate.kt new file mode 100644 index 000000000..c95c0f43d --- /dev/null +++ b/messagingpush/src/main/java/io/customer/messagingpush/livenotification/template/LiveNotificationTemplate.kt @@ -0,0 +1,35 @@ +package io.customer.messagingpush.livenotification.template + +import android.content.Context +import androidx.annotation.ColorInt +import androidx.annotation.DrawableRes +import io.customer.messagingpush.livenotification.LiveNotificationBranding +import org.json.JSONObject + +/** + * Renders a single live-notification template. + * + * Each template owns its typed schema (e.g. `deliverytracking` knows about + * `orderId`, `stepCurrent`, `statusImageKey`). The handler dispatches to a + * concrete subtype via [TemplateRegistry.find] using the `activity_type` key + * from the FCM envelope. + * + * All template fields arrive flattened in a single [data] object alongside the + * envelope keys (unlike iOS, Android does not split static `attributes` from + * dynamic `content-state`). Each template reads the fields it documents. + * + * Sealed and `internal` — the v1 closed set of templates is the only path. + * Adding a template means adding a subtype to this hierarchy and an entry to + * [TemplateRegistry]. + */ +internal sealed interface LiveNotificationTemplate { + val name: String + + fun render( + context: Context, + data: JSONObject, + branding: LiveNotificationBranding?, + @DrawableRes smallIcon: Int, + @ColorInt fallbackTintColor: Int? + ): TemplateRenderResult +} diff --git a/messagingpush/src/main/java/io/customer/messagingpush/livenotification/template/LiveScoreTemplate.kt b/messagingpush/src/main/java/io/customer/messagingpush/livenotification/template/LiveScoreTemplate.kt new file mode 100644 index 000000000..b63ccf2f0 --- /dev/null +++ b/messagingpush/src/main/java/io/customer/messagingpush/livenotification/template/LiveScoreTemplate.kt @@ -0,0 +1,64 @@ +package io.customer.messagingpush.livenotification.template + +import android.content.Context +import io.customer.messagingpush.livenotification.LiveNotificationBranding +import org.json.JSONObject + +/** + * `livescore` template — text-only live update for sports scores. + * + * Fields: homeTeam{name, logoKey?}, awayTeam{name, logoKey?}, sport (ignored — + * Android can't change layout per sport without custom views), leagueLogoKey (opt), + * homeScore/awayScore (int), period (req), clock (opt), + * statusMessage (opt — overrides body for special situations). + * + * Limitation: Android exposes a single large-icon slot; we use [leagueLogoKey]. + * Per-team logos are not rendered. + */ +internal object LiveScoreTemplate : LiveNotificationTemplate { + + override val name: String = TemplateRegistry.LIVE_SCORE + + override fun render( + context: Context, + data: JSONObject, + branding: LiveNotificationBranding?, + smallIcon: Int, + fallbackTintColor: Int? + ): TemplateRenderResult { + val homeTeam = data.optJSONObject(LiveScoreFields.HOME_TEAM) + val awayTeam = data.optJSONObject(LiveScoreFields.AWAY_TEAM) + val homeName = homeTeam?.optString(TeamFields.NAME).orEmpty() + val awayName = awayTeam?.optString(TeamFields.NAME).orEmpty() + val homeScore = data.optInt(LiveScoreFields.HOME_SCORE, 0) + val awayScore = data.optInt(LiveScoreFields.AWAY_SCORE, 0) + val period = data.optString(LiveScoreFields.PERIOD) + val clock = data.optStringNonEmpty(LiveScoreFields.CLOCK) + val statusMessage = data.optStringNonEmpty(LiveScoreFields.STATUS_MESSAGE) + val leagueLogoKey = data.optStringNonEmpty(LiveScoreFields.LEAGUE_LOGO_KEY) + + val title = "$homeName $homeScore - $awayScore $awayName" + val body = statusMessage + ?: clock?.let { "$period · $it" } + ?: period + + return TemplateRenderResult( + title = title, + body = body, + subText = null, + largeIcon = TemplateAssets.resolveBitmap(context, leagueLogoKey), + accentColor = branding?.accentColor ?: fallbackTintColor, + colorized = false, + showProgress = false, + progress = 0, + progressMax = 0, + segments = emptyList(), + points = emptyList(), + startIconRes = null, + endIconRes = null, + trackerIconRes = null, + countdownUntil = null, + deepLink = null + ) + } +} diff --git a/messagingpush/src/main/java/io/customer/messagingpush/livenotification/template/TemplateAssets.kt b/messagingpush/src/main/java/io/customer/messagingpush/livenotification/template/TemplateAssets.kt new file mode 100644 index 000000000..17c43b0cf --- /dev/null +++ b/messagingpush/src/main/java/io/customer/messagingpush/livenotification/template/TemplateAssets.kt @@ -0,0 +1,63 @@ +package io.customer.messagingpush.livenotification.template + +import android.content.Context +import android.graphics.Bitmap +import android.graphics.Canvas +import android.graphics.drawable.BitmapDrawable +import androidx.annotation.DrawableRes +import androidx.core.content.ContextCompat +import androidx.core.graphics.createBitmap +import io.customer.messagingpush.extensions.getDrawableByName +import io.customer.sdk.core.di.SDKComponent + +/** + * Drawable lookup + bitmap conversion shared by every template. + * + * Templates carry image keys as kebab-case strings (e.g. `delivery-warehouse`) + * but Android resource names only allow `[a-z0-9_]`. We normalize hyphens to + * underscores before delegating to [Context.getDrawableByName], so spec values + * resolve to the matching `R.drawable.*` entries the host app ships. + */ +internal object TemplateAssets { + + @DrawableRes + fun resolveDrawable(context: Context, key: String?): Int? { + if (key.isNullOrBlank()) return null + val normalized = key.replace('-', '_') + val resolved = context.getDrawableByName(normalized) + if (resolved == null) { + // The key was specified but no drawable matched. This is a configuration + // mismatch (server pushing keys the host app hasn't bundled). The template + // still renders — large-icon slot stays empty per "design for absence" — + // but a warning surfaces so developers can diagnose missing assets. + SDKComponent.logger.debug( + "Live notification asset key '$key' did not resolve to a drawable; rendering without it." + ) + } + return resolved + } + + fun resolveBitmap(context: Context, key: String?): Bitmap? { + val res = resolveDrawable(context, key) ?: return null + return drawableResToBitmap(context, res) + } + + fun drawableResToBitmap(context: Context, @DrawableRes res: Int): Bitmap? { + val drawable = ContextCompat.getDrawable(context, res) ?: return null + if (drawable is BitmapDrawable) { + return drawable.bitmap + } + val width = drawable.intrinsicWidth.takeIf { it > 0 } ?: 1 + val height = drawable.intrinsicHeight.takeIf { it > 0 } ?: 1 + return try { + val bitmap = createBitmap(width, height) + val canvas = Canvas(bitmap) + drawable.setBounds(0, 0, canvas.width, canvas.height) + drawable.draw(canvas) + bitmap + } catch (e: Exception) { + SDKComponent.logger.error("Failed to convert drawable $res to bitmap: ${e.message}") + null + } + } +} diff --git a/messagingpush/src/main/java/io/customer/messagingpush/livenotification/template/TemplateRegistry.kt b/messagingpush/src/main/java/io/customer/messagingpush/livenotification/template/TemplateRegistry.kt new file mode 100644 index 000000000..5dfa5930f --- /dev/null +++ b/messagingpush/src/main/java/io/customer/messagingpush/livenotification/template/TemplateRegistry.kt @@ -0,0 +1,22 @@ +package io.customer.messagingpush.livenotification.template + +import io.customer.messagingpush.livenotification.LiveNotificationType + +internal object TemplateRegistry { + + // Aliases to the public identifiers (single source of truth: LiveNotificationType). + const val DELIVERY_TRACKING = LiveNotificationType.DELIVERY_TRACKING + const val FLIGHT_STATUS = LiveNotificationType.FLIGHT_STATUS + const val LIVE_SCORE = LiveNotificationType.LIVE_SCORE + const val COUNTDOWN_TIMER = LiveNotificationType.COUNTDOWN_TIMER + const val AUCTION_BID = LiveNotificationType.AUCTION_BID + + fun find(name: String?): LiveNotificationTemplate? = when (name) { + DELIVERY_TRACKING -> DeliveryTrackingTemplate + FLIGHT_STATUS -> FlightStatusTemplate + LIVE_SCORE -> LiveScoreTemplate + COUNTDOWN_TIMER -> CountdownTimerTemplate + AUCTION_BID -> AuctionBidTemplate + else -> null + } +} diff --git a/messagingpush/src/main/java/io/customer/messagingpush/livenotification/template/TemplateRenderResult.kt b/messagingpush/src/main/java/io/customer/messagingpush/livenotification/template/TemplateRenderResult.kt new file mode 100644 index 000000000..672d9e485 --- /dev/null +++ b/messagingpush/src/main/java/io/customer/messagingpush/livenotification/template/TemplateRenderResult.kt @@ -0,0 +1,45 @@ +package io.customer.messagingpush.livenotification.template + +import android.graphics.Bitmap +import androidx.annotation.ColorInt +import androidx.annotation.DrawableRes + +/** + * Normalized intermediate produced by every [LiveNotificationTemplate]. + * + * The handler converts this into either [io.customer.messagingpush.Api36LiveNotificationParams] + * or [io.customer.messagingpush.BasicNotificationParams] depending on the device's + * API level — so this struct is the union of both target shapes. + * + * Fields that have no counterpart on the lower tier (segments, points, progress + * icons) are silently ignored on pre-API-36 builds. + */ +internal data class TemplateRenderResult( + val title: String, + val body: String, + val subText: String?, + val largeIcon: Bitmap?, + @ColorInt val accentColor: Int?, + val colorized: Boolean, + val showProgress: Boolean, + val progress: Int, + val progressMax: Int, + val segments: List, + val points: List, + @DrawableRes val startIconRes: Int?, + @DrawableRes val endIconRes: Int?, + @DrawableRes val trackerIconRes: Int?, + val countdownUntil: Long?, + val deepLink: String?, + val cancelImmediately: Boolean = false +) + +internal data class SegmentSpec( + val length: Int, + @ColorInt val color: Int? = null +) + +internal data class PointSpec( + val position: Int, + @ColorInt val color: Int? = null +) diff --git a/messagingpush/src/main/java/io/customer/messagingpush/util/BitmapDownloader.kt b/messagingpush/src/main/java/io/customer/messagingpush/util/BitmapDownloader.kt new file mode 100644 index 000000000..381c9729b --- /dev/null +++ b/messagingpush/src/main/java/io/customer/messagingpush/util/BitmapDownloader.kt @@ -0,0 +1,25 @@ +package io.customer.messagingpush.util + +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import io.customer.sdk.core.di.SDKComponent +import java.net.URL +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withContext + +internal object BitmapDownloader { + + fun download(imageUrl: String): Bitmap? = runBlocking { + withContext(Dispatchers.IO) { + try { + URL(imageUrl).openStream().use { input -> + BitmapFactory.decodeStream(input) + } + } catch (e: Exception) { + SDKComponent.logger.error("Failed to download bitmap from '$imageUrl': ${e.message}") + null + } + } + } +} diff --git a/messagingpush/src/main/java/io/customer/messagingpush/util/NotificationChannelCreator.kt b/messagingpush/src/main/java/io/customer/messagingpush/util/NotificationChannelCreator.kt index b49a58760..0928f1973 100644 --- a/messagingpush/src/main/java/io/customer/messagingpush/util/NotificationChannelCreator.kt +++ b/messagingpush/src/main/java/io/customer/messagingpush/util/NotificationChannelCreator.kt @@ -33,6 +33,18 @@ internal class NotificationChannelCreator( @VisibleForTesting internal const val METADATA_NOTIFICATION_CHANNEL_IMPORTANCE = "io.customer.notification_channel_importance" + + @VisibleForTesting + internal const val METADATA_LIVE_NOTIFICATION_CHANNEL_ID = + "io.customer.live_notification_channel_id" + + @VisibleForTesting + internal const val METADATA_LIVE_NOTIFICATION_CHANNEL_NAME = + "io.customer.live_notification_channel_name" + + @VisibleForTesting + internal const val METADATA_LIVE_NOTIFICATION_CHANNEL_IMPORTANCE = + "io.customer.live_notification_channel_importance" } /** @@ -51,51 +63,98 @@ internal class NotificationChannelCreator( appMetaData: Bundle?, notificationManager: NotificationManager ): String { - val defaultChannelId = context.packageName val channelId = appMetaData?.getMetaDataString(name = METADATA_NOTIFICATION_CHANNEL_ID) - ?: defaultChannelId + ?: context.packageName // Since android Oreo notification channel is needed. if (androidVersionChecker.isOreoOrHigher()) { - val existingChannel = notificationManager.getNotificationChannel(channelId) - - val channelName = - appMetaData?.getMetaDataString(name = METADATA_NOTIFICATION_CHANNEL_NAME) - ?: "$applicationName Notifications" - val rawImportance = appMetaData?.getInt( - METADATA_NOTIFICATION_CHANNEL_IMPORTANCE, - NotificationManager.IMPORTANCE_DEFAULT - ) ?: NotificationManager.IMPORTANCE_DEFAULT - - // Validate that the importance value is a valid NotificationManager constant - val importance = validateImportanceLevel(rawImportance) - - // Only create or update the channel if it doesn't exist or the name is different - if (existingChannel == null || existingChannel.name != channelName) { - logger.logCreatingNotificationChannel(channelId, channelName, importance) - val channel = NotificationChannel( - channelId, - channelName, - importance - ) - notificationManager.createNotificationChannel(channel) - } else { - logger.logNotificationChannelAlreadyExists(channelId) - } + val channelName = appMetaData?.getMetaDataString(name = METADATA_NOTIFICATION_CHANNEL_NAME) + ?: "$applicationName Notifications" + val importance = resolveImportance( + appMetaData = appMetaData, + metadataKey = METADATA_NOTIFICATION_CHANNEL_IMPORTANCE, + default = NotificationManager.IMPORTANCE_DEFAULT + ) + createOrUpdateChannel(notificationManager, channelId, channelName, importance) } return channelId } /** - * Validates that the provided importance level is a valid NotificationManager importance constant. - * If the value is not valid, it returns the default importance level. + * Creates a notification channel for live notifications on Android Oreo+ and returns its ID. + * + * Defaults to `{packageName}_cio_live` / `{appName} Live Updates` / `IMPORTANCE_HIGH`. + * Each value can be overridden via manifest ``: + * - `io.customer.live_notification_channel_id` + * - `io.customer.live_notification_channel_name` + * - `io.customer.live_notification_channel_importance` (integer, e.g. 4 for HIGH) * - * @param importanceLevel The importance level to validate - * @return A valid NotificationManager importance constant + * `IMPORTANCE_HIGH` is the recommended default — it ensures the first "start" event + * surfaces as a heads-up notification. Note: channel importance cannot be changed + * programmatically after the channel is created on a device. + * + * @param context The application context + * @param applicationName The application name used to derive the default channel name + * @param appMetaData The application metadata bundle + * @param notificationManager The notification manager instance + * @return The channel ID to use in the notification builder + */ + fun createLiveNotificationChannelIfNeededAndReturnChannelId( + context: Context, + applicationName: String, + appMetaData: Bundle?, + notificationManager: NotificationManager + ): String { + val channelId = appMetaData?.getMetaDataString(name = METADATA_LIVE_NOTIFICATION_CHANNEL_ID) + ?: "${context.packageName}_cio_live" + + if (androidVersionChecker.isOreoOrHigher()) { + val channelName = appMetaData?.getMetaDataString(name = METADATA_LIVE_NOTIFICATION_CHANNEL_NAME) + ?: "$applicationName Live Updates" + val importance = resolveImportance( + appMetaData = appMetaData, + metadataKey = METADATA_LIVE_NOTIFICATION_CHANNEL_IMPORTANCE, + default = NotificationManager.IMPORTANCE_HIGH + ) + createOrUpdateChannel(notificationManager, channelId, channelName, importance) + } + + return channelId + } + + @RequiresApi(Build.VERSION_CODES.O) + private fun createOrUpdateChannel( + notificationManager: NotificationManager, + channelId: String, + channelName: String, + importance: Int + ) { + val existingChannel = notificationManager.getNotificationChannel(channelId) + // Only create or update the channel if it doesn't exist or the name is different + if (existingChannel == null || existingChannel.name != channelName) { + logger.logCreatingNotificationChannel(channelId, channelName, importance) + val channel = NotificationChannel(channelId, channelName, importance) + notificationManager.createNotificationChannel(channel) + } else { + logger.logNotificationChannelAlreadyExists(channelId) + } + } + + @RequiresApi(Build.VERSION_CODES.N) + private fun resolveImportance(appMetaData: Bundle?, metadataKey: String, default: Int): Int { + val rawImportance = appMetaData?.getInt(metadataKey, default) ?: default + return validateImportanceLevel(rawImportance, fallback = default) + } + + /** + * Validates that the provided importance level is a valid NotificationManager importance constant. + * If the value is not valid, returns the supplied [fallback] so each caller preserves its own + * intended default (e.g. `IMPORTANCE_HIGH` for live notifications) rather than silently + * collapsing to `IMPORTANCE_DEFAULT`. */ @RequiresApi(Build.VERSION_CODES.N) - private fun validateImportanceLevel(importanceLevel: Int): Int { + private fun validateImportanceLevel(importanceLevel: Int, fallback: Int): Int { return when (importanceLevel) { NotificationManager.IMPORTANCE_NONE, NotificationManager.IMPORTANCE_MIN, @@ -106,7 +165,7 @@ internal class NotificationChannelCreator( else -> { logger.logInvalidNotificationChannelImportance(importanceLevel) - NotificationManager.IMPORTANCE_DEFAULT + fallback } } } diff --git a/messagingpush/src/test/java/io/customer/messagingpush/LiveNotificationCallbackTest.kt b/messagingpush/src/test/java/io/customer/messagingpush/LiveNotificationCallbackTest.kt new file mode 100644 index 000000000..3c8168dae --- /dev/null +++ b/messagingpush/src/test/java/io/customer/messagingpush/LiveNotificationCallbackTest.kt @@ -0,0 +1,115 @@ +package io.customer.messagingpush + +import android.app.Notification +import android.app.NotificationManager +import android.content.Context +import android.os.Bundle +import androidx.core.app.NotificationCompat +import io.customer.commontest.extensions.assertCalledNever +import io.customer.commontest.extensions.attachToSDKComponent +import io.customer.messagingpush.data.communication.CustomerIOPushNotificationCallback +import io.customer.messagingpush.data.model.CustomerIOParsedPushPayload +import io.customer.messagingpush.livenotification.LiveNotificationType +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.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +/** + * Covers the host-app render override (`createLiveNotification`) and + * customer-defined activity types. + */ +@RunWith(RobolectricTestRunner::class) +internal class LiveNotificationCallbackTest : IntegrationTest() { + + private val notificationManager: NotificationManager = mockk(relaxed = true) + private val customType = "com.acme.live.ride" + + private fun attach(callback: CustomerIOPushNotificationCallback?) { + ModuleMessagingPushFCM( + MessagingPushModuleConfig.Builder().apply { + callback?.let { setNotificationCallback(it) } + // Enable a built-in type (for the override test) and the custom type. + setLiveNotificationTypes(LiveNotificationType.DELIVERY_TRACKING, customType) + }.build() + ).attachToSDKComponent() + } + + private fun callbackReturning(notification: Notification) = object : CustomerIOPushNotificationCallback { + override fun createLiveNotification(payload: CustomerIOParsedPushPayload, context: Context): Notification = + notification + } + + private fun appNotification(title: String): Notification = + NotificationCompat.Builder(contextMock, "channel").setSmallIcon(0).setContentTitle(title).build() + + private fun bundle(activityType: String, event: String = "start"): Bundle = Bundle().apply { + putString(LiveNotificationHandler.ACTIVITY_ID_KEY, "act-cb") + putString(LiveNotificationHandler.EVENT_KEY, event) + putString(LiveNotificationHandler.ACTIVITY_TYPE_KEY, activityType) + } + + private fun invoke(b: Bundle) = LiveNotificationHandler(b).handle( + context = contextMock, + deliveryId = "d", + deliveryToken = "t", + smallIcon = 0, + tintColor = null, + channelId = "channel", + notificationManager = notificationManager + ) + + @Test + fun builtInType_callbackReturningNotification_isPostedInsteadOfTemplate() { + val custom = appNotification("App rendered") + attach(callbackReturning(custom)) + val posted = slot() + every { notificationManager.notify(any(), any(), capture(posted)) } returns Unit + + invoke(bundle(LiveNotificationType.DELIVERY_TRACKING)) + + posted.captured shouldBeEqualTo custom + } + + @Test + fun customType_withCallback_isRendered() { + val custom = appNotification("Custom render") + attach(callbackReturning(custom)) + + invoke(bundle(customType)) + + verify(exactly = 1) { + notificationManager.notify("act-cb", any(), custom) + } + } + + @Test + fun customType_withoutCallback_isDropped() { + attach(callback = null) // enabled type, but no renderer + + invoke(bundle(customType)) + + assertCalledNever { + notificationManager.notify(any(), any(), any()) + } + } + + @Test + fun customType_endWithoutRenderer_stillCancels() { + // Even with no notification to post (custom type, no callback), an `end` must + // still cancel/clean up the existing notification. + attach(callback = null) + val expectedNotifId = "act-cb".hashCode() and 0x7FFFFFFF + + invoke(bundle(customType, event = "end")) + + verify(exactly = 1) { + notificationManager.cancel("act-cb", expectedNotifId) + } + } +} diff --git a/messagingpush/src/test/java/io/customer/messagingpush/LiveNotificationHandlerTest.kt b/messagingpush/src/test/java/io/customer/messagingpush/LiveNotificationHandlerTest.kt new file mode 100644 index 000000000..4a1399e0e --- /dev/null +++ b/messagingpush/src/test/java/io/customer/messagingpush/LiveNotificationHandlerTest.kt @@ -0,0 +1,368 @@ +package io.customer.messagingpush + +import android.app.Notification +import android.app.NotificationManager +import android.os.Bundle +import io.customer.commontest.config.TestConfig +import io.customer.commontest.extensions.assertCalledNever +import io.customer.commontest.extensions.attachToSDKComponent +import io.customer.messagingpush.livenotification.LiveNotificationType +import io.customer.messagingpush.livenotification.template.TemplateRegistry +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.json.JSONObject +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.shadows.ShadowLooper + +/** + * Tests for [LiveNotificationHandler] focused on envelope parsing and dispatch: + * + * - top-level wire keys (`activity_id`, `event`, `activity_type`, `timestamp`, + * `dismissal_date`) are read from the [Bundle]; + * - template fields arrive flattened at the envelope top level (no + * `attributes` / `content_state` split); + * - missing `activity_id`, `event`, or unknown `activity_type` are dropped + * without posting a notification; + * - `event = "end"` cancels the notification immediately. + * + * The actual rendered notification is opaque to these tests — that's covered by + * the per-template render tests. Here we only assert the dispatch contract. + */ +@RunWith(RobolectricTestRunner::class) +internal class LiveNotificationHandlerTest : IntegrationTest() { + + private val notificationManager: NotificationManager = mockk(relaxed = true) + private val channelId = "live-notifications" + + override fun setup(testConfig: TestConfig) { + super.setup(testConfig) + // Live notifications are opt-in; enable all built-in types so the dispatch tests run. + ModuleMessagingPushFCM( + MessagingPushModuleConfig.Builder().setLiveNotificationTypes( + LiveNotificationType.DELIVERY_TRACKING, + LiveNotificationType.FLIGHT_STATUS, + LiveNotificationType.LIVE_SCORE, + LiveNotificationType.COUNTDOWN_TIMER, + LiveNotificationType.AUCTION_BID + ).build() + ).attachToSDKComponent() + } + + private fun newBundle( + activityId: String? = "live-act-1", + event: String? = "start", + activityType: String? = TemplateRegistry.DELIVERY_TRACKING, + data: JSONObject = JSONObject(), + timestamp: Long? = null, + dismissalDate: Long? = null + ): Bundle { + val bundle = Bundle() + if (activityId != null) bundle.putString(LiveNotificationHandler.ACTIVITY_ID_KEY, activityId) + if (event != null) bundle.putString(LiveNotificationHandler.EVENT_KEY, event) + if (activityType != null) bundle.putString(LiveNotificationHandler.ACTIVITY_TYPE_KEY, activityType) + if (timestamp != null) bundle.putString(LiveNotificationHandler.TIMESTAMP_KEY, timestamp.toString()) + if (dismissalDate != null) bundle.putString(LiveNotificationHandler.DISMISSAL_DATE_KEY, dismissalDate.toString()) + // Template fields ride flattened at the top level, as the backend delivers them. + for (key in data.keys()) bundle.putString(key, data.get(key).toString()) + return bundle + } + + private fun handlerFor(bundle: Bundle): LiveNotificationHandler = LiveNotificationHandler(bundle) + + private fun invoke(handler: LiveNotificationHandler) { + handler.handle( + context = contextMock, + deliveryId = "delivery-id-1", + deliveryToken = "delivery-token-1", + smallIcon = 0, + tintColor = null, + channelId = channelId, + notificationManager = notificationManager + ) + } + + // --- Envelope keys are exactly as documented --- + + @Test + fun envelopeKeys_areTheCrossPlatformSpecKeys() { + // Lock the wire-format constants so any future rename surfaces here. + // Failure to update both the SDK and CIO backend would silently break live notifications. + LiveNotificationHandler.ACTIVITY_ID_KEY shouldBeEqualTo "activity_id" + LiveNotificationHandler.EVENT_KEY shouldBeEqualTo "event" + LiveNotificationHandler.ACTIVITY_TYPE_KEY shouldBeEqualTo "activity_type" + LiveNotificationHandler.TIMESTAMP_KEY shouldBeEqualTo "timestamp" + LiveNotificationHandler.DISMISSAL_DATE_KEY shouldBeEqualTo "dismissal_date" + } + + // --- Happy-path dispatch --- + + @Test + fun handle_givenAllFiveTemplates_postsNotificationForEach() { + val templates = listOf( + TemplateRegistry.DELIVERY_TRACKING, + TemplateRegistry.FLIGHT_STATUS, + TemplateRegistry.LIVE_SCORE, + TemplateRegistry.COUNTDOWN_TIMER, + TemplateRegistry.AUCTION_BID + ) + for (activityType in templates) { + invoke(handlerFor(newBundle(activityType = activityType))) + } + + verify(exactly = templates.size) { + notificationManager.notify(any(), any(), any()) + } + } + + @Test + fun handle_postsNotificationKeyedByActivityIdHash() { + val activityId = "live-activity-id-xyz" + val expectedNotifId = activityId.hashCode() and 0x7FFFFFFF + val data = JSONObject().apply { + put("orderId", "A-1") + put("recipientName", "User") + put("statusMessage", "Out for delivery") + put("stepCurrent", 2) + put("stepTotal", 4) + } + val bundle = newBundle(activityId = activityId, data = data) + + invoke(handlerFor(bundle)) + + verify(exactly = 1) { + notificationManager.notify(activityId, expectedNotifId, any()) + } + } + + @Test + fun handle_givenTemplateFieldNamedTitle_isNotStrippedAsReservedKey() { + // Regression: "title" is a CountdownTimer template field and must reach the + // template, not be treated as the standard-push reserved key and dropped. + val posted = slot() + every { notificationManager.notify(any(), any(), capture(posted)) } returns Unit + + val data = JSONObject().apply { + put("title", "Flash Sale") + put("targetDate", System.currentTimeMillis() + 60_000L) + put("statusMessage", "Sale starts in") + } + invoke(handlerFor(newBundle(activityType = TemplateRegistry.COUNTDOWN_TIMER, data = data))) + + posted.captured.extras.getCharSequence(Notification.EXTRA_TITLE)?.toString() shouldBeEqualTo "Flash Sale" + } + + @Test + fun handle_givenNestedJsonFieldAsString_parsesAndPosts() { + // Nested objects (origin, homeTeam, …) arrive as JSON strings in FCM data; + // the handler parses them so templates can read the nested values. + val data = JSONObject().apply { + put("flightNumber", "AA1") + put("origin", JSONObject().put("code", "JFK")) + put("destination", JSONObject().put("code", "LAX")) + put("statusMessage", "On time") + } + val bundle = newBundle(activityType = TemplateRegistry.FLIGHT_STATUS, data = data) + + invoke(handlerFor(bundle)) + + verify(exactly = 1) { + notificationManager.notify(any(), any(), any()) + } + } + + // --- Missing required fields short-circuit --- + + @Test + fun handle_givenMissingActivityId_returnsEarlyWithoutNotifying() { + invoke(handlerFor(newBundle(activityId = null))) + + assertCalledNever { + notificationManager.notify(any(), any(), any()) + } + } + + @Test + fun handle_givenMissingEvent_dropsAndDoesNotNotify() { + // event is required — there is no implicit "update" default. + invoke(handlerFor(newBundle(event = null))) + + assertCalledNever { + notificationManager.notify(any(), any(), any()) + } + } + + @Test + fun handle_givenMissingActivityType_dropsAndDoesNotNotify() { + invoke(handlerFor(newBundle(activityType = null))) + + assertCalledNever { + notificationManager.notify(any(), any(), any()) + } + } + + @Test + fun handle_givenUnknownActivityType_dropsAndDoesNotNotify() { + invoke(handlerFor(newBundle(activityType = "io.customer.liveactivities.bogus"))) + + assertCalledNever { + notificationManager.notify(any(), any(), any()) + } + } + + @Test + fun handle_givenBareTemplateNameWithoutSpecPrefix_dropsAndDoesNotNotify() { + // The cross-platform spec requires the `io.customer.liveactivities.` prefix. + // Bare names like "deliverytracking" must be rejected to stay aligned with iOS. + invoke(handlerFor(newBundle(activityType = "deliverytracking"))) + + assertCalledNever { + notificationManager.notify(any(), any(), any()) + } + } + + // --- End event dismisses immediately --- + + @Test + fun handle_givenEventEnd_cancelsImmediately() { + val activityId = "ending-activity" + val expectedNotifId = activityId.hashCode() and 0x7FFFFFFF + val bundle = newBundle(activityId = activityId, event = "end") + + invoke(handlerFor(bundle)) + + // Final state is posted, then removed immediately (dismissal_date scheduling + // arrives with the lifecycle-reporting work). + verify(exactly = 1) { + notificationManager.notify(activityId, expectedNotifId, any()) + } + verify(exactly = 1) { + notificationManager.cancel(activityId, expectedNotifId) + } + } + + @Test + fun handle_givenEventStart_doesNotCancel() { + val activityId = "starting-activity" + val expectedNotifId = activityId.hashCode() and 0x7FFFFFFF + val bundle = newBundle(activityId = activityId, event = "start") + + invoke(handlerFor(bundle)) + + verify(exactly = 1) { + notificationManager.notify(activityId, expectedNotifId, any()) + } + assertCalledNever { + notificationManager.cancel(activityId, expectedNotifId) + } + } + + // --- Out-of-order / duplicate guard --- + + @Test + fun handle_givenOlderTimestamp_dropsTheStalePush() { + val activityId = "ooo-activity" + + invoke(handlerFor(newBundle(activityId = activityId, event = "update", timestamp = 100L))) + // Arrives late and is older than what was already rendered: must be dropped. + invoke(handlerFor(newBundle(activityId = activityId, event = "update", timestamp = 50L))) + + verify(exactly = 1) { + notificationManager.notify(activityId, any(), any()) + } + } + + @Test + fun handle_givenNewerTimestamp_rendersBoth() { + val activityId = "in-order-activity" + + invoke(handlerFor(newBundle(activityId = activityId, event = "update", timestamp = 100L))) + invoke(handlerFor(newBundle(activityId = activityId, event = "update", timestamp = 200L))) + + verify(exactly = 2) { + notificationManager.notify(activityId, any(), any()) + } + } + + // --- dismissal_date scheduling on end --- + + @Test + fun handle_givenEndWithFutureDismissalDate_cancelsOnlyAfterDelay() { + val activityId = "scheduled-end" + val expectedNotifId = activityId.hashCode() and 0x7FFFFFFF + val bundle = newBundle( + activityId = activityId, + event = "end", + dismissalDate = System.currentTimeMillis() + 60_000L + ) + + invoke(handlerFor(bundle)) + + // Posted now, but not cancelled until the dismissal_date is reached. + verify(exactly = 1) { + notificationManager.notify(activityId, expectedNotifId, any()) + } + assertCalledNever { + notificationManager.cancel(activityId, expectedNotifId) + } + + ShadowLooper.runUiThreadTasksIncludingDelayedTasks() + + verify(exactly = 1) { + notificationManager.cancel(activityId, expectedNotifId) + } + } + + @Test + fun handle_givenStaleEndTimestamp_stillCancels() { + // `end` is terminal and bypasses the out-of-order guard, so it always cancels + // even if its timestamp is not newer than the last update. + val activityId = "stale-end" + val expectedNotifId = activityId.hashCode() and 0x7FFFFFFF + + invoke(handlerFor(newBundle(activityId = activityId, event = "update", timestamp = 100L))) + invoke(handlerFor(newBundle(activityId = activityId, event = "end", timestamp = 50L))) + + verify(exactly = 1) { + notificationManager.cancel(activityId, expectedNotifId) + } + } + + @Test + fun handle_givenStaleUpdateAfterEnd_isDropped() { + // `end` records its timestamp as the high-water mark (not cleared), so a delayed + // older update arriving after `end` is dropped rather than resurrecting it. + val activityId = "update-after-end" + + invoke(handlerFor(newBundle(activityId = activityId, event = "update", timestamp = 100L))) + invoke(handlerFor(newBundle(activityId = activityId, event = "end", timestamp = 200L))) + invoke(handlerFor(newBundle(activityId = activityId, event = "update", timestamp = 150L))) + + // Only the first update and the end posted; the stale 150 update was dropped. + verify(exactly = 2) { + notificationManager.notify(activityId, any(), any()) + } + } + + @Test + fun handle_givenStaleEndThenStaleUpdate_doesNotResurrect() { + // A stale, out-of-order `end` (lower timestamp) bypasses the guard to cancel, but + // must NOT lower the high-water mark; otherwise a later stale update could slip + // through and resurrect the cancelled activity. + val activityId = "stale-end-then-update" + + invoke(handlerFor(newBundle(activityId = activityId, event = "update", timestamp = 100L))) + invoke(handlerFor(newBundle(activityId = activityId, event = "end", timestamp = 50L))) + invoke(handlerFor(newBundle(activityId = activityId, event = "update", timestamp = 75L))) + + // Mark stays at 100, so the 75 update is dropped: only the first update and the end posted. + verify(exactly = 2) { + notificationManager.notify(activityId, any(), any()) + } + } +} diff --git a/messagingpush/src/test/java/io/customer/messagingpush/MessagingPushModuleConfigTest.kt b/messagingpush/src/test/java/io/customer/messagingpush/MessagingPushModuleConfigTest.kt index d70704f01..1e6e95dfa 100644 --- a/messagingpush/src/test/java/io/customer/messagingpush/MessagingPushModuleConfigTest.kt +++ b/messagingpush/src/test/java/io/customer/messagingpush/MessagingPushModuleConfigTest.kt @@ -11,6 +11,6 @@ class MessagingPushModuleConfigTest : JUnit5Test() { val config = MessagingPushModuleConfig.default() val actual = config.toString() - assertEquals("MessagingPushModuleConfig(autoTrackPushEvents=true, notificationCallback=null, pushClickBehavior=ACTIVITY_PREVENT_RESTART)", actual) + assertEquals("MessagingPushModuleConfig(autoTrackPushEvents=true, notificationCallback=null, pushClickBehavior=ACTIVITY_PREVENT_RESTART, liveNotificationBranding=null, liveNotificationTypes=[])", actual) } } diff --git a/messagingpush/src/test/java/io/customer/messagingpush/livenotification/LiveNotificationDataTest.kt b/messagingpush/src/test/java/io/customer/messagingpush/livenotification/LiveNotificationDataTest.kt new file mode 100644 index 000000000..2f484aded --- /dev/null +++ b/messagingpush/src/test/java/io/customer/messagingpush/livenotification/LiveNotificationDataTest.kt @@ -0,0 +1,55 @@ +package io.customer.messagingpush.livenotification + +import io.customer.messagingpush.livenotification.template.TemplateRegistry +import io.customer.messagingpush.testutils.core.IntegrationTest +import org.amshove.kluent.shouldBeEqualTo +import org.amshove.kluent.shouldBeFalse +import org.amshove.kluent.shouldBeNull +import org.json.JSONObject +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +internal class LiveNotificationDataTest : IntegrationTest() { + + @Test + fun deliveryTracking_mapsActivityTypeAndScalarFields() { + val data = LiveNotificationData.DeliveryTracking( + orderId = "A-1", + statusMessage = "On the way", + stepCurrent = 1, + stepTotal = 3 + ) + + data.activityType shouldBeEqualTo TemplateRegistry.DELIVERY_TRACKING + val fields = data.fields() + fields["orderId"] shouldBeEqualTo "A-1" + fields["statusMessage"] shouldBeEqualTo "On the way" + fields["stepCurrent"] shouldBeEqualTo 1 + fields["stepTotal"] shouldBeEqualTo 3 + // Unset optional fields are present as null; the manager omits them from the envelope. + fields["recipientName"].shouldBeNull() + } + + @Test + fun flightStatus_nestedAirportsSerializeToJson() { + val data = LiveNotificationData.FlightStatus( + flightNumber = "AA1", + origin = LiveNotificationData.Airport("JFK", "New York"), + destination = LiveNotificationData.Airport("LAX"), + statusMessage = "On time" + ) + + data.activityType shouldBeEqualTo TemplateRegistry.FLIGHT_STATUS + + val origin = data.fields()["origin"] as JSONObject + origin.getString("code") shouldBeEqualTo "JFK" + origin.getString("city") shouldBeEqualTo "New York" + + val destination = data.fields()["destination"] as JSONObject + destination.getString("code") shouldBeEqualTo "LAX" + // city omitted when not provided. + destination.has("city").shouldBeFalse() + } +} diff --git a/messagingpush/src/test/java/io/customer/messagingpush/livenotification/LiveNotificationLifecycleClientTest.kt b/messagingpush/src/test/java/io/customer/messagingpush/livenotification/LiveNotificationLifecycleClientTest.kt new file mode 100644 index 000000000..d4e9a6732 --- /dev/null +++ b/messagingpush/src/test/java/io/customer/messagingpush/livenotification/LiveNotificationLifecycleClientTest.kt @@ -0,0 +1,112 @@ +package io.customer.messagingpush.livenotification + +import io.customer.messagingpush.testutils.core.IntegrationTest +import io.customer.sdk.core.network.CustomerIOHttpClient +import io.customer.sdk.core.network.HttpMethod +import io.customer.sdk.core.network.HttpRequestException +import io.customer.sdk.core.network.HttpRequestParams +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.mockk +import io.mockk.slot +import kotlinx.coroutines.test.runTest +import org.amshove.kluent.shouldBeEqualTo +import org.amshove.kluent.shouldBeTrue +import org.amshove.kluent.shouldContain +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +internal class LiveNotificationLifecycleClientTest : IntegrationTest() { + + private val httpClient: CustomerIOHttpClient = mockk() + private val client = LiveNotificationLifecycleClientImpl(httpClient) + + @Test + fun register_buildsPutWithAndroidFcmBody() = runTest { + val params = slot() + coEvery { httpClient.request(capture(params)) } returns Result.success("") + + val result = client.registerForActivityType( + activityType = "io.customer.liveactivities.deliverytracking", + token = "tok-1", + userId = "user-1" + ) + + result.isSuccess.shouldBeTrue() + params.captured.method shouldBeEqualTo HttpMethod.PUT + params.captured.path shouldBeEqualTo + "/v1/live_activities/registration/io.customer.liveactivities.deliverytracking" + val body = params.captured.body!! + body shouldContain "tok-1" + body shouldContain "\"os\":\"android\"" + body shouldContain "\"transport\":\"fcm\"" + body shouldContain "user-1" + } + + @Test + fun registerInstance_buildsPutPushTokenWithActivityType() = runTest { + val params = slot() + coEvery { httpClient.request(capture(params)) } returns Result.success("") + + client.registerInstance( + activityId = "act-9", + activityType = "io.customer.liveactivities.deliverytracking", + token = "tok", + userId = "user" + ) + + params.captured.method shouldBeEqualTo HttpMethod.PUT + params.captured.path shouldBeEqualTo "/v1/live_activities/act-9/push_token" + val body = params.captured.body!! + body shouldContain "\"activity_type\":\"io.customer.liveactivities.deliverytracking\"" + body shouldContain "\"os\":\"android\"" + body shouldContain "\"transport\":\"fcm\"" + } + + @Test + fun reportDismissed_buildsDeleteWithEmptyBody() = runTest { + val params = slot() + coEvery { httpClient.request(capture(params)) } returns Result.success("") + + client.reportDismissed("act-123") + + params.captured.method shouldBeEqualTo HttpMethod.DELETE + params.captured.path shouldBeEqualTo "/v1/live_activities/act-123" + params.captured.body shouldBeEqualTo "{}" + } + + @Test + fun send_retriesOn5xxUpToThreeAttempts() = runTest { + coEvery { httpClient.request(any()) } returns Result.failure(HttpRequestException(503, "boom")) + + val result = client.reportDismissed("act-1") + + result.isFailure.shouldBeTrue() + coVerify(exactly = 3) { httpClient.request(any()) } + } + + @Test + fun send_doesNotRetryOn4xx() = runTest { + coEvery { httpClient.request(any()) } returns Result.failure(HttpRequestException(400, "bad request")) + + val result = client.reportDismissed("act-1") + + result.isFailure.shouldBeTrue() + coVerify(exactly = 1) { httpClient.request(any()) } + } + + @Test + fun send_succeedsAfterTransientFailure() = runTest { + coEvery { httpClient.request(any()) } returnsMany listOf( + Result.failure(HttpRequestException(500, "x")), + Result.success("") + ) + + val result = client.reportDismissed("act-1") + + result.isSuccess.shouldBeTrue() + coVerify(exactly = 2) { httpClient.request(any()) } + } +} diff --git a/messagingpush/src/test/java/io/customer/messagingpush/livenotification/LiveNotificationStoreTest.kt b/messagingpush/src/test/java/io/customer/messagingpush/livenotification/LiveNotificationStoreTest.kt new file mode 100644 index 000000000..97e750de7 --- /dev/null +++ b/messagingpush/src/test/java/io/customer/messagingpush/livenotification/LiveNotificationStoreTest.kt @@ -0,0 +1,56 @@ +package io.customer.messagingpush.livenotification + +import io.customer.messagingpush.testutils.core.IntegrationTest +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) +internal class LiveNotificationStoreTest : IntegrationTest() { + + private val store by lazy { LiveNotificationStore(contextMock) } + + @Test + fun registrationSignature_setGetClear() { + store.registrationSignature("type-a").shouldBeNull() + + store.setRegistrationSignature("type-a", "tok|user") + store.setRegistrationSignature("type-b", "tok|user") + + store.registrationSignature("type-a") shouldBeEqualTo "tok|user" + + store.clearRegistrations() + + store.registrationSignature("type-a").shouldBeNull() + store.registrationSignature("type-b").shouldBeNull() + } + + @Test + fun lastTimestamp_setGetClear() { + store.lastTimestamp("act-1").shouldBeNull() + + store.setLastTimestamp("act-1", 1_000L) + store.lastTimestamp("act-1") shouldBeEqualTo 1_000L + + store.clearTimestamp("act-1") + store.lastTimestamp("act-1").shouldBeNull() + } + + @Test + fun trimStaleTimestamps_removesEntriesOlderThanTtl() { + val now = 10_000_000_000L + val ttl = 1_000L + + // Recorded before the cutoff -> stale. + store.setLastTimestamp("old", 1L, now = now - ttl - 1) + // Recorded within the ttl -> kept. + store.setLastTimestamp("fresh", 2L, now = now - 1) + + store.trimStaleTimestamps(ttlMs = ttl, now = now) + + store.lastTimestamp("old").shouldBeNull() + store.lastTimestamp("fresh") shouldBeEqualTo 2L + } +} diff --git a/messagingpush/src/test/java/io/customer/messagingpush/livenotification/template/AuctionBidTemplateTest.kt b/messagingpush/src/test/java/io/customer/messagingpush/livenotification/template/AuctionBidTemplateTest.kt new file mode 100644 index 000000000..b7c5e7f8a --- /dev/null +++ b/messagingpush/src/test/java/io/customer/messagingpush/livenotification/template/AuctionBidTemplateTest.kt @@ -0,0 +1,159 @@ +package io.customer.messagingpush.livenotification.template + +import io.customer.messagingpush.testutils.core.IntegrationTest +import org.amshove.kluent.shouldBeEqualTo +import org.amshove.kluent.shouldBeTrue +import org.json.JSONObject +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +/** + * Tests for [AuctionBidTemplate]. + * + * Locks the two visual-state branches that templates lean on for state + * differentiation: high-bidder (green) vs outbid (red), and the user-bid-amount + * subtext toggle. + */ +@RunWith(RobolectricTestRunner::class) +internal class AuctionBidTemplateTest : IntegrationTest() { + + private val winningGreen = -0xc951c1 // #36AE3F + private val outbidRed = -0x33ccd0 // #CC3330 + + private fun render( + attributes: JSONObject = JSONObject(), + contentState: JSONObject = JSONObject() + ): TemplateRenderResult = AuctionBidTemplate.render( + context = contextMock, + data = flatten(attributes, contentState), + branding = null, + smallIcon = 0, + fallbackTintColor = null + ) + + private fun baseAttributes() = JSONObject().apply { + put("itemTitle", "Vintage Camera") + put("itemImageKey", "auction_camera") + put("currencySymbol", "$") + } + + @Test + fun render_userIsHighBidder_setsGreenAccent() { + val contentState = JSONObject().apply { + put("currentBid", "1,250") + put("bidCount", 8) + put("endTime", 1700000000000L) + put("statusMessage", "You're winning") + put("isUserHighBidder", true) + put("userBidAmount", "1,250") + } + + val result = render(baseAttributes(), contentState) + + result.accentColor shouldBeEqualTo winningGreen + result.colorized.shouldBeTrue() + } + + @Test + fun render_userIsNotHighBidder_setsRedAccent() { + val contentState = JSONObject().apply { + put("currentBid", "1,200") + put("bidCount", 7) + put("statusMessage", "You've been outbid") + put("isUserHighBidder", false) + put("userBidAmount", "1,150") + } + + val result = render(baseAttributes(), contentState) + + result.accentColor shouldBeEqualTo outbidRed + } + + @Test + fun render_withUserBidAmount_subTextIncludesBidAndCount() { + val contentState = JSONObject().apply { + put("currentBid", "1,200") + put("bidCount", 7) + put("statusMessage", "You've been outbid") + put("userBidAmount", "1,150") + } + + val result = render(baseAttributes(), contentState) + + result.subText shouldBeEqualTo "Your bid: $1,150 · 7 bids" + } + + @Test + fun render_withoutUserBidAmount_subTextIsBidCountOnly() { + val contentState = JSONObject().apply { + put("currentBid", "1,200") + put("bidCount", 7) + put("statusMessage", "Auction live") + } + + val result = render(baseAttributes(), contentState) + + result.subText shouldBeEqualTo "7 bids" + } + + @Test + fun render_emptyUserBidAmount_subTextIsBidCountOnly() { + // Empty string is treated as absent by the template (takeIf isNotEmpty). + val contentState = JSONObject().apply { + put("currentBid", "1,200") + put("bidCount", 9) + put("statusMessage", "x") + put("userBidAmount", "") + } + + val result = render(baseAttributes(), contentState) + + result.subText shouldBeEqualTo "9 bids" + } + + @Test + fun render_defaultsCurrencySymbolToDollar() { + val attributes = JSONObject().apply { + put("itemTitle", "Vintage Camera") + } + val contentState = JSONObject().apply { + put("currentBid", "100") + put("bidCount", 1) + put("statusMessage", "Start") + } + + val result = render(attributes, contentState) + + result.body shouldBeEqualTo "Start · $100" + } + + @Test + fun render_bodyComposesStatusMessageAndCurrentBid() { + val contentState = JSONObject().apply { + put("currentBid", "1,300") + put("bidCount", 9) + put("statusMessage", "You've been outbid") + put("isUserHighBidder", false) + put("userBidAmount", "1,250") + } + + val result = render(baseAttributes(), contentState) + + result.body shouldBeEqualTo "You've been outbid · $1,300" + } + + @Test + fun render_endTimeNonPositive_countdownUntilIsNull() { + val contentState = JSONObject().apply { + put("currentBid", "1") + put("bidCount", 0) + put("statusMessage", "x") + put("endTime", 0L) + } + + val result = render(baseAttributes(), contentState) + + (result.countdownUntil == null).shouldBeTrue() + } +} diff --git a/messagingpush/src/test/java/io/customer/messagingpush/livenotification/template/CountdownTimerTemplateTest.kt b/messagingpush/src/test/java/io/customer/messagingpush/livenotification/template/CountdownTimerTemplateTest.kt new file mode 100644 index 000000000..3d65e9c9a --- /dev/null +++ b/messagingpush/src/test/java/io/customer/messagingpush/livenotification/template/CountdownTimerTemplateTest.kt @@ -0,0 +1,116 @@ +package io.customer.messagingpush.livenotification.template + +import io.customer.messagingpush.testutils.core.IntegrationTest +import org.amshove.kluent.shouldBeEqualTo +import org.amshove.kluent.shouldBeFalse +import org.amshove.kluent.shouldBeNull +import org.amshove.kluent.shouldBeTrue +import org.json.JSONObject +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +/** + * Tests for [CountdownTimerTemplate]. + * + * Exercises the three decision branches: + * - pre-target (`now < targetDate`) ⇒ body = `statusMessage`, countdownUntil = targetDate; + * - post-target with `expiredMessage` ⇒ body = `expiredMessage`, countdownUntil = null; + * - post-target without `expiredMessage` ⇒ `cancelImmediately = true` so the + * handler can dismiss the activity rather than render a stale countdown. + */ +@RunWith(RobolectricTestRunner::class) +internal class CountdownTimerTemplateTest : IntegrationTest() { + + private fun render( + attributes: JSONObject = JSONObject(), + contentState: JSONObject = JSONObject() + ): TemplateRenderResult = CountdownTimerTemplate.render( + context = contextMock, + data = flatten(attributes, contentState), + branding = null, + smallIcon = 0, + fallbackTintColor = null + ) + + private fun titleAttributes(title: String = "Flash Sale") = JSONObject().apply { + put("title", title) + put("heroImageKey", "flash_sale_hero") + } + + @Test + fun render_preTarget_setsCountdownAndStatusBody() { + val future = System.currentTimeMillis() + 60_000L + val contentState = JSONObject().apply { + put("targetDate", future) + put("statusMessage", "Sale starts in") + } + + val result = render(titleAttributes(), contentState) + + result.title shouldBeEqualTo "Flash Sale" + result.body shouldBeEqualTo "Sale starts in" + result.countdownUntil shouldBeEqualTo future + result.cancelImmediately.shouldBeFalse() + } + + @Test + fun render_postTargetWithExpiredMessage_swapsBodyAndClearsCountdown() { + val past = System.currentTimeMillis() - 60_000L + val contentState = JSONObject().apply { + put("targetDate", past) + put("statusMessage", "Sale starts in") + put("expiredMessage", "Sale is live!") + } + + val result = render(titleAttributes(), contentState) + + result.body shouldBeEqualTo "Sale is live!" + result.countdownUntil.shouldBeNull() + result.cancelImmediately.shouldBeFalse() + } + + @Test + fun render_postTargetWithoutExpiredMessage_flagsCancelImmediately() { + val past = System.currentTimeMillis() - 60_000L + val contentState = JSONObject().apply { + put("targetDate", past) + put("statusMessage", "Sale starts in") + } + + val result = render(titleAttributes(), contentState) + + result.cancelImmediately.shouldBeTrue() + // When cancelImmediately = true, the rest of the result is irrelevant because + // the handler short-circuits before rendering. We assert nothing else here. + } + + @Test + fun render_targetDateAbsent_isTreatedAsPreTarget() { + // Spec: targetDate is required. Lenient parsing — missing targetDate must not + // crash; should render with countdownUntil = null and body = statusMessage. + val contentState = JSONObject().apply { + put("statusMessage", "Sale starts in") + } + + val result = render(titleAttributes(), contentState) + + result.body shouldBeEqualTo "Sale starts in" + result.countdownUntil.shouldBeNull() + result.cancelImmediately.shouldBeFalse() + } + + @Test + fun render_emptyHeroImageKey_returnsNullLargeIcon() { + val attributes = JSONObject().apply { + put("title", "x") + } + val contentState = JSONObject().apply { + put("statusMessage", "x") + } + + val result = render(attributes, contentState) + + result.largeIcon.shouldBeNull() + } +} diff --git a/messagingpush/src/test/java/io/customer/messagingpush/livenotification/template/DeliveryTrackingTemplateTest.kt b/messagingpush/src/test/java/io/customer/messagingpush/livenotification/template/DeliveryTrackingTemplateTest.kt new file mode 100644 index 000000000..97daf5fe2 --- /dev/null +++ b/messagingpush/src/test/java/io/customer/messagingpush/livenotification/template/DeliveryTrackingTemplateTest.kt @@ -0,0 +1,167 @@ +package io.customer.messagingpush.livenotification.template + +import io.customer.messagingpush.testutils.core.IntegrationTest +import org.amshove.kluent.shouldBeEqualTo +import org.amshove.kluent.shouldBeNull +import org.amshove.kluent.shouldBeTrue +import org.json.JSONObject +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +/** + * Tests [DeliveryTrackingTemplate] rendering against the documented field schema. + * + * All fields arrive flattened in a single `data` object; the legacy + * `attributes` / `content_state` grouping in these tests is purely for + * readability and is merged via [flatten] before rendering. + */ +@RunWith(RobolectricTestRunner::class) +internal class DeliveryTrackingTemplateTest : IntegrationTest() { + + private fun render( + attributes: JSONObject = JSONObject(), + contentState: JSONObject = JSONObject() + ): TemplateRenderResult = DeliveryTrackingTemplate.render( + context = contextMock, + data = flatten(attributes, contentState), + branding = null, + smallIcon = 0, + fallbackTintColor = null + ) + + @Test + fun render_givenAllFields_producesTitleBodySubTextAndProgress() { + val attributes = JSONObject().apply { + put("orderId", "ORD-42") + put("recipientName", "Alex") + } + val contentState = JSONObject().apply { + put("statusMessage", "Out for delivery") + put("statusImageKey", "delivery_truck") + put("stepCurrent", 2) + put("stepTotal", 4) + put("estimatedArrival", 1700000000000L) + put("driverName", "Pat") + } + + val result = render(attributes, contentState) + + result.title shouldBeEqualTo "Delivery for Alex" + result.body shouldBeEqualTo "Out for delivery" + result.subText shouldBeEqualTo "Driver: Pat · Order #ORD-42" + result.showProgress.shouldBeTrue() + result.progress shouldBeEqualTo 2 + result.progressMax shouldBeEqualTo 4 + result.segments.size shouldBeEqualTo 4 + result.countdownUntil shouldBeEqualTo 1700000000000L + } + + @Test + fun render_givenNoRecipientName_fallsBackToOrderTitle() { + val attributes = JSONObject().apply { + put("orderId", "ORD-77") + } + val contentState = JSONObject().apply { + put("statusMessage", "Preparing") + put("stepCurrent", 1) + put("stepTotal", 3) + } + + val result = render(attributes, contentState) + + result.title shouldBeEqualTo "Order #ORD-77" + result.subText shouldBeEqualTo "Order #ORD-77" + } + + @Test + fun render_givenNoDriverNoOrderId_subTextIsNull() { + val contentState = JSONObject().apply { + put("statusMessage", "On the way") + put("stepTotal", 3) + } + + val result = render(contentState = contentState) + + result.subText.shouldBeNull() + } + + @Test + fun render_givenDriverButNoOrderId_subTextOmitsOrder() { + val contentState = JSONObject().apply { + put("statusMessage", "On the way") + put("driverName", "Pat") + put("stepTotal", 3) + } + + val result = render(contentState = contentState) + + result.subText shouldBeEqualTo "Driver: Pat" + } + + @Test + fun render_stepCurrentIsClampedIntoRange() { + val contentState = JSONObject().apply { + put("statusMessage", "Anywhere") + put("stepCurrent", 99) + put("stepTotal", 4) + } + + val result = render(contentState = contentState) + + result.progress shouldBeEqualTo 4 + } + + @Test + fun render_stepCurrentNegative_isClampedToZero() { + val contentState = JSONObject().apply { + put("statusMessage", "Anywhere") + put("stepCurrent", -5) + put("stepTotal", 4) + } + + val result = render(contentState = contentState) + + result.progress shouldBeEqualTo 0 + } + + @Test + fun render_stepTotalMissing_defaultsAtLeastToOne() { + val contentState = JSONObject().apply { + put("statusMessage", "Just placed") + put("stepCurrent", 0) + } + + val result = render(contentState = contentState) + + result.progressMax shouldBeEqualTo 1 + result.segments.size shouldBeEqualTo 1 + } + + @Test + fun render_stepTotalZero_isFlooredToOne() { + val contentState = JSONObject().apply { + put("statusMessage", "Edge case") + put("stepCurrent", 0) + put("stepTotal", 0) + } + + val result = render(contentState = contentState) + + result.progressMax shouldBeEqualTo 1 + result.segments.size shouldBeEqualTo 1 + } + + @Test + fun render_estimatedArrivalNonPositive_countdownUntilIsNull() { + val contentState = JSONObject().apply { + put("statusMessage", "No eta") + put("estimatedArrival", 0L) + put("stepTotal", 2) + } + + val result = render(contentState = contentState) + + result.countdownUntil.shouldBeNull() + } +} diff --git a/messagingpush/src/test/java/io/customer/messagingpush/livenotification/template/FlightStatusTemplateTest.kt b/messagingpush/src/test/java/io/customer/messagingpush/livenotification/template/FlightStatusTemplateTest.kt new file mode 100644 index 000000000..443fcfc06 --- /dev/null +++ b/messagingpush/src/test/java/io/customer/messagingpush/livenotification/template/FlightStatusTemplateTest.kt @@ -0,0 +1,147 @@ +package io.customer.messagingpush.livenotification.template + +import io.customer.messagingpush.testutils.core.IntegrationTest +import org.amshove.kluent.shouldBeEqualTo +import org.amshove.kluent.shouldBeFalse +import org.amshove.kluent.shouldBeTrue +import org.json.JSONObject +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +/** + * Tests for [FlightStatusTemplate]. + * + * Exercises: + * - basic title/body/subText composition with origin / destination codes; + * - the delay-red branch — `delayMinutes > 0` flips the accent and suffixes the body; + * - countdown target switching: `progressFraction` present ⇒ estimatedArrival, + * absent ⇒ scheduledDeparture. + * + * All fields arrive flattened; the legacy grouping is merged via [flatten]. + */ +@RunWith(RobolectricTestRunner::class) +internal class FlightStatusTemplateTest : IntegrationTest() { + + private val delayRed = -0x33ccd0 // #CC3330 + + private fun render( + attributes: JSONObject = JSONObject(), + contentState: JSONObject = JSONObject() + ): TemplateRenderResult = FlightStatusTemplate.render( + context = contextMock, + data = flatten(attributes, contentState), + branding = null, + smallIcon = 0, + fallbackTintColor = null + ) + + private fun baseAttributes() = JSONObject().apply { + put("flightNumber", "AA1234") + put("origin", JSONObject().put("code", "JFK").put("city", "New York")) + put("destination", JSONObject().put("code", "LAX").put("city", "Los Angeles")) + } + + @Test + fun render_happyPath_composesTitleAndSubText() { + val contentState = JSONObject().apply { + put("statusMessage", "On time") + put("gate", "B12") + put("terminal", "4") + put("scheduledDeparture", 1700000000000L) + put("estimatedArrival", 1700100000000L) + } + + val result = render(baseAttributes(), contentState) + + result.title shouldBeEqualTo "AA1234 · JFK → LAX" + result.subText shouldBeEqualTo "Gate B12 · Terminal 4" + result.body shouldBeEqualTo "On time" + } + + @Test + fun render_missingGateAndTerminal_fallsBackToTBA() { + val contentState = JSONObject().apply { + put("statusMessage", "Pre-departure") + put("scheduledDeparture", 1700000000000L) + } + + val result = render(baseAttributes(), contentState) + + result.subText shouldBeEqualTo "Gate TBA · Terminal TBA" + } + + // --- Delay-red decision branch --- + + @Test + fun render_delayMinutesPositive_setsRedAccentAndAppendsBody() { + val contentState = JSONObject().apply { + put("statusMessage", "Boarding") + put("delayMinutes", 25) + } + + val result = render(baseAttributes(), contentState) + + result.accentColor shouldBeEqualTo delayRed + result.body shouldBeEqualTo "Boarding · Delayed 25 min" + } + + @Test + fun render_delayMinutesZero_doesNotForceRed() { + val contentState = JSONObject().apply { + put("statusMessage", "On time") + put("delayMinutes", 0) + } + + val result = render(baseAttributes(), contentState) + + (result.accentColor == delayRed).shouldBeFalse() + result.body shouldBeEqualTo "On time" + } + + // --- Progress / countdown switching --- + + @Test + fun render_progressFractionPresent_targetsEstimatedArrival() { + val contentState = JSONObject().apply { + put("statusMessage", "In flight") + put("scheduledDeparture", 1700000000000L) + put("estimatedArrival", 1700100000000L) + put("progressFraction", 0.5) + } + + val result = render(baseAttributes(), contentState) + + result.showProgress.shouldBeTrue() + result.progress shouldBeEqualTo 50 + result.progressMax shouldBeEqualTo 100 + result.countdownUntil shouldBeEqualTo 1700100000000L + } + + @Test + fun render_progressFractionAbsent_targetsScheduledDeparture() { + val contentState = JSONObject().apply { + put("statusMessage", "Pre-departure") + put("scheduledDeparture", 1700000000000L) + put("estimatedArrival", 1700100000000L) + } + + val result = render(baseAttributes(), contentState) + + result.showProgress.shouldBeFalse() + result.countdownUntil shouldBeEqualTo 1700000000000L + } + + @Test + fun render_progressFractionOutsideZeroOne_isCoerced() { + val contentState = JSONObject().apply { + put("statusMessage", "In flight") + put("estimatedArrival", 1700100000000L) + put("progressFraction", 2.5) // out of range; spec coerces to [0,1] + } + + val result = render(baseAttributes(), contentState) + + result.progress shouldBeEqualTo 100 + } +} diff --git a/messagingpush/src/test/java/io/customer/messagingpush/livenotification/template/LiveScoreTemplateTest.kt b/messagingpush/src/test/java/io/customer/messagingpush/livenotification/template/LiveScoreTemplateTest.kt new file mode 100644 index 000000000..ee330cabb --- /dev/null +++ b/messagingpush/src/test/java/io/customer/messagingpush/livenotification/template/LiveScoreTemplateTest.kt @@ -0,0 +1,112 @@ +package io.customer.messagingpush.livenotification.template + +import io.customer.messagingpush.testutils.core.IntegrationTest +import org.amshove.kluent.shouldBeEqualTo +import org.json.JSONObject +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +/** + * Tests for [LiveScoreTemplate]. + * + * Exercises the body composition fallback chain: + * `statusMessage` overrides ⇒ `period · clock` ⇒ bare `period`. + * + * All fields arrive flattened; the legacy grouping is merged via [flatten]. + */ +@RunWith(RobolectricTestRunner::class) +internal class LiveScoreTemplateTest : IntegrationTest() { + + private fun render( + attributes: JSONObject = JSONObject(), + contentState: JSONObject = JSONObject() + ): TemplateRenderResult = LiveScoreTemplate.render( + context = contextMock, + data = flatten(attributes, contentState), + branding = null, + smallIcon = 0, + fallbackTintColor = null + ) + + private fun teamsAttributes() = JSONObject().apply { + put("homeTeam", JSONObject().put("name", "Lakers")) + put("awayTeam", JSONObject().put("name", "Celtics")) + put("sport", "basketball") + } + + @Test + fun render_givenScoresAndClock_composesTitleAndPeriodClockBody() { + val contentState = JSONObject().apply { + put("homeScore", 14) + put("awayScore", 7) + put("period", "2nd Quarter") + put("clock", "5:30") + } + + val result = render(teamsAttributes(), contentState) + + result.title shouldBeEqualTo "Lakers 14 - 7 Celtics" + result.body shouldBeEqualTo "2nd Quarter · 5:30" + } + + @Test + fun render_givenStatusMessage_overridesBody() { + val contentState = JSONObject().apply { + put("homeScore", 0) + put("awayScore", 0) + put("period", "Halftime") + put("clock", "0:00") + put("statusMessage", "Half time show") + } + + val result = render(teamsAttributes(), contentState) + + result.body shouldBeEqualTo "Half time show" + } + + @Test + fun render_clockAbsent_fallsBackToBarePeriod() { + val contentState = JSONObject().apply { + put("homeScore", 28) + put("awayScore", 24) + put("period", "FT") + } + + val result = render(teamsAttributes(), contentState) + + result.body shouldBeEqualTo "FT" + } + + @Test + fun render_emptyClockString_isTreatedAsAbsent() { + // Existing template logic treats empty-string clock as "no clock" (takeIf isNotEmpty). + val contentState = JSONObject().apply { + put("homeScore", 1) + put("awayScore", 1) + put("period", "1st") + put("clock", "") + } + + val result = render(teamsAttributes(), contentState) + + result.body shouldBeEqualTo "1st" + } + + @Test + fun render_emptyTeamNames_defaultsToEmptyStrings() { + val attributes = JSONObject().apply { + put("homeTeam", JSONObject()) + put("awayTeam", JSONObject()) + } + val contentState = JSONObject().apply { + put("homeScore", 3) + put("awayScore", 0) + put("period", "1st") + } + + val result = render(attributes, contentState) + + result.title shouldBeEqualTo " 3 - 0 " + } +} diff --git a/messagingpush/src/test/java/io/customer/messagingpush/livenotification/template/TemplateAssetsTest.kt b/messagingpush/src/test/java/io/customer/messagingpush/livenotification/template/TemplateAssetsTest.kt new file mode 100644 index 000000000..569eea04d --- /dev/null +++ b/messagingpush/src/test/java/io/customer/messagingpush/livenotification/template/TemplateAssetsTest.kt @@ -0,0 +1,119 @@ +package io.customer.messagingpush.livenotification.template + +import android.content.res.Resources +import io.customer.commontest.config.TestConfig +import io.customer.commontest.config.testConfigurationDefault +import io.customer.commontest.extensions.assertCalledNever +import io.customer.commontest.extensions.assertCalledOnce +import io.customer.messagingpush.testutils.core.IntegrationTest +import io.customer.sdk.core.util.Logger +import io.mockk.every +import io.mockk.mockk +import io.mockk.slot +import org.amshove.kluent.shouldBeNull +import org.amshove.kluent.shouldContain +import org.amshove.kluent.shouldNotBeNull +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +/** + * Tests for [TemplateAssets]. + * + * The host app's drawable namespace isn't reachable from a unit-test runtime, + * so these tests exercise the boundary behavior every template depends on: + * - null / blank keys return `null` without logging warnings; + * - non-resolvable keys return `null` and surface a debug log so developers + * can diagnose missing assets without crashing the notification. + * + * We intentionally do not assert kebab-to-underscore normalization on a real + * resource lookup because the test runtime doesn't ship the host app's + * drawables; the normalization is exercised by hand-mocking the resource lookup + * in `resolveDrawable_kebabCaseKey_isNormalizedToUnderscores`. + */ +@RunWith(RobolectricTestRunner::class) +internal class TemplateAssetsTest : IntegrationTest() { + + private val mockLogger: Logger = mockk(relaxed = true) + + override fun setup(testConfig: TestConfig) { + super.setup( + testConfigurationDefault { + diGraph { + sdk { + overrideDependency(mockLogger) + } + } + } + ) + } + + @Test + fun resolveDrawable_nullKey_returnsNullWithoutLogging() { + val result = TemplateAssets.resolveDrawable(contextMock, null) + + result.shouldBeNull() + assertCalledNever { mockLogger.debug(any(), any()) } + } + + @Test + fun resolveDrawable_blankKey_returnsNullWithoutLogging() { + val result = TemplateAssets.resolveDrawable(contextMock, " ") + + result.shouldBeNull() + assertCalledNever { mockLogger.debug(any(), any()) } + } + + @Test + fun resolveDrawable_emptyKey_returnsNullWithoutLogging() { + val result = TemplateAssets.resolveDrawable(contextMock, "") + + result.shouldBeNull() + assertCalledNever { mockLogger.debug(any(), any()) } + } + + @Test + fun resolveDrawable_unresolvedKey_returnsNullAndLogsWarning() { + val message = slot() + every { mockLogger.debug(capture(message), any()) } returns Unit + + val result = TemplateAssets.resolveDrawable(contextMock, "no_such_drawable_in_test_app") + + result.shouldBeNull() + assertCalledOnce { mockLogger.debug(any(), any()) } + message.captured shouldContain "did not resolve to a drawable" + } + + @Test + fun resolveDrawable_kebabCaseKey_isNormalizedToUnderscores() { + // We can't actually resolve a host drawable from a unit-test runtime, so we + // hand-mock the underlying resources lookup and assert it received the + // normalized name. This is what locks the kebab-to-underscore rule. + val mockResources = mockk() + // Underscored form must be queried; if the SDK forwards the kebab form, this + // expectation fails because no `every` was set up for it. + every { + mockResources.getIdentifier("delivery_warehouse", "drawable", any()) + } returns 12345 + + every { contextMock.resources } returns mockResources + + val result = TemplateAssets.resolveDrawable(contextMock, "delivery-warehouse") + + result.shouldNotBeNull() + } + + @Test + fun resolveBitmap_nullKey_returnsNull() { + val result = TemplateAssets.resolveBitmap(contextMock, null) + + result.shouldBeNull() + } + + @Test + fun resolveBitmap_unresolvedKey_returnsNull() { + val result = TemplateAssets.resolveBitmap(contextMock, "no_such_drawable_in_test_app") + + result.shouldBeNull() + } +} diff --git a/messagingpush/src/test/java/io/customer/messagingpush/livenotification/template/TemplateTestData.kt b/messagingpush/src/test/java/io/customer/messagingpush/livenotification/template/TemplateTestData.kt new file mode 100644 index 000000000..183afb0cb --- /dev/null +++ b/messagingpush/src/test/java/io/customer/messagingpush/livenotification/template/TemplateTestData.kt @@ -0,0 +1,20 @@ +package io.customer.messagingpush.livenotification.template + +import org.json.JSONObject + +/** + * Merges JSON objects into a single flattened [JSONObject], mirroring how the + * backend now delivers all live-notification template fields at the envelope + * top level (no `attributes` / `content_state` split). Lets per-template tests + * keep expressing inputs as two logical groups while exercising the flattened + * `render(data = …)` contract. + */ +internal fun flatten(vararg objects: JSONObject): JSONObject { + val data = JSONObject() + for (obj in objects) { + for (key in obj.keys()) { + data.put(key, obj.get(key)) + } + } + return data +} diff --git a/samples/java_layout/src/main/AndroidManifest.xml b/samples/java_layout/src/main/AndroidManifest.xml index ecfaae50e..bd96a19b5 100644 --- a/samples/java_layout/src/main/AndroidManifest.xml +++ b/samples/java_layout/src/main/AndroidManifest.xml @@ -3,6 +3,7 @@ xmlns:tools="http://schemas.android.com/tools"> + + + + + + + + + + + + + + + + + + + + + + + + +