diff --git a/core/src/main/kotlin/io/customer/sdk/core/pipeline/DataPipeline.kt b/core/src/main/kotlin/io/customer/sdk/core/pipeline/DataPipeline.kt index 26e0e83b6..01476fb75 100644 --- a/core/src/main/kotlin/io/customer/sdk/core/pipeline/DataPipeline.kt +++ b/core/src/main/kotlin/io/customer/sdk/core/pipeline/DataPipeline.kt @@ -12,6 +12,11 @@ import io.customer.base.internal.InternalCustomerIOApi */ @InternalCustomerIOApi interface DataPipeline { + /** + * Whether a user is currently identified. Updated synchronously on the caller's thread + * during identify()/clearIdentify(), so it is accurate the instant those calls return — + * a consumer may gate on it immediately after identify() without racing async propagation. + */ val isUserIdentified: Boolean fun track(name: String, properties: Map) } diff --git a/datapipelines/src/main/kotlin/io/customer/sdk/CustomerIO.kt b/datapipelines/src/main/kotlin/io/customer/sdk/CustomerIO.kt index ef92092db..79c47d000 100644 --- a/datapipelines/src/main/kotlin/io/customer/sdk/CustomerIO.kt +++ b/datapipelines/src/main/kotlin/io/customer/sdk/CustomerIO.kt @@ -118,6 +118,16 @@ class CustomerIO private constructor( // Reset on clearIdentify so a subsequent identify of the same userId is not deduped. private var lastIdentifiedUserIdThisSession: String? = null + // Caller-thread mirror of whether a user is currently identified, backing the synchronous + // [isUserIdentified]. analytics.userId() is updated asynchronously and lags a synchronous + // identify(), so a consumer that gates on it right after login (e.g. a live-notification + // start() on the next line) would otherwise read stale state and drop the event. Set on the + // caller's thread during identify()/clearIdentify() so it is accurate the instant they return. + // null = no identify/reset has happened this process yet, so fall back to the (possibly + // restored-from-persistence) analytics userId; true = identified; false = anonymous. + @Volatile + private var syncUserIdentified: Boolean? = null + init { // Set analytics logger and debug logs based on SDK logger configuration Analytics.debugLogsEnabled = logger.logLevel == CioLogLevel.DEBUG @@ -296,6 +306,12 @@ class CustomerIO private constructor( traits = traits, serializationStrategy = serializationStrategy ) + // Reflect identity synchronously on the caller's thread so isUserIdentified is correct + // immediately, before analytics.userId() catches up (see syncUserIdentified). Must be set + // before publishUserChanged: the mirror takes precedence over the analytics fallback, so a + // subscriber that gates on isUserIdentified would otherwise read a stale `false` left by an + // earlier clearIdentify() even though analytics.userId() is already correct. + syncUserIdentified = true // Publish for other modules. Must come after analytics.identify() so analytics.userId() // returns the new userId for subscribers that gate on it (e.g. location resync). publishUserChanged(userId) @@ -344,6 +360,9 @@ class CustomerIO private constructor( // Reset the dedup marker so a subsequent identify of the same userId is not deduped. lastIdentifiedUserIdThisSession = null + // Reflect logout synchronously so isUserIdentified reads false immediately, before + // analytics.reset() propagates (see syncUserIdentified). + syncUserIdentified = false logger.debug("deleting device token to remove device from user profile") @@ -377,7 +396,10 @@ class CustomerIO private constructor( get() = analytics.userId() override val isUserIdentified: Boolean - get() = !analytics.userId().isNullOrEmpty() + // Prefer the caller-thread mirror so this is correct synchronously right after + // identify()/clearIdentify(); fall back to analytics for a session restored from + // persistence, where neither was called this process. See [syncUserIdentified]. + get() = syncUserIdentified ?: !analytics.userId().isNullOrEmpty() @Deprecated("Use setDeviceAttributes() function instead") @set:JvmName("setDeviceAttributesDeprecated") diff --git a/datapipelines/src/test/java/io/customer/datapipelines/IsUserIdentifiedSyncTests.kt b/datapipelines/src/test/java/io/customer/datapipelines/IsUserIdentifiedSyncTests.kt new file mode 100644 index 000000000..460e9908e --- /dev/null +++ b/datapipelines/src/test/java/io/customer/datapipelines/IsUserIdentifiedSyncTests.kt @@ -0,0 +1,47 @@ +package io.customer.datapipelines + +import io.customer.commontest.extensions.random +import io.customer.datapipelines.testutils.core.JUnitTest +import org.amshove.kluent.shouldBeFalse +import org.amshove.kluent.shouldBeTrue +import org.junit.jupiter.api.Test + +/** + * Direct coverage for the synchronous `CustomerIO.isUserIdentified` mirror at the layer that owns + * it. The messagingpush live-notification tests mock `isUserIdentified`, so they don't prove the + * contract itself: it must flip true on the caller thread the instant `identify()` returns (before + * the async `analytics.userId()` catches up), flip false the instant `clearIdentify()` returns, and + * still reflect a session restored from persistence where neither was called this process. + */ +class IsUserIdentifiedSyncTests : JUnitTest() { + + @Test + fun isUserIdentified_isTrueSynchronouslyAfterIdentify() { + sdkInstance.isUserIdentified.shouldBeFalse() + + sdkInstance.identify(String.random) + + // No async wait: the mirror is set on the caller thread inside identify(), so a consumer + // gating on it immediately after login (e.g. a live-notification start()) is not dropped. + sdkInstance.isUserIdentified.shouldBeTrue() + } + + @Test + fun isUserIdentified_isFalseSynchronouslyAfterClearIdentify() { + sdkInstance.identify(String.random) + sdkInstance.isUserIdentified.shouldBeTrue() + + sdkInstance.clearIdentify() + + sdkInstance.isUserIdentified.shouldBeFalse() + } + + @Test + fun isUserIdentified_fallsBackToAnalyticsForRestoredSession() { + // Cold launch: no identify() this process (the mirror stays unset), but analytics restored a + // userId from persistence. isUserIdentified must reflect the restored user via the fallback. + analytics.identify(String.random) + + sdkInstance.isUserIdentified.shouldBeTrue() + } +} diff --git a/messagingpush/api/messagingpush.api b/messagingpush/api/messagingpush.api index 019e24b43..187da6fa8 100644 --- a/messagingpush/api/messagingpush.api +++ b/messagingpush/api/messagingpush.api @@ -22,8 +22,11 @@ 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;Lio/customer/messagingpush/data/communication/CustomerIOLiveNotificationsCallback;Lkotlin/jvm/internal/DefaultConstructorMarker;)V public final fun getAutoTrackPushEvents ()Z + public final fun getLiveNotificationBranding ()Lio/customer/messagingpush/livenotification/LiveNotificationBranding; + public final fun getLiveNotificationCallback ()Lio/customer/messagingpush/data/communication/CustomerIOLiveNotificationsCallback; + 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; @@ -33,7 +36,11 @@ public final class io/customer/messagingpush/MessagingPushModuleConfig$Builder : public fun ()V public fun build ()Lio/customer/messagingpush/MessagingPushModuleConfig; public synthetic fun build ()Lio/customer/sdk/core/module/CustomerIOModuleConfig; + public final fun enableCustomLiveNotificationTypes ([Ljava/lang/String;)Lio/customer/messagingpush/MessagingPushModuleConfig$Builder; + public final fun enableLiveNotificationTypes ([Lio/customer/messagingpush/livenotification/LiveNotificationType;)Lio/customer/messagingpush/MessagingPushModuleConfig$Builder; 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 setLiveNotificationCallback (Lio/customer/messagingpush/data/communication/CustomerIOLiveNotificationsCallback;)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; } @@ -46,10 +53,15 @@ public final class io/customer/messagingpush/ModuleMessagingPushFCM : io/custome public fun ()V public fun (Lio/customer/messagingpush/MessagingPushModuleConfig;)V public synthetic fun (Lio/customer/messagingpush/MessagingPushModuleConfig;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun endLiveNotification (Ljava/lang/String;)V public fun getModuleConfig ()Lio/customer/messagingpush/MessagingPushModuleConfig; 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 fun updateLiveNotification (Ljava/lang/String;Lio/customer/messagingpush/livenotification/LiveNotificationData;)V + public final fun updateLiveNotification (Ljava/lang/String;Ljava/lang/String;Ljava/util/Map;)V } public final class io/customer/messagingpush/ModuleMessagingPushFCM$Companion { @@ -74,6 +86,10 @@ public final class io/customer/messagingpush/config/PushClickBehavior : java/lan public static fun values ()[Lio/customer/messagingpush/config/PushClickBehavior; } +public abstract interface class io/customer/messagingpush/data/communication/CustomerIOLiveNotificationsCallback { + public abstract fun createLiveNotification (Lio/customer/messagingpush/data/model/CustomerIOParsedPushPayload;Landroid/content/Context;)Landroid/app/Notification; +} + public abstract interface class io/customer/messagingpush/data/communication/CustomerIOPushNotificationCallback { 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 @@ -87,6 +103,8 @@ public final class io/customer/messagingpush/data/communication/CustomerIOPushNo 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 +112,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 +140,136 @@ public final class io/customer/messagingpush/di/DiGraphMessagingPushKt { public static final fun getPushModuleConfig (Lio/customer/sdk/core/di/SDKComponent;)Lio/customer/messagingpush/MessagingPushModuleConfig; } +public abstract interface class io/customer/messagingpush/livenotification/LiveNotificationAsset { +} + +public final class io/customer/messagingpush/livenotification/LiveNotificationAsset$Bytes : io/customer/messagingpush/livenotification/LiveNotificationAsset { + public fun ([B)V + public fun equals (Ljava/lang/Object;)Z + public final fun getData ()[B + public fun hashCode ()I +} + +public final class io/customer/messagingpush/livenotification/LiveNotificationAsset$Drawable : io/customer/messagingpush/livenotification/LiveNotificationAsset { + public fun (I)V + public final fun component1 ()I + public final fun copy (I)Lio/customer/messagingpush/livenotification/LiveNotificationAsset$Drawable; + public static synthetic fun copy$default (Lio/customer/messagingpush/livenotification/LiveNotificationAsset$Drawable;IILjava/lang/Object;)Lio/customer/messagingpush/livenotification/LiveNotificationAsset$Drawable; + public fun equals (Ljava/lang/Object;)Z + public final fun getResId ()I + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class io/customer/messagingpush/livenotification/LiveNotificationAsset$RemoteUrl : io/customer/messagingpush/livenotification/LiveNotificationAsset { + public fun (Ljava/lang/String;)V + public final fun component1 ()Ljava/lang/String; + public final fun copy (Ljava/lang/String;)Lio/customer/messagingpush/livenotification/LiveNotificationAsset$RemoteUrl; + public static synthetic fun copy$default (Lio/customer/messagingpush/livenotification/LiveNotificationAsset$RemoteUrl;Ljava/lang/String;ILjava/lang/Object;)Lio/customer/messagingpush/livenotification/LiveNotificationAsset$RemoteUrl; + public fun equals (Ljava/lang/Object;)Z + public final fun getUrl ()Ljava/lang/String; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class io/customer/messagingpush/livenotification/LiveNotificationAsset$Resource : io/customer/messagingpush/livenotification/LiveNotificationAsset { + public fun (Landroid/net/Uri;)V + public final fun component1 ()Landroid/net/Uri; + public final fun copy (Landroid/net/Uri;)Lio/customer/messagingpush/livenotification/LiveNotificationAsset$Resource; + public static synthetic fun copy$default (Lio/customer/messagingpush/livenotification/LiveNotificationAsset$Resource;Landroid/net/Uri;ILjava/lang/Object;)Lio/customer/messagingpush/livenotification/LiveNotificationAsset$Resource; + public fun equals (Ljava/lang/Object;)Z + public final fun getUri ()Landroid/net/Uri; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class io/customer/messagingpush/livenotification/LiveNotificationBranding { + public fun (Ljava/lang/String;ILjava/lang/Integer;Lio/customer/messagingpush/livenotification/LiveNotificationAsset;)V + public synthetic fun (Ljava/lang/String;ILjava/lang/Integer;Lio/customer/messagingpush/livenotification/LiveNotificationAsset;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun component1 ()Ljava/lang/String; + public final fun component2 ()I + public final fun component3 ()Ljava/lang/Integer; + public final fun component4 ()Lio/customer/messagingpush/livenotification/LiveNotificationAsset; + public final fun copy (Ljava/lang/String;ILjava/lang/Integer;Lio/customer/messagingpush/livenotification/LiveNotificationAsset;)Lio/customer/messagingpush/livenotification/LiveNotificationBranding; + public static synthetic fun copy$default (Lio/customer/messagingpush/livenotification/LiveNotificationBranding;Ljava/lang/String;ILjava/lang/Integer;Lio/customer/messagingpush/livenotification/LiveNotificationAsset;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 getLogo ()Lio/customer/messagingpush/livenotification/LiveNotificationAsset; + public final fun getSmallIcon ()Ljava/lang/Integer; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public abstract interface class io/customer/messagingpush/livenotification/LiveNotificationData { + public abstract fun attributes ()Ljava/util/Map; + public abstract fun contentState ()Ljava/util/Map; + public abstract fun fields ()Ljava/util/Map; + public abstract fun getActivityType ()Ljava/lang/String; +} + +public final class io/customer/messagingpush/livenotification/LiveNotificationData$CountdownTimer : io/customer/messagingpush/livenotification/LiveNotificationData { + public fun (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/Long;)V + public synthetic fun (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/Long;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun attributes ()Ljava/util/Map; + 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/Long; + public fun contentState ()Ljava/util/Map; + public final fun copy (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/Long;)Lio/customer/messagingpush/livenotification/LiveNotificationData$CountdownTimer; + public static synthetic fun copy$default (Lio/customer/messagingpush/livenotification/LiveNotificationData$CountdownTimer;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/Long;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 getEndTime ()Ljava/lang/Long; + public final fun getHeader ()Ljava/lang/String; + public final fun getStatusMessage ()Ljava/lang/String; + 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$Segments : io/customer/messagingpush/livenotification/LiveNotificationData { + public fun (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;IILjava/lang/String;)V + public synthetic fun (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;IILjava/lang/String;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun attributes ()Ljava/util/Map; + 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 ()I + public final fun component5 ()I + public final fun component6 ()Ljava/lang/String; + public fun contentState ()Ljava/util/Map; + public final fun copy (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;IILjava/lang/String;)Lio/customer/messagingpush/livenotification/LiveNotificationData$Segments; + public static synthetic fun copy$default (Lio/customer/messagingpush/livenotification/LiveNotificationData$Segments;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;IILjava/lang/String;ILjava/lang/Object;)Lio/customer/messagingpush/livenotification/LiveNotificationData$Segments; + public fun equals (Ljava/lang/Object;)Z + public fun fields ()Ljava/util/Map; + public fun getActivityType ()Ljava/lang/String; + public final fun getHeader ()Ljava/lang/String; + public final fun getSegmentsComplete ()I + public final fun getSegmentsTotal ()I + public final fun getStatus ()Ljava/lang/String; + public final fun getSubstatus ()Ljava/lang/String; + public final fun getTrailingText ()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 fun ()V + public fun onReceive (Landroid/content/Context;Landroid/content/Intent;)V +} + +public final class io/customer/messagingpush/livenotification/LiveNotificationType : java/lang/Enum { + public static final field COUNTDOWN_TIMER Lio/customer/messagingpush/livenotification/LiveNotificationType; + public static final field SEGMENTS Lio/customer/messagingpush/livenotification/LiveNotificationType; + public static fun getEntries ()Lkotlin/enums/EnumEntries; + public final fun getIdentifier ()Ljava/lang/String; + public static fun valueOf (Ljava/lang/String;)Lio/customer/messagingpush/livenotification/LiveNotificationType; + public static fun values ()[Lio/customer/messagingpush/livenotification/LiveNotificationType; +} + 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 3fedc923e..023c58b47 100644 --- a/messagingpush/src/main/AndroidManifest.xml +++ b/messagingpush/src/main/AndroidManifest.xml @@ -4,6 +4,14 @@ + + + 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..035fcd953 --- /dev/null +++ b/messagingpush/src/main/java/io/customer/messagingpush/Api36LiveNotificationBuilder.kt @@ -0,0 +1,153 @@ +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). */ +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, + val ongoing: Boolean = true +) + +/** Builds promoted live notifications on API 36+ (BAKLAVA). */ +internal object Api36LiveNotificationBuilder { + + // Raw string value (EXTRA_REQUEST_PROMOTED_ONGOING is only in extension SDK 36.1). + 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(params.ongoing) + .setAutoCancel(!params.ongoing) + .setOnlyAlertOnce(true) + + if (params.ongoing) { + 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 a standard ongoing " + + "notification. Declare in the " + + "app manifest to get the promoted live-update treatment on Android 16+." + ) + } + } + + 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 count down to a future instant; a past target can suppress the notification. + 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..d512da63f --- /dev/null +++ b/messagingpush/src/main/java/io/customer/messagingpush/BasicNotificationBuilder.kt @@ -0,0 +1,85 @@ +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 (no segments, points, or promoted 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, + val ongoing: Boolean = true +) + +/** + * 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(params.ongoing) + .setAutoCancel(!params.ongoing) + .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 can suppress the notification. + 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..62caf45ab 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 @@ -17,20 +16,18 @@ import com.google.firebase.messaging.FirebaseMessagingService import com.google.firebase.messaging.RemoteMessage import io.customer.messagingpush.activity.NotificationClickReceiverActivity import io.customer.messagingpush.data.model.CustomerIOParsedPushPayload +import io.customer.messagingpush.di.liveNotificationManager 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 +114,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() @@ -144,12 +138,46 @@ internal class CustomerIOPushNotificationHandler( val notificationManager = context.getSystemService(FirebaseMessagingService.NOTIFICATION_SERVICE) as NotificationManager + // Branch before creating the standard channel: live notifications use their own + // channel, so registering the standard one here would add a channel the user sees + // in app settings but that nothing ever posts to. + val activityId = bundle.getString(LiveNotificationHandler.CIO_INSTANCE_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. + // Dispatched rather than run inline: the handler performs a blocking branding-logo + // download (up to ~20s), which would otherwise hold Firebase's onMessageReceived + // thread and delay follow-up pushes. + SDKComponent.liveNotificationManager.renderFromPush(activityId) { isSuperseded -> + LiveNotificationHandler(bundle).handle( + context = context, + deliveryId = deliveryId, + deliveryToken = deliveryToken, + smallIcon = smallIcon, + tintColor = tintColor, + channelId = liveChannelId, + notificationManager = notificationManager, + isSuperseded = isSuperseded + ) + } + return + } + val channelId = notificationChannelCreator.createNotificationChannelIfNeededAndReturnChannelId( context = context, applicationName = applicationName, appMetaData = appMetaData, notificationManager = notificationManager ) + + 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 +254,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..18c38e4bc --- /dev/null +++ b/messagingpush/src/main/java/io/customer/messagingpush/LiveNotificationHandler.kt @@ -0,0 +1,523 @@ +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 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.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 org.json.JSONArray +import org.json.JSONException +import org.json.JSONObject + +/** + * Dispatches templated live notifications — ongoing notifications updated + * in-place (the Android counterpart of iOS Live Activities). Pushes sharing a + * [CIO_INSTANCE_ID_KEY] replace the previous notification rather than stacking. + */ +internal class LiveNotificationHandler( + private val bundle: Bundle +) { + + companion object { + const val CIO_INSTANCE_ID_KEY = "cioInstanceId" + const val EVENT_KEY = "event" + const val NOTIFICATION_TYPE_KEY = "notification_type" + const val TIMESTAMP_KEY = "timestamp" + const val PAYLOAD_KEY = "payload" + + private const val EVENT_END = "end" + + /** Deterministic notification id for an [activityId] so successive events address the same notification. */ + internal fun notificationId(activityId: String): Int = activityId.hashCode() and 0x7FFFFFFF + + /** Envelope keys that are never template fields; everything else is flattened into the template `data`. */ + private val RESERVED_KEYS = setOf( + CIO_INSTANCE_ID_KEY, + EVENT_KEY, + NOTIFICATION_TYPE_KEY, + TIMESTAMP_KEY, + PAYLOAD_KEY, + PushTrackingUtil.DELIVERY_ID_KEY, + PushTrackingUtil.DELIVERY_TOKEN_KEY + ) + } + + /** + * Renders and posts the live notification described by the bundle. + * + * Every failure is contained here rather than at the call sites, because both callers + * run somewhere a throw is fatal: the FCM path executes on + * `FirebaseMessagingService.onMessageReceived`, and the local path on an SDK-owned + * coroutine scope with no exception handler. The risky work — template rendering, asset + * decoding/download, the host app's + * [io.customer.messagingpush.data.communication.CustomerIOLiveNotificationsCallback], and + * the `notify` call itself — is all inside. A failure drops this render only. + */ + fun handle( + context: Context, + // Null for locally-started activities: they were never delivered by Customer.io, so + // there is nothing to attribute a delivery metric to. The public + // [CustomerIOParsedPushPayload] types these as non-null, so they are flattened to "" + // when the payload is built below, and the click path skips metric reporting on blank. + deliveryId: String?, + deliveryToken: String?, + @DrawableRes smallIcon: Int, + @ColorInt tintColor: Int?, + channelId: String, + notificationManager: NotificationManager, + // Locally-initiated renders skip the entire server-push guard block (both the + // out-of-order timestamp dedupe and the terminal ended check/claim). They are + // governed by LiveNotificationManager instead, which enforces terminal state on + // the local start/update/end paths before rendering. + bypassOrderGuard: Boolean = false, + // Re-checked after the (potentially slow) render work and immediately before the + // notification is posted and the activity type is written back. Returns true when a + // logout/reset landed during rendering, in which case the render is dropped so it + // can't post or re-store a previous user's activity. Supplied for every render that + // runs on LiveNotificationManager's render chain — local and server-delivered alike. + isSuperseded: () -> Boolean = { false } + ) { + runCatching { + handleInternal( + context = context, + deliveryId = deliveryId, + deliveryToken = deliveryToken, + smallIcon = smallIcon, + tintColor = tintColor, + channelId = channelId, + notificationManager = notificationManager, + bypassOrderGuard = bypassOrderGuard, + isSuperseded = isSuperseded + ) + }.onFailure { cause -> + val activityId = bundle.getString(CIO_INSTANCE_ID_KEY) + SDKComponent.logger.error( + "Failed to render live notification '$activityId': ${cause.message}" + ) + // An `end` claims the terminal transition in the store *before* the render runs — + // server pushes in the order guard below, local ends in LiveNotificationManager.end. + // So a failure here would leave the previous ongoing (non-dismissible) notification on + // screen while every retry `end` is dropped by that same claim. Cancel it on both + // paths so a failed render can't strand the activity visible and unclearable. + if (bundle.getString(EVENT_KEY) == EVENT_END && activityId != null) { + notificationManager.cancel(activityId, notificationId(activityId)) + } + } + } + + /** + * The activity types the host app has opted into. + * + * Prefers the live module config and falls back to the persisted set. When Android starts a + * process solely to deliver an FCM push, no app code runs — the FCM service only wires up the + * Android context — so the push module is never registered and its config reads back as + * `MessagingPushModuleConfig.default()`, an empty set. + * + * Without the fallback that empty set is ambiguous: it means both "this app never enabled live + * notifications", which must be ignored, and "this app enabled them, but this process has not + * loaded that yet", which must not. The latter is every live notification delivered after + * ordinary process death, and for an `end` it also strands the notification a previous process + * posted, since ongoing notifications are not user-dismissible. + * + * The config still wins whenever it is populated, so disabling a type takes effect immediately + * in a running process rather than waiting for the persisted copy to be rewritten. + */ + private fun enabledActivityTypes(): Set = + SDKComponent.pushModuleConfig.liveNotificationTypes + .ifEmpty { SDKComponent.liveNotificationStore.enabledActivityTypes() } + + @Suppress("LongParameterList") + private fun handleInternal( + context: Context, + deliveryId: String?, + deliveryToken: String?, + @DrawableRes smallIcon: Int, + @ColorInt tintColor: Int?, + channelId: String, + notificationManager: NotificationManager, + bypassOrderGuard: Boolean, + isSuperseded: () -> Boolean + ) { + 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(CIO_INSTANCE_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 + } + + val activityType = bundle.getString(NOTIFICATION_TYPE_KEY) + if (activityType == null || activityType !in enabledActivityTypes()) { + SDKComponent.logger.debug( + "Live notification type '$activityType' is not enabled; ignoring activity '$activityId'." + ) + return + } + val isEnd = event == EVENT_END + + val store = SDKComponent.liveNotificationStore + val timestamp = bundle.getString(TIMESTAMP_KEY)?.toLongOrNull() + + // Terminal / out-of-order guard for server pushes. Local renders are host-ordered + // and governed by LiveNotificationManager, so they bypass this entirely. + if (!bypassOrderGuard) { + if (isEnd) { + // Claim the terminal transition. Only the first `end` renders the terminal + // state; a duplicate or late `end` — including one arriving after the user + // already dismissed the notification (the dismiss receiver marks it ended) — + // is dropped so it can't re-post a notification the user already cleared. + if (!store.markEnded(activityId)) { + SDKComponent.logger.debug( + "Dropping duplicate/late end for already-ended live notification '$activityId'." + ) + return + } + } else if (store.isEnded(activityId)) { + // A non-end event for an ended id (delayed/duplicate start or update) is stale. + SDKComponent.logger.debug("Dropping event for ended live notification '$activityId'.") + return + } else { + // Services emits whole-second timestamps, so an in-order start+update can + // share a second; reject only strictly-older pushes. + val lastSeen = store.lastTimestamp(activityId) + if (timestamp != null && lastSeen != null && timestamp < lastSeen) { + SDKComponent.logger.debug( + "Dropping out-of-order/duplicate live notification for '$activityId' (timestamp $timestamp < $lastSeen)." + ) + return + } + } + } else if (!isEnd && store.isEnded(activityId)) { + // Local start/update renders are queued, so the activity can go terminal between + // enqueue and render — most commonly a user swipe, which the dismiss receiver marks + // ended. Without re-checking here the render would repost a notification the user + // already cleared. `end` is exempt: it renders precisely because it just went terminal. + SDKComponent.logger.debug( + "Dropping local render for ended live notification '$activityId'." + ) + return + } + + // Advances the high-water mark on BOTH paths: a local render must also bump it + // so a later delayed remote push at an intermediate timestamp can't overwrite + // newer local content. + // + // Deliberately invoked only once this render is committed (past the supersede + // check below), not up-front: a local render invalidated by a logout must not + // write any state back into the store that reset just cleared. + fun advanceHighWaterMark() { + if (timestamp == null) return + val lastSeen = store.lastTimestamp(activityId) + if (lastSeen == null || timestamp > lastSeen) { + store.setLastTimestamp(activityId, timestamp) + } + } + + val template = TemplateRegistry.find(activityType) + val data = extractData(bundle) + val branding = SDKComponent.pushModuleConfig.liveNotificationBranding + // Branding overrides the status-bar icon for live notifications only. + val effectiveSmallIcon = branding?.smallIcon ?: smallIcon + + val result = template?.render( + context = context, + data = data, + branding = branding, + smallIcon = effectiveSmallIcon, + fallbackTintColor = tintColor + )?.let { rendered -> + // The brand logo fills the large-icon slot when the template didn't set one. + val brandingLogo = branding?.logo + if (!rendered.cancelImmediately && rendered.largeIcon == null && brandingLogo != null) { + rendered.copy(largeIcon = TemplateAssets.toBitmap(context, brandingLogo)) + } else { + rendered + } + } + + val notifId = notificationId(activityId) + + if (result?.cancelImmediately == true) { + // Same supersede gate as the post path below: a render invalidated by a logout + // must not touch the store the reset just cleared, on any exit path. + if (isSuperseded()) { + SDKComponent.logger.debug( + "Live notification render for '$activityId' was superseded by a reset; dropping." + ) + return + } + advanceHighWaterMark() + notificationManager.cancel(activityId, notifId) + return + } + + bundle.putInt(CustomerIOPushNotificationHandler.NOTIFICATION_REQUEST_CODE, notifId) + val parsedPayload = CustomerIOParsedPushPayload( + extras = flattenedExtras(bundle, data), + deepLink = result?.deepLink ?: bundle.getString(CustomerIOPushNotificationHandler.DEEP_LINK_KEY), + cioDeliveryId = deliveryId.orEmpty(), + cioDeliveryToken = deliveryToken.orEmpty(), + 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) + // Only in-progress notifications carry the dismiss intent (user-swipe -> end). + // A terminal `end` notification must not, or swiping it would report a second end. + val deletePendingIntent = if (isEnd) null else createDeleteIntent(context, notifId, activityId, activityType) + + // The host app may fully render the notification; otherwise fall back to the SDK template. + val appNotification = SDKComponent.pushModuleConfig.liveNotificationCallback + ?.createLiveNotification(parsedPayload, context) + ?.withDefaultIntents(pendingIntent, deletePendingIntent) + val notification = appNotification ?: result?.let { + buildSdkNotification(context, channelId, effectiveSmallIcon, it, pendingIntent, deletePendingIntent, ongoing = !isEnd) + } + + // A logout/reset may have landed while the branding logo downloaded or the app + // renderer ran above. If so, drop this render: don't post the notification and don't + // write the activity type or the high-water mark back into the store that reset + // just cleared. + if (isSuperseded()) { + SDKComponent.logger.debug( + "Live notification render for '$activityId' was superseded by a reset; dropping." + ) + return + } + + // Re-check terminal state immediately before committing, on both paths. The guard above + // runs before the template render and the app callback, either of which can block for a + // long time — a remote branding logo waits up to ~20s. A user swipe during that work has + // the dismiss receiver mark the activity ended, and posting now would resurrect a + // notification they already cleared. `end` is exempt: it posts precisely because it just + // went terminal. + if (!isEnd && store.isEnded(activityId)) { + SDKComponent.logger.debug( + "Live notification '$activityId' was ended while rendering; not posting." + ) + return + } + + when { + notification != null -> { + notificationManager.notify(activityId, notifId, notification) + store.setActivityType(activityId, activityType) + } + isEnd -> { + // No renderable end-state, so there's nothing to leave in the shade: + // cancel the (previously ongoing) notification instead of stranding it. + notificationManager.cancel(activityId, notifId) + } + else -> { + val reason = if (template != null) { + "required content fields are missing (payload not flattened, or empty)" + } else { + "no built-in template and createLiveNotification returned null" + } + SDKComponent.logger.error( + "Not posting live notification '$activityId' (type '$activityType'): $reason." + ) + return + } + } + + // Only now, past every early return above: advancing on a render that showed nothing + // would let the out-of-order guard drop a later push carrying an older timestamp even + // though this one never reached the shade. + advanceHighWaterMark() + + // Pushes are server-initiated, so the handler never reports a lifecycle event. + // The terminal marker was already claimed above (remote) or by the manager + // (local); the tracked activity type is intentionally kept (not cleared) so a + // subsequent logout can still cancel an ended-but-still-visible notification. + } + + private fun buildSdkNotification( + context: Context, + channelId: String, + @DrawableRes effectiveSmallIcon: Int, + result: TemplateRenderResult, + pendingIntent: PendingIntent, + deletePendingIntent: PendingIntent?, + ongoing: Boolean + ): 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, + ongoing = ongoing + ) + ) + } + 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, + ongoing = ongoing + ) + ) + } + } + + private fun createDeleteIntent( + context: Context, + requestCode: Int, + activityId: String, + activityType: String + ): PendingIntent { + val intent = Intent(context, LiveNotificationDismissReceiver::class.java).apply { + putExtra(LiveNotificationDismissReceiver.EXTRA_ACTIVITY_ID, activityId) + putExtra(LiveNotificationDismissReceiver.EXTRA_ACTIVITY_TYPE, activityType) + } + 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 template fields from the FCM envelope: top-level keys not in + * [RESERVED_KEYS], merged with any nested `payload` object (which wins on + * collision). JSON-object/array strings are parsed; scalars kept verbatim. + */ + /** + * Fills in the SDK's click and dismiss intents on an app-rendered notification that didn't + * set its own. The SDK still owns posting and lifecycle for these, so without this a custom + * notification could not open the app, report `opened`, deep-link, or report a user swipe — + * and every host would have to reconstruct internal receiver details to get them. An app that + * does set either intent keeps it. + */ + private fun Notification.withDefaultIntents( + clickIntent: PendingIntent?, + dismissIntent: PendingIntent? + ): Notification = apply { + if (contentIntent == null) contentIntent = clickIntent + if (deleteIntent == null) deleteIntent = dismissIntent + } + + /** + * The bundle a host callback receives. Server pushes carry customer content nested in the + * JSON `payload` field, which [extractData] unwraps for built-in templates; without the same + * unwrapping here an app-rendered type would see `payload` instead of its own fields, which + * is not what [io.customer.messagingpush.data.communication.CustomerIOLiveNotificationsCallback] + * promises. Envelope keys already in the bundle win, so nothing the SDK owns is overwritten. + */ + private fun flattenedExtras(bundle: Bundle, data: JSONObject): Bundle = Bundle(bundle).apply { + remove(PAYLOAD_KEY) + for (key in data.keys()) { + if (containsKey(key)) continue + val value = data.opt(key) ?: continue + putString(key, value.toString()) + } + } + + 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)) + } + val payload = bundle.getString(PAYLOAD_KEY)?.let { coerceJsonValue(it) as? JSONObject } + if (payload != null) { + for (key in payload.keys()) { + data.put(key, payload.get(key)) + } + } + 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 + } + } + + 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..fe151d774 100644 --- a/messagingpush/src/main/java/io/customer/messagingpush/MessagingPushModuleConfig.kt +++ b/messagingpush/src/main/java/io/customer/messagingpush/MessagingPushModuleConfig.kt @@ -2,7 +2,10 @@ 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.CustomerIOLiveNotificationsCallback import io.customer.messagingpush.data.communication.CustomerIOPushNotificationCallback +import io.customer.messagingpush.livenotification.LiveNotificationBranding +import io.customer.messagingpush.livenotification.LiveNotificationType import io.customer.sdk.core.module.CustomerIOModuleConfig /** @@ -12,16 +15,28 @@ 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. + * @property liveNotificationTypes activity type identifiers (built-in + custom) + * the host app enabled. + * @property liveNotificationCallback lets the host app render live notifications + * itself. `null` means the SDK renders its built-in templates. */ class MessagingPushModuleConfig private constructor( val autoTrackPushEvents: Boolean, val notificationCallback: CustomerIOPushNotificationCallback?, - val pushClickBehavior: PushClickBehavior + val pushClickBehavior: PushClickBehavior, + val liveNotificationBranding: LiveNotificationBranding?, + val liveNotificationTypes: Set, + val liveNotificationCallback: CustomerIOLiveNotificationsCallback? ) : 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 val liveNotificationTypes: MutableSet = mutableSetOf() + private var liveNotificationCallback: CustomerIOLiveNotificationsCallback? = null /** * Allows to enable/disable automatic tracking of push events. Auto tracking will generate @@ -56,17 +71,76 @@ 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 built-in [types] (rendered by + * the SDK's templates). At least one type — built-in or custom — must be + * enabled or the feature is a no-op. Repeatable and additive with + * [enableCustomLiveNotificationTypes]. + * + * For the promoted "live update" treatment on Android 16+ (a notification + * pinned to the status bar / lock screen), the host app must declare + * `android.permission.POST_PROMOTED_NOTIFICATIONS` in its manifest — the + * SDK deliberately does not declare it on the app's behalf. Without it, + * live notifications still post as standard ongoing notifications. + * + * @param types built-in activity types to enable. + */ + fun enableLiveNotificationTypes(vararg types: LiveNotificationType): Builder { + types.forEach { liveNotificationTypes.add(it.identifier) } + return this + } + + /** + * Enables live notifications for customer-defined [types] (reverse-DNS + * identifier strings). Custom types have no built-in template, so a + * [CustomerIOLiveNotificationsCallback] set via + * [setLiveNotificationCallback] must render them. + * + * Repeatable and additive with [enableLiveNotificationTypes]. + * + * @param types custom activity type identifiers to enable. + */ + fun enableCustomLiveNotificationTypes(vararg types: String): Builder { + liveNotificationTypes.addAll(types) + return this + } + + /** + * Lets the host app render live notifications itself instead of using the + * SDK's built-in templates. Required for custom activity types enabled via + * [enableCustomLiveNotificationTypes], which have no built-in template. + * + * @param liveNotificationCallback renderer invoked for every live notification. + */ + fun setLiveNotificationCallback(liveNotificationCallback: CustomerIOLiveNotificationsCallback): Builder { + this.liveNotificationCallback = liveNotificationCallback + return this + } + override fun build(): MessagingPushModuleConfig { return MessagingPushModuleConfig( autoTrackPushEvents = autoTrackPushEvents, notificationCallback = notificationCallback, - pushClickBehavior = pushClickBehavior + pushClickBehavior = pushClickBehavior, + liveNotificationBranding = liveNotificationBranding, + liveNotificationTypes = liveNotificationTypes.toSet(), + liveNotificationCallback = liveNotificationCallback ) } } override fun toString(): String { - return "MessagingPushModuleConfig(autoTrackPushEvents=$autoTrackPushEvents, notificationCallback=$notificationCallback, pushClickBehavior=$pushClickBehavior)" + return "MessagingPushModuleConfig(autoTrackPushEvents=$autoTrackPushEvents, notificationCallback=$notificationCallback, pushClickBehavior=$pushClickBehavior, liveNotificationBranding=$liveNotificationBranding, liveNotificationTypes=$liveNotificationTypes, liveNotificationCallback=$liveNotificationCallback)" } 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 e51b838eb..354bc45ea 100644 --- a/messagingpush/src/main/java/io/customer/messagingpush/ModuleMessagingPushFCM.kt +++ b/messagingpush/src/main/java/io/customer/messagingpush/ModuleMessagingPushFCM.kt @@ -7,9 +7,14 @@ import androidx.lifecycle.LifecycleEventObserver import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.ProcessLifecycleOwner import io.customer.messagingpush.di.fcmTokenProvider +import io.customer.messagingpush.di.liveNotificationManager +import io.customer.messagingpush.di.liveNotificationRegistrar +import io.customer.messagingpush.di.liveNotificationStore import io.customer.messagingpush.di.pushDeliveryFlusher import io.customer.messagingpush.di.pushLogger import io.customer.messagingpush.di.pushTrackingUtil +import io.customer.messagingpush.livenotification.LiveNotificationData +import io.customer.messagingpush.livenotification.ULID import io.customer.messagingpush.logger.PushNotificationLogger import io.customer.messagingpush.provider.DeviceTokenProvider import io.customer.messagingpush.store.PendingPushDeliveryMetric @@ -38,11 +43,117 @@ class ModuleMessagingPushFCM @JvmOverloads constructor( get() = MODULE_NAME override fun initialize() { + // Persist the opt-in set on every initialization, including when it is empty. A process + // Android starts only to deliver an FCM push runs no app code, so this module is never + // registered there and the config reads back as empty; LiveNotificationHandler falls back + // to this to tell "never enabled" from "not loaded in this process yet". + SDKComponent.liveNotificationStore.setEnabledActivityTypes(moduleConfig.liveNotificationTypes) + // Live notifications are opt-in; 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. + * + * The notification renders regardless of identity, but its lifecycle events + * (start/end) are only reported to Customer.io for an **identified user** with a + * **registered device token** — call `identify` first if you need the backend to + * track this activity and push updates/remote end (matches iOS Live Activities). + * + * A lifecycle event dropped because either prerequisite was missing is **not + * retried**: the notification still renders and this still returns an id, but the + * backend never learns about that instance and so can neither update nor end it. + * The device token is requested asynchronously at initialization, so a start + * issued immediately after a first install can fall into that window. + * + * @return the generated `activity_id`, used to correlate subsequent updates. + */ + fun startLiveNotification(data: LiveNotificationData): String { + val activityId = ULID.generate() + SDKComponent.liveNotificationManager.start( + activityId = activityId, + activityType = data.activityType, + attributes = data.attributes(), + contentState = data.contentState() + ) + return activityId + } + + /** + * Starts a live notification locally for a customer-defined [activityType] + * (one enabled via [MessagingPushModuleConfig.Builder.enableCustomLiveNotificationTypes]). + * Custom types have no built-in template, so a + * [io.customer.messagingpush.data.communication.CustomerIOLiveNotificationsCallback] + * must render them. + * + * As with the templated overload, lifecycle events are reported to Customer.io only + * for an identified user with a registered device token, and an event dropped for a + * missing prerequisite is not retried. + * + * @param data flattened fields delivered to the renderer. + * @return the generated `activity_id`. + */ + fun startLiveNotification(activityType: String, data: Map): String { + val activityId = ULID.generate() + SDKComponent.liveNotificationManager.start( + activityId = activityId, + activityType = activityType, + attributes = emptyMap(), + contentState = data + ) + return activityId + } + + /** + * Updates a live notification previously started via [startLiveNotification] + * for a built-in template type: re-renders it in place. The update is + * intentionally not reported to Customer.io — only start/end emit CDP events. + * + * @param activityId the id returned by [startLiveNotification]. + */ + fun updateLiveNotification(activityId: String, data: LiveNotificationData) = + SDKComponent.liveNotificationManager.update( + activityId = activityId, + activityType = data.activityType, + attributes = data.attributes(), + contentState = data.contentState() + ) + + /** + * Updates a live notification previously started via [startLiveNotification] + * for a customer-defined [activityType]. + * + * @param activityId the id returned by [startLiveNotification]. + * @param data flattened fields delivered to the renderer. + */ + fun updateLiveNotification(activityId: String, activityType: String, data: Map) { + SDKComponent.liveNotificationManager.update( + activityId = activityId, + activityType = activityType, + attributes = emptyMap(), + contentState = data + ) + } + + /** + * Ends a live notification previously started via [startLiveNotification]: + * removes it and reports an `end` event. Only the [activityId] returned by + * [startLiveNotification] is needed — the SDK remembers the activity type. + * + * @param activityId the id returned by [startLiveNotification]. + */ + fun endLiveNotification(activityId: String) { + SDKComponent.liveNotificationManager.end(activityId) + } + private fun subscribeToLifecycleEvents() { activityLifecycleCallbacks.subscribe { events -> events diff --git a/messagingpush/src/main/java/io/customer/messagingpush/data/communication/CustomerIOLiveNotificationsCallback.kt b/messagingpush/src/main/java/io/customer/messagingpush/data/communication/CustomerIOLiveNotificationsCallback.kt new file mode 100644 index 000000000..954119b61 --- /dev/null +++ b/messagingpush/src/main/java/io/customer/messagingpush/data/communication/CustomerIOLiveNotificationsCallback.kt @@ -0,0 +1,53 @@ +package io.customer.messagingpush.data.communication + +import android.app.Notification +import android.content.Context +import io.customer.messagingpush.data.model.CustomerIOParsedPushPayload + +/** + * Lets the host app render live notifications itself, instead of using the SDK's + * built-in templates. + * + * Registered via + * [io.customer.messagingpush.MessagingPushModuleConfig.Builder.setLiveNotificationCallback]. + * This is deliberately separate from [CustomerIOPushNotificationCallback] so that + * implementing live-notification rendering never forces a change on apps that only + * customise standard push, and vice versa. + */ +interface CustomerIOLiveNotificationsCallback { + /** + * Called for every live notification the SDK is about to post. Return a + * fully-built [Notification] to take complete control of its appearance and + * intents, or `null` to fall back to the SDK's built-in template. + * + * Required for customer-defined activity types (enabled via + * [io.customer.messagingpush.MessagingPushModuleConfig.Builder.enableCustomLiveNotificationTypes]), + * 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 the activity id, so later updates replace it in place. + * + * The SDK fills in its own click and dismiss intents on the returned notification + * wherever the app left them unset, so a tap reports `opened` and follows the + * deep link, and a swipe reports `end`, without the app wiring up internal + * receivers. Setting either intent yourself keeps it — and makes the + * corresponding reporting your responsibility. + * + * On `end` this is called again to render the terminal, non-ongoing state; + * returning `null` there cancels the notification instead. A terminal + * notification deliberately carries no dismiss intent, so swiping it away does + * not report a second `end`. + * + * Called on a background thread. Throwing from here is caught and logged by + * the SDK, but the notification is then dropped — prefer returning `null`. + * + * @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? +} 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..4577fa44b 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,15 +21,32 @@ data class CustomerIOParsedPushPayload( val cioDeliveryId: String, val cioDeliveryToken: String, val title: String, - val body: String + val body: String, + val activityId: String? = null ) : Parcelable { + /** + * Preserves the pre-live-notifications 6-argument constructor in the ABI. + * Adding [activityId] with a default value replaces the old 6-arg entry point + * with a 7-arg one plus a synthetic bridge, so host apps compiled against an + * earlier release would otherwise hit `NoSuchMethodError` at runtime. + */ + constructor( + extras: Bundle, + deepLink: String?, + cioDeliveryId: String, + cioDeliveryToken: String, + title: String, + body: String + ) : this(extras, deepLink, cioDeliveryId, cioDeliveryToken, title, body, null) + constructor(parcel: Parcel) : this( extras = parcel.readBundle(Bundle::class.java.classLoader) ?: Bundle(), deepLink = parcel.readString(), 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 +56,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 6bed5ea03..368990ce5 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 @@ -96,6 +101,24 @@ 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 + ) + } + internal val SDKComponent.pushDeliveryTracker: PushDeliveryTracker get() = singleton { PushDeliveryTrackerImpl() } diff --git a/messagingpush/src/main/java/io/customer/messagingpush/livenotification/LiveNotificationAsset.kt b/messagingpush/src/main/java/io/customer/messagingpush/livenotification/LiveNotificationAsset.kt new file mode 100644 index 000000000..0187eca79 --- /dev/null +++ b/messagingpush/src/main/java/io/customer/messagingpush/livenotification/LiveNotificationAsset.kt @@ -0,0 +1,27 @@ +package io.customer.messagingpush.livenotification + +import android.net.Uri +import androidx.annotation.DrawableRes + +/** + * A strongly-typed image source for a live notification, passed directly to + * [LiveNotificationBranding.logo]. The SDK loads it when the notification renders. + */ +sealed interface LiveNotificationAsset { + /** A bundled drawable resource. */ + data class Drawable(@DrawableRes val resId: Int) : LiveNotificationAsset + + /** A `file://`, `content://`, or `android.resource://` image. */ + data class Resource(val uri: Uri) : LiveNotificationAsset + + /** Raw encoded image bytes (PNG/JPEG/…). */ + class Bytes(val data: ByteArray) : LiveNotificationAsset { + override fun equals(other: Any?): Boolean = + this === other || (other is Bytes && data.contentEquals(other.data)) + + override fun hashCode(): Int = data.contentHashCode() + } + + /** A remote http(s) image, downloaded and disk-cached at render time. */ + data class RemoteUrl(val url: String) : LiveNotificationAsset +} 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..1ce59e36b --- /dev/null +++ b/messagingpush/src/main/java/io/customer/messagingpush/livenotification/LiveNotificationBranding.kt @@ -0,0 +1,24 @@ +package io.customer.messagingpush.livenotification + +import androidx.annotation.ColorInt +import androidx.annotation.DrawableRes + +/** + * 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. + * + * @property companyName Reserved for future templates; not consumed today. + * @property accentColor Accent color applied via [android.app.Notification.Builder.setColor]. + * @property smallIcon Optional bundled drawable overriding the status-bar small + * icon for live notifications only (tinted with [accentColor]). Must be a + * bundled drawable resource, not a remote or byte-backed image. + * @property logo Optional image rendered as the large icon when the active + * template does not provide one. Accepts any [LiveNotificationAsset]. + */ +data class LiveNotificationBranding( + val companyName: String, + @ColorInt val accentColor: Int, + @DrawableRes val smallIcon: Int? = null, + val logo: LiveNotificationAsset? = 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..7c6a950c2 --- /dev/null +++ b/messagingpush/src/main/java/io/customer/messagingpush/livenotification/LiveNotificationData.kt @@ -0,0 +1,82 @@ +package io.customer.messagingpush.livenotification + +import io.customer.messagingpush.livenotification.template.CountdownTimerFields +import io.customer.messagingpush.livenotification.template.SegmentsFields + +/** + * Typed payload for starting a built-in live notification locally via + * `ModuleMessagingPushFCM.startLiveNotification`. Each subtype exposes its + * [fields], plus the static [attributes] and dynamic [contentState] subsets. + * + * Time fields such as `endTime` are **epoch seconds**, not milliseconds. + * 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 + + /** Static fields (iOS `attributes`); null values are omitted by the caller. */ + fun attributes(): Map + + /** Dynamic fields (iOS `contentState`); null values are omitted by the caller. */ + fun contentState(): Map + + /** + * Segments template — a status headline over a discrete, multi-step progress + * bar (matches iOS `CIOSegmentsAttributes`). [segmentsComplete] is clamped to + * `0..segmentsTotal` at render time. + */ + data class Segments( + val header: String, + val status: String, + val substatus: String? = null, + val segmentsTotal: Int, + val segmentsComplete: Int, + val trailingText: String? = null + ) : LiveNotificationData { + override val activityType = LiveNotificationType.SEGMENTS.identifier + + override fun attributes() = mapOf( + SegmentsFields.HEADER to header + ) + + override fun contentState() = mapOf( + SegmentsFields.STATUS to status, + SegmentsFields.SUBSTATUS to substatus, + SegmentsFields.SEGMENTS_TOTAL to segmentsTotal, + SegmentsFields.SEGMENTS_COMPLETE to segmentsComplete, + SegmentsFields.TRAILING_TEXT to trailingText + ) + + override fun fields() = attributes() + contentState() + } + + /** + * Countdown timer template — a status headline over a live countdown to + * [endTime] (matches iOS `CIOCountdownTimerAttributes`). [endTime] is epoch + * seconds; drive the finished state by starting/updating with no [endTime]. + */ + data class CountdownTimer( + val header: String, + val title: String, + val statusMessage: String? = null, + val endTime: Long? = null + ) : LiveNotificationData { + override val activityType = LiveNotificationType.COUNTDOWN_TIMER.identifier + + override fun attributes() = mapOf( + CountdownTimerFields.HEADER to header + ) + + override fun contentState() = mapOf( + CountdownTimerFields.TITLE to title, + CountdownTimerFields.STATUS_MESSAGE to statusMessage, + CountdownTimerFields.END_TIME to endTime + ) + + override fun fields() = attributes() + contentState() + } +} 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..71b8ff52a --- /dev/null +++ b/messagingpush/src/main/java/io/customer/messagingpush/livenotification/LiveNotificationDismissReceiver.kt @@ -0,0 +1,70 @@ +package io.customer.messagingpush.livenotification + +import android.content.BroadcastReceiver +import android.content.BroadcastReceiver.PendingResult +import android.content.Context +import android.content.Intent +import io.customer.messagingpush.di.liveNotificationLifecycleClient +import io.customer.messagingpush.di.liveNotificationStore +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 fired when the user dismisses a live notification + * and reports an `end` event to Customer.io. + */ +class LiveNotificationDismissReceiver : BroadcastReceiver() { + + override fun onReceive(context: Context, intent: Intent) { + val activityId = intent.getStringExtra(EXTRA_ACTIVITY_ID) ?: return + val activityType = intent.getStringExtra(EXTRA_ACTIVITY_TYPE) ?: return + SDKComponent.setupAndroidComponent(context = context) + + // Claim the terminal transition first, whether or not the end turns out to be + // reportable: the user dismissed this notification, so nothing may repost it. Renders + // consult this marker, so a dismissal left unmarked would let a queued local update or + // a later push bring back a notification the user already cleared. Report `end` at most + // once per id; if already ended (e.g. endLiveNotification ran) skip the duplicate. The + // marker also retains the store's ordering guard for the same reason. + if (!SDKComponent.liveNotificationStore.markEnded(activityId)) { + SDKComponent.logger.debug( + "Live notification '$activityId' already ended; skipping dismiss end event." + ) + return + } + + // Lifecycle events require a registered device token and are never retried, so a + // dismissal without one stays local: the activity is terminal on the device, but + // Customer.io never learns the notification was cleared. + val deviceId = SDKComponent.android().globalPreferenceStore.getDeviceToken() + if (deviceId.isNullOrBlank()) { + SDKComponent.logger.debug( + "No FCM token available; skipping end event for live notification '$activityId'." + ) + return + } + // Keep the process alive past onReceive() so the async CDP pipeline can + // persist the end event before the receiver's process is torn down. + // Only available while the framework is dispatching the broadcast — null when + // onReceive is invoked directly, in which case there is nothing to finish. + val pendingResult: PendingResult? = goAsync() + CoroutineScope(SDKComponent.dispatchersProvider.background).launch { + try { + SDKComponent.liveNotificationLifecycleClient.reportEnd( + instanceUUID = activityId, + activityType = activityType, + deviceId = deviceId + ) + } finally { + pendingResult?.finish() + } + } + } + + internal companion object { + internal const val EXTRA_ACTIVITY_ID = "io.customer.messagingpush.EXTRA_LIVE_ACTIVITY_ID" + internal const val EXTRA_ACTIVITY_TYPE = "io.customer.messagingpush.EXTRA_LIVE_ACTIVITY_TYPE" + } +} 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..36c0c8014 --- /dev/null +++ b/messagingpush/src/main/java/io/customer/messagingpush/livenotification/LiveNotificationLifecycleClient.kt @@ -0,0 +1,131 @@ +package io.customer.messagingpush.livenotification + +import io.customer.base.internal.InternalCustomerIOApi +import io.customer.sdk.core.di.SDKComponent +import io.customer.sdk.core.pipeline.DataPipeline + +/** + * Reports live-notification lifecycle to Customer.io as CDP track events. + */ +internal interface LiveNotificationLifecycleClient { + /** Reports a `start` event with the static [attributes] and dynamic [contentState]. */ + fun reportStart( + instanceUUID: String, + activityType: String, + deviceId: String, + attributes: Map, + contentState: Map + ) + + /** Reports an `end` event, optionally carrying a final dynamic [contentState]. */ + fun reportEnd( + instanceUUID: String, + activityType: String, + deviceId: String, + contentState: Map = emptyMap() + ) + + /** Reports a `register_push_to_start` event; returns true if it was emitted. */ + fun registerPushToStart(activityType: String, deviceId: String): Boolean +} + +@OptIn(InternalCustomerIOApi::class) +internal class LiveNotificationLifecycleClientImpl( + private val dataPipelineProvider: () -> DataPipeline? = { SDKComponent.getOrNull() } +) : LiveNotificationLifecycleClient { + + override fun reportStart( + instanceUUID: String, + activityType: String, + deviceId: String, + attributes: Map, + contentState: Map + ) { + track( + event = EVENT_LIVE_NOTIFICATION, + properties = buildMap { + put(PROP_EVENT_TYPE, EVENT_TYPE_START) + put(PROP_CIO_INSTANCE_ID, instanceUUID) + put(PROP_DEVICE_ID, deviceId) + put(PROP_PLATFORM, PLATFORM_ANDROID) + put(PROP_NOTIFICATION_TYPE, activityType) + if (attributes.isNotEmpty()) put(PROP_ATTRIBUTES, attributes) + if (contentState.isNotEmpty()) put(PROP_CONTENT_STATE, contentState) + } + ) + } + + override fun reportEnd( + instanceUUID: String, + activityType: String, + deviceId: String, + contentState: Map + ) { + track( + event = EVENT_LIVE_NOTIFICATION, + properties = buildMap { + put(PROP_EVENT_TYPE, EVENT_TYPE_END) + put(PROP_CIO_INSTANCE_ID, instanceUUID) + put(PROP_DEVICE_ID, deviceId) + put(PROP_PLATFORM, PLATFORM_ANDROID) + put(PROP_NOTIFICATION_TYPE, activityType) + if (contentState.isNotEmpty()) put(PROP_CONTENT_STATE, contentState) + } + ) + } + + override fun registerPushToStart(activityType: String, deviceId: String): Boolean = + track( + event = EVENT_LIVE_NOTIFICATION_TOKEN, + properties = mapOf( + PROP_REGISTRATION_TYPE to REGISTRATION_TYPE_PUSH_TO_START, + PROP_NOTIFICATION_TYPE to activityType, + PROP_PLATFORM to PLATFORM_ANDROID, + PROP_DEVICE_ID to deviceId, + PROP_PUSH_TO_START_TOKEN to deviceId + ), + // Identity is gated upstream by LiveNotificationRegistrar for registration. + requireIdentifiedUser = false + ) + + /** + * Emits [event]; returns true if it was sent. Requires a ready pipeline and, + * unless [requireIdentifiedUser] is false, an identified user. + */ + private fun track(event: String, properties: Map, requireIdentifiedUser: Boolean = true): Boolean { + val pipeline = dataPipelineProvider() + if (pipeline == null) { + SDKComponent.logger.debug("Data pipeline unavailable; dropping live notification event '$event'.") + return false + } + // pipeline.isUserIdentified is updated synchronously during identify()/clearIdentify(), + // so an identify() immediately followed by start() is not dropped by a stale flag. + if (requireIdentifiedUser && !pipeline.isUserIdentified) { + SDKComponent.logger.debug("Live notifications require an identified user; dropping event '$event'.") + return false + } + pipeline.track(event, properties) + return true + } + + companion object { + const val EVENT_LIVE_NOTIFICATION = "Live Notification Event" + const val EVENT_LIVE_NOTIFICATION_TOKEN = "Live Notification Token" + + const val PROP_EVENT_TYPE = "eventType" + const val PROP_REGISTRATION_TYPE = "registrationType" + const val PROP_CIO_INSTANCE_ID = "cioInstanceId" + const val PROP_DEVICE_ID = "deviceId" + const val PROP_PLATFORM = "platform" + + const val PROP_NOTIFICATION_TYPE = "notificationType" + const val PROP_ATTRIBUTES = "attributes" + const val PROP_CONTENT_STATE = "contentState" + const val PROP_PUSH_TO_START_TOKEN = "pushToStartToken" + + const val EVENT_TYPE_START = "start" + const val EVENT_TYPE_END = "end" + const val REGISTRATION_TYPE_PUSH_TO_START = "push_to_start" + const val PLATFORM_ANDROID = "android" + } +} 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..1b8ffd5c2 --- /dev/null +++ b/messagingpush/src/main/java/io/customer/messagingpush/livenotification/LiveNotificationManager.kt @@ -0,0 +1,349 @@ +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.di.liveNotificationStore +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 io.customer.sdk.core.util.DispatchersProvider +import java.util.concurrent.ConcurrentHashMap +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.launch + +/** + * Renders live notifications locally on behalf of the host app and reports the + * corresponding start/end lifecycle events to Customer.io. Local updates are + * rendered but intentionally not reported — only start/end emit CDP events. + */ +internal class LiveNotificationManager( + private val lifecycleClient: LiveNotificationLifecycleClient, + private val notificationChannelCreator: NotificationChannelCreator = NotificationChannelCreator() +) { + private val context: Context + get() = SDKComponent.android().applicationContext + + private val dispatchers: DispatchersProvider + get() = SDKComponent.dispatchersProvider + + // Last bundle rendered per activity id, so end() can re-render the final + // state as a dismissible notification and resolve the activity type even if + // the initial render never posted. + // + // Entries are dropped by end() and by logout, but not by a remote end or a user + // swipe, so an activity finished either of those ways keeps its bundle until the + // next logout or process death. That is a few hundred bytes per activity against + // a count bounded by how many live notifications one app start actually shows, so + // it is left unbounded rather than adding eviction that could discard the terminal + // state a later end() wants to render. + private val lastBundles = ConcurrentHashMap() + + private val renderScope: CoroutineScope by lazy { + CoroutineScope(SupervisorJob() + dispatchers.background) + } + + // Renders are chained so each waits for the previous one to finish, keeping + // them in submission order — a slow earlier render (e.g. a RemoteUrl logo + // download) can't complete after, and overwrite, a later one. + private var renderChain: Job = Job().also { it.complete() } + + // Bumped on reset. A render enqueued before a logout captures the current + // generation; if it changed by the time the render runs, the render is dropped + // so it can't re-post a previous user's activity into a just-cleared store. + @Volatile + private var renderGeneration = 0 + + /** Starts a live notification locally and reports a `start` event. */ + fun start( + activityId: String, + activityType: String, + attributes: Map, + contentState: Map + ) { + val bundle = buildBundle(activityId, activityType, attributes + contentState, EVENT_START) + lastBundles[activityId] = Bundle(bundle) + render(bundle) + // Lifecycle reporting is intentionally decoupled from the local render: the + // app called start/end, so Customer.io must track the activity (and be + // able to push updates / remote-end it) even when the local render posts nothing + // — e.g. a custom type with no built-in template, or a transient render failure. + // The report is not blocking (token read is an in-memory pref; track() enqueues), + // so it stays on the caller thread; identity gating still applies in the client. + reportStart(activityId, activityType, attributes, contentState) + } + + /** + * Re-renders a previously started live notification locally. The update is + * intentionally not reported to Customer.io — only start/end emit CDP events. + */ + fun update( + activityId: String, + activityType: String, + attributes: Map, + contentState: Map + ) { + // Terminal state is governed here for local renders (they bypass the handler's + // server-push guard): an update after the activity ended locally or was dismissed + // must not repost it. `start` mints a fresh id, and `end` guards itself, so only + // `update` needs this check. + if (SDKComponent.liveNotificationStore.isEnded(activityId)) { + SDKComponent.logger.debug( + "Live notification '$activityId' already ended; ignoring update." + ) + return + } + val bundle = buildBundle(activityId, activityType, attributes + contentState, EVENT_UPDATE) + lastBundles[activityId] = Bundle(bundle) + render(bundle) + } + + /** + * Ends a live notification locally and reports an `end` event. Rather than + * cancelling, it re-renders the last state as a terminal (non-ongoing) + * notification so the final state stays in the shade and is dismissible, + * matching push-delivered end. + */ + fun end(activityId: String) { + val store = SDKComponent.liveNotificationStore + val lastBundle = lastBundles.remove(activityId) + // Already terminal (e.g. the user dismissed it, or end was already called): + // end is idempotent per id, so don't re-render a dismissed notification or + // report a second end. + if (store.isEnded(activityId)) { + SDKComponent.logger.debug( + "Live notification '$activityId' already ended; nothing to do." + ) + return + } + // Prefer the type from the last local render; fall back to the persisted + // type (e.g. after process death) so a locally-started activity always + // reports a matching end. + val activityType = lastBundle?.getString(LiveNotificationHandler.NOTIFICATION_TYPE_KEY) + ?: store.activityType(activityId) + + val notificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + + if (activityType == null) { + SDKComponent.logger.debug( + "No known live notification for '$activityId'; nothing to end." + ) + // Nothing to report or claim, but still cancel so a stale ongoing notification + // isn't left stuck and non-dismissible. + notificationManager.cancel(activityId, LiveNotificationHandler.notificationId(activityId)) + return + } + + // Claim the terminal transition so `end` is reported at most once per id, and claim it + // BEFORE rendering: the render is queued, so a user swipe landing first would have the + // dismiss receiver end and clear the notification, and a later terminal render would + // repost what they just dismissed. Losing the claim means exactly that happened, so + // there is nothing left to render. The store's timestamp/type are intentionally NOT + // cleared: the terminal marker and the high-water mark must survive so a delayed older + // push can't resurrect the activity, and logout can still cancel a still-visible ended + // notification. Reclamation happens via the store's TTL trim / logout clear. + if (!store.markEnded(activityId)) { + SDKComponent.logger.debug( + "Live notification '$activityId' was already ended elsewhere; not re-rendering." + ) + return + } + reportEnd(activityId, activityType) + + if (lastBundle != null) { + val endBundle = Bundle(lastBundle).apply { + putString(LiveNotificationHandler.EVENT_KEY, EVENT_END) + putString(LiveNotificationHandler.TIMESTAMP_KEY, (System.currentTimeMillis() / 1000).toString()) + } + render(endBundle) + } else { + // No cached content to render a terminal state (e.g. after process death): + // cancel so a previously ongoing notification isn't left stuck and non-dismissible. + notificationManager.cancel(activityId, LiveNotificationHandler.notificationId(activityId)) + } + } + + /** + * Cancels every tracked live notification and clears their stored state, + * without reporting `end` events. Called on logout (reset). + */ + @Synchronized + fun cancelAllActivities() { + // Invalidate any render queued before this reset (see render()) so a + // start()'s enqueued work can't re-add a previous user's activity after the + // store is cleared below. + renderGeneration++ + val store = SDKComponent.liveNotificationStore + val ids = store.trackedActivityIds() + if (ids.isNotEmpty()) { + val notificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + for (activityId in ids) { + notificationManager.cancel(activityId, LiveNotificationHandler.notificationId(activityId)) + } + } + store.clearAllActivities() + // Drop cached per-instance bundles so a post-logout end() can't reuse a + // previous session's payload. + lastBundles.clear() + } + + private fun buildBundle( + activityId: String, + activityType: String, + fields: Map, + event: String + ): Bundle = + Bundle().apply { + // Write template fields first so the reserved envelope keys below win on collision. + for ((key, value) in fields) { + if (value != null) putString(key, value.toString()) + } + putString(LiveNotificationHandler.CIO_INSTANCE_ID_KEY, activityId) + putString(LiveNotificationHandler.EVENT_KEY, event) + putString(LiveNotificationHandler.NOTIFICATION_TYPE_KEY, activityType) + // Epoch seconds, matching the backend push wire contract. + putString(LiveNotificationHandler.TIMESTAMP_KEY, (System.currentTimeMillis() / 1000).toString()) + } + + /** + * Renders off the caller's thread: a `RemoteUrl` branding logo triggers a + * blocking image download inside the handler, which must never run on the + * caller's (possibly main) thread. + */ + @Synchronized + private fun render(bundle: Bundle) { + enqueueRender(bundle.getString(LiveNotificationHandler.CIO_INSTANCE_ID_KEY)) { isSuperseded -> + renderLocally(bundle, isSuperseded) + } + } + + /** + * Renders a server-delivered live notification off the caller's thread. + * + * Push renders used to run inline on Firebase's `onMessageReceived` thread, where the + * blocking branding-logo download (up to ~20s) held up Firebase's message handler and + * delayed follow-up pushes. Routing them through the same chain as local renders releases + * that thread immediately and keeps push and local renders from interleaving. + */ + @Synchronized + fun renderFromPush(activityId: String?, render: (isSuperseded: () -> Boolean) -> Unit) { + enqueueRender(activityId, render) + } + + /** + * Queues [render] behind any in-flight render so the two can't interleave, and drops it if a + * logout/reset lands first. + */ + private fun enqueueRender(activityId: String?, render: (isSuperseded: () -> Boolean) -> Unit) { + val generation = renderGeneration + val previous = renderChain + renderChain = renderScope.launch { + previous.join() + // A logout/reset after this render was queued invalidates it, so it can't + // re-post a previous user's activity into a store that reset just cleared. + if (generation != renderGeneration) return@launch + // The render below performs a blocking branding-logo download (up to ~20s) and + // may invoke a slow app renderer. A logout can land during that window — after + // this pre-check passed — so the handler re-checks the generation immediately + // before it posts the notification and writes the activity type back. + // + // LiveNotificationHandler.handle contains its own failures, so this covers the + // setup around it (metadata lookup, channel creation, system service). Needed + // because this coroutine runs on an SDK-owned scope with no exception handler, + // where anything escaping would take the host process down from a thread the app + // cannot guard. Drop the render and keep the chain alive instead. + runCatching { + render { generation != renderGeneration } + }.onFailure { cause -> + SDKComponent.logger.error( + "Failed to render live notification '$activityId': ${cause.message}" + ) + } + } + } + + private fun renderLocally(bundle: Bundle, isSuperseded: () -> Boolean) { + 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, + // Locally started: no Customer.io delivery to attribute metrics to. + deliveryId = null, + deliveryToken = null, + smallIcon = smallIcon, + tintColor = tintColor, + channelId = channelId, + notificationManager = notificationManager, + bypassOrderGuard = true, + isSuperseded = isSuperseded + ) + } + + private fun reportStart( + activityId: String, + activityType: String, + attributes: Map, + contentState: Map + ) { + val deviceId = SDKComponent.android().globalPreferenceStore.getDeviceToken() + if (deviceId.isNullOrBlank()) { + SDKComponent.logger.debug( + "No FCM token available yet; skipping start event for live notification '$activityId'." + ) + return + } + lifecycleClient.reportStart( + instanceUUID = activityId, + activityType = activityType, + deviceId = deviceId, + attributes = attributes.toJsonSafePayload(), + contentState = contentState.toJsonSafePayload() + ) + } + + private fun reportEnd(activityId: String, activityType: String) { + val deviceId = SDKComponent.android().globalPreferenceStore.getDeviceToken() + if (deviceId.isNullOrBlank()) { + SDKComponent.logger.debug( + "No FCM token available yet; skipping end event for live notification '$activityId'." + ) + return + } + lifecycleClient.reportEnd( + instanceUUID = activityId, + activityType = activityType, + deviceId = deviceId + ) + } + + companion object { + private const val EVENT_START = "start" + private const val EVENT_UPDATE = "update" + private const val EVENT_END = "end" + 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/LiveNotificationPayload.kt b/messagingpush/src/main/java/io/customer/messagingpush/livenotification/LiveNotificationPayload.kt new file mode 100644 index 000000000..1ae7bff6c --- /dev/null +++ b/messagingpush/src/main/java/io/customer/messagingpush/livenotification/LiveNotificationPayload.kt @@ -0,0 +1,26 @@ +package io.customer.messagingpush.livenotification + +import org.json.JSONArray +import org.json.JSONObject + +/** + * Coerces live-notification template fields into values the data pipeline can + * serialize: `org.json` containers become plain + * [Map]/[List], and null entries are dropped. Used by the on-device start/update + * report path ([LiveNotificationManager]). + */ +internal fun Map.toJsonSafePayload(): Map = + buildMap { + for ((key, value) in this@toJsonSafePayload) { + if (value != null) put(key, value.toJsonSafe()) + } + } + +private fun Any?.toJsonSafe(): Any? = when (this) { + null -> null + is JSONObject -> buildMap { + for (key in keys()) put(key, opt(key)?.takeIf { it != JSONObject.NULL }?.toJsonSafe()) + } + is JSONArray -> (0 until length()).map { opt(it)?.takeIf { v -> v != JSONObject.NULL }?.toJsonSafe() } + else -> this +} 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..ed62d3620 --- /dev/null +++ b/messagingpush/src/main/java/io/customer/messagingpush/livenotification/LiveNotificationRegistrar.kt @@ -0,0 +1,105 @@ +package io.customer.messagingpush.livenotification + +import io.customer.messagingpush.di.liveNotificationManager +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 + +/** + * Emits `register_push_to_start` track events for the enabled live-notification + * types when the device token or user changes, deduping already-registered + * `token|userId` signatures. Registers only for identified users. + */ +internal class LiveNotificationRegistrar( + private val client: LiveNotificationLifecycleClient, + private val store: LiveNotificationStore +) { + + @Volatile + private var token: String? = null + + @Volatile + private var userId: String = "" + + @Volatile + private var isIdentified: Boolean = false + + private val enabledTypes: Set + get() = SDKComponent.pushModuleConfig.liveNotificationTypes + + // This registrar is a DI singleton and its subscriptions are never torn down, so a repeat + // initialize() would otherwise stack a second set of handlers on every event and register + // (or re-register) each type once per call. + @Volatile + private var started: Boolean = false + + @Synchronized + fun start() { + if (started) { + SDKComponent.logger.debug("Live Notifications: registrar already started; ignoring.") + return + } + started = true + + val clearedByMigration = store.migrate() + if (clearedByMigration > 0) { + SDKComponent.logger.debug( + "Live Notifications: migration cleared $clearedByMigration stale registration(s) from the old namespace." + ) + } + store.trimStaleTimestamps() + + eventBus.subscribe(Event.RegisterDeviceTokenEvent::class) { onDeviceTokenChanged(it.token) } + eventBus.subscribe(Event.UserChangedEvent::class) { onUserChanged(it) } + eventBus.subscribe(Event.DeleteDeviceTokenEvent::class) { onDeviceTokenDeleted() } + eventBus.subscribe(Event.ResetEvent::class) { onReset() } + } + + internal fun onDeviceTokenChanged(newToken: String) { + token = newToken + registerAll() + } + + internal fun onUserChanged(event: Event.UserChangedEvent) { + userId = event.userId ?: event.anonymousId + isIdentified = event.userId != null + registerAll() + } + + internal fun onDeviceTokenDeleted() { + token = null + // Drop dedup signatures so re-registering the same token later isn't skipped. + store.clearRegistrations() + } + + internal fun onReset() { + SDKComponent.liveNotificationManager.cancelAllActivities() + store.clearRegistrations() + // Drop identity so a post-logout push-to-start registration isn't sent for the + // previous (identified) user until a new UserChangedEvent arrives. Local + // start/end gating uses pipeline.isUserIdentified, cleared synchronously by + // clearIdentify(). + userId = "" + isIdentified = false + } + + private fun registerAll() { + if (!isIdentified) { + if (token != null && enabledTypes.isNotEmpty()) { + SDKComponent.logger.debug( + "Live Notifications: holding push-to-start registration for ${enabledTypes.size} type(s); no identified user." + ) + } + return + } + val currentToken = token ?: return + val signature = "$currentToken|$userId" + for (activityType in enabledTypes) { + if (store.registrationSignature(activityType) == signature) continue + if (client.registerPushToStart(activityType, currentToken)) { + store.setRegistrationSignature(activityType, signature) + } + } + } +} 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..4646f9586 --- /dev/null +++ b/messagingpush/src/main/java/io/customer/messagingpush/livenotification/LiveNotificationStore.kt @@ -0,0 +1,224 @@ +package io.customer.messagingpush.livenotification + +import android.content.Context +import androidx.core.content.edit +import java.util.concurrent.TimeUnit + +/** + * Persistent live-notification state (dedicated SharedPreferences file): the + * app-wide set of enabled activity types, per-`activity_type` registration + * signatures, per-`activity_id` last-seen timestamps for the out-of-order guard, + * and per-`activity_id` activity types. + */ +internal class LiveNotificationStore(context: Context) { + + private val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) + + /** + * One-time cleanup for the namespace rename: drops registration signatures + * keyed under the old `io.customer.liveactivities.*` namespace. Idempotent. + * + * @return the number of stale registration signatures cleared. + */ + fun migrate(): Int { + val stale = prefs.all.keys.filter { + it.startsWith(REG_PREFIX) && it.contains(LEGACY_ACTIVITY_TYPE_PREFIX) + } + if (stale.isNotEmpty()) { + prefs.edit { stale.forEach { remove(it) } } + } + return stale.size + } + + // --- Enabled activity types (app-wide) --- + + /** + * The activity types the host app last opted into, or an empty set if it never enabled + * live notifications. + * + * Persisted because the opt-in set otherwise lives only in the module config built during + * SDK initialization. When Android starts a process solely to deliver an FCM push, no app + * code runs, so nothing rebuilds that config — see [setEnabledActivityTypes]. + */ + fun enabledActivityTypes(): Set = + prefs.getStringSet(ENABLED_TYPES_KEY, null) + // getStringSet hands back an instance owned by SharedPreferences that must not be + // mutated or retained; copy it. + ?.toSet() + .orEmpty() + + /** + * Records the app's opt-in set, replacing any previous value. + * + * Replaces rather than merges, and clears the entry when [types] is empty, so an app that + * stops enabling a type — or the feature entirely — is not still treated as opted in by the + * next process that starts without running app code. + */ + fun setEnabledActivityTypes(types: Set) { + prefs.edit { + if (types.isEmpty()) remove(ENABLED_TYPES_KEY) else putStringSet(ENABLED_TYPES_KEY, types) + } + } + + // --- 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) } + } + + /** + * Reclaims every per-activity entry (timestamp + activity type + ended marker) whose most + * recent write is older than [ttlMs]. Intended to run on app launch. + * + * All three families are swept, not just `ts:`: a push that arrives without a `timestamp` + * records an activity type and possibly an ended marker but no timestamp entry, so keying + * reclamation off `ts:` alone would leak those entries forever and keep inflating + * [trackedActivityIds]. An id is only dropped once *all* of its present entries have aged + * out, so a fresh write in one family always keeps the whole id alive. + */ + fun trimStaleTimestamps(ttlMs: Long = DEFAULT_TS_TTL_MS, now: Long = System.currentTimeMillis()) { + // activity id -> most recent write time across its entries; null once any entry's write + // time is unknown but a newer one may still be found (unknown is treated as oldest). + val lastWriteByActivityId = mutableMapOf() + + fun record(activityId: String, writtenAt: Long?) { + if (!lastWriteByActivityId.containsKey(activityId)) { + lastWriteByActivityId[activityId] = writtenAt + return + } + val known = lastWriteByActivityId[activityId] + if (known == null || (writtenAt != null && writtenAt > known)) { + lastWriteByActivityId[activityId] = writtenAt ?: known + } + } + + for ((key, value) in prefs.all) { + val raw = value as? String + when { + key.startsWith(TS_PREFIX) -> + record(key.removePrefix(TS_PREFIX), raw?.substringAfterLast('|')?.toLongOrNull()) + key.startsWith(TYPE_PREFIX) -> + record(key.removePrefix(TYPE_PREFIX), raw?.writeTimeOrNull()) + key.startsWith(END_PREFIX) -> + record(key.removePrefix(END_PREFIX), raw?.toLongOrNull()) + } + } + + val staleActivityIds = lastWriteByActivityId + .filterValues { lastWrite -> lastWrite == null || now - lastWrite > ttlMs } + .keys + if (staleActivityIds.isNotEmpty()) { + prefs.edit { + staleActivityIds.forEach { + remove(TS_PREFIX + it) + remove(TYPE_PREFIX + it) + remove(END_PREFIX + it) + } + } + } + } + + // --- Terminal state (per activity_id) --- + + /** + * True once [activityId] has reached a terminal state (local end, remote end, + * or user dismissal). `activity_id`s are unique per activity and `end` is + * terminal, so any later event for an ended id is stale and must be dropped. + */ + fun isEnded(activityId: String): Boolean = + prefs.contains(END_PREFIX + activityId) + + /** + * Marks [activityId] terminal, returning `true` only if this call set it (i.e. + * it was not already ended). Callers use the return value to report `end` at + * most once per id. The marker is never cleared per-id; it is reclaimed by + * [trimStaleTimestamps] (TTL) and [clearAllActivities] (logout). + */ + fun markEnded(activityId: String, now: Long = System.currentTimeMillis()): Boolean { + if (prefs.contains(END_PREFIX + activityId)) return false + prefs.edit { putString(END_PREFIX + activityId, now.toString()) } + return true + } + + // --- Activity type (per activity_id) --- + + /** The activity type last rendered for [activityId], or null if unknown. */ + fun activityType(activityId: String): String? = + prefs.getString(TYPE_PREFIX + activityId, null)?.let { stored -> + // Values are stamped `type|writeTimeMillis` so [trimStaleTimestamps] can age them + // out. Split from the right so a customer-defined type containing '|' round-trips, + // and fall back to the raw value for entries written before stamping existed. + if (stored.writeTimeOrNull() != null) stored.substringBeforeLast('|') else stored + } + + fun setActivityType(activityId: String, activityType: String, now: Long = System.currentTimeMillis()) { + prefs.edit { putString(TYPE_PREFIX + activityId, "$activityType|$now") } + } + + fun clearActivityType(activityId: String) { + prefs.edit { remove(TYPE_PREFIX + activityId) } + } + + /** Every activity id the SDK currently tracks (rendered and not yet ended). */ + fun trackedActivityIds(): Set = + prefs.all.keys + .filter { it.startsWith(TYPE_PREFIX) } + .map { it.removePrefix(TYPE_PREFIX) } + .toSet() + + /** Clears all per-activity state (timestamps + types + ended markers). Used on logout/reset. */ + fun clearAllActivities() { + prefs.edit { + prefs.all.keys + .filter { it.startsWith(TS_PREFIX) || it.startsWith(TYPE_PREFIX) || it.startsWith(END_PREFIX) } + .forEach { remove(it) } + } + } + + /** + * The trailing `|writeTimeMillis` stamp on a stored value, or null when the value carries + * no stamp (written by a build predating stamping, so treated as arbitrarily old). + */ + private fun String.writeTimeOrNull(): Long? = + if (contains('|')) substringAfterLast('|').toLongOrNull() else null + + companion object { + private const val PREFS_NAME = "io.customer.messagingpush.live_notifications" + + // Deliberately unprefixed: the reclamation sweeps below key off the `ts:`/`type:`/`end:` + // prefixes, and this entry is app-wide rather than per-activity, so it must not match. + private const val ENABLED_TYPES_KEY = "enabled_types" + private const val REG_PREFIX = "reg:" + private const val TS_PREFIX = "ts:" + private const val TYPE_PREFIX = "type:" + private const val END_PREFIX = "end:" + + // Old built-in namespace, replaced by `io.customer.livenotifications.` — used only by migrate(). + private const val LEGACY_ACTIVITY_TYPE_PREFIX = "io.customer.liveactivities." + 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..7774b635c --- /dev/null +++ b/messagingpush/src/main/java/io/customer/messagingpush/livenotification/LiveNotificationType.kt @@ -0,0 +1,13 @@ +package io.customer.messagingpush.livenotification + +/** + * Built-in live-notification activity types for the SDK's bundled templates. + * + * Pass these to [io.customer.messagingpush.MessagingPushModuleConfig.Builder.enableLiveNotificationTypes]. + * For customer-defined types use `enableCustomLiveNotificationTypes` instead. + * The feature is a no-op until at least one type is enabled. + */ +enum class LiveNotificationType(val identifier: String) { + SEGMENTS("io.customer.livenotifications.segments"), + COUNTDOWN_TIMER("io.customer.livenotifications.countdowntimer") +} diff --git a/messagingpush/src/main/java/io/customer/messagingpush/livenotification/ULID.kt b/messagingpush/src/main/java/io/customer/messagingpush/livenotification/ULID.kt new file mode 100644 index 000000000..40794bc1a --- /dev/null +++ b/messagingpush/src/main/java/io/customer/messagingpush/livenotification/ULID.kt @@ -0,0 +1,62 @@ +package io.customer.messagingpush.livenotification + +import java.security.SecureRandom + +/** + * Generates canonical uppercase [ULID](https://github.com/ulid/spec) identifiers + * (26-char Crockford Base32) for locally-started live notifications. + */ +internal object ULID { + private const val ALPHABET = "0123456789ABCDEFGHJKMNPQRSTVWXYZ" + + private val secureRandom = SecureRandom() + + /** Generate a new 26-character uppercase Crockford Base32 ULID. */ + fun generate(timestampMillis: Long = System.currentTimeMillis()): String = + encodeTimestamp(clampToRange(timestampMillis)) + encodeRandomness(randomBytes()) + + private fun clampToRange(millis: Long): Long { + if (millis <= 0L) return 0L + val max = (1L shl 48) - 1L + return if (millis > max) max else millis + } + + private fun encodeTimestamp(timestamp: Long): String { + val builder = StringBuilder(10) + for (index in 0 until 10) { + val shift = (9 - index) * 5 + val value = ((timestamp shr shift) and 0x1F).toInt() + builder.append(ALPHABET[value]) + } + return builder.toString() + } + + private fun randomBytes(): ByteArray = ByteArray(10).also(secureRandom::nextBytes) + + private fun encodeRandomness(bytes: ByteArray): String { + val builder = StringBuilder(16) + for (group in 0 until 16) { + var value = 0 + for (offset in 0 until 5) { + val bitIndex = group * 5 + offset + val byte = bytes[bitIndex / 8].toInt() and 0xFF + val bit = (byte shr (7 - (bitIndex % 8))) and 1 + value = (value shl 1) or bit + } + builder.append(ALPHABET[value]) + } + return builder.toString() + } + + /** Decode the millisecond timestamp from a ULID, or `null` if malformed. */ + fun timestampMillis(ulid: String): Long? { + if (ulid.length != 26) return null + var timestamp = 0L + for (character in ulid.take(10)) { + val index = ALPHABET.indexOf(character) + if (index < 0) return null + timestamp = (timestamp shl 5) or index.toLong() + } + return timestamp + } +} 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..28784e70d --- /dev/null +++ b/messagingpush/src/main/java/io/customer/messagingpush/livenotification/template/CountdownTimerTemplate.kt @@ -0,0 +1,53 @@ +package io.customer.messagingpush.livenotification.template + +import android.content.Context +import io.customer.messagingpush.livenotification.LiveNotificationBranding +import org.json.JSONObject + +/** + * `countdowntimer` template — a status headline over a live countdown to + * `endTime` (epoch seconds), matching iOS `CIOCountdownTimerAttributes`. + */ +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 header = data.optStringNonEmpty(CountdownTimerFields.HEADER) + val title = data.optString(CountdownTimerFields.TITLE) + val statusMessage = data.optStringNonEmpty(CountdownTimerFields.STATUS_MESSAGE) + val endTime = data.optEpochSecondsAsMillis(CountdownTimerFields.END_TIME) + + if (title.isBlank()) { + return null + } + + val now = System.currentTimeMillis() + val isCountingDown = endTime != null && now < endTime + + return TemplateRenderResult( + title = title, + body = statusMessage.orEmpty(), + subText = header, + largeIcon = null, + accentColor = branding?.accentColor ?: fallbackTintColor, + colorized = false, + showProgress = false, + progress = 0, + progressMax = 0, + segments = emptyList(), + points = emptyList(), + startIconRes = null, + endIconRes = null, + trackerIconRes = null, + countdownUntil = if (isCountingDown) endTime else null, + 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..f1a80bd75 --- /dev/null +++ b/messagingpush/src/main/java/io/customer/messagingpush/livenotification/template/JsonExtensions.kt @@ -0,0 +1,36 @@ +package io.customer.messagingpush.livenotification.template + +import android.graphics.Color +import androidx.annotation.ColorInt +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. + */ +internal fun JSONObject.optStringNonEmpty(key: String): String? { + if (isNull(key)) return null + return optString(key).takeIf { it.isNotEmpty() } +} + +/** + * Parses [key] as a hex/named color to an `@ColorInt`, or null when + * absent/blank/unparseable. A leading `#` is added when missing. + */ +@ColorInt +internal fun JSONObject.optColorInt(key: String): Int? { + val raw = optStringNonEmpty(key) ?: return null + val candidate = if (raw.startsWith("#")) raw else "#$raw" + return try { + Color.parseColor(candidate) + } catch (e: IllegalArgumentException) { + null + } +} + +/** + * Reads [key] as an epoch-**seconds** value and returns it as epoch + * **milliseconds**, or null when the key is absent or non-positive. + */ +internal fun JSONObject.optEpochSecondsAsMillis(key: String): Long? = + optLong(key).takeIf { it > 0 }?.times(1000L) 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..872ad8990 --- /dev/null +++ b/messagingpush/src/main/java/io/customer/messagingpush/livenotification/template/LiveNotificationFields.kt @@ -0,0 +1,27 @@ +package io.customer.messagingpush.livenotification.template + +/** + * Single source of truth for live-notification field names, shared by the + * push-render and local-start paths and matching the iOS property names. + */ + +/** Freeform slots shared by both templates. */ +internal object CommonFields { + const val HEADER = "header" +} + +internal object SegmentsFields { + const val HEADER = CommonFields.HEADER + const val STATUS = "status" + const val SUBSTATUS = "substatus" + const val SEGMENTS_TOTAL = "segmentsTotal" + const val SEGMENTS_COMPLETE = "segmentsComplete" + const val TRAILING_TEXT = "trailingText" +} + +internal object CountdownTimerFields { + const val HEADER = CommonFields.HEADER + const val TITLE = "title" + const val STATUS_MESSAGE = "statusMessage" + const val END_TIME = "endTime" +} 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..5e1d41c3d --- /dev/null +++ b/messagingpush/src/main/java/io/customer/messagingpush/livenotification/template/LiveNotificationTemplate.kt @@ -0,0 +1,27 @@ +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. The handler dispatches to a + * concrete subtype via [TemplateRegistry.find] using the `activity_type` key. + */ +internal sealed interface LiveNotificationTemplate { + val name: String + + /** + * Renders [data] into a [TemplateRenderResult], or `null` when the payload + * lacks the fields this template needs (the handler then skips posting). + */ + 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/SegmentsTemplate.kt b/messagingpush/src/main/java/io/customer/messagingpush/livenotification/template/SegmentsTemplate.kt new file mode 100644 index 000000000..f13f4386e --- /dev/null +++ b/messagingpush/src/main/java/io/customer/messagingpush/livenotification/template/SegmentsTemplate.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 + +/** + * `segments` template — a status headline over a discrete, multi-step progress + * bar, matching iOS `CIOSegmentsAttributes`. + */ +internal object SegmentsTemplate : LiveNotificationTemplate { + + // Upper bound on rendered segments. segmentsTotal comes from an untrusted push + // payload; without a cap a very large value allocates that many segment views and + // can exhaust memory while handling the push. Kept in sync with iOS + services. + private const val MAX_SEGMENTS = 20 + + override val name: String = TemplateRegistry.SEGMENTS + + override fun render( + context: Context, + data: JSONObject, + branding: LiveNotificationBranding?, + smallIcon: Int, + fallbackTintColor: Int? + ): TemplateRenderResult? { + val header = data.optStringNonEmpty(SegmentsFields.HEADER) + val status = data.optString(SegmentsFields.STATUS) + val substatus = data.optStringNonEmpty(SegmentsFields.SUBSTATUS) + val trailingText = data.optStringNonEmpty(SegmentsFields.TRAILING_TEXT) + val segmentsTotal = data.optInt(SegmentsFields.SEGMENTS_TOTAL, 1).coerceIn(1, MAX_SEGMENTS) + val segmentsComplete = data.optInt(SegmentsFields.SEGMENTS_COMPLETE, 0) + + if (status.isBlank()) { + return null + } + + val accent = branding?.accentColor ?: fallbackTintColor + val segments = List(segmentsTotal) { SegmentSpec(length = 1, color = accent) } + + return TemplateRenderResult( + title = status, + // Android has a single body slot, so trailingText only shows when + // substatus is absent; when both are set, trailingText is dropped + // (unlike iOS, which renders it on the progress bar's trailing edge). + body = substatus ?: trailingText.orEmpty(), + subText = header, + largeIcon = null, + accentColor = accent, + colorized = false, + showProgress = true, + progress = segmentsComplete.coerceIn(0, segmentsTotal), + progressMax = segmentsTotal, + segments = segments, + 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..1ba591055 --- /dev/null +++ b/messagingpush/src/main/java/io/customer/messagingpush/livenotification/template/TemplateAssets.kt @@ -0,0 +1,79 @@ +package io.customer.messagingpush.livenotification.template + +import android.content.Context +import android.graphics.Bitmap +import android.graphics.BitmapFactory +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.livenotification.LiveNotificationAsset +import io.customer.messagingpush.util.BitmapDownloader +import io.customer.sdk.core.di.SDKComponent +import java.io.File +import java.security.MessageDigest + +/** + * Resolves a strongly-typed [LiveNotificationAsset] to a [Bitmap] for the + * notification's color large-icon slot. + */ +internal object TemplateAssets { + + private const val URL_CACHE_DIR = "cio_live_notification_assets" + + fun toBitmap(context: Context, asset: LiveNotificationAsset): Bitmap? = + try { + when (asset) { + is LiveNotificationAsset.Drawable -> drawableResToBitmap(context, asset.resId) + is LiveNotificationAsset.Bytes -> BitmapFactory.decodeByteArray(asset.data, 0, asset.data.size) + is LiveNotificationAsset.Resource -> + context.contentResolver.openInputStream(asset.uri).use { stream -> + stream?.let { BitmapFactory.decodeStream(it) } + } + is LiveNotificationAsset.RemoteUrl -> downloadCached(context, asset.url) + } + } catch (e: Exception) { + SDKComponent.logger.error("Failed to load live notification asset: ${e.message}") + null + } + + 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 + } + } + + /** Downloads [url], caching the bytes on disk to avoid re-fetching. */ + private fun downloadCached(context: Context, url: String): Bitmap? { + val cacheFile = File(File(context.cacheDir, URL_CACHE_DIR).apply { mkdirs() }, sha256(url)) + if (cacheFile.exists()) { + BitmapFactory.decodeFile(cacheFile.path)?.let { return it } + } + val bitmap = BitmapDownloader.download(url) ?: return null + try { + cacheFile.outputStream().use { bitmap.compress(Bitmap.CompressFormat.PNG, 100, it) } + } catch (e: Exception) { + SDKComponent.logger.debug("Failed to cache live notification image '$url': ${e.message}") + } + return bitmap + } + + private fun sha256(value: String): String = + MessageDigest.getInstance("SHA-256") + .digest(value.toByteArray()) + .joinToString("") { "%02x".format(it) } +} 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..519680270 --- /dev/null +++ b/messagingpush/src/main/java/io/customer/messagingpush/livenotification/template/TemplateRegistry.kt @@ -0,0 +1,16 @@ +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). + val SEGMENTS = LiveNotificationType.SEGMENTS.identifier + val COUNTDOWN_TIMER = LiveNotificationType.COUNTDOWN_TIMER.identifier + + fun find(name: String?): LiveNotificationTemplate? = when (name) { + SEGMENTS -> SegmentsTemplate + COUNTDOWN_TIMER -> CountdownTimerTemplate + 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..5bbceb1bc --- /dev/null +++ b/messagingpush/src/main/java/io/customer/messagingpush/livenotification/template/TemplateRenderResult.kt @@ -0,0 +1,40 @@ +package io.customer.messagingpush.livenotification.template + +import android.graphics.Bitmap +import androidx.annotation.ColorInt +import androidx.annotation.DrawableRes + +/** + * Normalized render output produced by every [LiveNotificationTemplate]; the + * handler converts it into the API-36 or basic notification params. Fields with + * no pre-API-36 counterpart are ignored on lower tiers. + */ +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/logger/PushNotificationLogger.kt b/messagingpush/src/main/java/io/customer/messagingpush/logger/PushNotificationLogger.kt index 9ed6789e2..c6860c078 100644 --- a/messagingpush/src/main/java/io/customer/messagingpush/logger/PushNotificationLogger.kt +++ b/messagingpush/src/main/java/io/customer/messagingpush/logger/PushNotificationLogger.kt @@ -218,6 +218,14 @@ internal class PushNotificationLogger(private val logger: Logger) { ) } + fun logNotificationClickMetricsSkippedForLocalNotification(payload: CustomerIOParsedPushPayload) { + logger.debug( + tag = TAG, + message = "Skipping opened metric for notification without a delivery id/token " + + "(locally started, not delivered by Customer.io): $payload" + ) + } + fun logFailedToHandlePushClick(throwable: Throwable) { logger.error( tag = TAG, diff --git a/messagingpush/src/main/java/io/customer/messagingpush/processor/PushMessageProcessorImpl.kt b/messagingpush/src/main/java/io/customer/messagingpush/processor/PushMessageProcessorImpl.kt index b53bd2af0..5dd56327e 100644 --- a/messagingpush/src/main/java/io/customer/messagingpush/processor/PushMessageProcessorImpl.kt +++ b/messagingpush/src/main/java/io/customer/messagingpush/processor/PushMessageProcessorImpl.kt @@ -139,18 +139,25 @@ internal class PushMessageProcessorImpl( } private fun trackNotificationClickMetrics(payload: CustomerIOParsedPushPayload) { - if (moduleConfig.autoTrackPushEvents) { - pushLogger.logTrackingPushMessageOpened(payload) - eventBus.publish( - Event.TrackPushMetricEvent( - event = Metric.Opened, - deliveryId = payload.cioDeliveryId, - deviceToken = payload.cioDeliveryToken - ) - ) - } else { + if (!moduleConfig.autoTrackPushEvents) { pushLogger.logPushMetricsAutoTrackingDisabled() + return } + // A locally-started live notification was never delivered by Customer.io, so it has + // no delivery id/token. Reporting an `opened` metric for it would send a delivery + // event with empty identifiers, which the backend cannot attribute to anything. + if (payload.cioDeliveryId.isBlank() || payload.cioDeliveryToken.isBlank()) { + pushLogger.logNotificationClickMetricsSkippedForLocalNotification(payload) + return + } + pushLogger.logTrackingPushMessageOpened(payload) + eventBus.publish( + Event.TrackPushMetricEvent( + event = Metric.Opened, + deliveryId = payload.cioDeliveryId, + deviceToken = payload.cioDeliveryToken + ) + ) } private fun handleNotificationDeepLink( 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..71aff040f --- /dev/null +++ b/messagingpush/src/main/java/io/customer/messagingpush/util/BitmapDownloader.kt @@ -0,0 +1,33 @@ +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 { + // Bound connect + read so a stalled fetch can't block the caller + // (e.g. the FCM delivery thread) indefinitely. + val connection = URL(imageUrl).openConnection().apply { + connectTimeout = DOWNLOAD_TIMEOUT_MS + readTimeout = DOWNLOAD_TIMEOUT_MS + } + connection.getInputStream().use { input -> + BitmapFactory.decodeStream(input) + } + } catch (e: Exception) { + SDKComponent.logger.error("Failed to download bitmap from '$imageUrl': ${e.message}") + null + } + } + } + + private const val DOWNLOAD_TIMEOUT_MS = 10_000 +} 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..b2f3de95d --- /dev/null +++ b/messagingpush/src/test/java/io/customer/messagingpush/LiveNotificationCallbackTest.kt @@ -0,0 +1,351 @@ +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.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.CustomerIOLiveNotificationsCallback +import io.customer.messagingpush.data.model.CustomerIOParsedPushPayload +import io.customer.messagingpush.di.liveNotificationStore +import io.customer.messagingpush.livenotification.LiveNotificationDismissReceiver +import io.customer.messagingpush.livenotification.LiveNotificationType +import io.customer.messagingpush.testutils.core.IntegrationTest +import io.customer.sdk.core.di.SDKComponent +import io.mockk.every +import io.mockk.mockk +import io.mockk.slot +import io.mockk.verify +import org.amshove.kluent.shouldBeEqualTo +import org.amshove.kluent.shouldBeNull +import org.amshove.kluent.shouldNotBeNull +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +/** + * Covers the host-app render override ([CustomerIOLiveNotificationsCallback]) and + * customer-defined activity types. Live-notification rendering is deliberately a + * separate callback from `CustomerIOPushNotificationCallback`, so it is registered + * via `setLiveNotificationCallback`, not `setNotificationCallback`. + */ +@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: CustomerIOLiveNotificationsCallback?) { + ModuleMessagingPushFCM( + MessagingPushModuleConfig.Builder().apply { + callback?.let { setLiveNotificationCallback(it) } + // Enable a built-in type (for the override test) and the custom type. + enableLiveNotificationTypes(LiveNotificationType.SEGMENTS) + enableCustomLiveNotificationTypes(customType) + }.build() + ).attachToSDKComponent() + } + + private fun callbackReturning(notification: Notification) = object : CustomerIOLiveNotificationsCallback { + 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.CIO_INSTANCE_ID_KEY, "act-cb") + putString(LiveNotificationHandler.EVENT_KEY, event) + putString(LiveNotificationHandler.NOTIFICATION_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.SEGMENTS.identifier)) + + 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 throwingCallback_onPushPath_isContainedAndPostsNothing() { + // The FCM path runs on FirebaseMessagingService.onMessageReceived, which has no + // try/catch of its own, so a throwing host renderer would take the process down. + // Containment lives in LiveNotificationHandler.handle and therefore covers this + // path as well as the local one — matching what the callback KDoc promises. + attach( + object : CustomerIOLiveNotificationsCallback { + override fun createLiveNotification( + payload: CustomerIOParsedPushPayload, + context: Context + ): Notification = throw IllegalStateException("app renderer blew up") + } + ) + + // Must not propagate (invoke() drives the push path: bypassOrderGuard = false). + invoke(bundle(customType)) + + assertCalledNever { + notificationManager.notify(any(), any(), any()) + } + } + + @Test + fun customType_endWithoutRenderer_cancelsSinceThereIsNoEndState() { + // Custom type, no callback → no end-state to post. With nothing to show for the + // end, the SDK cancels the notification rather than leaving the prior ongoing + // one stuck and non-dismissible. + attach(callback = null) + val expectedNotifId = "act-cb".hashCode() and 0x7FFFFFFF + + invoke(bundle(customType, event = "end")) + + verify(exactly = 1) { + notificationManager.cancel("act-cb", expectedNotifId) + } + } + + @Test + fun serverPush_customType_callbackSeesFlattenedPayloadFields() { + // Services nests customer content in the JSON `payload` data field. Built-in templates get + // it unwrapped, and the callback KDoc promises the same, so a custom type must see its own + // fields rather than a raw `payload` string. + val seen = slot() + attach( + object : CustomerIOLiveNotificationsCallback { + override fun createLiveNotification( + payload: CustomerIOParsedPushPayload, + context: Context + ): Notification? { + seen.captured = payload + return null + } + } + ) + val b = bundle(customType).apply { + putString(LiveNotificationHandler.PAYLOAD_KEY, """{"driverName":"Ada","status":"On the way"}""") + } + + invoke(b) + + seen.captured.extras.getString("driverName") shouldBeEqualTo "Ada" + seen.captured.extras.getString("status") shouldBeEqualTo "On the way" + } + + @Test + fun appRenderedNotification_getsSdkClickAndDismissIntents() { + // The SDK owns posting and lifecycle for app-rendered notifications, so a callback that + // returns a bare notification must still be able to open, report `opened` and report a swipe. + attach(callbackReturning(appNotification("App rendered"))) + val posted = slot() + every { notificationManager.notify(any(), any(), capture(posted)) } returns Unit + + invoke(bundle(customType)) + + posted.captured.contentIntent.shouldNotBeNull() + posted.captured.deleteIntent.shouldNotBeNull() + } + + @Test + fun appRenderedNotification_keepsItsOwnIntents() { + val ownIntent = PendingIntent.getActivity( + contextMock, + 0, + Intent("io.customer.test.OWN"), + PendingIntent.FLAG_IMMUTABLE + ) + val withOwn = NotificationCompat.Builder(contextMock, "channel") + .setSmallIcon(0) + .setContentIntent(ownIntent) + .build() + attach(callbackReturning(withOwn)) + val posted = slot() + every { notificationManager.notify(any(), any(), capture(posted)) } returns Unit + + invoke(bundle(customType)) + + posted.captured.contentIntent shouldBeEqualTo ownIntent + } + + @Test + fun localRender_forAlreadyEndedActivity_isDropped() { + // Local start/update renders bypass the server order guard, so a dismissal landing between + // enqueue and render must not let the render repost what the user cleared. + attach(callbackReturning(appNotification("Should not post"))) + SDKComponent.liveNotificationStore.markEnded("act-cb") + + LiveNotificationHandler(bundle(customType, event = "update")).handle( + context = contextMock, + deliveryId = null, + deliveryToken = null, + smallIcon = 0, + tintColor = null, + channelId = "channel", + notificationManager = notificationManager, + bypassOrderGuard = true + ) + + assertCalledNever { + notificationManager.notify(any(), any(), any()) + } + } + + @Test + fun dismissalDuringRender_isNotReposted() { + // The real window: the pre-render guard has already passed, then the template render and + // the app callback run — a remote logo download can hold that for ~20s. A swipe in there + // has the dismiss receiver mark the activity ended, so committing now would resurrect it. + // Marking ended from inside the callback reproduces exactly that ordering. + attach( + object : CustomerIOLiveNotificationsCallback { + override fun createLiveNotification( + payload: CustomerIOParsedPushPayload, + context: Context + ): Notification { + SDKComponent.liveNotificationStore.markEnded("act-cb") + return appNotification("Rendered after dismissal") + } + } + ) + + invoke(bundle(customType, event = "update")) + + assertCalledNever { + notificationManager.notify(any(), any(), any()) + } + } + + @Test + fun updateAfterTokenlessDismissal_isNotReposted() { + // A dismissal with no device token can't report `end`, but it still marks the activity + // terminal — otherwise this update would repost what the user swiped away. Covers the + // dismiss receiver and the render guard together, which is where the gap actually was. + attach(callbackReturning(appNotification("Should not post"))) + SDKComponent.android().globalPreferenceStore.removeDeviceToken() + LiveNotificationDismissReceiver().onReceive( + contextMock, + Intent().apply { + putExtra(LiveNotificationDismissReceiver.EXTRA_ACTIVITY_ID, "act-cb") + putExtra(LiveNotificationDismissReceiver.EXTRA_ACTIVITY_TYPE, customType) + } + ) + + invoke(bundle(customType, event = "update")) + + assertCalledNever { + notificationManager.notify(any(), any(), any()) + } + } + + @Test + fun renderThatPostsNothing_doesNotAdvanceTheHighWaterMark() { + // A render that shows nothing must not move the out-of-order guard, or a later push + // carrying an older timestamp would be dropped despite never having been rendered. + attach(callback = null) // custom type with no renderer -> nothing to post + val b = bundle(customType).apply { + putString(LiveNotificationHandler.TIMESTAMP_KEY, "5000") + } + + invoke(b) + + SDKComponent.liveNotificationStore.lastTimestamp("act-cb").shouldBeNull() + } + + @Test + fun throwingCallback_onLocalEnd_cancelsToo() { + // Same stranding risk as the server path: LiveNotificationManager.end claims the terminal + // transition before the queued render, so a failed local end render must also cancel. + attach( + object : CustomerIOLiveNotificationsCallback { + override fun createLiveNotification( + payload: CustomerIOParsedPushPayload, + context: Context + ): Notification = throw IllegalStateException("app renderer blew up") + } + ) + val expectedNotifId = "act-cb".hashCode() and 0x7FFFFFFF + + LiveNotificationHandler(bundle(customType, event = "end")).handle( + context = contextMock, + deliveryId = null, + deliveryToken = null, + smallIcon = 0, + tintColor = null, + channelId = "channel", + notificationManager = notificationManager, + bypassOrderGuard = true + ) + + verify(exactly = 1) { + notificationManager.cancel("act-cb", expectedNotifId) + } + } + + @Test + fun throwingCallback_onEnd_cancelsRatherThanStrandingTheOngoingNotification() { + // A server-delivered `end` claims the terminal transition in the store *before* rendering. + // If the render then throws, the previous ongoing (non-dismissible) notification would be + // left on screen and every retry `end` dropped by that same claim — stranding it. So + // containment must cancel on the end path, not just swallow the failure. + attach( + object : CustomerIOLiveNotificationsCallback { + override fun createLiveNotification( + payload: CustomerIOParsedPushPayload, + context: Context + ): Notification = throw IllegalStateException("app renderer blew up") + } + ) + val expectedNotifId = "act-cb".hashCode() and 0x7FFFFFFF + + invoke(bundle(customType, event = "end")) + + verify(exactly = 1) { + notificationManager.cancel("act-cb", expectedNotifId) + } + assertCalledNever { + notificationManager.notify(any(), any(), any()) + } + } +} 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..e550d3ca3 --- /dev/null +++ b/messagingpush/src/test/java/io/customer/messagingpush/LiveNotificationHandlerTest.kt @@ -0,0 +1,716 @@ +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.di.liveNotificationStore +import io.customer.messagingpush.di.pushModuleConfig +import io.customer.messagingpush.livenotification.LiveNotificationAsset +import io.customer.messagingpush.livenotification.LiveNotificationBranding +import io.customer.messagingpush.livenotification.LiveNotificationType +import io.customer.messagingpush.livenotification.template.TemplateRegistry +import io.customer.messagingpush.testutils.core.IntegrationTest +import io.customer.sdk.core.di.SDKComponent +import io.mockk.every +import io.mockk.mockk +import io.mockk.slot +import io.mockk.verify +import org.amshove.kluent.shouldBeEmpty +import org.amshove.kluent.shouldBeEqualTo +import org.amshove.kluent.shouldBeNull +import org.amshove.kluent.shouldNotBeNull +import org.json.JSONObject +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +/** + * Tests for [LiveNotificationHandler] focused on envelope parsing and dispatch: + * + * - top-level wire keys (`cioInstanceId`, `event`, `notification_type`, `timestamp`) + * are read from the [Bundle]; + * - template fields arrive flattened at the envelope top level or nested under + * a `payload` object; + * - missing `cioInstanceId`, `event`, or unknown `notification_type` are dropped + * without posting a notification; + * - `event = "end"` posts the final state and leaves it visible for the user to dismiss. + * + * 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().enableLiveNotificationTypes( + LiveNotificationType.SEGMENTS, + LiveNotificationType.COUNTDOWN_TIMER + ).build() + ).attachToSDKComponent() + } + + private fun newBundle( + activityId: String? = "live-act-1", + event: String? = "start", + activityType: String? = TemplateRegistry.SEGMENTS, + // Minimal renderable content: Segments treats `status` as required and CountdownTimer + // treats `title` as required, so supplying both means envelope/ordering tests post a + // notification for either template rather than being dropped by the "no usable content" + // guard (which is exercised separately). + data: JSONObject = JSONObject().apply { + put("status", "Status") + put("title", "Status") + }, + timestamp: Long? = null + ): Bundle { + val bundle = Bundle() + if (activityId != null) bundle.putString(LiveNotificationHandler.CIO_INSTANCE_ID_KEY, activityId) + if (event != null) bundle.putString(LiveNotificationHandler.EVENT_KEY, event) + if (activityType != null) bundle.putString(LiveNotificationHandler.NOTIFICATION_TYPE_KEY, activityType) + if (timestamp != null) bundle.putString(LiveNotificationHandler.TIMESTAMP_KEY, timestamp.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, + bypassOrderGuard: Boolean = false, + isSuperseded: () -> Boolean = { false } + ) { + handler.handle( + context = contextMock, + deliveryId = "delivery-id-1", + deliveryToken = "delivery-token-1", + smallIcon = 0, + tintColor = null, + channelId = channelId, + notificationManager = notificationManager, + bypassOrderGuard = bypassOrderGuard, + isSuperseded = isSuperseded + ) + } + + // --- 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.CIO_INSTANCE_ID_KEY shouldBeEqualTo "cioInstanceId" + LiveNotificationHandler.EVENT_KEY shouldBeEqualTo "event" + LiveNotificationHandler.NOTIFICATION_TYPE_KEY shouldBeEqualTo "notification_type" + LiveNotificationHandler.TIMESTAMP_KEY shouldBeEqualTo "timestamp" + } + + // --- Happy-path dispatch --- + + @Test + fun handle_givenBothTemplates_postsNotificationForEach() { + val templates = listOf( + TemplateRegistry.SEGMENTS, + TemplateRegistry.COUNTDOWN_TIMER + ) + 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("status", "Out for delivery") + put("substatus", "For User") + put("segmentsTotal", 4) + put("segmentsComplete", 2) + } + val bundle = newBundle(activityId = activityId, data = data) + + invoke(handlerFor(bundle)) + + verify(exactly = 1) { + notificationManager.notify(activityId, expectedNotifId, any()) + } + } + + @Test + fun handle_givenUpdateEvent_postsNotificationInPlace() { + // A server-pushed `update` re-renders the activity (same id) rather than being dropped. + // It is NOT reported as a lifecycle event — the backend initiated it, so it already knows. + val activityId = "live-activity-update" + val expectedNotifId = activityId.hashCode() and 0x7FFFFFFF + val bundle = newBundle(activityId = activityId, event = "update") + + invoke(handlerFor(bundle)) + + verify(exactly = 1) { + notificationManager.notify(activityId, expectedNotifId, any()) + } + } + + @Test + fun handle_givenTemplateFieldsNestedUnderPayload_unwrapsAndPosts() { + // Backend delivers template fields nested under a `payload` object (JSON string), + // not flattened. The handler must unwrap them so the template renders. + val activityId = "payload-nested" + val expectedNotifId = activityId.hashCode() and 0x7FFFFFFF + val bundle = newBundle(activityId = activityId, data = JSONObject()).apply { + putString( + LiveNotificationHandler.PAYLOAD_KEY, + JSONObject().apply { + put("status", "preparing") + put("substatus", "order abc-123") + put("segmentsTotal", 4) + put("segmentsComplete", 1) + }.toString() + ) + } + + 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") + // Epoch SECONDS on the wire (60s ahead), not millis. + put("endTime", System.currentTimeMillis() / 1000 + 60L) + 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 can arrive as JSON strings in FCM data; the handler parses them into + // JSON containers. The 2 built-in templates read only flat fields, so a stray nested + // object is simply ignored — but parsing it must not break rendering. + val data = JSONObject().apply { + put("status", "On the way") + put("segmentsTotal", 3) + put("segmentsComplete", 1) + put("extra", JSONObject().put("ignored", "value")) + } + val bundle = newBundle(activityType = TemplateRegistry.SEGMENTS, data = data) + + invoke(handlerFor(bundle)) + + verify(exactly = 1) { + notificationManager.notify(any(), any(), any()) + } + } + + // --- Branding (small icon + large-icon logo) --- + + @Test + fun handle_givenBrandingLogo_rendersLogoAsLargeIcon() { + // Segments is branding-only (sets no largeIcon of its own), so the handler fills the + // color large-icon slot from branding.logo — a strongly-typed LiveNotificationAsset. + attachBranding( + LiveNotificationBranding( + companyName = "Acme", + accentColor = 0xFF00FF00.toInt(), + logo = LiveNotificationAsset.Bytes(byteArrayOf(1, 2, 3, 4)) + ) + ) + val posted = slot() + every { notificationManager.notify(any(), any(), capture(posted)) } returns Unit + + invoke(handlerFor(newBundle())) + + posted.captured.getLargeIcon().shouldNotBeNull() + } + + @Test + fun handle_givenBrandingSmallIcon_overridesFallback() { + // invoke() passes fallback smallIcon = 0; branding.smallIcon must override it. + val brandedSmallIcon = android.R.drawable.ic_dialog_info + attachBranding( + LiveNotificationBranding( + companyName = "Acme", + accentColor = 0xFF00FF00.toInt(), + smallIcon = brandedSmallIcon + ) + ) + val posted = slot() + every { notificationManager.notify(any(), any(), capture(posted)) } returns Unit + + invoke(handlerFor(newBundle())) + + // The legacy int `icon` field mirrors the resId passed to setSmallIcon. + @Suppress("DEPRECATION") + posted.captured.icon shouldBeEqualTo brandedSmallIcon + } + + private fun attachBranding(branding: LiveNotificationBranding) { + ModuleMessagingPushFCM( + MessagingPushModuleConfig.Builder() + .enableLiveNotificationTypes( + LiveNotificationType.SEGMENTS, + LiveNotificationType.COUNTDOWN_TIMER + ) + .setLiveNotificationBranding(branding) + .build() + ).attachToSDKComponent() + } + + // --- Missing required fields short-circuit --- + + // --- Superseded-by-reset guard (logout during a slow render) --- + + @Test + fun handle_givenSupersededByReset_dropsRenderWithoutNotifyingOrStoring() { + // A logout can land while the branding logo downloads or the app renderer runs. When it + // does, the render must be dropped after that work completes: it must not post the + // notification, nor write the activity type OR the timestamp high-water mark back into + // the store the reset just cleared. + val activityId = "live-act-1" + + invoke( + handlerFor(newBundle(activityId = activityId, timestamp = 100L)), + bypassOrderGuard = true, + isSuperseded = { true } + ) + + assertCalledNever { + notificationManager.notify(any(), any(), any()) + } + SDKComponent.liveNotificationStore.activityType(activityId).shouldBeNull() + SDKComponent.liveNotificationStore.lastTimestamp(activityId).shouldBeNull() + } + + @Test + fun handle_givenNotSuperseded_postsAndStoresActivityType() { + // The complement of the guard above: a render that is still current posts and writes back. + val activityId = "live-act-1" + + invoke( + handlerFor(newBundle(activityId = activityId, timestamp = 100L)), + bypassOrderGuard = true, + isSuperseded = { false } + ) + + verify { + notificationManager.notify(activityId, any(), any()) + } + SDKComponent.liveNotificationStore.activityType(activityId).shouldNotBeNull() + SDKComponent.liveNotificationStore.lastTimestamp(activityId) shouldBeEqualTo 100L + } + + @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_givenStartWithNoContentFields_doesNotPostEmptyNotification() { + // Enabled type + valid envelope, but the template fields never arrived (e.g. content + // wasn't flattened). The template can't render anything meaningful, so we must NOT post + // a blank notification. + val bundle = newBundle( + activityId = "no-content", + event = "start", + activityType = TemplateRegistry.SEGMENTS, + data = JSONObject() + ) + + invoke(handlerFor(bundle)) + + 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.livenotifications.bogus"))) + + assertCalledNever { + notificationManager.notify(any(), any(), any()) + } + } + + @Test + fun handle_givenBareTemplateNameWithoutSpecPrefix_dropsAndDoesNotNotify() { + // The cross-platform spec requires the `io.customer.livenotifications.` prefix. + // Bare names like "segments" must be rejected to stay aligned with iOS. + invoke(handlerFor(newBundle(activityType = "segments"))) + + assertCalledNever { + notificationManager.notify(any(), any(), any()) + } + } + + // --- End event: final state stays posted and dismissible --- + + @Test + fun handle_givenEventEnd_postsEndStateAndLeavesVisible() { + val activityId = "ending-activity" + val expectedNotifId = activityId.hashCode() and 0x7FFFFFFF + val bundle = newBundle(activityId = activityId, event = "end") + + invoke(handlerFor(bundle)) + + // The end-state is posted and must REMAIN visible for the user to swipe away — the SDK + // never auto-removes an ended activity (matching iOS, which leaves it on screen). + verify(exactly = 1) { + notificationManager.notify(activityId, expectedNotifId, any()) + } + assertCalledNever { + notificationManager.cancel(activityId, expectedNotifId) + } + } + + @Test + fun handle_givenEventEnd_postsUserDismissibleNotification() { + val activityId = "ending-dismissible" + val posted = slot() + every { notificationManager.notify(any(), any(), capture(posted)) } returns Unit + val bundle = newBundle(activityId = activityId, event = "end") + + invoke(handlerFor(bundle)) + + // Non-ongoing so a swipe removes it; auto-cancel so a tap clears it. + (posted.captured.flags and Notification.FLAG_ONGOING_EVENT) shouldBeEqualTo 0 + (posted.captured.flags and Notification.FLAG_AUTO_CANCEL) shouldBeEqualTo Notification.FLAG_AUTO_CANCEL + } + + @Test + fun handle_givenEndWithNoRenderableContent_cancelsNotification() { + // An `end` whose final state can't render (empty template fields) has no end-state + // to show, so the prior (ongoing) notification is cancelled rather than left stuck. + val activityId = "empty-end" + val expectedNotifId = activityId.hashCode() and 0x7FFFFFFF + + invoke(handlerFor(newBundle(activityId = activityId, event = "end", data = JSONObject()))) + + verify(exactly = 1) { notificationManager.cancel(activityId, expectedNotifId) } + assertCalledNever { + notificationManager.notify(any(), any(), any()) + } + } + + @Test + fun handle_givenEventEnd_attachesNoDeleteIntent() { + // A terminal end-state must not carry the dismiss intent, or swiping it would + // report a second `end` (duplicate) after the activity already ended. + val posted = slot() + every { notificationManager.notify(any(), any(), capture(posted)) } returns Unit + + invoke(handlerFor(newBundle(activityId = "end-no-delete", event = "end"))) + + posted.captured.deleteIntent.shouldBeNull() + } + + @Test + fun handle_givenInProgressEvent_attachesDeleteIntent() { + // Start/update keep the dismiss intent so a user-swipe reports `end`. + val posted = slot() + every { notificationManager.notify(any(), any(), capture(posted)) } returns Unit + + invoke(handlerFor(newBundle(activityId = "start-with-delete", event = "start"))) + + posted.captured.deleteIntent.shouldNotBeNull() + } + + @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_givenLocalBypass_rendersDespiteNonAdvancingTimestamp() { + // Local renders are host-ordered and bypass the guard, so two updates within + // the same wall-clock second (same epoch-second timestamp) both post, instead + // of the second being dropped as a duplicate. + val activityId = "local-bypass" + + invoke(handlerFor(newBundle(activityId = activityId, event = "update", timestamp = 100L)), bypassOrderGuard = true) + invoke(handlerFor(newBundle(activityId = activityId, event = "update", timestamp = 100L)), bypassOrderGuard = true) + + verify(exactly = 2) { + 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()) + } + } + + @Test + fun handle_givenStaleEndTimestamp_stillRendersEndState() { + // `end` is terminal and bypasses the out-of-order guard, so it still renders its + // final state even if its timestamp is not newer than the last update. + val activityId = "stale-end" + + invoke(handlerFor(newBundle(activityId = activityId, event = "update", timestamp = 100L))) + invoke(handlerFor(newBundle(activityId = activityId, event = "end", timestamp = 50L))) + + // Both the update and the stale end post (2 notifies); the SDK never cancels on end. + verify(exactly = 2) { + notificationManager.notify(activityId, any(), any()) + } + } + + @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()) + } + } + + @Test + fun handle_givenSameSecondTimestamp_isNotDropped() { + // Services emits whole-second timestamps, so an in-order start+update can share a + // second. The guard rejects only strictly-older pushes, so the same-second update + // must still render. + val activityId = "same-second" + + invoke(handlerFor(newBundle(activityId = activityId, event = "start", timestamp = 100L))) + invoke(handlerFor(newBundle(activityId = activityId, event = "update", timestamp = 100L))) + + verify(exactly = 2) { + notificationManager.notify(activityId, any(), any()) + } + } + + @Test + fun handle_afterEnded_dropsDelayedStart() { + // Ended is terminal per id: a delayed/duplicate start arriving after end (any + // timestamp) is stale and dropped — there is no same-id revive. + val activityId = "ended-then-start" + + invoke(handlerFor(newBundle(activityId = activityId, event = "update", timestamp = 100L))) + invoke(handlerFor(newBundle(activityId = activityId, event = "end", timestamp = 100L))) + invoke(handlerFor(newBundle(activityId = activityId, event = "start", timestamp = 500L))) + + // Only the update and the end posted; the post-end start was dropped. + verify(exactly = 2) { + notificationManager.notify(activityId, any(), any()) + } + } + + @Test + fun handle_givenLocalRender_advancesHighWaterMark() { + // A local render bypasses rejection but must still advance the high-water mark, + // so a later delayed remote push at an intermediate timestamp is dropped rather + // than overwriting the newer local content. + val activityId = "local-advances-mark" + + invoke(handlerFor(newBundle(activityId = activityId, event = "update", timestamp = 200L)), bypassOrderGuard = true) + invoke(handlerFor(newBundle(activityId = activityId, event = "update", timestamp = 150L))) + + // The local update posted; the delayed remote 150 update was dropped. + verify(exactly = 1) { + notificationManager.notify(activityId, any(), any()) + } + } + + @Test + fun handle_duplicateRemoteEnd_isDropped() { + // Only the first `end` renders the terminal state; a later/duplicate remote end + // is dropped (it must not re-post the notification). + val activityId = "dup-end" + + invoke(handlerFor(newBundle(activityId = activityId, event = "update", timestamp = 100L))) + invoke(handlerFor(newBundle(activityId = activityId, event = "end", timestamp = 100L))) + invoke(handlerFor(newBundle(activityId = activityId, event = "end", timestamp = 200L))) + + // update + first end posted; the second end dropped. + verify(exactly = 2) { + notificationManager.notify(activityId, any(), any()) + } + } + + @Test + fun handle_remoteEndAfterDismiss_doesNotRepost() { + // The user swiped an in-progress notification: the dismiss receiver marked the id + // ended (simulated here). A subsequent server `end` for the same id must be dropped + // so it can't re-show the notification the user already cleared. + val activityId = "dismissed-then-end" + SDKComponent.liveNotificationStore.markEnded(activityId) + + invoke(handlerFor(newBundle(activityId = activityId, event = "end", timestamp = 100L))) + + verify(exactly = 0) { + notificationManager.notify(activityId, any(), any()) + } + } + + // --- Cold process: the module is not registered, so the config carries no enabled types --- + + /** + * Simulates a process Android started only to deliver an FCM push: the service wires up the + * Android context, but no app code runs, so the push module is never registered and + * `pushModuleConfig` falls back to its empty default. + */ + private fun simulateColdProcess() { + SDKComponent.modules.remove(ModuleMessagingPushFCM.MODULE_NAME) + SDKComponent.pushModuleConfig.liveNotificationTypes.shouldBeEmpty() + } + + @Test + fun handle_givenColdProcessAndPersistedTypes_stillPosts() { + simulateColdProcess() + SDKComponent.liveNotificationStore.setEnabledActivityTypes(setOf(TemplateRegistry.SEGMENTS)) + + invoke(handlerFor(newBundle(activityType = TemplateRegistry.SEGMENTS))) + + verify(exactly = 1) { + notificationManager.notify(any(), any(), any()) + } + } + + @Test + fun handle_givenColdProcessAndNoPersistedTypes_dropsNotification() { + // The opt-in guarantee still holds: an app that never enabled live notifications must + // not start rendering them just because the config is empty in this process. + simulateColdProcess() + + invoke(handlerFor(newBundle(activityType = TemplateRegistry.SEGMENTS))) + + assertCalledNever { + notificationManager.notify(any(), any(), any()) + } + } + + @Test + fun handle_givenColdProcessAndPersistedTypes_endCancelsStrandedNotification() { + // An `end` is the case that matters most: the earlier `start` was posted by a live + // process as an ongoing (non-dismissible) notification, so dropping the `end` would + // leave it on screen with no way for the user to clear it. + val activityId = "cold-end" + simulateColdProcess() + SDKComponent.liveNotificationStore.setEnabledActivityTypes(setOf(TemplateRegistry.SEGMENTS)) + + invoke(handlerFor(newBundle(activityId = activityId, event = "end"))) + + verify(exactly = 1) { + notificationManager.notify(activityId, any(), any()) + } + } + + @Test + fun handle_givenLiveConfig_ignoresStalePersistedTypes() { + // A populated config always wins, so disabling a type takes effect immediately in a + // running process instead of waiting for the persisted copy to be rewritten. + SDKComponent.liveNotificationStore.setEnabledActivityTypes(setOf("io.customer.stale.type")) + + invoke(handlerFor(newBundle(activityType = "io.customer.stale.type"))) + + assertCalledNever { + notificationManager.notify(any(), 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..1708486db 100644 --- a/messagingpush/src/test/java/io/customer/messagingpush/MessagingPushModuleConfigTest.kt +++ b/messagingpush/src/test/java/io/customer/messagingpush/MessagingPushModuleConfigTest.kt @@ -1,7 +1,9 @@ package io.customer.messagingpush import io.customer.commontest.core.JUnit5Test +import io.customer.messagingpush.livenotification.LiveNotificationType import org.amshove.kluent.internal.assertEquals +import org.amshove.kluent.shouldContainSame import org.junit.jupiter.api.Test class MessagingPushModuleConfigTest : JUnit5Test() { @@ -11,6 +13,35 @@ 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=[], liveNotificationCallback=null)", actual) + } + + @Test + fun enableLiveNotificationTypes_mapsBuiltInTypesToIdentifiers() { + val config = MessagingPushModuleConfig.Builder() + .enableLiveNotificationTypes( + LiveNotificationType.SEGMENTS, + LiveNotificationType.COUNTDOWN_TIMER + ) + .build() + + config.liveNotificationTypes shouldContainSame setOf( + LiveNotificationType.SEGMENTS.identifier, + LiveNotificationType.COUNTDOWN_TIMER.identifier + ) + } + + @Test + fun enableTypes_builtInAndCustom_areAdditive() { + val config = MessagingPushModuleConfig.Builder() + .enableLiveNotificationTypes(LiveNotificationType.SEGMENTS) + .enableCustomLiveNotificationTypes("com.acme.ride", "com.acme.workout") + .build() + + config.liveNotificationTypes shouldContainSame setOf( + LiveNotificationType.SEGMENTS.identifier, + "com.acme.ride", + "com.acme.workout" + ) } } diff --git a/messagingpush/src/test/java/io/customer/messagingpush/ModuleMessagingPushFCMTest.kt b/messagingpush/src/test/java/io/customer/messagingpush/ModuleMessagingPushFCMTest.kt index 113584f68..ee7037e28 100644 --- a/messagingpush/src/test/java/io/customer/messagingpush/ModuleMessagingPushFCMTest.kt +++ b/messagingpush/src/test/java/io/customer/messagingpush/ModuleMessagingPushFCMTest.kt @@ -10,6 +10,8 @@ import io.customer.commontest.extensions.assertCalledOnce import io.customer.commontest.extensions.random import io.customer.commontest.util.DispatchersProviderStub import io.customer.messagingpush.di.fcmTokenProvider +import io.customer.messagingpush.di.liveNotificationStore +import io.customer.messagingpush.livenotification.LiveNotificationType import io.customer.messagingpush.logger.PushNotificationLogger import io.customer.messagingpush.provider.DeviceTokenProvider import io.customer.messagingpush.store.PendingPushDeliveryMetric @@ -26,7 +28,9 @@ import io.mockk.mockk import io.mockk.verify import io.mockk.verifyOrder import kotlinx.coroutines.test.runTest +import org.amshove.kluent.shouldBeEmpty import org.amshove.kluent.shouldBeTrue +import org.amshove.kluent.shouldContainSame import org.amshove.kluent.shouldNotBeNull import org.junit.Test import org.junit.runner.RunWith @@ -286,4 +290,42 @@ class ModuleMessagingPushFCMTest : IntegrationTest() { // removed from the process lifecycle before the new one was added. (secondObserver !== firstObserver).shouldBeTrue() } + + // --- Enabled types are persisted so a cold FCM process can recover the opt-in --- + + private fun stubNoFcmToken() { + every { fcmTokenProviderMock.getCurrentToken(any()) } answers { + val callback = firstArg<(String?) -> Unit>() + callback(null) + } + } + + @Test + fun initialize_givenEnabledTypes_expectPersistThemForColdProcess() { + stubNoFcmToken() + + ModuleMessagingPushFCM( + MessagingPushModuleConfig.Builder() + .enableLiveNotificationTypes(LiveNotificationType.SEGMENTS) + .enableCustomLiveNotificationTypes("com.example.rideshare") + .build() + ).initialize() + + SDKComponent.liveNotificationStore.enabledActivityTypes() shouldContainSame setOf( + LiveNotificationType.SEGMENTS.identifier, + "com.example.rideshare" + ) + } + + @Test + fun initialize_givenNoEnabledTypes_expectClearPersistedTypes() { + // An app that stops enabling live notifications must not leave a stale opt-in behind for + // the next process that starts without running app code. + stubNoFcmToken() + SDKComponent.liveNotificationStore.setEnabledActivityTypes(setOf("com.example.stale")) + + ModuleMessagingPushFCM().initialize() + + SDKComponent.liveNotificationStore.enabledActivityTypes().shouldBeEmpty() + } } 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..894078cb8 --- /dev/null +++ b/messagingpush/src/test/java/io/customer/messagingpush/livenotification/LiveNotificationDataTest.kt @@ -0,0 +1,95 @@ +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.amshove.kluent.shouldBeTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +internal class LiveNotificationDataTest : IntegrationTest() { + + @Test + fun segments_mapsActivityTypeAndFlatCounts() { + val data = LiveNotificationData.Segments( + header = "Order update", + status = "On the way", + segmentsTotal = 3, + segmentsComplete = 1 + ) + + data.activityType shouldBeEqualTo TemplateRegistry.SEGMENTS + val fields = data.fields() + fields["status"] shouldBeEqualTo "On the way" + fields["header"] shouldBeEqualTo "Order update" + // Flat integer segment counts (matches iOS content-state), not a nested progress object. + fields["segmentsTotal"] shouldBeEqualTo 3 + fields["segmentsComplete"] shouldBeEqualTo 1 + // Unset optional fields are present as null; the manager omits them from the envelope. + fields["substatus"].shouldBeNull() + fields["trailingText"].shouldBeNull() + } + + @Test + fun segments_splitsStaticAttributesFromDynamicContentState() { + val data = LiveNotificationData.Segments( + header = "Order update", + status = "On the way", + substatus = "For Alex", + segmentsTotal = 3, + segmentsComplete = 1, + trailingText = "5 min" + ) + + // Static (attributes): header only. + data.attributes().containsKey("header").shouldBeTrue() + data.attributes().containsKey("status").shouldBeFalse() + + // Dynamic (contentState): status, substatus, flat counts, trailingText. + val contentState = data.contentState() + contentState["status"] shouldBeEqualTo "On the way" + contentState["substatus"] shouldBeEqualTo "For Alex" + contentState["segmentsTotal"] shouldBeEqualTo 3 + contentState["segmentsComplete"] shouldBeEqualTo 1 + contentState["trailingText"] shouldBeEqualTo "5 min" + contentState.containsKey("header").shouldBeFalse() + } + + @Test + fun countdownTimer_mapsActivityTypeAndSplitsAttributes() { + val data = LiveNotificationData.CountdownTimer( + header = "Limited time", + title = "Flash sale ends in", + statusMessage = "Hurry!", + endTime = 1700000000L + ) + + data.activityType shouldBeEqualTo TemplateRegistry.COUNTDOWN_TIMER + + // Static (attributes): header only. + data.attributes().containsKey("header").shouldBeTrue() + data.attributes().containsKey("title").shouldBeFalse() + + // Dynamic (contentState): title, statusMessage, endTime. + val contentState = data.contentState() + contentState["title"] shouldBeEqualTo "Flash sale ends in" + contentState["statusMessage"] shouldBeEqualTo "Hurry!" + contentState["endTime"] shouldBeEqualTo 1700000000L + } + + @Test + fun countdownTimer_optionalFieldsAreNull() { + val data = LiveNotificationData.CountdownTimer( + header = "Limited time", + title = "Done" + ) + + val contentState = data.contentState() + contentState["statusMessage"].shouldBeNull() + contentState["endTime"].shouldBeNull() + } +} diff --git a/messagingpush/src/test/java/io/customer/messagingpush/livenotification/LiveNotificationDismissReceiverTest.kt b/messagingpush/src/test/java/io/customer/messagingpush/livenotification/LiveNotificationDismissReceiverTest.kt new file mode 100644 index 000000000..cac5a0f77 --- /dev/null +++ b/messagingpush/src/test/java/io/customer/messagingpush/livenotification/LiveNotificationDismissReceiverTest.kt @@ -0,0 +1,101 @@ +package io.customer.messagingpush.livenotification + +import android.content.Intent +import io.customer.commontest.config.TestConfig +import io.customer.commontest.config.testConfigurationDefault +import io.customer.commontest.util.DispatchersProviderStub +import io.customer.messagingpush.di.liveNotificationStore +import io.customer.messagingpush.testutils.core.IntegrationTest +import io.customer.sdk.core.di.SDKComponent +import io.customer.sdk.core.util.DispatchersProvider +import io.mockk.mockk +import io.mockk.verify +import org.amshove.kluent.shouldBeFalse +import org.amshove.kluent.shouldBeTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +internal class LiveNotificationDismissReceiverTest : IntegrationTest() { + + private val type = "io.customer.livenotifications.segments" + private val lifecycleClient: LiveNotificationLifecycleClient = mockk(relaxed = true) + + override fun setup(testConfig: TestConfig) { + // The end report runs on the background dispatcher; the stub makes it inline+synchronous. + super.setup( + testConfigurationDefault { + diGraph { + sdk { + overrideDependency(DispatchersProviderStub()) + overrideDependency(lifecycleClient) + } + } + } + testConfig + ) + } + + private fun dismiss(activityId: String, activityType: String? = type) { + val intent = Intent().apply { + putExtra(LiveNotificationDismissReceiver.EXTRA_ACTIVITY_ID, activityId) + activityType?.let { putExtra(LiveNotificationDismissReceiver.EXTRA_ACTIVITY_TYPE, it) } + } + LiveNotificationDismissReceiver().onReceive(contextMock, intent) + } + + @Test + fun onReceive_givenTokenAndUnendedActivity_reportsEndAndMarksTerminal() { + // The core swipe-to-dismiss contract: a user clearing an in-progress live notification + // reports `end` to Customer.io and marks the id terminal so a later push can't repost it. + SDKComponent.android().globalPreferenceStore.saveDeviceToken("fcm-tok") + + dismiss("act-1") + + verify(exactly = 1) { + lifecycleClient.reportEnd( + instanceUUID = "act-1", + activityType = type, + deviceId = "fcm-tok", + contentState = any() + ) + } + SDKComponent.liveNotificationStore.isEnded("act-1").shouldBeTrue() + } + + @Test + fun onReceive_givenAlreadyEndedActivity_doesNotReportSecondEnd() { + // endLiveNotification (or a remote end) already claimed the terminal transition, so a + // subsequent swipe must not emit a duplicate end. + SDKComponent.android().globalPreferenceStore.saveDeviceToken("fcm-tok") + SDKComponent.liveNotificationStore.markEnded("act-1") + + dismiss("act-1") + + verify(exactly = 0) { lifecycleClient.reportEnd(any(), any(), any(), any()) } + } + + @Test + fun onReceive_givenMissingActivityType_isIgnored() { + SDKComponent.android().globalPreferenceStore.saveDeviceToken("fcm-tok") + + dismiss("act-1", activityType = null) + + verify(exactly = 0) { lifecycleClient.reportEnd(any(), any(), any(), any()) } + SDKComponent.liveNotificationStore.isEnded("act-1").shouldBeFalse() + } + + @Test + fun onReceive_withoutFcmToken_stillMarksEndedSoNothingRepostsIt() { + // The end isn't reportable without a token, and lifecycle events are never retried — but + // the user did dismiss the notification, so the activity must still go terminal. Renders + // consult that marker; leaving it unset let a queued local update or a later push repost + // a notification the user had already cleared. + SDKComponent.android().globalPreferenceStore.removeDeviceToken() + + dismiss("act-1") + + verify(exactly = 0) { lifecycleClient.reportEnd(any(), any(), any(), any()) } + SDKComponent.liveNotificationStore.isEnded("act-1").shouldBeTrue() + } +} 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..f6b1a846c --- /dev/null +++ b/messagingpush/src/test/java/io/customer/messagingpush/livenotification/LiveNotificationLifecycleClientTest.kt @@ -0,0 +1,181 @@ +package io.customer.messagingpush.livenotification + +import io.customer.base.internal.InternalCustomerIOApi +import io.customer.messagingpush.livenotification.LiveNotificationLifecycleClientImpl.Companion.EVENT_LIVE_NOTIFICATION +import io.customer.messagingpush.livenotification.LiveNotificationLifecycleClientImpl.Companion.EVENT_LIVE_NOTIFICATION_TOKEN +import io.customer.messagingpush.livenotification.LiveNotificationLifecycleClientImpl.Companion.EVENT_TYPE_END +import io.customer.messagingpush.livenotification.LiveNotificationLifecycleClientImpl.Companion.EVENT_TYPE_START +import io.customer.messagingpush.livenotification.LiveNotificationLifecycleClientImpl.Companion.PLATFORM_ANDROID +import io.customer.messagingpush.livenotification.LiveNotificationLifecycleClientImpl.Companion.PROP_ATTRIBUTES +import io.customer.messagingpush.livenotification.LiveNotificationLifecycleClientImpl.Companion.PROP_CIO_INSTANCE_ID +import io.customer.messagingpush.livenotification.LiveNotificationLifecycleClientImpl.Companion.PROP_CONTENT_STATE +import io.customer.messagingpush.livenotification.LiveNotificationLifecycleClientImpl.Companion.PROP_DEVICE_ID +import io.customer.messagingpush.livenotification.LiveNotificationLifecycleClientImpl.Companion.PROP_EVENT_TYPE +import io.customer.messagingpush.livenotification.LiveNotificationLifecycleClientImpl.Companion.PROP_NOTIFICATION_TYPE +import io.customer.messagingpush.livenotification.LiveNotificationLifecycleClientImpl.Companion.PROP_PLATFORM +import io.customer.messagingpush.livenotification.LiveNotificationLifecycleClientImpl.Companion.PROP_PUSH_TO_START_TOKEN +import io.customer.messagingpush.livenotification.LiveNotificationLifecycleClientImpl.Companion.PROP_REGISTRATION_TYPE +import io.customer.messagingpush.livenotification.LiveNotificationLifecycleClientImpl.Companion.REGISTRATION_TYPE_PUSH_TO_START +import io.customer.messagingpush.testutils.core.IntegrationTest +import io.customer.sdk.core.pipeline.DataPipeline +import io.mockk.every +import io.mockk.mockk +import io.mockk.slot +import io.mockk.verify +import org.amshove.kluent.shouldBeEqualTo +import org.amshove.kluent.shouldBeFalse +import org.amshove.kluent.shouldBeTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@OptIn(InternalCustomerIOApi::class) +@RunWith(RobolectricTestRunner::class) +internal class LiveNotificationLifecycleClientTest : IntegrationTest() { + + private val pipeline: DataPipeline = mockk(relaxed = true) + private val client = LiveNotificationLifecycleClientImpl(dataPipelineProvider = { pipeline }) + + private fun identified() { + // The client gates on pipeline.isUserIdentified, which the data pipeline updates + // synchronously during identify()/clearIdentify() (so it doesn't lag a login). + every { pipeline.isUserIdentified } returns true + } + + @Test + fun reportStart_emitsLiveNotificationWithAttributesAndContentState() { + identified() + val name = slot() + val props = slot>() + every { pipeline.track(capture(name), capture(props)) } returns Unit + + client.reportStart( + instanceUUID = "inst-1", + activityType = "io.customer.livenotifications.segments", + deviceId = "fcm-tok", + attributes = mapOf("header" to "Order update"), + contentState = mapOf("title" to "On the way") + ) + + name.captured shouldBeEqualTo EVENT_LIVE_NOTIFICATION + props.captured[PROP_EVENT_TYPE] shouldBeEqualTo EVENT_TYPE_START + props.captured[PROP_CIO_INSTANCE_ID] shouldBeEqualTo "inst-1" + props.captured[PROP_DEVICE_ID] shouldBeEqualTo "fcm-tok" + props.captured[PROP_PLATFORM] shouldBeEqualTo PLATFORM_ANDROID + props.captured[PROP_NOTIFICATION_TYPE] shouldBeEqualTo "io.customer.livenotifications.segments" + @Suppress("UNCHECKED_CAST") + (props.captured[PROP_ATTRIBUTES] as Map)["header"] shouldBeEqualTo "Order update" + @Suppress("UNCHECKED_CAST") + (props.captured[PROP_CONTENT_STATE] as Map)["title"] shouldBeEqualTo "On the way" + } + + @Test + fun reportStart_omitsAttributesAndContentStateWhenEmpty() { + identified() + val props = slot>() + every { pipeline.track(any(), capture(props)) } returns Unit + + client.reportStart("inst-1", "type", "fcm-tok", attributes = emptyMap(), contentState = emptyMap()) + + props.captured.containsKey(PROP_ATTRIBUTES).shouldBeFalse() + props.captured.containsKey(PROP_CONTENT_STATE).shouldBeFalse() + } + + @Test + fun reportEnd_emitsLiveNotificationWithEndProperties() { + identified() + val name = slot() + val props = slot>() + every { pipeline.track(capture(name), capture(props)) } returns Unit + + client.reportEnd(instanceUUID = "inst-9", activityType = "type-x", deviceId = "fcm-tok") + + name.captured shouldBeEqualTo EVENT_LIVE_NOTIFICATION + props.captured[PROP_EVENT_TYPE] shouldBeEqualTo EVENT_TYPE_END + props.captured[PROP_CIO_INSTANCE_ID] shouldBeEqualTo "inst-9" + props.captured[PROP_NOTIFICATION_TYPE] shouldBeEqualTo "type-x" + props.captured[PROP_DEVICE_ID] shouldBeEqualTo "fcm-tok" + // No final content-state supplied: neither attributes nor contentState are sent. + props.captured.containsKey(PROP_ATTRIBUTES).shouldBeFalse() + props.captured.containsKey(PROP_CONTENT_STATE).shouldBeFalse() + } + + @Test + fun reportEnd_withFinalContentState_carriesIt() { + identified() + val props = slot>() + every { pipeline.track(any(), capture(props)) } returns Unit + + client.reportEnd( + instanceUUID = "inst-9", + activityType = "type-x", + deviceId = "fcm-tok", + contentState = mapOf("title" to "Delivered") + ) + + @Suppress("UNCHECKED_CAST") + (props.captured[PROP_CONTENT_STATE] as Map)["title"] shouldBeEqualTo "Delivered" + } + + @Test + fun registerPushToStart_emitsTokenEventWithFcmAsBothIds() { + identified() + val name = slot() + val props = slot>() + every { pipeline.track(capture(name), capture(props)) } returns Unit + + val emitted = client.registerPushToStart(activityType = "type-x", deviceId = "fcm-tok") + + emitted.shouldBeTrue() + name.captured shouldBeEqualTo EVENT_LIVE_NOTIFICATION_TOKEN + props.captured[PROP_REGISTRATION_TYPE] shouldBeEqualTo REGISTRATION_TYPE_PUSH_TO_START + props.captured[PROP_PLATFORM] shouldBeEqualTo PLATFORM_ANDROID + props.captured[PROP_DEVICE_ID] shouldBeEqualTo "fcm-tok" + props.captured[PROP_PUSH_TO_START_TOKEN] shouldBeEqualTo "fcm-tok" + } + + @Test + fun lifecycleEvents_areDroppedForAnonymousUser() { + every { pipeline.isUserIdentified } returns false + + client.reportStart("inst-1", "type", "fcm-tok", emptyMap(), emptyMap()) + client.reportEnd("inst-1", "type", "fcm-tok") + + verify(exactly = 0) { pipeline.track(any(), any()) } + } + + @Test + fun lifecycleEvents_gateOnPipelineIsUserIdentified() { + // The gate reads pipeline.isUserIdentified directly. The pipeline updates it + // synchronously during identify(), so an identify() immediately followed by start() + // is reported rather than dropped by a stale flag. + every { pipeline.isUserIdentified } returns true + every { pipeline.track(any(), any()) } returns Unit + + client.reportStart("inst-1", "type", "fcm-tok", emptyMap(), emptyMap()) + + verify(exactly = 1) { pipeline.track(EVENT_LIVE_NOTIFICATION, any()) } + } + + @Test + fun registerPushToStart_isNotGatedByIdentity() { + // Identity is gated upstream by LiveNotificationRegistrar; the client must NOT + // re-check identity for the registration event (that re-introduced the login race). + every { pipeline.isUserIdentified } returns false + every { pipeline.track(any(), any()) } returns Unit + + val emitted = client.registerPushToStart("type", "fcm-tok") + + emitted.shouldBeTrue() + verify(exactly = 1) { pipeline.track(EVENT_LIVE_NOTIFICATION_TOKEN, any()) } + } + + @Test + fun events_areDroppedWhenPipelineUnavailable() { + val noPipeline = LiveNotificationLifecycleClientImpl(dataPipelineProvider = { null }) + + val emitted = noPipeline.registerPushToStart("type", "fcm-tok") + + emitted.shouldBeFalse() + } +} diff --git a/messagingpush/src/test/java/io/customer/messagingpush/livenotification/LiveNotificationManagerTest.kt b/messagingpush/src/test/java/io/customer/messagingpush/livenotification/LiveNotificationManagerTest.kt new file mode 100644 index 000000000..10f812620 --- /dev/null +++ b/messagingpush/src/test/java/io/customer/messagingpush/livenotification/LiveNotificationManagerTest.kt @@ -0,0 +1,281 @@ +package io.customer.messagingpush.livenotification + +import android.app.Notification +import android.app.NotificationManager +import android.content.Context +import io.customer.commontest.config.TestConfig +import io.customer.commontest.config.testConfigurationDefault +import io.customer.commontest.extensions.attachToSDKComponent +import io.customer.commontest.util.DispatchersProviderStub +import io.customer.messagingpush.MessagingPushModuleConfig +import io.customer.messagingpush.ModuleMessagingPushFCM +import io.customer.messagingpush.data.communication.CustomerIOLiveNotificationsCallback +import io.customer.messagingpush.data.model.CustomerIOParsedPushPayload +import io.customer.messagingpush.di.liveNotificationStore +import io.customer.messagingpush.testutils.core.IntegrationTest +import io.customer.sdk.core.di.SDKComponent +import io.customer.sdk.core.util.DispatchersProvider +import io.mockk.mockk +import io.mockk.verify +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.TestCoroutineScheduler +import kotlinx.coroutines.test.advanceUntilIdle +import org.amshove.kluent.shouldBeEmpty +import org.amshove.kluent.shouldBeEqualTo +import org.amshove.kluent.shouldBeTrue +import org.amshove.kluent.shouldNotBeEmpty +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.Shadows + +/** + * Tests for [LiveNotificationManager], the on-device (client-initiated) path. + * + * Local start/update are reported to Customer.io; push-delivered start/update + * are backend-initiated and reported by neither the manager nor the handler + * (see [io.customer.messagingpush.LiveNotificationHandler]). Reporting happens + * after rendering regardless of whether the type is enabled, so these tests do + * not need a module config attached. + */ +@RunWith(RobolectricTestRunner::class) +internal class LiveNotificationManagerTest : IntegrationTest() { + + private val lifecycleClient: LiveNotificationLifecycleClient = mockk(relaxed = true) + private val manager = LiveNotificationManager(lifecycleClient) + + private val type = "io.customer.livenotifications.segments" + + override fun setup(testConfig: TestConfig) { + // Render runs on the background dispatcher; the stub makes it inline+synchronous. + super.setup( + testConfigurationDefault { + diGraph { + sdk { + overrideDependency(DispatchersProviderStub()) + } + } + } + testConfig + ) + } + + private fun saveToken() = SDKComponent.android().globalPreferenceStore.saveDeviceToken("fcm-tok") + + @Test + fun start_reportsStartEventWithAttributesAndContentState() { + saveToken() + + manager.start( + "act-1", + type, + attributes = mapOf("header" to "Order update"), + contentState = mapOf("title" to "Preparing") + ) + + verify { lifecycleClient.reportStart("act-1", type, "fcm-tok", any(), any()) } + } + + @Test + fun update_reportsNoLifecycleEvent() { + // A locally-triggered update re-renders the activity but must NOT emit a CDP + // "Live Notification Event": only start/end are reported. + saveToken() + + manager.update( + "act-1", + type, + attributes = emptyMap(), + contentState = mapOf("title" to "Arriving") + ) + + verify(exactly = 0) { lifecycleClient.reportStart(any(), any(), any(), any(), any()) } + verify(exactly = 0) { lifecycleClient.reportEnd(any(), any(), any()) } + } + + @Test + fun update_afterEnded_isIgnored() { + // The activity ended locally or was dismissed (marked terminal): a later update + // must not repost it. (Updates are never reported regardless; this covers the guard.) + saveToken() + SDKComponent.liveNotificationStore.markEnded("act-1") + + manager.update("act-1", type, attributes = emptyMap(), contentState = mapOf("title" to "Late")) + + verify(exactly = 0) { lifecycleClient.reportStart(any(), any(), any(), any(), any()) } + verify(exactly = 0) { lifecycleClient.reportEnd(any(), any(), any()) } + } + + @Test + fun end_reportsEndUsingStoredType() { + saveToken() + // The SDK records the type when it renders the activity; the host ends with just the id. + SDKComponent.liveNotificationStore.setActivityType("act-1", type) + + manager.end("act-1") + + verify { lifecycleClient.reportEnd("act-1", type, "fcm-tok") } + } + + @Test + fun end_marksEndedAndRetainsTypeForLogoutCancel() { + saveToken() + val store = SDKComponent.liveNotificationStore + store.setActivityType("act-1", type) + + manager.end("act-1") + + // End is terminal: the id is marked ended and its activity type is retained so a + // later logout can still cancel an ended-but-still-visible notification. + store.isEnded("act-1").shouldBeTrue() + store.activityType("act-1") shouldBeEqualTo type + } + + @Test + fun end_calledTwice_reportsEndOnce() { + saveToken() + SDKComponent.liveNotificationStore.setActivityType("act-1", type) + + manager.end("act-1") + manager.end("act-1") + + // The second end is a no-op (already terminal): end is reported at most once per id. + verify(exactly = 1) { lifecycleClient.reportEnd("act-1", type, "fcm-tok") } + } + + @Test + fun end_afterLocalStart_reportsEndEvenWhenTypeNotPersisted() { + // A local start remembers the type for its id, so end() reports a matching + // end even if the render never persisted the activity type. + saveToken() + manager.start("act-1", type, attributes = emptyMap(), contentState = mapOf("title" to "Preparing")) + SDKComponent.liveNotificationStore.clearActivityType("act-1") + + manager.end("act-1") + + verify { lifecycleClient.reportEnd("act-1", type, "fcm-tok") } + } + + @Test + fun end_unknownActivity_doesNotReport() { + saveToken() + SDKComponent.liveNotificationStore.clearActivityType("act-unknown") + + manager.end("act-unknown") + + verify(exactly = 0) { lifecycleClient.reportEnd(any(), any(), any()) } + } + + @Test + fun end_afterCancelAllActivities_reportsNothing() { + // Logout must drop cached bundles too, so a later end() for a recycled id + // can't resurrect a previous session's activity. + saveToken() + manager.start("act-1", type, attributes = emptyMap(), contentState = mapOf("title" to "Preparing")) + + manager.cancelAllActivities() + manager.end("act-1") + + verify(exactly = 0) { lifecycleClient.reportEnd(any(), any(), any()) } + } + + @Test + fun cancelAllActivities_clearsTrackedStateWithoutReporting() { + saveToken() + val store = SDKComponent.liveNotificationStore + store.setActivityType("act-1", type) + store.setActivityType("act-2", type) + + manager.cancelAllActivities() + + store.trackedActivityIds().shouldBeEmpty() + // Logout must NOT emit end events. + verify(exactly = 0) { lifecycleClient.reportEnd(any(), any(), any()) } + } + + @OptIn(ExperimentalCoroutinesApi::class) + @Test + fun renderQueuedBeforeReset_isDroppedAndNotReAddedToStore() { + // A render enqueued by start() before a logout must not run afterward and + // re-add the previous user's activity into the just-cleared store. + saveToken() + // Defer the render (StandardTestDispatcher) so we can interleave the reset + // between enqueue and execution deterministically. + val scheduler = TestCoroutineScheduler() + val deferred = StandardTestDispatcher(scheduler) + SDKComponent.overrideDependency( + object : DispatchersProvider { + override val background: CoroutineDispatcher = deferred + override val main: CoroutineDispatcher = deferred + override val default: CoroutineDispatcher = deferred + } + ) + val manager = LiveNotificationManager(lifecycleClient) + + manager.start("act-1", type, attributes = emptyMap(), contentState = mapOf("title" to "Preparing")) + manager.cancelAllActivities() + scheduler.advanceUntilIdle() + + // The queued render saw the bumped generation and dropped, so nothing was re-added. + SDKComponent.liveNotificationStore.trackedActivityIds().shouldBeEmpty() + } + + @Test + fun start_givenThrowingAppRenderer_doesNotCrashAndStillReports() { + // The render runs on an SDK-owned coroutine scope with no exception handler, so an + // app-supplied renderer that throws would otherwise take the host process down from a + // thread the app cannot guard. The failure must be contained to this render. + saveToken() + ModuleMessagingPushFCM( + MessagingPushModuleConfig.Builder() + .enableLiveNotificationTypes(LiveNotificationType.SEGMENTS) + .setLiveNotificationCallback( + object : CustomerIOLiveNotificationsCallback { + override fun createLiveNotification( + payload: CustomerIOParsedPushPayload, + context: Context + ): Notification = throw IllegalStateException("app renderer blew up") + } + ) + .build() + ).attachToSDKComponent() + + // Would propagate out of the render coroutine and crash the process before the fix. + manager.start("act-throw", type, attributes = emptyMap(), contentState = mapOf("status" to "Preparing")) + + // Reporting is decoupled from rendering, so the lifecycle event is still emitted. + verify { lifecycleClient.reportStart("act-throw", type, "fcm-tok", any(), any()) } + } + + @Test + fun update_givenThrowingAppRenderer_laterRendersStillRun() { + // The render chain must survive a failed render: a subsequent update still posts. + saveToken() + var shouldThrow = true + val notificationManager = SDKComponent.android().applicationContext + .getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + ModuleMessagingPushFCM( + MessagingPushModuleConfig.Builder() + .enableLiveNotificationTypes(LiveNotificationType.SEGMENTS) + .setLiveNotificationCallback( + object : CustomerIOLiveNotificationsCallback { + override fun createLiveNotification( + payload: CustomerIOParsedPushPayload, + context: Context + ): Notification? { + if (shouldThrow) throw IllegalStateException("app renderer blew up") + return null // fall back to the SDK template + } + } + ) + .build() + ).attachToSDKComponent() + + manager.start("act-chain", type, attributes = emptyMap(), contentState = mapOf("status" to "Preparing")) + shouldThrow = false + manager.update("act-chain", type, attributes = emptyMap(), contentState = mapOf("status" to "Arriving")) + + Shadows.shadowOf(notificationManager).allNotifications.shouldNotBeEmpty() + } +} diff --git a/messagingpush/src/test/java/io/customer/messagingpush/livenotification/LiveNotificationRegistrarTest.kt b/messagingpush/src/test/java/io/customer/messagingpush/livenotification/LiveNotificationRegistrarTest.kt new file mode 100644 index 000000000..3769a495c --- /dev/null +++ b/messagingpush/src/test/java/io/customer/messagingpush/livenotification/LiveNotificationRegistrarTest.kt @@ -0,0 +1,103 @@ +package io.customer.messagingpush.livenotification + +import io.customer.commontest.config.TestConfig +import io.customer.commontest.extensions.attachToSDKComponent +import io.customer.messagingpush.MessagingPushModuleConfig +import io.customer.messagingpush.ModuleMessagingPushFCM +import io.customer.messagingpush.testutils.core.IntegrationTest +import io.customer.sdk.communication.Event +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +/** + * Tests for [LiveNotificationRegistrar]'s identity gate. + * + * The registrar registers push-to-start tokens only for identified users, deriving identity from + * [Event.UserChangedEvent] (which carries the userId synchronously) rather than the data pipeline's + * asynchronously-updated `isUserIdentified` flag — that flag can still read false on the login turn, + * which used to drop the registration with no retry (the G5 race). + */ +@RunWith(RobolectricTestRunner::class) +internal class LiveNotificationRegistrarTest : IntegrationTest() { + + private val client: LiveNotificationLifecycleClient = mockk(relaxed = true) + private val store: LiveNotificationStore = mockk(relaxed = true) + private val type = "io.customer.livenotifications.segments" + + private lateinit var registrar: LiveNotificationRegistrar + + override fun setup(testConfig: TestConfig) { + super.setup(testConfig) + ModuleMessagingPushFCM( + MessagingPushModuleConfig.Builder() + .enableLiveNotificationTypes(LiveNotificationType.SEGMENTS) + .build() + ).attachToSDKComponent() + every { store.registrationSignature(any()) } returns null + every { client.registerPushToStart(any(), any()) } returns true + registrar = LiveNotificationRegistrar(client, store) + } + + @Test + fun register_firesForIdentifiedUser_evenIfPipelineFlagWouldLag() { + registrar.onDeviceTokenChanged("fcm-tok") + // Identify: the event carries the userId synchronously even though the pipeline's + // isUserIdentified flag may still read false at this instant. Registration must fire. + registrar.onUserChanged(Event.UserChangedEvent(userId = "u1", anonymousId = "anon")) + + verify(exactly = 1) { client.registerPushToStart(type, "fcm-tok") } + verify { store.setRegistrationSignature(type, "fcm-tok|u1") } + } + + @Test + fun register_skippedForAnonymousUser() { + registrar.onDeviceTokenChanged("fcm-tok") + registrar.onUserChanged(Event.UserChangedEvent(userId = null, anonymousId = "anon")) + + verify(exactly = 0) { client.registerPushToStart(any(), any()) } + } + + @Test + fun onReset_skipsRegistrationUntilNextIdentify() { + // Local lifecycle events gate on pipeline.isUserIdentified (cleared synchronously by + // clearIdentify), so the registrar no longer feeds identity to the client. It still + // drops its own identity on reset so a held token isn't re-registered while anonymous. + registrar.onDeviceTokenChanged("fcm-tok") + registrar.onUserChanged(Event.UserChangedEvent(userId = "u1", anonymousId = "anon")) + + registrar.onReset() + registrar.onDeviceTokenChanged("fcm-tok-2") + + // The token arriving after reset (while anonymous) must not register. + verify(exactly = 0) { client.registerPushToStart(type, "fcm-tok-2") } + } + + @Test + fun register_defersUntilIdentified_whenTokenArrivesFirstWhileAnonymous() { + // Token before any identify → nothing registered yet (still anonymous). + registrar.onDeviceTokenChanged("fcm-tok") + verify(exactly = 0) { client.registerPushToStart(any(), any()) } + + // Later identify → the held token registers. + registrar.onUserChanged(Event.UserChangedEvent(userId = "u1", anonymousId = "anon")) + verify(exactly = 1) { client.registerPushToStart(type, "fcm-tok") } + } + + @Test + fun register_skipped_whenNoTokenYet() { + registrar.onUserChanged(Event.UserChangedEvent(userId = "u1", anonymousId = "anon")) + verify(exactly = 0) { client.registerPushToStart(any(), any()) } + } + + @Test + fun tokenDeleted_clearsRegistrationSignatures() { + // Otherwise re-registering the same token later would be deduped away. + registrar.onDeviceTokenDeleted() + + verify { store.clearRegistrations() } + } +} 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..831730e9c --- /dev/null +++ b/messagingpush/src/test/java/io/customer/messagingpush/livenotification/LiveNotificationStoreTest.kt @@ -0,0 +1,253 @@ +package io.customer.messagingpush.livenotification + +import io.customer.messagingpush.testutils.core.IntegrationTest +import org.amshove.kluent.shouldBeEmpty +import org.amshove.kluent.shouldBeEqualTo +import org.amshove.kluent.shouldBeFalse +import org.amshove.kluent.shouldBeNull +import org.amshove.kluent.shouldBeTrue +import org.amshove.kluent.shouldContainSame +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 enabledActivityTypes_setGetAndReplace() { + store.enabledActivityTypes().shouldBeEmpty() + + store.setEnabledActivityTypes(setOf("type-a", "type-b")) + store.enabledActivityTypes() shouldContainSame setOf("type-a", "type-b") + + // Replaces rather than merges, so a type the app stopped enabling really goes away. + store.setEnabledActivityTypes(setOf("type-b")) + store.enabledActivityTypes() shouldContainSame setOf("type-b") + } + + @Test + fun enabledActivityTypes_givenEmptySet_clearsTheEntry() { + store.setEnabledActivityTypes(setOf("type-a")) + + // An app that disables live notifications entirely must not still read as opted in. + store.setEnabledActivityTypes(emptySet()) + + store.enabledActivityTypes().shouldBeEmpty() + } + + @Test + fun enabledActivityTypes_survivesPerActivityReclamation() { + // The entry is app-wide and deliberately unprefixed, so neither the TTL sweep nor the + // logout clear — both of which key off the per-activity prefixes — may remove it. + val now = 10_000_000_000L + val ttl = 1_000L + store.setEnabledActivityTypes(setOf("type-a")) + store.setLastTimestamp("old", 1L, now = now - ttl - 1) + store.setActivityType("old", "type-a", now = now - ttl - 1) + + store.trimStaleTimestamps(ttlMs = ttl, now = now) + store.clearAllActivities() + + store.activityType("old").shouldBeNull() + store.enabledActivityTypes() shouldContainSame setOf("type-a") + } + + @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 + } + + @Test + fun activityType_setGetClear() { + store.activityType("act-1").shouldBeNull() + + store.setActivityType("act-1", "io.customer.livenotifications.segments") + store.activityType("act-1") shouldBeEqualTo "io.customer.livenotifications.segments" + + store.clearActivityType("act-1") + store.activityType("act-1").shouldBeNull() + } + + @Test + fun trackedActivityIds_andClearAllActivities() { + store.setActivityType("a1", "type.a") + store.setActivityType("a2", "type.b") + store.setLastTimestamp("a1", 5L) + + store.trackedActivityIds() shouldContainSame setOf("a1", "a2") + + store.clearAllActivities() + + store.trackedActivityIds().shouldBeEmpty() + store.activityType("a1").shouldBeNull() + store.lastTimestamp("a1").shouldBeNull() + } + + @Test + fun trimStaleTimestamps_alsoRemovesPairedActivityType() { + val now = 10_000_000_000L + val ttl = 1_000L + + store.setLastTimestamp("old", 1L, now = now - ttl - 1) + store.setActivityType("old", "io.customer.livenotifications.segments", now = now - ttl - 1) + + store.trimStaleTimestamps(ttlMs = ttl, now = now) + + store.activityType("old").shouldBeNull() + } + + @Test + fun trimStaleTimestamps_removesOrphanedActivityTypeWithNoTimestampEntry() { + // A push that arrives without a `timestamp` records an activity type but no timestamp + // entry. Reclamation must not key off `ts:` alone, or such ids leak forever and keep + // inflating trackedActivityIds(). + val now = 10_000_000_000L + val ttl = 1_000L + + store.setActivityType("orphan", "io.customer.livenotifications.segments", now = now - ttl - 1) + store.lastTimestamp("orphan").shouldBeNull() + + store.trimStaleTimestamps(ttlMs = ttl, now = now) + + store.activityType("orphan").shouldBeNull() + store.trackedActivityIds().shouldBeEmpty() + } + + @Test + fun trimStaleTimestamps_removesOrphanedEndedMarkerWithNoTimestampEntry() { + val now = 10_000_000_000L + val ttl = 1_000L + + store.markEnded("orphan-end", now = now - ttl - 1) + + store.trimStaleTimestamps(ttlMs = ttl, now = now) + + store.isEnded("orphan-end").shouldBeFalse() + } + + @Test + fun trimStaleTimestamps_keepsIdWhenAnyEntryIsStillFresh() { + // The timestamp aged out but the activity was re-rendered recently, so the id is still + // live: dropping it would lose the ability to cancel it on logout. + val now = 10_000_000_000L + val ttl = 1_000L + + store.setLastTimestamp("mixed", 1L, now = now - ttl - 1) + store.setActivityType("mixed", "io.customer.livenotifications.segments", now = now - 1) + + store.trimStaleTimestamps(ttlMs = ttl, now = now) + + store.activityType("mixed") shouldBeEqualTo "io.customer.livenotifications.segments" + store.lastTimestamp("mixed") shouldBeEqualTo 1L + } + + @Test + fun activityType_givenTypeContainingSeparator_roundTrips() { + // Types are customer-defined strings, and the stored value is stamped `type|writeTime`, + // so the split must tolerate a '|' inside the type itself. + store.setActivityType("act-1", "com.acme.live|weird") + + store.activityType("act-1") shouldBeEqualTo "com.acme.live|weird" + } + + @Test + fun markEnded_claimsOnceAndReportsTerminalState() { + store.isEnded("act-1").shouldBeFalse() + + // First mark wins (claims the terminal transition); repeats return false. + store.markEnded("act-1").shouldBeTrue() + store.isEnded("act-1").shouldBeTrue() + store.markEnded("act-1").shouldBeFalse() + store.markEnded("act-1").shouldBeFalse() + } + + @Test + fun clearAllActivities_removesEndedMarker() { + store.markEnded("a1") + store.setActivityType("a1", "type.a") + + store.clearAllActivities() + + store.isEnded("a1").shouldBeFalse() + } + + @Test + fun trimStaleTimestamps_alsoRemovesEndedMarker() { + val now = 10_000_000_000L + val ttl = 1_000L + + store.setLastTimestamp("old", 1L, now = now - ttl - 1) + store.markEnded("old", now = now - ttl - 1) + + store.trimStaleTimestamps(ttlMs = ttl, now = now) + + store.isEnded("old").shouldBeFalse() + } + + @Test + fun migrate_clearsOldNamespaceRegistrationsAndKeepsNewOnes() { + // Legacy registrations recorded under the old `io.customer.liveactivities.*` namespace... + store.setRegistrationSignature("io.customer.liveactivities.deliverytracking", "tok|user") + store.setRegistrationSignature("io.customer.liveactivities.auctionbid", "tok|user") + // ...alongside new-namespace and custom-type registrations that must survive. + store.setRegistrationSignature("io.customer.livenotifications.segments", "tok|user") + store.setRegistrationSignature("com.acme.custom", "tok|user") + + store.migrate() + + store.registrationSignature("io.customer.liveactivities.deliverytracking").shouldBeNull() + store.registrationSignature("io.customer.liveactivities.auctionbid").shouldBeNull() + store.registrationSignature("io.customer.livenotifications.segments") shouldBeEqualTo "tok|user" + store.registrationSignature("com.acme.custom") shouldBeEqualTo "tok|user" + } + + @Test + fun migrate_isIdempotentAndSafeWhenNothingStale() { + store.setRegistrationSignature("io.customer.livenotifications.segments", "tok|user") + + store.migrate() + store.migrate() + + store.registrationSignature("io.customer.livenotifications.segments") shouldBeEqualTo "tok|user" + } +} diff --git a/messagingpush/src/test/java/io/customer/messagingpush/livenotification/ULIDTest.kt b/messagingpush/src/test/java/io/customer/messagingpush/livenotification/ULIDTest.kt new file mode 100644 index 000000000..6936e4bc6 --- /dev/null +++ b/messagingpush/src/test/java/io/customer/messagingpush/livenotification/ULIDTest.kt @@ -0,0 +1,87 @@ +package io.customer.messagingpush.livenotification + +import org.amshove.kluent.shouldBeEqualTo +import org.amshove.kluent.shouldBeIn +import org.amshove.kluent.shouldBeLessThan +import org.amshove.kluent.shouldBeNull +import org.amshove.kluent.shouldBeTrue +import org.junit.Test + +internal class ULIDTest { + private val crockfordAlphabet = "0123456789ABCDEFGHJKMNPQRSTVWXYZ".toSet() + + @Test + fun generate_givenAnyTimestamp_isExactly26Characters() { + repeat(1000) { ULID.generate().length shouldBeEqualTo 26 } + } + + @Test + fun generate_givenAnyTimestamp_usesOnlyCrockfordAlphabet() { + repeat(1000) { + ULID.generate().all { it in crockfordAlphabet }.shouldBeTrue() + } + } + + @Test + fun generate_givenAnyTimestamp_isUppercase() { + repeat(1000) { + val ulid = ULID.generate() + ulid shouldBeEqualTo ulid.uppercase() + } + } + + @Test + fun generate_givenAnyTimestamp_leadingCharacterIsAtMost7() { + // 26 * 5 = 130 bits but the value is 128 bits, so the first character carries just + // 3 significant timestamp bits and can never exceed '7'. + repeat(1000) { ULID.generate().first() shouldBeIn "01234567".toList() } + } + + @Test + fun generate_givenCanonicalSpecTimestamp_encodesKnownPrefix() { + // Canonical ULID spec example: 1469918176385 ms -> timestamp prefix "01ARYZ6S41" + // (full spec ULID 01ARYZ6S41QJQECH4KPG6SEF3Y). + ULID.generate(timestampMillis = 1_469_918_176_385L).take(10) shouldBeEqualTo "01ARYZ6S41" + } + + @Test + fun generate_givenEpoch_encodesAllZeroPrefix() { + ULID.generate(timestampMillis = 0L).take(10) shouldBeEqualTo "0000000000" + } + + @Test + fun generate_givenManyIterations_producesUniqueValues() { + val seen = HashSet() + repeat(10_000) { seen.add(ULID.generate()).shouldBeTrue() } + } + + @Test + fun generate_givenSameTimestamp_randomnessDiffers() { + val a = ULID.generate(timestampMillis = 1_700_000_000_000L) + val b = ULID.generate(timestampMillis = 1_700_000_000_000L) + a.take(10) shouldBeEqualTo b.take(10) + (a.takeLast(16) != b.takeLast(16)).shouldBeTrue() + } + + @Test + fun generate_givenIncreasingTimestamps_sortsLexicographically() { + val earlier = ULID.generate(timestampMillis = 1_000L) + val middle = ULID.generate(timestampMillis = 1_700_000_000_000L) + val later = ULID.generate(timestampMillis = 4_000_000_000_000L) + earlier shouldBeLessThan middle + middle shouldBeLessThan later + } + + @Test + fun timestampMillis_givenGeneratedULID_roundTripsValue() { + val expected = 1_700_000_000_000L + ULID.timestampMillis(ULID.generate(timestampMillis = expected)) shouldBeEqualTo expected + } + + @Test + fun timestampMillis_givenMalformedInput_returnsNull() { + ULID.timestampMillis("TOOSHORT").shouldBeNull() + // 'I' is excluded from the Crockford alphabet. + ULID.timestampMillis("I" + "0".repeat(25)).shouldBeNull() + } +} 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..ad221ce9e --- /dev/null +++ b/messagingpush/src/test/java/io/customer/messagingpush/livenotification/template/CountdownTimerTemplateTest.kt @@ -0,0 +1,131 @@ +package io.customer.messagingpush.livenotification.template + +import android.graphics.Color +import io.customer.messagingpush.livenotification.LiveNotificationBranding +import io.customer.messagingpush.testutils.core.IntegrationTest +import org.amshove.kluent.shouldBeEqualTo +import org.amshove.kluent.shouldBeNull +import org.json.JSONObject +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +/** + * Tests for [CountdownTimerTemplate] against the iOS `CIOCountdownTimerAttributes` + * field model. + * + * Freeform slots (`header`/`title`/`statusMessage`) are rendered verbatim. + * Exercises the countdown branches: + * - future `endTime` ⇒ live chronometer (countdownUntil = endTime); + * - absent/past `endTime` ⇒ no chronometer (push-driven finished state), showing + * `title` + `statusMessage`. + * Styling is branding-only (accent from branding/fallback, no per-push image). + */ +@RunWith(RobolectricTestRunner::class) +internal class CountdownTimerTemplateTest : IntegrationTest() { + + private fun render( + attributes: JSONObject = JSONObject(), + contentState: JSONObject = JSONObject(), + branding: LiveNotificationBranding? = null, + fallbackTintColor: Int? = null + ): TemplateRenderResult = CountdownTimerTemplate.render( + context = contextMock, + data = flatten(attributes, contentState), + branding = branding, + smallIcon = 0, + fallbackTintColor = fallbackTintColor + )!! + + @Test + fun render_givenNoUsableContent_returnsNull() { + // Required `title` missing: render returns null so the handler skips posting. + val result = CountdownTimerTemplate.render( + context = contextMock, + data = JSONObject(), + branding = null, + smallIcon = 0, + fallbackTintColor = null + ) + + result.shouldBeNull() + } + + @Test + fun render_futureEndTime_setsCountdownAndStatusMessageBody() { + // endTime is epoch SECONDS on the wire; the template converts to millis. + val futureSeconds = System.currentTimeMillis() / 1000 + 60L + val attributes = JSONObject().apply { put("header", "Limited time") } + val contentState = JSONObject().apply { + put("title", "Flash sale ends in") + put("statusMessage", "Hurry!") + put("endTime", futureSeconds) + } + + val result = render(attributes, contentState) + + result.title shouldBeEqualTo "Flash sale ends in" + result.body shouldBeEqualTo "Hurry!" + result.subText shouldBeEqualTo "Limited time" + result.countdownUntil shouldBeEqualTo futureSeconds * 1000L + } + + @Test + fun render_pastEndTime_showsFinishedStateWithNoChronometer() { + val pastSeconds = System.currentTimeMillis() / 1000 - 60L + val contentState = JSONObject().apply { + put("title", "Sale is live!") + put("statusMessage", "Shop now") + put("endTime", pastSeconds) + } + + val result = render(contentState = contentState) + + result.title shouldBeEqualTo "Sale is live!" + result.body shouldBeEqualTo "Shop now" + result.countdownUntil.shouldBeNull() + } + + @Test + fun render_endTimeAbsent_showsFinishedStateWithNoChronometer() { + // Push-driven finished state: a new content-state with a "done" title and no endTime. + val contentState = JSONObject().apply { + put("title", "Done") + put("statusMessage", "Thanks for shopping") + } + + val result = render(contentState = contentState) + + result.title shouldBeEqualTo "Done" + result.body shouldBeEqualTo "Thanks for shopping" + result.countdownUntil.shouldBeNull() + } + + @Test + fun render_brandingOnly_accentAndNoImage() { + val accent = Color.parseColor("#1B5E20") + val branding = LiveNotificationBranding(companyName = "Acme", accentColor = accent) + val contentState = JSONObject().apply { + put("title", "Flash sale ends in") + put("endTime", System.currentTimeMillis() / 1000 + 60L) + } + + val result = render(contentState = contentState, branding = branding) + + result.accentColor shouldBeEqualTo accent + // Branding-only: no per-push image; the handler fills the large icon from branding. + result.largeIcon.shouldBeNull() + } + + @Test + fun render_noStatusMessage_bodyEmpty() { + val contentState = JSONObject().apply { + put("title", "Flash sale ends in") + put("endTime", System.currentTimeMillis() / 1000 + 60L) + } + + val result = render(contentState = contentState) + + result.body shouldBeEqualTo "" + } +} diff --git a/messagingpush/src/test/java/io/customer/messagingpush/livenotification/template/SegmentsTemplateTest.kt b/messagingpush/src/test/java/io/customer/messagingpush/livenotification/template/SegmentsTemplateTest.kt new file mode 100644 index 000000000..ef3b0e10e --- /dev/null +++ b/messagingpush/src/test/java/io/customer/messagingpush/livenotification/template/SegmentsTemplateTest.kt @@ -0,0 +1,226 @@ +package io.customer.messagingpush.livenotification.template + +import android.graphics.Color +import io.customer.messagingpush.livenotification.LiveNotificationBranding +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 [SegmentsTemplate] rendering against the finalized field contract: + * freeform slots (`header`/`status`/`substatus`) are rendered verbatim (never + * composed), `segmentsTotal`/`segmentsComplete` are flat integers driving the + * segmented bar, and styling is branding-only (accent from branding/fallback, + * no per-push image/statusColor). + * + * All fields arrive flattened in a single `data` object; the `attributes` / + * `contentState` grouping in these tests is purely for readability and is merged + * via [flatten] before rendering. + */ +@RunWith(RobolectricTestRunner::class) +internal class SegmentsTemplateTest : IntegrationTest() { + + private fun render( + attributes: JSONObject = JSONObject(), + contentState: JSONObject = JSONObject(), + branding: LiveNotificationBranding? = null, + fallbackTintColor: Int? = null + ): TemplateRenderResult = SegmentsTemplate.render( + context = contextMock, + data = flatten(attributes, contentState), + branding = branding, + smallIcon = 0, + fallbackTintColor = fallbackTintColor + )!! + + @Test + fun render_givenNoUsableContent_returnsNull() { + // Required `status` missing: render returns null so the handler skips posting. + val result = SegmentsTemplate.render( + context = contextMock, + data = JSONObject(), + branding = null, + smallIcon = 0, + fallbackTintColor = null + ) + + result.shouldBeNull() + } + + @Test + fun render_givenAllFields_mapsFreeformSlotsVerbatim() { + val attributes = JSONObject().apply { + put("header", "Order #ORD-42") + } + val contentState = JSONObject().apply { + put("status", "Out for delivery") + put("substatus", "Driver: Pat") + put("segmentsTotal", 4) + put("segmentsComplete", 2) + } + + val result = render(attributes, contentState) + + // Freeform slots are rendered verbatim, never composed. + result.title shouldBeEqualTo "Out for delivery" + result.body shouldBeEqualTo "Driver: Pat" + result.subText shouldBeEqualTo "Order #ORD-42" + result.showProgress.shouldBeTrue() + result.progress shouldBeEqualTo 2 + result.progressMax shouldBeEqualTo 4 + result.segments.size shouldBeEqualTo 4 + // Branding-only: no per-push image, no live timer. + result.largeIcon.shouldBeNull() + result.countdownUntil.shouldBeNull() + } + + @Test + fun render_givenNoSubstatusButTrailingText_bodyShowsTrailingTextVerbatim() { + // trailingText has no dedicated Android slot; it fills the otherwise-empty body verbatim. + val contentState = JSONObject().apply { + put("status", "Preparing") + put("segmentsTotal", 3) + put("segmentsComplete", 1) + put("trailingText", "5 min") + } + + val result = render(contentState = contentState) + + result.body shouldBeEqualTo "5 min" + } + + @Test + fun render_givenBothSubstatusAndTrailingText_substatusWinsBody() { + // substatus owns the body line; trailingText is dropped (no distinct slot, never composed). + val contentState = JSONObject().apply { + put("status", "Preparing") + put("substatus", "Almost there") + put("segmentsTotal", 3) + put("segmentsComplete", 1) + put("trailingText", "5 min") + } + + val result = render(contentState = contentState) + + result.body shouldBeEqualTo "Almost there" + } + + @Test + fun render_givenNoSubstatusOrHeaderOrTrailing_bodyEmptyAndSubTextNull() { + val contentState = JSONObject().apply { + put("status", "Preparing") + put("segmentsTotal", 3) + put("segmentsComplete", 1) + } + + val result = render(contentState = contentState) + + result.title shouldBeEqualTo "Preparing" + result.body shouldBeEqualTo "" + result.subText.shouldBeNull() + } + + @Test + fun render_brandingOnly_accentComesFromBranding() { + val accent = Color.parseColor("#36AE3F") + val branding = LiveNotificationBranding( + companyName = "Acme", + accentColor = accent + ) + val contentState = JSONObject().apply { + put("status", "Delivered") + put("segmentsTotal", 3) + put("segmentsComplete", 3) + } + + val result = render(contentState = contentState, branding = branding) + + result.accentColor shouldBeEqualTo accent + } + + @Test + fun render_noBranding_fallsBackToTintColor() { + val fallback = Color.parseColor("#123456") + val contentState = JSONObject().apply { + put("status", "Delivered") + put("segmentsTotal", 3) + put("segmentsComplete", 3) + } + + val result = render(contentState = contentState, fallbackTintColor = fallback) + + result.accentColor shouldBeEqualTo fallback + } + + @Test + fun render_segmentsCompleteAboveTotal_isClampedToTotal() { + val contentState = JSONObject().apply { + put("status", "Anywhere") + put("segmentsTotal", 4) + put("segmentsComplete", 99) + } + + val result = render(contentState = contentState) + + result.progress shouldBeEqualTo 4 + } + + @Test + fun render_segmentsCompleteNegative_isClampedToZero() { + val contentState = JSONObject().apply { + put("status", "Anywhere") + put("segmentsTotal", 4) + put("segmentsComplete", -5) + } + + val result = render(contentState = contentState) + + result.progress shouldBeEqualTo 0 + } + + @Test + fun render_segmentsTotalMissing_defaultsAtLeastToOne() { + val contentState = JSONObject().apply { + put("status", "Just placed") + } + + val result = render(contentState = contentState) + + result.progressMax shouldBeEqualTo 1 + result.segments.size shouldBeEqualTo 1 + } + + @Test + fun render_segmentsTotalZero_isFlooredToOne() { + val contentState = JSONObject().apply { + put("status", "Edge case") + put("segmentsTotal", 0) + put("segmentsComplete", 0) + } + + val result = render(contentState = contentState) + + result.progressMax shouldBeEqualTo 1 + result.segments.size shouldBeEqualTo 1 + } + + @Test + fun render_segmentsTotalAboveMax_isCappedAtTwenty() { + // An untrusted push payload can't blow up the segment allocation. + val contentState = JSONObject().apply { + put("status", "Huge payload") + put("segmentsTotal", 5000) + put("segmentsComplete", 4000) + } + + val result = render(contentState = contentState) + + result.progressMax shouldBeEqualTo 20 + result.segments.size shouldBeEqualTo 20 + } +} 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..f9b9cb324 --- /dev/null +++ b/messagingpush/src/test/java/io/customer/messagingpush/livenotification/template/TemplateAssetsTest.kt @@ -0,0 +1,39 @@ +package io.customer.messagingpush.livenotification.template + +import io.customer.messagingpush.livenotification.LiveNotificationAsset +import io.customer.messagingpush.testutils.core.IntegrationTest +import org.amshove.kluent.shouldNotBeNull +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +/** + * Tests for [TemplateAssets.toBitmap]. + * + * Branding hands the SDK a strongly-typed [LiveNotificationAsset] for the color + * large-icon slot; these tests exercise that each supported source type resolves + * to a bitmap. The remote-URL case is not covered here because it performs a + * real network fetch via `BitmapDownloader`; the disk-cache path is exercised + * separately by the handler-level tests. + */ +@RunWith(RobolectricTestRunner::class) +internal class TemplateAssetsTest : IntegrationTest() { + + @Test + fun toBitmap_drawableAsset_rendersBitmap() { + val asset = LiveNotificationAsset.Drawable(android.R.drawable.ic_dialog_info) + + val result = TemplateAssets.toBitmap(contextMock, asset) + + result.shouldNotBeNull() + } + + @Test + fun toBitmap_bytesAsset_decodesBitmap() { + val asset = LiveNotificationAsset.Bytes(byteArrayOf(1, 2, 3, 4)) + + val result = TemplateAssets.toBitmap(contextMock, asset) + + result.shouldNotBeNull() + } +} 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/messagingpush/src/test/java/io/customer/messagingpush/processor/PushMessageProcessorTest.kt b/messagingpush/src/test/java/io/customer/messagingpush/processor/PushMessageProcessorTest.kt index d7c6a4435..104f0b057 100644 --- a/messagingpush/src/test/java/io/customer/messagingpush/processor/PushMessageProcessorTest.kt +++ b/messagingpush/src/test/java/io/customer/messagingpush/processor/PushMessageProcessorTest.kt @@ -428,6 +428,70 @@ class PushMessageProcessorTest : IntegrationTest() { } } + @Test + fun processNotificationClick_givenLocallyStartedLiveNotification_expectDoNotTrackOpened() { + // A locally started live notification is rendered by the SDK on the app's behalf and was + // never delivered by Customer.io, so it carries no delivery id/token. Tracking `opened` + // for it would emit a delivery metric with empty identifiers that cannot be attributed. + setupModuleConfig(autoTrackPushEvents = true) + val processor = pushMessageProcessor() + val givenPayload = CustomerIOParsedPushPayload( + extras = Bundle.EMPTY, + deepLink = null, + cioDeliveryId = "", + cioDeliveryToken = "", + title = String.random, + body = String.random, + activityId = String.random + ) + val intent = Intent().apply { + putExtra(NotificationClickReceiverActivity.NOTIFICATION_PAYLOAD_EXTRA, givenPayload) + } + setupDeepLinkResponse( + deepLink = null, + defaultHostAppIntent = emptyIntent().withTestFlags() + ) + + processor.processNotificationClick(contextMock, intent) + + // No metric published, but the click itself is still handled (deep link / launcher). + assertCalledNever { + eventBus.publish(any()) + } + assertCalledOnce { + mockPushLogger.logNotificationClickMetricsSkippedForLocalNotification(givenPayload) + deepLinkUtilMock.createDefaultHostAppIntent(any()) + } + } + + @Test + fun processNotificationClick_givenPushDeliveredNotification_expectTrackOpened() { + // Complement of the guard above: a real Customer.io push has both identifiers, so the + // `opened` metric must still be reported. + setupModuleConfig(autoTrackPushEvents = true) + val processor = pushMessageProcessor() + val givenPayload = pushMessagePayload() + val intent = Intent().apply { + putExtra(NotificationClickReceiverActivity.NOTIFICATION_PAYLOAD_EXTRA, givenPayload) + } + setupDeepLinkResponse( + deepLink = null, + defaultHostAppIntent = emptyIntent().withTestFlags() + ) + + processor.processNotificationClick(contextMock, intent) + + assertCalledOnce { + eventBus.publish( + Event.TrackPushMetricEvent( + event = Metric.Opened, + deliveryId = givenPayload.cioDeliveryId, + deviceToken = givenPayload.cioDeliveryToken + ) + ) + } + } + @Test fun processNotificationClick_givenAutoTrackingDisabled_expectDoNotTrackOpened() { setupModuleConfig(autoTrackPushEvents = false) diff --git a/samples/java_layout/src/main/AndroidManifest.xml b/samples/java_layout/src/main/AndroidManifest.xml index 912cd6a73..faf60902c 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"> + @@ -142,6 +143,10 @@ android:name=".ui.inbox.InboxMessagesActivity" android:exported="false" android:label="@string/label_inbox_messages_activity" /> + buildRideshare(context, extras, ended) + ACTIVITY_TYPE_WORKOUT -> buildWorkout(context, extras, ended) + // Not one of ours — let the SDK render its built-in template. + else -> null + } + } + + // --- Custom type 1: fully custom RemoteViews layout --- + + private fun buildRideshare(context: Context, extras: Bundle, ended: Boolean): Notification { + val driver = extras.getString("driverName") ?: "Your driver" + val vehicle = extras.getString("vehicle") ?: "" + val plate = extras.getString("plate") ?: "" + val rating = extras.getString("rating") ?: "" + val eta = extras.getString("etaText") ?: "" + val status = extras.getString("statusMessage") ?: "" + val step = extras.getString("step")?.toIntOrNull() ?: 0 + val progress = extras.getString("progress")?.toIntOrNull() ?: 0 + + val title = if (ended) "Trip complete" else "$driver is on the way" + val avatarInitial = driver.trim().firstOrNull()?.uppercaseChar()?.toString() ?: "?" + val vehicleLine = listOf(vehicle, plate).filter { it.isNotBlank() }.joinToString(" · ") + val etaText = if (ended) "Done" else eta + + fun applyHeader(rv: RemoteViews) { + rv.setTextViewText(R.id.tv_avatar, avatarInitial) + rv.setTextViewText(R.id.tv_title, title) + rv.setTextViewText(R.id.tv_eta, etaText) + } + + // Collapsed is a compact single line; expanded adds the gold star rating + vehicle. + val collapsed = RemoteViews(context.packageName, R.layout.notification_rideshare_collapsed) + applyHeader(collapsed) + + val expanded = RemoteViews(context.packageName, R.layout.notification_rideshare_expanded) + applyHeader(expanded) + expanded.setTextViewText(R.id.tv_subtitle, ratingLine(rating, vehicleLine)) + expanded.setTextViewText(R.id.tv_status, if (ended) "Thanks for riding with us" else status) + + val stepIds = intArrayOf(R.id.iv_step1, R.id.iv_step2, R.id.iv_step3, R.id.iv_step4) + for (i in stepIds.indices) { + val icon = when { + ended || i < step -> R.drawable.ic_step_done + i == step -> R.drawable.ic_step_active + else -> R.drawable.ic_step_pending + } + expanded.setImageViewResource(stepIds[i], icon) + } + expanded.setProgressBar(R.id.progress_bar, 100, if (ended) 100 else progress, false) + + return NotificationCompat.Builder(context, CHANNEL_ID) + .setSmallIcon(R.drawable.ic_rideshare_car) + .setCustomContentView(collapsed) + .setCustomBigContentView(expanded) + .setOngoing(!ended) + .setOnlyAlertOnce(true) + .setCategory(NotificationCompat.CATEGORY_PROGRESS) + .build() + } + + /** Builds "★ 4.9 · Toyota Prius · 7XYZ123" with the star tinted gold. */ + private fun ratingLine(rating: String, vehicleLine: String): CharSequence { + val parts = listOfNotNull( + rating.takeIf { it.isNotBlank() }?.let { "★ $it" }, + vehicleLine.takeIf { it.isNotBlank() } + ) + val full = parts.joinToString(" · ") + if (rating.isBlank() || full.isEmpty()) return full + return SpannableString(full).apply { + setSpan(ForegroundColorSpan(0xFFF5A623.toInt()), 0, 1, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE) + } + } + + // --- Custom type 2: standard NotificationCompat builder API --- + + private fun buildWorkout(context: Context, extras: Bundle, ended: Boolean): Notification { + val title = extras.getString("workoutTitle") ?: "Workout" + val distance = extras.getString("distance") ?: "" + val duration = extras.getString("duration") ?: "" + val pace = extras.getString("pace") ?: "" + val progress = extras.getString("progress")?.toIntOrNull() ?: 0 + val summary = listOf(distance, duration, pace).filter { it.isNotBlank() }.joinToString(" · ") + + val builder = NotificationCompat.Builder(context, CHANNEL_ID) + .setSmallIcon(R.drawable.ic_workout_run) + .setContentTitle(if (ended) "$title complete" else title) + .setContentText(summary) + .setProgress(100, if (ended) 100 else progress, false) + .setStyle( + NotificationCompat.BigTextStyle() + .bigText(if (ended) "$summary\nGreat job — workout saved." else "$summary\nKeep going!") + ) + .setOngoing(!ended) + .setOnlyAlertOnce(true) + .setCategory(NotificationCompat.CATEGORY_PROGRESS) + + if (!ended) { + val pauseIntent = PendingIntent.getBroadcast( + context, + 0, + Intent(ACTION_WORKOUT_PAUSE), + PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT + ) + builder.addAction(R.drawable.ic_workout_run, "Pause", pauseIntent) + } + + // Request promoted-ongoing (live-update) treatment on Android 16+ (BAKLAVA). + if (Build.VERSION.SDK_INT >= 36 && !ended) { + builder.addExtras(Bundle().apply { putBoolean(EXTRA_REQUEST_PROMOTED_ONGOING, true) }) + } + + return builder.build() + } + + private fun ensureChannel(context: Context) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val manager = context.getSystemService(NotificationManager::class.java) ?: return + if (manager.getNotificationChannel(CHANNEL_ID) == null) { + manager.createNotificationChannel( + NotificationChannel( + CHANNEL_ID, + "Custom Live Updates", + NotificationManager.IMPORTANCE_DEFAULT + ) + ) + } + } + } + + companion object { + // Custom activity types (not built-in). Being custom String identifiers, + // they must be enabled via + // MessagingPushModuleConfig.Builder.enableCustomLiveNotificationTypes(...) + // (not enableLiveNotificationTypes(...), which takes the LiveNotificationType enum). + const val ACTIVITY_TYPE_RIDESHARE = "io.customer.livenotifications.custom.rideshare" + const val ACTIVITY_TYPE_WORKOUT = "io.customer.livenotifications.custom.workout" + + private const val CHANNEL_ID = "cio_custom_live" + private const val KEY_ACTIVITY_TYPE = "notification_type" + private const val KEY_EVENT = "event" + private const val EVENT_END = "end" + private const val ACTION_WORKOUT_PAUSE = "io.customer.android.sample.java_layout.WORKOUT_PAUSE" + + // Notification.EXTRA_REQUEST_PROMOTED_ONGOING (extension SDK 36.1) by raw value. + private const val EXTRA_REQUEST_PROMOTED_ONGOING = "android.requestPromotedOngoing" + } +} diff --git a/samples/java_layout/src/main/java/io/customer/android/sample/java_layout/ui/dashboard/DashboardActivity.java b/samples/java_layout/src/main/java/io/customer/android/sample/java_layout/ui/dashboard/DashboardActivity.java index 6358b5807..277687365 100644 --- a/samples/java_layout/src/main/java/io/customer/android/sample/java_layout/ui/dashboard/DashboardActivity.java +++ b/samples/java_layout/src/main/java/io/customer/android/sample/java_layout/ui/dashboard/DashboardActivity.java @@ -34,6 +34,7 @@ import io.customer.android.sample.java_layout.ui.core.BaseActivity; import io.customer.android.sample.java_layout.ui.inbox.InboxMessagesActivity; import io.customer.android.sample.java_layout.ui.inline.InlineExamplesActivity; +import io.customer.android.sample.java_layout.ui.livenotification.LiveNotificationDemoActivity; import io.customer.android.sample.java_layout.ui.location.LocationTestActivity; import io.customer.android.sample.java_layout.ui.login.LoginActivity; import io.customer.android.sample.java_layout.ui.settings.InternalSettingsActivity; @@ -160,6 +161,9 @@ private void setupViews() { binding.inboxMessagesButton.setOnClickListener(view -> { startInboxMessagesActivity(); }); + binding.liveNotificationDemoButton.setOnClickListener(view -> { + startActivity(new Intent(DashboardActivity.this, LiveNotificationDemoActivity.class)); + }); binding.logoutButton.setOnClickListener(view -> { authViewModel.clearLoggedInUser(); }); diff --git a/samples/java_layout/src/main/java/io/customer/android/sample/java_layout/ui/livenotification/LiveNotificationDemoActivity.java b/samples/java_layout/src/main/java/io/customer/android/sample/java_layout/ui/livenotification/LiveNotificationDemoActivity.java new file mode 100644 index 000000000..057cc7751 --- /dev/null +++ b/samples/java_layout/src/main/java/io/customer/android/sample/java_layout/ui/livenotification/LiveNotificationDemoActivity.java @@ -0,0 +1,439 @@ +package io.customer.android.sample.java_layout.ui.livenotification; + +import android.os.Bundle; +import android.os.Handler; +import android.os.Looper; +import android.widget.ArrayAdapter; + +import com.google.android.material.snackbar.Snackbar; +import com.google.firebase.messaging.RemoteMessage; + +import org.json.JSONException; +import org.json.JSONObject; + +import java.util.HashMap; +import java.util.Map; +import java.util.UUID; + +import io.customer.android.sample.java_layout.R; +import io.customer.android.sample.java_layout.databinding.ActivityLiveNotificationDemoBinding; +import io.customer.android.sample.java_layout.sdk.CustomerIORepository; +import io.customer.android.sample.java_layout.sdk.LiveNotificationCallback; +import io.customer.android.sample.java_layout.ui.core.BaseActivity; +import io.customer.messagingpush.CustomerIOFirebaseMessagingService; +import io.customer.messagingpush.ModuleMessagingPushFCM; +import io.customer.messagingpush.livenotification.LiveNotificationData; + +/** + * Demo activity that simulates templated live-notification updates by sending + * synthetic push messages through the SDK's real push handling path, and also + * exercises the local-start API and a backend-campaign trigger. + */ +public class LiveNotificationDemoActivity extends BaseActivity { + + private static final String DEMO_DELIVERY_TOKEN = "demo-token-live"; + private static final long AUTO_STEP_DELAY_MS = 5000; + + private static final String EVENT_START = "start"; + private static final String EVENT_UPDATE = "update"; + private static final String EVENT_END = "end"; + + // activity_type values match the iOS Live Notification identifiers per the cross-platform spec. + private static final String ACTIVITY_TYPE_SEGMENTS = "io.customer.livenotifications.segments"; + private static final String ACTIVITY_TYPE_COUNTDOWN_TIMER = "io.customer.livenotifications.countdowntimer"; + private static final String ACTIVITY_TYPE_UNKNOWN = "io.customer.livenotifications.bogus"; + // Custom (app-rendered) types — rendered by LiveNotificationCallback, not an SDK template. + private static final String ACTIVITY_TYPE_RIDESHARE = LiveNotificationCallback.ACTIVITY_TYPE_RIDESHARE; + private static final String ACTIVITY_TYPE_WORKOUT = LiveNotificationCallback.ACTIVITY_TYPE_WORKOUT; + + private enum TemplateChoice { + SEGMENTS, COUNTDOWN_TIMER, + CUSTOM_RIDESHARE, CUSTOM_WORKOUT + } + + // Event the backend campaign listens for; its `activity_type` property selects the template. + private static final String CAMPAIGN_EVENT = "trigger_live"; + // Dropdown order must match the labels built in setupCampaignDropdown below. + private static final String[] CAMPAIGN_ACTIVITY_TYPES = { + ACTIVITY_TYPE_SEGMENTS, + ACTIVITY_TYPE_COUNTDOWN_TIMER + }; + + private int currentStep = 0; + private boolean isActive = false; + private final Handler autoHandler = new Handler(Looper.getMainLooper()); + private boolean isAutoRunning = false; + private int selectedCampaignIndex = 0; + private CustomerIORepository customerIORepository; + private String lastApiActivityId = null; + + @Override + protected ActivityLiveNotificationDemoBinding inflateViewBinding() { + return ActivityLiveNotificationDemoBinding.inflate(getLayoutInflater()); + } + + @Override + protected void injectDependencies() { + customerIORepository = applicationGraph.getCustomerIORepository(); + } + + @Override + protected void setupContent() { + binding.topAppBar.setNavigationOnClickListener(v -> finish()); + + binding.startButton.setOnClickListener(v -> start()); + binding.updateButton.setOnClickListener(v -> update()); + binding.endButton.setOnClickListener(v -> end()); + binding.autoButton.setOnClickListener(v -> autoRun()); + binding.unknownActivityTypeButton.setOnClickListener(v -> sendUnknownActivityType()); + binding.apiStartButton.setOnClickListener(v -> startViaApi()); + binding.apiUpdateButton.setOnClickListener(v -> updateViaApi()); + binding.apiEndButton.setOnClickListener(v -> endViaApi()); + + setupCampaignDropdown(); + binding.campaignTriggerButton.setOnClickListener(v -> triggerCampaign()); + + binding.typeRadioGroup.setOnCheckedChangeListener((group, checkedId) -> { + if (isActive) { + autoHandler.removeCallbacksAndMessages(null); + isActive = false; + isAutoRunning = false; + currentStep = 0; + updateButtonStates(); + binding.statusTextView.setText(R.string.live_notification_status_idle); + } + }); + } + + private TemplateChoice getSelectedTemplate() { + int checkedId = binding.typeRadioGroup.getCheckedRadioButtonId(); + if (checkedId == R.id.radio_countdown_timer) return TemplateChoice.COUNTDOWN_TIMER; + if (checkedId == R.id.radio_custom_rideshare) return TemplateChoice.CUSTOM_RIDESHARE; + if (checkedId == R.id.radio_custom_workout) return TemplateChoice.CUSTOM_WORKOUT; + return TemplateChoice.SEGMENTS; + } + + private int getStepCount() { + switch (getSelectedTemplate()) { + // Segments: ordered → preparing → out-for-delivery → delivered + case SEGMENTS: return 4; + // Countdown: 5 min out → 30s out → finished (push-driven, no endTime) + case COUNTDOWN_TIMER: return 3; + // Rideshare (custom RemoteViews): en route → arriving → in trip → dropoff + case CUSTOM_RIDESHARE: return 4; + // Workout (builder API): warmup → running → final push → cooldown + case CUSTOM_WORKOUT: return 4; + default: return 1; + } + } + + // --- Manual controls --- + + private void start() { + currentStep = 0; + isActive = true; + updateButtonStates(); + sendPush(EVENT_START, currentStep); + updateStatusText(); + } + + private void update() { + if (!isActive) return; + currentStep = Math.min(currentStep + 1, getStepCount() - 1); + updateButtonStates(); + sendPush(EVENT_UPDATE, currentStep); + updateStatusText(); + } + + private void end() { + if (!isActive) return; + isActive = false; + updateButtonStates(); + sendPush(EVENT_END, getStepCount() - 1); + binding.statusTextView.setText(R.string.live_notification_status_ended); + } + + // --- Auto sequence --- + + private void autoRun() { + if (isAutoRunning) return; + isAutoRunning = true; + binding.autoButton.setEnabled(false); + binding.startButton.setEnabled(false); + + start(); + + int totalSteps = getStepCount(); + for (int step = 1; step < totalSteps; step++) { + autoHandler.postDelayed(this::update, AUTO_STEP_DELAY_MS * step); + } + autoHandler.postDelayed(() -> { + end(); + isAutoRunning = false; + updateButtonStates(); + }, AUTO_STEP_DELAY_MS * totalSteps); + } + + // --- Push construction --- + + private void sendPush(String event, int step) { + switch (getSelectedTemplate()) { + case SEGMENTS: sendSegments(event, step); break; + case COUNTDOWN_TIMER: sendCountdownTimer(event, step); break; + case CUSTOM_RIDESHARE: sendCustomRideshare(event, step); break; + case CUSTOM_WORKOUT: sendCustomWorkout(event, step); break; + } + } + + private void sendSegments(String event, int step) { + // A food-delivery order flow (bold status headline + supporting line), the + // canonical Android 16 ProgressStyle "Live Update" use case. + String[] statuses = { + "Thanks for your order", + "Your order is being prepared", + "Out for delivery", + "Delivered" + }; + String[] substatuses = { + "We've received your order and we're on it", + "The kitchen is cooking it up", + "Your rider is on the way", + "Enjoy your meal!" + }; + JSONObject attributes = new JSONObject(); + JSONObject contentState = new JSONObject(); + try { + attributes.put("header", "Order #4021"); + + contentState.put("status", statuses[step]); + contentState.put("substatus", substatuses[step]); + contentState.put("segmentsTotal", statuses.length); + contentState.put("segmentsComplete", step + 1); + } catch (JSONException ignored) { } + fire(buildBundle("demo-segments", event, ACTIVITY_TYPE_SEGMENTS, attributes, contentState)); + } + + private void sendCountdownTimer(String event, int step) { + JSONObject attributes = new JSONObject(); + JSONObject contentState = new JSONObject(); + try { + attributes.put("header", "Limited time"); + + // endTime is epoch SECONDS on the wire, not millis. + long nowSeconds = System.currentTimeMillis() / 1000; + if (step == 2) { + // Finished state: a "done" title with no endTime, so the template drops the timer. + contentState.put("title", "Sale is live!"); + contentState.put("statusMessage", "Shop now"); + } else { + long[] offsets = {5 * 60L, 30L}; + contentState.put("title", "Flash sale ends in"); + contentState.put("statusMessage", "Don't miss out"); + contentState.put("endTime", nowSeconds + offsets[step]); + } + } catch (JSONException ignored) { } + fire(buildBundle("demo-countdown-timer", event, ACTIVITY_TYPE_COUNTDOWN_TIMER, attributes, contentState)); + } + + // --- Custom (app-rendered) types: rendered by LiveNotificationCallback, not an SDK template --- + + /** + * Custom type rendered by the host app through a completely custom RemoteViews + * layout (see {@link LiveNotificationCallback}). The SDK has no template for it. + */ + private void sendCustomRideshare(String event, int step) { + String[] statuses = { + "Heading to your pickup", + "Arriving now — look for the car", + "On the way to your destination", + "You've arrived" + }; + String[] etas = {"6 min", "1 min", "12 min", "Now"}; + int[] progress = {15, 40, 80, 100}; + JSONObject attributes = new JSONObject(); + JSONObject contentState = new JSONObject(); + try { + attributes.put("driverName", "Alex"); + attributes.put("vehicle", "Toyota Prius"); + attributes.put("plate", "7XYZ123"); + attributes.put("rating", "4.9"); + + contentState.put("statusMessage", statuses[step]); + contentState.put("etaText", etas[step]); + contentState.put("step", step); + contentState.put("progress", progress[step]); + } catch (JSONException ignored) { } + fire(buildBundle("demo-rideshare", event, ACTIVITY_TYPE_RIDESHARE, attributes, contentState)); + } + + /** + * Custom type rendered by the host app via the standard NotificationCompat builder + * API (determinate progress + BigTextStyle + action), requesting promoted-ongoing. + */ + private void sendCustomWorkout(String event, int step) { + String[] distances = {"0.4 km", "2.4 km", "4.1 km", "5.0 km"}; + String[] durations = {"02:10", "14:32", "24:48", "30:15"}; + String[] paces = {"5'25\"/km", "6'03\"/km", "6'02\"/km", "6'03\"/km"}; + int[] progress = {8, 48, 82, 100}; + JSONObject attributes = new JSONObject(); + JSONObject contentState = new JSONObject(); + try { + attributes.put("workoutTitle", "Morning Run"); + + contentState.put("distance", distances[step]); + contentState.put("duration", durations[step]); + contentState.put("pace", paces[step]); + contentState.put("step", step); + contentState.put("progress", progress[step]); + } catch (JSONException ignored) { } + fire(buildBundle("demo-workout", event, ACTIVITY_TYPE_WORKOUT, attributes, contentState)); + } + + /** Demonstrates the typed local-start API: {@code startLiveNotification(LiveNotificationData)}. */ + private void startViaApi() { + ModuleMessagingPushFCM module = customerIORepository.getMessagingPushModule(); + if (module == null) return; + LiveNotificationData.Segments data = new LiveNotificationData.Segments( + /* header */ "Order #API-1001", + /* status */ "Out for delivery (started via API)", + /* substatus */ "Driver: Sara", + /* segmentsTotal */ 4, + /* segmentsComplete */ 3, + /* trailingText */ "5 min" + ); + String activityId = module.startLiveNotification(data); + lastApiActivityId = activityId; + binding.statusTextView.setText(getString(R.string.live_notification_status_format, "API:" + activityId, 1)); + } + + /** + * Demonstrates the typed local-update API: + * {@code updateLiveNotification(activityId, LiveNotificationData)} against the activity + * started via {@link #startViaApi()}. + */ + private void updateViaApi() { + ModuleMessagingPushFCM module = customerIORepository.getMessagingPushModule(); + if (module == null || lastApiActivityId == null) return; + LiveNotificationData.Segments data = new LiveNotificationData.Segments( + /* header */ "Order #API-1001", + /* status */ "Arriving now (updated via API)", + /* substatus */ "Driver: Sara", + /* segmentsTotal */ 4, + /* segmentsComplete */ 4, + /* trailingText */ "Now" + ); + module.updateLiveNotification(lastApiActivityId, data); + binding.statusTextView.setText(getString(R.string.live_notification_status_format, "API:" + lastApiActivityId, 2)); + } + + /** Demonstrates the local-end API: {@code endLiveNotification(activityId)}. */ + private void endViaApi() { + ModuleMessagingPushFCM module = customerIORepository.getMessagingPushModule(); + if (module == null || lastApiActivityId == null) return; + module.endLiveNotification(lastApiActivityId); + binding.statusTextView.setText(R.string.live_notification_status_ended); + lastApiActivityId = null; + } + + // --- Campaign trigger --- + + private void setupCampaignDropdown() { + String[] labels = { + getString(R.string.live_notification_type_segments), + getString(R.string.live_notification_type_countdown_timer) + }; + ArrayAdapter adapter = + new ArrayAdapter<>(this, android.R.layout.simple_list_item_1, labels); + binding.campaignTemplateDropdown.setAdapter(adapter); + binding.campaignTemplateDropdown.setText(labels[selectedCampaignIndex], false); + binding.campaignTemplateDropdown.setOnItemClickListener( + (parent, view, position, id) -> selectedCampaignIndex = position); + } + + /** + * Tracks the {@code trigger_live} event so a backend campaign pushes the real + * start/update/end lifecycle through the live-notification path. + */ + private void triggerCampaign() { + String activityType = CAMPAIGN_ACTIVITY_TYPES[selectedCampaignIndex]; + Map properties = new HashMap<>(); + properties.put("activity_type", activityType); + // Unique per trigger; the campaign builds a fresh cioInstanceId from it via Liquid. + properties.put("timestamp", String.valueOf(System.currentTimeMillis())); + customerIORepository.trackEvent(CAMPAIGN_EVENT, properties); + Snackbar.make( + binding.campaignTriggerButton, + getString(R.string.live_notification_campaign_event_sent, activityType), + Snackbar.LENGTH_SHORT + ).show(); + } + + private void sendUnknownActivityType() { + // Exercises LiveNotificationHandler's "Unknown live notification template" log path. + JSONObject attributes = new JSONObject(); + JSONObject contentState = new JSONObject(); + Bundle bundle = buildBundle( + "demo-unknown-activity-type", + EVENT_START, + ACTIVITY_TYPE_UNKNOWN, + attributes, + contentState + ); + fire(bundle); + } + + private Bundle buildBundle( + String activityId, + String event, + String activityType, + JSONObject attributes, + JSONObject contentState + ) { + Bundle bundle = new Bundle(); + bundle.putString("CIO-Delivery-ID", UUID.randomUUID().toString()); + bundle.putString("CIO-Delivery-Token", DEMO_DELIVERY_TOKEN); + // The SDK routes to the live-notification handler by the presence of `cioInstanceId`. + bundle.putString("cioInstanceId", activityId); + bundle.putString("event", event); + bundle.putString("notification_type", activityType); + putFlattened(bundle, attributes); + putFlattened(bundle, contentState); + return bundle; + } + + private void putFlattened(Bundle bundle, JSONObject obj) { + java.util.Iterator keys = obj.keys(); + while (keys.hasNext()) { + String key = keys.next(); + Object value = obj.opt(key); + if (value == null || value == JSONObject.NULL) continue; + // Nested objects ride along as JSON strings, matching how FCM delivers them. + bundle.putString(key, value.toString()); + } + } + + private void fire(Bundle bundle) { + RemoteMessage remoteMessage = new RemoteMessage(bundle); + CustomerIOFirebaseMessagingService.onMessageReceived(this, remoteMessage); + } + + // --- UI state --- + + private void updateButtonStates() { + int maxStep = getStepCount() - 1; + binding.startButton.setEnabled(!isActive && !isAutoRunning); + binding.updateButton.setEnabled(isActive && currentStep < maxStep); + binding.endButton.setEnabled(isActive); + binding.autoButton.setEnabled(!isAutoRunning); + } + + private void updateStatusText() { + binding.statusTextView.setText(getString(R.string.live_notification_status_format, getSelectedTemplate().name(), currentStep + 1)); + } + + @Override + protected void onDestroy() { + super.onDestroy(); + autoHandler.removeCallbacksAndMessages(null); + } +} diff --git a/samples/java_layout/src/main/res/drawable/auction_camera.xml b/samples/java_layout/src/main/res/drawable/auction_camera.xml new file mode 100644 index 000000000..adeb9a7b8 --- /dev/null +++ b/samples/java_layout/src/main/res/drawable/auction_camera.xml @@ -0,0 +1,9 @@ + + + diff --git a/samples/java_layout/src/main/res/drawable/bg_rideshare_avatar.xml b/samples/java_layout/src/main/res/drawable/bg_rideshare_avatar.xml new file mode 100644 index 000000000..5944e1ddf --- /dev/null +++ b/samples/java_layout/src/main/res/drawable/bg_rideshare_avatar.xml @@ -0,0 +1,5 @@ + + + + diff --git a/samples/java_layout/src/main/res/drawable/bg_rideshare_eta_pill.xml b/samples/java_layout/src/main/res/drawable/bg_rideshare_eta_pill.xml new file mode 100644 index 000000000..7247c8fdc --- /dev/null +++ b/samples/java_layout/src/main/res/drawable/bg_rideshare_eta_pill.xml @@ -0,0 +1,6 @@ + + + + + diff --git a/samples/java_layout/src/main/res/drawable/delivery_delivered.xml b/samples/java_layout/src/main/res/drawable/delivery_delivered.xml new file mode 100644 index 000000000..35b46a3ff --- /dev/null +++ b/samples/java_layout/src/main/res/drawable/delivery_delivered.xml @@ -0,0 +1,9 @@ + + + diff --git a/samples/java_layout/src/main/res/drawable/delivery_door.xml b/samples/java_layout/src/main/res/drawable/delivery_door.xml new file mode 100644 index 000000000..fc7674b61 --- /dev/null +++ b/samples/java_layout/src/main/res/drawable/delivery_door.xml @@ -0,0 +1,9 @@ + + + diff --git a/samples/java_layout/src/main/res/drawable/delivery_ordered.xml b/samples/java_layout/src/main/res/drawable/delivery_ordered.xml new file mode 100644 index 000000000..be249da86 --- /dev/null +++ b/samples/java_layout/src/main/res/drawable/delivery_ordered.xml @@ -0,0 +1,9 @@ + + + diff --git a/samples/java_layout/src/main/res/drawable/delivery_preparing.xml b/samples/java_layout/src/main/res/drawable/delivery_preparing.xml new file mode 100644 index 000000000..8f404459b --- /dev/null +++ b/samples/java_layout/src/main/res/drawable/delivery_preparing.xml @@ -0,0 +1,9 @@ + + + diff --git a/samples/java_layout/src/main/res/drawable/delivery_truck.xml b/samples/java_layout/src/main/res/drawable/delivery_truck.xml new file mode 100644 index 000000000..0ee16f3e3 --- /dev/null +++ b/samples/java_layout/src/main/res/drawable/delivery_truck.xml @@ -0,0 +1,9 @@ + + + diff --git a/samples/java_layout/src/main/res/drawable/delivery_warehouse.xml b/samples/java_layout/src/main/res/drawable/delivery_warehouse.xml new file mode 100644 index 000000000..3e45f12f5 --- /dev/null +++ b/samples/java_layout/src/main/res/drawable/delivery_warehouse.xml @@ -0,0 +1,9 @@ + + + diff --git a/samples/java_layout/src/main/res/drawable/flash_sale_hero.xml b/samples/java_layout/src/main/res/drawable/flash_sale_hero.xml new file mode 100644 index 000000000..c6ae374f8 --- /dev/null +++ b/samples/java_layout/src/main/res/drawable/flash_sale_hero.xml @@ -0,0 +1,9 @@ + + + diff --git a/samples/java_layout/src/main/res/drawable/ic_live_delivery.xml b/samples/java_layout/src/main/res/drawable/ic_live_delivery.xml new file mode 100644 index 000000000..e804f30c0 --- /dev/null +++ b/samples/java_layout/src/main/res/drawable/ic_live_delivery.xml @@ -0,0 +1,13 @@ + + + + diff --git a/samples/java_layout/src/main/res/drawable/ic_live_delivery_scooter.xml b/samples/java_layout/src/main/res/drawable/ic_live_delivery_scooter.xml new file mode 100644 index 000000000..f7e8c022d --- /dev/null +++ b/samples/java_layout/src/main/res/drawable/ic_live_delivery_scooter.xml @@ -0,0 +1,21 @@ + + + + + + diff --git a/samples/java_layout/src/main/res/drawable/ic_rideshare_car.xml b/samples/java_layout/src/main/res/drawable/ic_rideshare_car.xml new file mode 100644 index 000000000..052fc5cb0 --- /dev/null +++ b/samples/java_layout/src/main/res/drawable/ic_rideshare_car.xml @@ -0,0 +1,9 @@ + + + diff --git a/samples/java_layout/src/main/res/drawable/ic_step_active.xml b/samples/java_layout/src/main/res/drawable/ic_step_active.xml new file mode 100644 index 000000000..488be8dc5 --- /dev/null +++ b/samples/java_layout/src/main/res/drawable/ic_step_active.xml @@ -0,0 +1,9 @@ + + + diff --git a/samples/java_layout/src/main/res/drawable/ic_step_done.xml b/samples/java_layout/src/main/res/drawable/ic_step_done.xml new file mode 100644 index 000000000..848364a99 --- /dev/null +++ b/samples/java_layout/src/main/res/drawable/ic_step_done.xml @@ -0,0 +1,9 @@ + + + diff --git a/samples/java_layout/src/main/res/drawable/ic_step_pending.xml b/samples/java_layout/src/main/res/drawable/ic_step_pending.xml new file mode 100644 index 000000000..e3b429513 --- /dev/null +++ b/samples/java_layout/src/main/res/drawable/ic_step_pending.xml @@ -0,0 +1,9 @@ + + + diff --git a/samples/java_layout/src/main/res/drawable/ic_workout_run.xml b/samples/java_layout/src/main/res/drawable/ic_workout_run.xml new file mode 100644 index 000000000..74c362673 --- /dev/null +++ b/samples/java_layout/src/main/res/drawable/ic_workout_run.xml @@ -0,0 +1,9 @@ + + + diff --git a/samples/java_layout/src/main/res/drawable/league_nba.xml b/samples/java_layout/src/main/res/drawable/league_nba.xml new file mode 100644 index 000000000..ade51077a --- /dev/null +++ b/samples/java_layout/src/main/res/drawable/league_nba.xml @@ -0,0 +1,9 @@ + + + diff --git a/samples/java_layout/src/main/res/layout/activity_dashboard.xml b/samples/java_layout/src/main/res/layout/activity_dashboard.xml index f5e1b5cf2..0992c0289 100644 --- a/samples/java_layout/src/main/res/layout/activity_dashboard.xml +++ b/samples/java_layout/src/main/res/layout/activity_dashboard.xml @@ -233,12 +233,25 @@ android:layout_height="wrap_content" android:layout_marginTop="@dimen/margin_default" android:text="@string/inbox_messages" - app:layout_constraintBottom_toTopOf="@id/logout_button" + app:layout_constraintBottom_toTopOf="@id/live_notification_demo_button" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toBottomOf="@id/inline_examples_button" app:layout_constraintWidth_max="@dimen/material_button_max_width" /> +