diff --git a/messaginginapp/api/messaginginapp.api b/messaginginapp/api/messaginginapp.api index 0fc6f893d..8ebb2c7e2 100644 --- a/messaginginapp/api/messaginginapp.api +++ b/messaginginapp/api/messaginginapp.api @@ -21,6 +21,7 @@ public final class io/customer/messaginginapp/ModuleMessagingInApp : io/customer public fun getModuleConfig ()Lio/customer/messaginginapp/MessagingInAppModuleConfig; public synthetic fun getModuleConfig ()Lio/customer/sdk/core/module/CustomerIOModuleConfig; public fun getModuleName ()Ljava/lang/String; + public final fun inbox ()Lio/customer/messaginginapp/inbox/NotificationInbox; public fun initialize ()V public static final fun instance ()Lio/customer/messaginginapp/ModuleMessagingInApp; public fun onAction (Lio/customer/messaginginapp/gist/data/model/Message;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;)V @@ -57,27 +58,6 @@ public final class io/customer/messaginginapp/gist/data/NetworkUtilities { public final class io/customer/messaginginapp/gist/data/NetworkUtilities$Companion { } -public abstract interface class io/customer/messaginginapp/gist/data/listeners/GistQueue { - public abstract fun fetchUserMessages ()V - public abstract fun logView (Lio/customer/messaginginapp/gist/data/model/Message;)V -} - -public abstract interface class io/customer/messaginginapp/gist/data/listeners/GistQueueService { - public abstract fun fetchMessagesForUser (Ljava/lang/Object;Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; - public abstract fun logMessageView (Ljava/lang/String;Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; - public abstract fun logUserMessageView (Ljava/lang/String;Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; -} - -public final class io/customer/messaginginapp/gist/data/listeners/GistQueueService$DefaultImpls { - public static synthetic fun fetchMessagesForUser$default (Lio/customer/messaginginapp/gist/data/listeners/GistQueueService;Ljava/lang/Object;Ljava/lang/String;Lkotlin/coroutines/Continuation;ILjava/lang/Object;)Ljava/lang/Object; -} - -public final class io/customer/messaginginapp/gist/data/listeners/Queue : io/customer/messaginginapp/gist/data/listeners/GistQueue { - public fun ()V - public fun fetchUserMessages ()V - public fun logView (Lio/customer/messaginginapp/gist/data/model/Message;)V -} - public final class io/customer/messaginginapp/gist/data/model/BroadcastFrequency { public fun (IIZ)V public synthetic fun (IIZILkotlin/jvm/internal/DefaultConstructorMarker;)V @@ -128,6 +108,33 @@ public final class io/customer/messaginginapp/gist/data/model/GistProperties { public fun toString ()Ljava/lang/String; } +public final class io/customer/messaginginapp/gist/data/model/InboxMessage { + public fun (Ljava/lang/String;Ljava/lang/String;Ljava/util/Date;Ljava/util/Date;Ljava/util/List;Ljava/lang/String;ZLjava/lang/Integer;Ljava/util/Map;)V + public final fun component1 ()Ljava/lang/String; + public final fun component2 ()Ljava/lang/String; + public final fun component3 ()Ljava/util/Date; + public final fun component4 ()Ljava/util/Date; + public final fun component5 ()Ljava/util/List; + public final fun component6 ()Ljava/lang/String; + public final fun component7 ()Z + public final fun component8 ()Ljava/lang/Integer; + public final fun component9 ()Ljava/util/Map; + public final fun copy (Ljava/lang/String;Ljava/lang/String;Ljava/util/Date;Ljava/util/Date;Ljava/util/List;Ljava/lang/String;ZLjava/lang/Integer;Ljava/util/Map;)Lio/customer/messaginginapp/gist/data/model/InboxMessage; + public static synthetic fun copy$default (Lio/customer/messaginginapp/gist/data/model/InboxMessage;Ljava/lang/String;Ljava/lang/String;Ljava/util/Date;Ljava/util/Date;Ljava/util/List;Ljava/lang/String;ZLjava/lang/Integer;Ljava/util/Map;ILjava/lang/Object;)Lio/customer/messaginginapp/gist/data/model/InboxMessage; + public fun equals (Ljava/lang/Object;)Z + public final fun getDeliveryId ()Ljava/lang/String; + public final fun getExpiry ()Ljava/util/Date; + public final fun getOpened ()Z + public final fun getPriority ()Ljava/lang/Integer; + public final fun getProperties ()Ljava/util/Map; + public final fun getQueueId ()Ljava/lang/String; + public final fun getSentAt ()Ljava/util/Date; + public final fun getTopics ()Ljava/util/List; + public final fun getType ()Ljava/lang/String; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + public final class io/customer/messaginginapp/gist/data/model/Message { public fun ()V public fun (Ljava/lang/String;Ljava/lang/Integer;Ljava/lang/String;Ljava/util/Map;)V @@ -200,22 +207,26 @@ public final class io/customer/messaginginapp/gist/utilities/ElapsedTimer { public final fun start (Ljava/lang/String;)V } -public abstract interface class io/customer/messaginginapp/store/InAppPreferenceStore { - public abstract fun clearAll ()V - public abstract fun clearAllAnonymousData ()V - public abstract fun clearAnonymousTracking (Ljava/lang/String;)V - public abstract fun getAnonymousMessages ()Ljava/lang/String; - public abstract fun getAnonymousNextShowTime (Ljava/lang/String;)J - public abstract fun getAnonymousTimesShown (Ljava/lang/String;)I - public abstract fun getNetworkResponse (Ljava/lang/String;)Ljava/lang/String; - public abstract fun incrementAnonymousTimesShown (Ljava/lang/String;)V - public abstract fun isAnonymousDismissed (Ljava/lang/String;)Z - public abstract fun isAnonymousInDelayPeriod (Ljava/lang/String;)Z - public abstract fun isAnonymousMessagesExpired ()Z - public abstract fun saveAnonymousMessages (Ljava/lang/String;J)V - public abstract fun saveNetworkResponse (Ljava/lang/String;Ljava/lang/String;)V - public abstract fun setAnonymousDismissed (Ljava/lang/String;Z)V - public abstract fun setAnonymousNextShowTime (Ljava/lang/String;J)V +public final class io/customer/messaginginapp/inbox/NotificationInbox { + public final fun addChangeListener (Lio/customer/messaginginapp/inbox/NotificationInboxChangeListener;)V + public final fun addChangeListener (Lio/customer/messaginginapp/inbox/NotificationInboxChangeListener;Ljava/lang/String;)V + public static synthetic fun addChangeListener$default (Lio/customer/messaginginapp/inbox/NotificationInbox;Lio/customer/messaginginapp/inbox/NotificationInboxChangeListener;Ljava/lang/String;ILjava/lang/Object;)V + public final fun fetchMessages (Ljava/lang/String;Lkotlin/jvm/functions/Function1;)V + public final fun fetchMessages (Lkotlin/jvm/functions/Function1;)V + public final fun getMessages (Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public final fun getMessages (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public static synthetic fun getMessages$default (Lio/customer/messaginginapp/inbox/NotificationInbox;Ljava/lang/String;Lkotlin/coroutines/Continuation;ILjava/lang/Object;)Ljava/lang/Object; + public final fun markMessageDeleted (Lio/customer/messaginginapp/gist/data/model/InboxMessage;)V + public final fun markMessageOpened (Lio/customer/messaginginapp/gist/data/model/InboxMessage;)V + public final fun markMessageUnopened (Lio/customer/messaginginapp/gist/data/model/InboxMessage;)V + public final fun removeChangeListener (Lio/customer/messaginginapp/inbox/NotificationInboxChangeListener;)V + public final fun trackMessageClicked (Lio/customer/messaginginapp/gist/data/model/InboxMessage;)V + public final fun trackMessageClicked (Lio/customer/messaginginapp/gist/data/model/InboxMessage;Ljava/lang/String;)V + public static synthetic fun trackMessageClicked$default (Lio/customer/messaginginapp/inbox/NotificationInbox;Lio/customer/messaginginapp/gist/data/model/InboxMessage;Ljava/lang/String;ILjava/lang/Object;)V +} + +public abstract interface class io/customer/messaginginapp/inbox/NotificationInboxChangeListener { + public abstract fun onMessagesChanged (Ljava/util/List;)V } public abstract interface class io/customer/messaginginapp/type/InAppEventListener { diff --git a/messaginginapp/src/main/java/io/customer/messaginginapp/ModuleMessagingInApp.kt b/messaginginapp/src/main/java/io/customer/messaginginapp/ModuleMessagingInApp.kt index 8b338da3a..c0e7166ee 100644 --- a/messaginginapp/src/main/java/io/customer/messaginginapp/ModuleMessagingInApp.kt +++ b/messaginginapp/src/main/java/io/customer/messaginginapp/ModuleMessagingInApp.kt @@ -2,9 +2,11 @@ package io.customer.messaginginapp import io.customer.messaginginapp.di.gistCustomAttributes import io.customer.messaginginapp.di.gistProvider +import io.customer.messaginginapp.di.notificationInbox import io.customer.messaginginapp.gist.data.model.Message import io.customer.messaginginapp.gist.presentation.GistListener import io.customer.messaginginapp.gist.presentation.GistProvider +import io.customer.messaginginapp.inbox.NotificationInbox import io.customer.messaginginapp.type.InAppMessage import io.customer.sdk.communication.Event import io.customer.sdk.communication.subscribe @@ -23,6 +25,15 @@ class ModuleMessagingInApp( get() = SDKComponent.gistProvider private val logger = SDKComponent.logger + /** + * Access the inbox messages instance for managing user inbox messages. + * + * @return [NotificationInbox] instance for inbox operations + */ + fun inbox(): NotificationInbox { + return SDKComponent.notificationInbox + } + fun dismissMessage() { gistProvider.dismissMessage() } diff --git a/messaginginapp/src/main/java/io/customer/messaginginapp/di/DIGraphMessagingInApp.kt b/messaginginapp/src/main/java/io/customer/messaginginapp/di/DIGraphMessagingInApp.kt index 394367595..eb457678e 100644 --- a/messaginginapp/src/main/java/io/customer/messaginginapp/di/DIGraphMessagingInApp.kt +++ b/messaginginapp/src/main/java/io/customer/messaginginapp/di/DIGraphMessagingInApp.kt @@ -20,6 +20,7 @@ import io.customer.messaginginapp.gist.presentation.SseLifecycleManager import io.customer.messaginginapp.gist.utilities.ModalMessageGsonParser import io.customer.messaginginapp.gist.utilities.ModalMessageParser import io.customer.messaginginapp.gist.utilities.ModalMessageParserDefault +import io.customer.messaginginapp.inbox.NotificationInbox import io.customer.messaginginapp.state.InAppMessagingManager import io.customer.messaginginapp.store.InAppPreferenceStore import io.customer.messaginginapp.store.InAppPreferenceStoreImpl @@ -134,3 +135,16 @@ fun CustomerIOInstance.inAppMessaging(): ModuleMessagingInApp = SDKComponent.inA internal val SDKComponent.inAppMessaging: ModuleMessagingInApp get() = ModuleMessagingInApp.instance() + +/** + * Provides singleton instance of [NotificationInbox] for managing inbox messages. + */ +internal val SDKComponent.notificationInbox: NotificationInbox + get() = singleton { + NotificationInbox( + logger = logger, + coroutineScope = scopeProvider.inAppLifecycleScope, + dispatchersProvider = dispatchersProvider, + inAppMessagingManager = inAppMessagingManager + ) + } diff --git a/messaginginapp/src/main/java/io/customer/messaginginapp/gist/data/listeners/Queue.kt b/messaginginapp/src/main/java/io/customer/messaginginapp/gist/data/listeners/Queue.kt index e3acc1d03..2f1f3ff13 100644 --- a/messaginginapp/src/main/java/io/customer/messaginginapp/gist/data/listeners/Queue.kt +++ b/messaginginapp/src/main/java/io/customer/messaginginapp/gist/data/listeners/Queue.kt @@ -7,8 +7,12 @@ import io.customer.messaginginapp.di.inAppPreferenceStore import io.customer.messaginginapp.di.inAppSseLogger import io.customer.messaginginapp.gist.data.AnonymousMessageManager import io.customer.messaginginapp.gist.data.NetworkUtilities +import io.customer.messaginginapp.gist.data.model.InboxMessage import io.customer.messaginginapp.gist.data.model.Message import io.customer.messaginginapp.gist.data.model.isMessageAnonymous +import io.customer.messaginginapp.gist.data.model.response.InboxMessageFactory +import io.customer.messaginginapp.gist.data.model.response.QueueMessagesResponse +import io.customer.messaginginapp.gist.data.model.response.toLogString import io.customer.messaginginapp.state.InAppMessagingAction import io.customer.messaginginapp.state.InAppMessagingState import io.customer.messaginginapp.store.InAppPreferenceStore @@ -25,27 +29,33 @@ import retrofit2.Response import retrofit2.Retrofit import retrofit2.converter.gson.GsonConverterFactory import retrofit2.http.Body +import retrofit2.http.PATCH import retrofit2.http.POST import retrofit2.http.Path import retrofit2.http.Query -interface GistQueueService { - @POST("/api/v3/users") - suspend fun fetchMessagesForUser(@Body body: Any = Object(), @Query("sessionId") sessionId: String): Response> +internal interface GistQueueService { + @POST("/api/v4/users") + suspend fun fetchMessagesForUser(@Body body: Any = Object(), @Query("sessionId") sessionId: String): Response @POST("/api/v1/logs/message/{messageId}") suspend fun logMessageView(@Path("messageId") messageId: String, @Query("sessionId") sessionId: String) @POST("/api/v1/logs/queue/{queueId}") suspend fun logUserMessageView(@Path("queueId") queueId: String, @Query("sessionId") sessionId: String) + + @PATCH("/api/v1/messages/{queueId}") + suspend fun logInboxMessageOpened(@Path("queueId") queueId: String, @Query("sessionId") sessionId: String, @Body body: Map) } -interface GistQueue { +internal interface GistQueue { fun fetchUserMessages() fun logView(message: Message) + fun logOpenedStatus(message: InboxMessage, opened: Boolean) + fun logDeleted(message: InboxMessage) } -class Queue : GistQueue { +internal class Queue : GistQueue { private val inAppMessagingManager = SDKComponent.inAppMessagingManager private val state: InAppMessagingState @@ -108,12 +118,16 @@ class Queue : GistQueue { return response } + // Populates 304 response body with cached data and converts to 200 for Retrofit compatibility. + // Retrofit only populates body() for 2xx responses, so we must convert 304->200. + // Adds custom header to track that this was originally a 304 response. private fun interceptNotModifiedResponse(response: okhttp3.Response, originalRequest: okhttp3.Request): okhttp3.Response { val cachedResponse = inAppPreferenceStore.getNetworkResponse(originalRequest.url.toString()) return cachedResponse?.let { response.newBuilder() .body(it.toResponseBody(null)) .code(200) + .header(HEADER_FROM_CACHE, "true") .build() } ?: response } @@ -125,9 +139,14 @@ class Queue : GistQueue { val latestMessagesResponse = gistQueueService.fetchMessagesForUser(sessionId = state.sessionId) val code = latestMessagesResponse.code() + val fromCache = latestMessagesResponse.headers()[HEADER_FROM_CACHE] == "true" when { (code == 204 || code == 304) -> handleNoContent(code) - latestMessagesResponse.isSuccessful -> handleSuccessfulFetch(latestMessagesResponse.body()) + latestMessagesResponse.isSuccessful -> handleSuccessfulFetch( + responseBody = latestMessagesResponse.body(), + fromCache = fromCache + ) + else -> handleFailedFetch(code) } @@ -141,34 +160,64 @@ class Queue : GistQueue { private fun handleNoContent(responseCode: Int) { logger.debug("No messages found for user with response code: $responseCode") - inAppMessagingManager.dispatch(InAppMessagingAction.ClearMessageQueue) + inAppMessagingManager.dispatch(InAppMessagingAction.ClearMessageQueue(isContentEmpty = true)) } - private fun handleSuccessfulFetch(responseBody: List?) { - logger.debug("Found ${responseBody?.count()} messages for user") - responseBody?.let { messages -> + // For cached responses (304), apply locally cached opened status to preserve user's changes. + // For fresh responses (200), clear cached status and use server's data. + private fun handleSuccessfulFetch(responseBody: QueueMessagesResponse?, fromCache: Boolean) { + if (responseBody == null) { + logger.error("Received null response body for successful fetch") + return + } + + responseBody.let { response -> + // Process in-app messages first + val inAppMessages = response.inAppMessages + logger.debug("Found ${inAppMessages.count()} in-app messages for user") // Store anonymous messages locally for frequency management - anonymousMessageManager.updateAnonymousMessagesLocalStore(messages) + anonymousMessageManager.updateAnonymousMessagesLocalStore(inAppMessages) // Get eligible anonymous messages from local storage (respects frequency/dismissal rules) val eligibleAnonymousMessages = anonymousMessageManager.getEligibleAnonymousMessages() // Filter out anonymous messages from server response (we use local ones instead) - val regularMessages = messages.filter { !it.isMessageAnonymous() } + val regularMessages = inAppMessages.filter { !it.isMessageAnonymous() } // Combine regular messages with eligible anonymous messages val allMessages = regularMessages + eligibleAnonymousMessages logger.debug("Processing ${regularMessages.size} regular messages and ${eligibleAnonymousMessages.size} eligible anonymous messages") - // Process all messages through the normal queue + // Process all in-app messages through the normal queue inAppMessagingManager.dispatch(InAppMessagingAction.ProcessMessageQueue(allMessages)) + + // Process inbox messages next + val inboxMessages = response.inboxMessages + logger.debug("Found ${inboxMessages.count()} inbox messages for user") + val inboxMessagesMapped = inboxMessages.mapNotNull { item -> + InboxMessageFactory.fromResponse(item)?.let { message -> + if (fromCache) { + // 304: apply cached opened status if available + val cachedOpenedStatus = inAppPreferenceStore.getInboxMessageOpenedStatus(message.queueId) + return@let cachedOpenedStatus?.let { message.copy(opened = it) } ?: message + } else { + // 200: clear cached status and use server's data + inAppPreferenceStore.clearInboxMessageOpenedStatus(message.queueId) + return@let message + } + } + } + if (inboxMessagesMapped.size < inboxMessages.size) { + logger.debug("Filtered out ${inboxMessages.size - inboxMessagesMapped.size} invalid inbox message(s)") + } + inAppMessagingManager.dispatch(InAppMessagingAction.ProcessInboxMessages(inboxMessagesMapped)) } } private fun handleFailedFetch(responseCode: Int) { logger.error("Failed to fetch messages: $responseCode") - inAppMessagingManager.dispatch(InAppMessagingAction.ClearMessageQueue) + inAppMessagingManager.dispatch(InAppMessagingAction.ClearMessageQueue(isContentEmpty = false)) } private fun updatePollingInterval(headers: Headers) { @@ -207,4 +256,44 @@ class Queue : GistQueue { } } } + + override fun logOpenedStatus(message: InboxMessage, opened: Boolean) { + scope.launch { + try { + logger.debug("Updating inbox message ${message.toLogString()} opened status to: $opened") + // Log updated opened status to server + gistQueueService.logInboxMessageOpened( + queueId = message.queueId, + sessionId = state.sessionId, + body = mapOf("opened" to opened) + ) + // Cache the opened status locally for 304 responses + inAppPreferenceStore.saveInboxMessageOpenedStatus(message.queueId, opened) + } catch (e: Exception) { + logger.error("Failed to update inbox message ${message.toLogString()} opened status: ${e.message}") + } + } + } + + override fun logDeleted(message: InboxMessage) { + scope.launch { + try { + logger.debug("Deleting inbox message: ${message.toLogString()}") + // Log deletion event to server + gistQueueService.logUserMessageView( + queueId = message.queueId, + sessionId = state.sessionId + ) + // Clear any cached opened status for deleted message + inAppPreferenceStore.clearInboxMessageOpenedStatus(message.queueId) + } catch (e: Exception) { + logger.error("Failed to delete inbox message ${message.toLogString()}: ${e.message}") + } + } + } + + companion object { + // Custom header to distinguish 304 responses (converted to 200) from genuine 200 responses + private const val HEADER_FROM_CACHE = "X-CIO-MOBILE-SDK-Cache" + } } diff --git a/messaginginapp/src/main/java/io/customer/messaginginapp/gist/data/model/InboxMessage.kt b/messaginginapp/src/main/java/io/customer/messaginginapp/gist/data/model/InboxMessage.kt new file mode 100644 index 000000000..afdaad433 --- /dev/null +++ b/messaginginapp/src/main/java/io/customer/messaginginapp/gist/data/model/InboxMessage.kt @@ -0,0 +1,31 @@ +package io.customer.messaginginapp.gist.data.model + +import java.util.Date + +/** + * Represents an inbox message for a user. + * + * Inbox messages are persistent messages that can be displayed in a message center or inbox UI. + * They support read/unread states, expiration, and custom properties. + * + * @property queueId Internal queue identifier (for SDK use) + * @property deliveryId Unique identifier for this message delivery + * @property expiry Optional expiration date. Messages may be hidden after this date. + * @property sentAt Optional date when the message was sent + * @property topics List of topic identifiers associated with this message. Empty list if no topics. + * @property type Message type identifier + * @property opened Whether the user has opened/read this message + * @property priority Optional priority for message ordering. Lower values = higher priority (e.g., 1 is higher priority than 100) + * @property properties Custom key-value properties associated with this message + */ +data class InboxMessage( + val queueId: String, + val deliveryId: String?, + val expiry: Date?, + val sentAt: Date, + val topics: List, + val type: String, + val opened: Boolean, + val priority: Int?, + val properties: Map +) diff --git a/messaginginapp/src/main/java/io/customer/messaginginapp/gist/data/model/adapters/Iso8601DateAdapter.kt b/messaginginapp/src/main/java/io/customer/messaginginapp/gist/data/model/adapters/Iso8601DateAdapter.kt new file mode 100644 index 000000000..678e0a79b --- /dev/null +++ b/messaginginapp/src/main/java/io/customer/messaginginapp/gist/data/model/adapters/Iso8601DateAdapter.kt @@ -0,0 +1,104 @@ +package io.customer.messaginginapp.gist.data.model.adapters + +import com.google.gson.TypeAdapter +import com.google.gson.stream.JsonReader +import com.google.gson.stream.JsonToken +import com.google.gson.stream.JsonWriter +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale +import java.util.TimeZone + +/** + * Gson TypeAdapter for parsing ISO 8601 date strings to Date objects. + * Handles formats with milliseconds, microseconds, or no fractional seconds. + * Gracefully handles null/invalid values by returning null instead of throwing. + * + * Note: Java's Date only supports millisecond precision. Microseconds are truncated to milliseconds. + */ +internal class Iso8601DateAdapter : TypeAdapter() { + override fun write(out: JsonWriter, value: Date?) { + val formatter = formatterWithMillis.get() + if (value == null || formatter == null) { + out.nullValue() + } else { + out.value(formatter.format(value)) + } + } + + override fun read(input: JsonReader): Date? { + if (input.peek() == JsonToken.NULL) { + input.nextNull() + return null + } + + val dateString = input.nextString() + if (dateString.isBlank()) { + return null + } + + // Try parsing with milliseconds first + return try { + // Normalize microseconds to milliseconds if present + // e.g., "2026-01-27T12:30:45.123456Z" -> "2026-01-27T12:30:45.123Z" + val normalizedDateString = normalizeFractionalSeconds(dateString) + formatterWithMillis.get()?.parse(normalizedDateString) + } catch (_: Exception) { + // Try parsing without milliseconds + try { + formatterWithoutMillis.get()?.parse(dateString) + } catch (_: Exception) { + // If both fail, return null instead of throwing + // This makes the API resilient to unexpected date formats + null + } + } + } + + /** + * Normalizes fractional seconds to exactly 3 digits (milliseconds). + * - Pads if fewer than 3 digits (e.g., ".1Z" -> ".100Z", ".12Z" -> ".120Z") + * - Truncates if more than 3 digits (e.g., ".123456Z" -> ".123Z") + * Per ISO 8601, ".1" represents 0.1 seconds = 100 milliseconds. + */ + private fun normalizeFractionalSeconds(dateString: String): String { + return try { + // Pattern: decimal point followed by 1+ digits before 'Z' + val regex = """\.\d+Z""".toRegex() + return regex.replace(dateString) { matchResult -> + // Extract the digits after the decimal point (without '.' and 'Z') + val fractionalPart = matchResult.value.substring(1, matchResult.value.length - 1) + + // Normalize to exactly 3 digits + val normalized = when { + fractionalPart.length >= 3 -> fractionalPart.take(3) // Truncate + else -> fractionalPart.padEnd(3, '0') // Pad with zeros + } + + return@replace ".$normalized" + "Z" + } + } catch (_: Exception) { + return dateString // Return unchanged if normalization fails + } + } + + companion object { + // ThreadLocal caches formatters per thread to avoid creating new instances repeatedly + // while still being thread-safe (SimpleDateFormat is not thread-safe) + private val formatterWithMillis = object : ThreadLocal() { + override fun initialValue(): SimpleDateFormat { + return SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", Locale.US).apply { + timeZone = TimeZone.getTimeZone("UTC") + } + } + } + + private val formatterWithoutMillis = object : ThreadLocal() { + override fun initialValue(): SimpleDateFormat { + return SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'", Locale.US).apply { + timeZone = TimeZone.getTimeZone("UTC") + } + } + } + } +} diff --git a/messaginginapp/src/main/java/io/customer/messaginginapp/gist/data/model/response/InboxMessageExtensions.kt b/messaginginapp/src/main/java/io/customer/messaginginapp/gist/data/model/response/InboxMessageExtensions.kt new file mode 100644 index 000000000..afa337325 --- /dev/null +++ b/messaginginapp/src/main/java/io/customer/messaginginapp/gist/data/model/response/InboxMessageExtensions.kt @@ -0,0 +1,6 @@ +package io.customer.messaginginapp.gist.data.model.response + +import io.customer.messaginginapp.gist.data.model.InboxMessage + +// Formats inbox message for logging. +internal fun InboxMessage.toLogString(): String = "$queueId (deliveryId: $deliveryId)" diff --git a/messaginginapp/src/main/java/io/customer/messaginginapp/gist/data/model/response/InboxMessageFactory.kt b/messaginginapp/src/main/java/io/customer/messaginginapp/gist/data/model/response/InboxMessageFactory.kt new file mode 100644 index 000000000..92746c9d8 --- /dev/null +++ b/messaginginapp/src/main/java/io/customer/messaginginapp/gist/data/model/response/InboxMessageFactory.kt @@ -0,0 +1,56 @@ +package io.customer.messaginginapp.gist.data.model.response + +import io.customer.base.internal.InternalCustomerIOApi +import io.customer.messaginginapp.gist.data.model.InboxMessage +import java.util.Date + +/** + * Factory for creating InboxMessage domain models from various sources (API responses, Maps). + */ +@InternalCustomerIOApi +object InboxMessageFactory { + /** + * Converts InboxMessageResponse to InboxMessage with safe defaults for nullable fields. + * Returns null if required fields (queueId, sentAt) are missing. + */ + internal fun fromResponse(response: InboxMessageResponse): InboxMessage? { + // Skip invalid messages missing required fields + if (response.queueId == null || response.sentAt == null) { + return null + } + + return InboxMessage( + queueId = response.queueId, + deliveryId = response.deliveryId, + expiry = response.expiry, + sentAt = response.sentAt, + topics = response.topics ?: emptyList(), + type = response.type ?: "", + opened = response.opened ?: false, + priority = response.priority, + properties = response.properties ?: emptyMap() + ) + } + + /** + * Converts Map to InboxMessage for SDK wrapper integrations. + * Returns null if required fields (queueId, sentAt) are missing or invalid. + */ + fun fromMap(map: Map): InboxMessage? { + @Suppress("UNCHECKED_CAST") + val properties = map["properties"] as? Map ?: emptyMap() + return fromResponse( + InboxMessageResponse( + queueId = map["queueId"] as? String, + deliveryId = map["deliveryId"] as? String, + expiry = (map["expiry"] as? Number)?.toLong()?.let { Date(it) }, + sentAt = (map["sentAt"] as? Number)?.toLong()?.let { Date(it) }, + topics = (map["topics"] as? List<*>)?.mapNotNull { it as? String }, + type = map["type"] as? String, + opened = map["opened"] as? Boolean, + priority = (map["priority"] as? Number)?.toInt(), + properties = properties + ) + ) + } +} diff --git a/messaginginapp/src/main/java/io/customer/messaginginapp/gist/data/model/response/InboxMessageResponse.kt b/messaginginapp/src/main/java/io/customer/messaginginapp/gist/data/model/response/InboxMessageResponse.kt new file mode 100644 index 000000000..f8848684d --- /dev/null +++ b/messaginginapp/src/main/java/io/customer/messaginginapp/gist/data/model/response/InboxMessageResponse.kt @@ -0,0 +1,23 @@ +package io.customer.messaginginapp.gist.data.model.response + +import com.google.gson.annotations.JsonAdapter +import io.customer.messaginginapp.gist.data.model.adapters.Iso8601DateAdapter +import java.util.Date + +/** + * API response model matching backend JSON contract. + * All fields nullable to allow for safe parsing. + */ +internal data class InboxMessageResponse( + val queueId: String? = null, + val deliveryId: String? = null, + @JsonAdapter(Iso8601DateAdapter::class) + val expiry: Date? = null, + @JsonAdapter(Iso8601DateAdapter::class) + val sentAt: Date? = null, + val topics: List? = null, + val type: String? = null, + val opened: Boolean? = null, + val priority: Int? = null, + val properties: Map? = null +) diff --git a/messaginginapp/src/main/java/io/customer/messaginginapp/gist/data/model/response/QueueMessagesResponse.kt b/messaginginapp/src/main/java/io/customer/messaginginapp/gist/data/model/response/QueueMessagesResponse.kt new file mode 100644 index 000000000..7416a0921 --- /dev/null +++ b/messaginginapp/src/main/java/io/customer/messaginginapp/gist/data/model/response/QueueMessagesResponse.kt @@ -0,0 +1,14 @@ +package io.customer.messaginginapp.gist.data.model.response + +import com.google.gson.annotations.SerializedName +import io.customer.messaginginapp.gist.data.model.Message + +/** + * Response model for fetching user messages from the queue. + */ +internal data class QueueMessagesResponse( + @SerializedName("inAppMessages") + val inAppMessages: List = emptyList(), + @SerializedName("inboxMessages") + val inboxMessages: List = emptyList() +) diff --git a/messaginginapp/src/main/java/io/customer/messaginginapp/gist/data/sse/InAppSseLogger.kt b/messaginginapp/src/main/java/io/customer/messaginginapp/gist/data/sse/InAppSseLogger.kt index 251550eed..e27c47c68 100644 --- a/messaginginapp/src/main/java/io/customer/messaginginapp/gist/data/sse/InAppSseLogger.kt +++ b/messaginginapp/src/main/java/io/customer/messaginginapp/gist/data/sse/InAppSseLogger.kt @@ -83,8 +83,8 @@ internal class InAppSseLogger(private val logger: Logger) { logger.debug(tag = TAG, message = "Received heartbeat") } - fun logReceivedMessages(count: Int) { - logger.debug(tag = TAG, message = "Received $count messages") + fun logReceivedMessages(count: Int, type: String?) { + logger.debug(tag = TAG, message = "Received $count $type messages") } fun logReceivedEmptyMessagesEvent() { @@ -219,6 +219,10 @@ internal class InAppSseLogger(private val logger: Logger) { logger.debug(tag = TAG, message = "Error parsing heartbeat timeout: $errorMessage, data: $data") } + fun logFilteredInvalidInboxMessages(count: Int) { + logger.debug(tag = TAG, message = "Filtered out $count invalid inbox message(s) from SSE") + } + // ===================== // Flow Collector Errors // ===================== diff --git a/messaginginapp/src/main/java/io/customer/messaginginapp/gist/data/sse/SseConnectionManager.kt b/messaginginapp/src/main/java/io/customer/messaginginapp/gist/data/sse/SseConnectionManager.kt index 169fb2e98..45e6a2e3f 100644 --- a/messaginginapp/src/main/java/io/customer/messaginginapp/gist/data/sse/SseConnectionManager.kt +++ b/messaginginapp/src/main/java/io/customer/messaginginapp/gist/data/sse/SseConnectionManager.kt @@ -217,9 +217,9 @@ internal class SseConnectionManager( ServerEvent.MESSAGES -> { try { - val messages = sseDataParser.parseMessages(event.data) + val messages = sseDataParser.parseInAppMessages(event.data) if (messages.isNotEmpty()) { - sseLogger.logReceivedMessages(messages.size) + sseLogger.logReceivedMessages(messages.size, "in-app") inAppMessagingManager.dispatch( InAppMessagingAction.ProcessMessageQueue( messages @@ -233,6 +233,22 @@ internal class SseConnectionManager( } } + ServerEvent.INBOX_MESSAGES -> { + try { + val inboxMessages = sseDataParser.parseInboxMessages(event.data) + if (inboxMessages.isNotEmpty()) { + sseLogger.logReceivedMessages(inboxMessages.size, "inbox") + inAppMessagingManager.dispatch( + InAppMessagingAction.ProcessInboxMessages(inboxMessages) + ) + } else { + sseLogger.logReceivedEmptyMessagesEvent() + } + } catch (e: Exception) { + sseLogger.logFailedToParseMessages(e.message) + } + } + ServerEvent.TTL_EXCEEDED -> { sseLogger.logTtlExceeded() cleanupForReconnect() diff --git a/messaginginapp/src/main/java/io/customer/messaginginapp/gist/data/sse/SseDataParser.kt b/messaginginapp/src/main/java/io/customer/messaginginapp/gist/data/sse/SseDataParser.kt index 2e682449e..50b94f6a8 100644 --- a/messaginginapp/src/main/java/io/customer/messaginginapp/gist/data/sse/SseDataParser.kt +++ b/messaginginapp/src/main/java/io/customer/messaginginapp/gist/data/sse/SseDataParser.kt @@ -4,29 +4,60 @@ import com.google.gson.Gson import com.google.gson.JsonObject import com.google.gson.JsonSyntaxException import io.customer.messaginginapp.gist.data.NetworkUtilities +import io.customer.messaginginapp.gist.data.model.InboxMessage import io.customer.messaginginapp.gist.data.model.Message +import io.customer.messaginginapp.gist.data.model.response.InboxMessageFactory +import io.customer.messaginginapp.gist.data.model.response.InboxMessageResponse internal class SseDataParser( private val sseLogger: InAppSseLogger, private val gson: Gson ) { /** - * Parse messages from SSE event data. + * Parse in-app messages from SSE event data. + * + * @param data The JSON data string from the messages event + * @return List of Message objects or empty list if parsing fails + */ + fun parseInAppMessages(data: String): List { + return parseMessageArray(data, Array::class.java) + } + + /** + * Parse inbox messages from SSE event data. + * + * @param data The JSON data string from the inbox_messages event + * @return List of InboxMessage objects or empty list if parsing fails + */ + fun parseInboxMessages(data: String): List { + val responses = parseMessageArray(data, Array::class.java) + val messages = responses.mapNotNull(InboxMessageFactory::fromResponse) + + if (messages.size < responses.size) { + sseLogger.logFilteredInvalidInboxMessages(responses.size - messages.size) + } + + return messages + } + + /** + * Generic method to parse message arrays from SSE event data. * * This method is resilient and will never throw exceptions. * Invalid or malformed data will result in an empty list being returned. * - * @param data The JSON data string from the messages event - * @return List of Message objects or empty list if parsing fails + * @param data The JSON data string from the SSE event + * @param arrayClass The class type of the array to parse + * @return List of parsed objects or empty list if parsing fails */ - fun parseMessages(data: String): List { + private fun parseMessageArray(data: String, arrayClass: Class>): List { if (data.isBlank()) { sseLogger.logReceivedEmptyMessageData() return emptyList() } return try { - val messages = gson.fromJson(data, Array::class.java) + val messages = gson.fromJson(data, arrayClass) messages.toList() } catch (e: JsonSyntaxException) { sseLogger.logMessageParsingFailedInvalidJson(e.message, data) diff --git a/messaginginapp/src/main/java/io/customer/messaginginapp/gist/data/sse/SseService.kt b/messaginginapp/src/main/java/io/customer/messaginginapp/gist/data/sse/SseService.kt index 1d9569af7..be3e0f4ba 100644 --- a/messaginginapp/src/main/java/io/customer/messaginginapp/gist/data/sse/SseService.kt +++ b/messaginginapp/src/main/java/io/customer/messaginginapp/gist/data/sse/SseService.kt @@ -200,6 +200,7 @@ internal data class ServerEvent( const val CONNECTED = "connected" const val HEARTBEAT = "heartbeat" const val MESSAGES = "messages" + const val INBOX_MESSAGES = "inbox_messages" const val TTL_EXCEEDED = "ttl_exceeded" } } diff --git a/messaginginapp/src/main/java/io/customer/messaginginapp/inbox/NotificationInbox.kt b/messaginginapp/src/main/java/io/customer/messaginginapp/inbox/NotificationInbox.kt new file mode 100644 index 000000000..34fca0118 --- /dev/null +++ b/messaginginapp/src/main/java/io/customer/messaginginapp/inbox/NotificationInbox.kt @@ -0,0 +1,260 @@ +package io.customer.messaginginapp.inbox + +import androidx.annotation.MainThread +import io.customer.messaginginapp.gist.data.model.InboxMessage +import io.customer.messaginginapp.state.InAppMessagingAction +import io.customer.messaginginapp.state.InAppMessagingManager +import io.customer.messaginginapp.state.InAppMessagingState +import io.customer.sdk.core.util.DispatchersProvider +import io.customer.sdk.core.util.Logger +import java.util.concurrent.CopyOnWriteArraySet +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch + +/** + * Manages inbox messages for the current user. + * + * Inbox messages are persistent messages that users can view, mark as read/unread, and delete. + * Messages are automatically fetched and kept in sync for identified users. + * + * Example usage: + * ``` + * val inbox = CustomerIO.instance().inAppMessaging().inbox() + * + * // Get messages synchronously + * inbox.getMessages() + * ``` + */ +class NotificationInbox internal constructor( + private val logger: Logger, + private val coroutineScope: CoroutineScope, + private val dispatchersProvider: DispatchersProvider, + private val inAppMessagingManager: InAppMessagingManager +) { + private val currentState: InAppMessagingState + get() = inAppMessagingManager.getCurrentState() + + // CopyOnWriteArraySet provides thread safe iteration without blocking + // Ideal for this use case where iteration (on state changes) is more frequent than add/remove + private val listeners = CopyOnWriteArraySet() + + init { + // Subscribe to inbox messages on initialization to simplify listener management + // and eliminate race conditions from conditional subscription + inAppMessagingManager.subscribeToAttribute( + selector = { state -> state.inboxMessages }, + areEquivalent = { old, new -> old == new } + ) { inboxMessages -> + notifyAllListeners(messages = inboxMessages.toList()) + } + } + + /** + * Retrieves the current list of inbox messages synchronously. + * + * @param topic Optional topic filter. If provided, listener only receives messages + * that have this topic in their topics list. If null, all messages are delivered. + * @return List of inbox messages for the current user + */ + @JvmOverloads + @Suppress("RedundantSuspendModifier") + suspend fun getMessages(topic: String? = null): List { + // Intentionally suspend for API stability + val messages = currentState.inboxMessages.toList() + return filterMessagesByTopic(messages, topic) + } + + /** + * Fetches all inbox messages asynchronously via callback. + * + * @param callback Called with [Result] containing the list of messages or an error + * if failed to retrieve + */ + fun fetchMessages(callback: (Result>) -> Unit) { + fetchMessagesWithCallback(null, callback) + } + + /** + * Fetches inbox messages for a specific topic asynchronously via callback. + * + * @param topic Optional topic filter. If provided, listener only receives messages + * that have this topic in their topics list. If null, all messages are delivered. + * @param callback Called with [Result] containing the list of messages or an error + * if failed to retrieve + */ + fun fetchMessages(topic: String?, callback: (Result>) -> Unit) { + fetchMessagesWithCallback(topic, callback) + } + + // Internal helper to avoid code duplication between callback-based overloads + private fun fetchMessagesWithCallback(topic: String?, callback: (Result>) -> Unit) { + coroutineScope.launch { + try { + val messages = getMessages(topic) + callback(Result.success(messages)) + } catch (ex: Exception) { + callback(Result.failure(ex)) + } + } + } + + /** + * Registers a listener for inbox changes. + * + * Must be called from main thread. The listener is immediately notified with current state, + * then receives all future updates. + * + * IMPORTANT: Call [removeChangeListener] when done (e.g., in Activity.onDestroy or Fragment.onDestroyView) + * to prevent memory leaks. + * + * @param listener The listener to receive inbox updates + * @param topic Optional topic filter. If provided, listener only receives messages + * that have this topic in their topics list. If null, all messages are delivered. + */ + @JvmOverloads + @MainThread + fun addChangeListener(listener: NotificationInboxChangeListener, topic: String? = null) { + val registration = ListenerRegistration(listener, topic) + listeners.add(registration) + + // Notify immediately with current state + // Since we're on main thread and subscription coroutines are queued on main dispatcher, + // this should be completed atomically before any queued subscription notifications can execute + val messages = currentState.inboxMessages.toList() + val filteredMessages = filterMessagesByTopic(messages, topic) + notifyListener(listener, filteredMessages) + } + + /** + * Unregisters a listener for inbox changes. + * Removes all registrations of this listener, regardless of topic filters. + */ + fun removeChangeListener(listener: NotificationInboxChangeListener) { + listeners.forEach { registration -> + if (registration.listener == listener) { + listeners.remove(registration) + } + } + } + + /** + * Notifies all registered listeners with filtered messages. + * Prepares notifications on background thread, then switches to main thread for callbacks. + */ + private fun notifyAllListeners(messages: List) { + // Prepare all data on background thread to avoid blocking main thread + val notificationsToSend = listeners.map { (listener, topic) -> + listener to filterMessagesByTopic(messages, topic) + } + + // Switch to main thread for notifications + coroutineScope.launch(dispatchersProvider.main) { + notificationsToSend.forEach { (listener, filteredMessages) -> + notifyListener(listener, filteredMessages) + } + } + } + + /** + * Filters messages by topic if specified and sorts by sentAt (newest first). + * Topic matching is case-insensitive. + * + * @param messages The messages to filter + * @param topic The topic filter, or null to return all messages + * @return Filtered and sorted list of messages + */ + private fun filterMessagesByTopic(messages: List, topic: String?): List { + val filteredMessages = if (topic == null) { + messages + } else { + messages.filter { message -> + message.topics.any { it.equals(topic, ignoreCase = true) } + } + } + return filteredMessages.sortedByDescending { it.sentAt } + } + + /** + * Notifies a single listener with messages, handling errors gracefully. + * Must be called on main thread (callers are responsible for dispatching to main). + * + * @param listener The listener to notify + * @param messages The messages to send to the listener + */ + @MainThread + private fun notifyListener(listener: NotificationInboxChangeListener, messages: List) { + try { + listener.onMessagesChanged(messages) + } catch (ex: Exception) { + // Log and continue to prevent one bad listener from breaking others + logger.error("Error notifying inbox listener: ${ex.message}") + } + } + + /** + * Marks an inbox message as opened/read. + * Updates local state immediately and syncs with the server. + * + * @param message The inbox message to mark as opened + */ + fun markMessageOpened(message: InboxMessage) { + inAppMessagingManager.dispatch( + InAppMessagingAction.InboxAction.UpdateOpened( + message = message, + opened = true + ) + ) + } + + /** + * Marks an inbox message as unopened/unread. + * Updates local state immediately and syncs with the server. + * + * @param message The inbox message to mark as unopened + */ + fun markMessageUnopened(message: InboxMessage) { + inAppMessagingManager.dispatch( + InAppMessagingAction.InboxAction.UpdateOpened( + message = message, + opened = false + ) + ) + } + + /** + * Marks an inbox message as deleted. + * Removes the message from local state and syncs with the server. + * + * @param message The inbox message to mark as deleted + */ + fun markMessageDeleted(message: InboxMessage) { + inAppMessagingManager.dispatch( + InAppMessagingAction.InboxAction.DeleteMessage(message) + ) + } + + /** + * Tracks a click event for an inbox message. + * Sends metric event to data pipelines to track message interaction. + * + * @param message The inbox message that was clicked + * @param actionName Optional name of the action clicked (e.g., "view_details", "dismiss") + */ + @JvmOverloads + fun trackMessageClicked(message: InboxMessage, actionName: String? = null) { + inAppMessagingManager.dispatch( + InAppMessagingAction.InboxAction.TrackClicked( + message = message, + actionName = actionName + ) + ) + } + + /** + * Wrapper class to store listener with optional topic filter. + */ + private data class ListenerRegistration( + val listener: NotificationInboxChangeListener, + val topic: String? = null + ) +} diff --git a/messaginginapp/src/main/java/io/customer/messaginginapp/inbox/NotificationInboxChangeListener.kt b/messaginginapp/src/main/java/io/customer/messaginginapp/inbox/NotificationInboxChangeListener.kt new file mode 100644 index 000000000..336fa1380 --- /dev/null +++ b/messaginginapp/src/main/java/io/customer/messaginginapp/inbox/NotificationInboxChangeListener.kt @@ -0,0 +1,24 @@ +package io.customer.messaginginapp.inbox + +import io.customer.messaginginapp.gist.data.model.InboxMessage + +/** + * Listener for notification inbox message changes. + * + * Receives real time notifications when inbox messages are added, updated, or removed. + * Callbacks are invoked on the main thread for safe UI updates. + * + * **Important:** Call [NotificationInbox.removeChangeListener] when done (e.g., in `onDestroy()`) + * to prevent memory leaks. + */ +interface NotificationInboxChangeListener { + /** + * Called when messages change. + * + * Invoked immediately with current messages when registered, then again whenever + * messages are added, updated, or removed. + * + * @param messages Current inbox messages. Filtered by topic if specified during registration. + */ + fun onMessagesChanged(messages: List) +} diff --git a/messaginginapp/src/main/java/io/customer/messaginginapp/state/InAppMessageReducer.kt b/messaginginapp/src/main/java/io/customer/messaginginapp/state/InAppMessageReducer.kt index fcb259131..cd06111fe 100644 --- a/messaginginapp/src/main/java/io/customer/messaginginapp/state/InAppMessageReducer.kt +++ b/messaginginapp/src/main/java/io/customer/messaginginapp/state/InAppMessageReducer.kt @@ -24,11 +24,44 @@ internal val inAppMessagingReducer: Reducer = { state, acti state.copy(anonymousId = action.anonymousId) is InAppMessagingAction.ClearMessageQueue -> - state.copy(messagesInQueue = emptySet()) + if (action.isContentEmpty) { + state.copy(messagesInQueue = emptySet(), inboxMessages = emptySet()) + } else { + // Only clear the message queue, keep inbox messages until explicitly cleared to show cached content if needed + state.copy(messagesInQueue = emptySet()) + } is InAppMessagingAction.ProcessMessageQueue -> state.copy(messagesInQueue = action.messages.toSet()) + is InAppMessagingAction.ProcessInboxMessages -> + state.copy(inboxMessages = action.messages.toSet()) + + is InAppMessagingAction.InboxAction.UpdateOpened -> { + // Update opened status for given message + val queueId = action.message.queueId + val updatedMessages = state.inboxMessages.map { message -> + if (message.queueId == queueId) { + message.copy(opened = action.opened) + } else { + message + } + }.toSet() + state.copy(inboxMessages = updatedMessages) + } + + is InAppMessagingAction.InboxAction.DeleteMessage -> { + // Remove deleted message from state + val queueId = action.message.queueId + val updatedMessages = state.inboxMessages.filterNot { + it.queueId == queueId + }.toSet() + state.copy(inboxMessages = updatedMessages) + } + + is InAppMessagingAction.InboxAction.TrackClicked -> + state // No state changes for tap action + is InAppMessagingAction.SetPollingInterval -> state.copy(pollInterval = action.interval) @@ -51,6 +84,7 @@ internal val inAppMessagingReducer: Reducer = { state, acti modalMessageState = ModalMessageState.Initial, queuedInlineMessagesState = QueuedInlineMessagesState(), messagesInQueue = emptySet(), + inboxMessages = emptySet(), shownMessageQueueIds = emptySet(), sseEnabled = false ) diff --git a/messaginginapp/src/main/java/io/customer/messaginginapp/state/InAppMessagingAction.kt b/messaginginapp/src/main/java/io/customer/messaginginapp/state/InAppMessagingAction.kt index bc898e876..6032f48fe 100644 --- a/messaginginapp/src/main/java/io/customer/messaginginapp/state/InAppMessagingAction.kt +++ b/messaginginapp/src/main/java/io/customer/messaginginapp/state/InAppMessagingAction.kt @@ -1,6 +1,7 @@ package io.customer.messaginginapp.state import io.customer.messaginginapp.gist.GistEnvironment +import io.customer.messaginginapp.gist.data.model.InboxMessage import io.customer.messaginginapp.gist.data.model.Message import io.customer.messaginginapp.gist.data.model.MessagePosition @@ -14,6 +15,7 @@ internal sealed class InAppMessagingAction { data class SetUserIdentifier(val user: String) : InAppMessagingAction() data class SetAnonymousIdentifier(val anonymousId: String) : InAppMessagingAction() data class ProcessMessageQueue(val messages: List) : InAppMessagingAction() + data class ProcessInboxMessages(val messages: List) : InAppMessagingAction() data class DisplayMessage(val message: Message) : InAppMessagingAction() data class DismissMessage(val message: Message, val shouldLog: Boolean = true, val viaCloseAction: Boolean = true) : InAppMessagingAction() data class ReportError(val message: String) : InAppMessagingAction() @@ -23,7 +25,13 @@ internal sealed class InAppMessagingAction { data class MessageLoadingFailed(val message: Message) : InAppMessagingAction() } - object ClearMessageQueue : InAppMessagingAction() + sealed class InboxAction(open val message: InboxMessage) : InAppMessagingAction() { + data class UpdateOpened(override val message: InboxMessage, val opened: Boolean) : InboxAction(message) + data class DeleteMessage(override val message: InboxMessage) : InboxAction(message) + data class TrackClicked(override val message: InboxMessage, val actionName: String?) : InboxAction(message) + } + + data class ClearMessageQueue(val isContentEmpty: Boolean) : InAppMessagingAction() object Reset : InAppMessagingAction() } diff --git a/messaginginapp/src/main/java/io/customer/messaginginapp/state/InAppMessagingManager.kt b/messaginginapp/src/main/java/io/customer/messaginginapp/state/InAppMessagingManager.kt index 10b4d8285..c043adaf9 100644 --- a/messaginginapp/src/main/java/io/customer/messaginginapp/state/InAppMessagingManager.kt +++ b/messaginginapp/src/main/java/io/customer/messaginginapp/state/InAppMessagingManager.kt @@ -38,6 +38,7 @@ internal data class InAppMessagingManager(val listener: GistListener? = null) { displayModalMessageMiddleware(), gistLoggingMessageMiddleware(), processMessages(), + processInboxMessages(), errorLoggerMiddleware(), gistListenerMiddleware(listener) ) diff --git a/messaginginapp/src/main/java/io/customer/messaginginapp/state/InAppMessagingMiddlewares.kt b/messaginginapp/src/main/java/io/customer/messaginginapp/state/InAppMessagingMiddlewares.kt index 3fd21cb74..50108cb0f 100644 --- a/messaginginapp/src/main/java/io/customer/messaginginapp/state/InAppMessagingMiddlewares.kt +++ b/messaginginapp/src/main/java/io/customer/messaginginapp/state/InAppMessagingMiddlewares.kt @@ -6,14 +6,18 @@ import io.customer.messaginginapp.di.anonymousMessageManager import io.customer.messaginginapp.di.gistQueue import io.customer.messaginginapp.di.gistSdk import io.customer.messaginginapp.di.inAppSseLogger +import io.customer.messaginginapp.gist.data.model.InboxMessage import io.customer.messaginginapp.gist.data.model.Message import io.customer.messaginginapp.gist.data.model.isMessageAnonymous import io.customer.messaginginapp.gist.data.model.matchesRoute +import io.customer.messaginginapp.gist.data.model.response.toLogString import io.customer.messaginginapp.gist.presentation.GistListener import io.customer.messaginginapp.gist.presentation.GistModalActivity import io.customer.messaginginapp.gist.utilities.ModalMessageParser +import io.customer.sdk.communication.Event import io.customer.sdk.core.di.SDKComponent import io.customer.sdk.core.util.Logger +import io.customer.sdk.events.Metric import org.reduxkotlin.Store import org.reduxkotlin.middleware @@ -218,6 +222,88 @@ internal fun processMessages() = middleware { store, next, } } +/** + * Middleware for processing inbox messages and inbox-related actions. + * Deduplicates messages by queueId and syncs InboxAction updates to the server. + */ +internal fun processInboxMessages() = middleware { store, next, action -> + when (action) { + is InAppMessagingAction.ProcessInboxMessages -> { + if (action.messages.isNotEmpty()) { + // Deduplicate by queueId - keep first occurrence of each unique queueId + val uniqueMessages = action.messages.distinctBy(InboxMessage::queueId) + next(InAppMessagingAction.ProcessInboxMessages(uniqueMessages)) + } else { + next(action) + } + } + + is InAppMessagingAction.InboxAction -> { + val logger = SDKComponent.logger + val queueId = action.message.queueId + val currentMessage = store.state.inboxMessages.find { it.queueId == queueId } + + // Only proceed if message exists in state + if (currentMessage == null) { + logger.debug("Skipping inbox message update for ${action.message.toLogString()} - message not found in state") + } else { + // Handle all inbox related actions + when (action) { + is InAppMessagingAction.InboxAction.UpdateOpened -> { + // Only proceed if state is actually changing + if (currentMessage.opened == action.opened) { + logger.debug("Skipping inbox message update for ${currentMessage.toLogString()} - already in desired state (opened=${action.opened})") + } else { + // Call API to update opened status on server using current message from state + SDKComponent.gistQueue.logOpenedStatus(currentMessage, action.opened) + + // Track metric when transitioning from unopened to opened + if (action.opened) { + logger.debug("Inbox message opened: ${currentMessage.toLogString()}") + currentMessage.deliveryId?.let { deliveryId -> + SDKComponent.eventBus.publish( + Event.TrackInAppMetricEvent( + deliveryID = deliveryId, + event = Metric.Opened + ) + ) + } + } + } + } + + is InAppMessagingAction.InboxAction.DeleteMessage -> { + // Call API to delete message on server + logger.debug("Deleting inbox message: ${currentMessage.toLogString()}") + SDKComponent.gistQueue.logDeleted(currentMessage) + } + + is InAppMessagingAction.InboxAction.TrackClicked -> { + // Track click metric for analytics + val params = action.actionName?.let { mapOf("actionName" to it) } ?: emptyMap() + + logger.debug("Inbox message clicked: ${currentMessage.toLogString()}") + currentMessage.deliveryId?.let { deliveryId -> + SDKComponent.eventBus.publish( + Event.TrackInAppMetricEvent( + deliveryID = deliveryId, + event = Metric.Clicked, + params = params + ) + ) + } + } + } + } + + // Always pass action to reducer to update local state + next(action) + } + + else -> next(action) + } +} + /** * Middleware to handle Gist listener actions. * diff --git a/messaginginapp/src/main/java/io/customer/messaginginapp/state/InAppMessagingState.kt b/messaginginapp/src/main/java/io/customer/messaginginapp/state/InAppMessagingState.kt index f33642e13..9594130cf 100644 --- a/messaginginapp/src/main/java/io/customer/messaginginapp/state/InAppMessagingState.kt +++ b/messaginginapp/src/main/java/io/customer/messaginginapp/state/InAppMessagingState.kt @@ -1,6 +1,7 @@ package io.customer.messaginginapp.state import io.customer.messaginginapp.gist.GistEnvironment +import io.customer.messaginginapp.gist.data.model.InboxMessage import io.customer.messaginginapp.gist.data.model.Message internal data class InAppMessagingState( @@ -15,6 +16,7 @@ internal data class InAppMessagingState( val modalMessageState: ModalMessageState = ModalMessageState.Initial, val queuedInlineMessagesState: QueuedInlineMessagesState = QueuedInlineMessagesState(), val messagesInQueue: Set = emptySet(), + val inboxMessages: Set = emptySet(), val shownMessageQueueIds: Set = emptySet(), val sseEnabled: Boolean = false ) { @@ -36,6 +38,7 @@ internal data class InAppMessagingState( */ val shouldUseSse: Boolean get() = sseEnabled && isUserIdentified + override fun toString(): String = buildString { append("InAppMessagingState(") append("siteId='$siteId',\n") @@ -49,6 +52,7 @@ internal data class InAppMessagingState( append("modalMessageState=$modalMessageState,\n") append("embeddedMessagesState=$queuedInlineMessagesState,\n") append("messagesInQueue=${messagesInQueue.map(Message::queueId)},\n") + append("inboxMessages=${inboxMessages.map(InboxMessage::deliveryId)},\n") append("shownMessageQueueIds=$shownMessageQueueIds,\n") append("sseEnabled=$sseEnabled)") } @@ -66,6 +70,7 @@ internal data class InAppMessagingState( if (modalMessageState != other.modalMessageState) put("modalMessageState", modalMessageState to other.modalMessageState) if (queuedInlineMessagesState != other.queuedInlineMessagesState) put("embeddedMessagesState", queuedInlineMessagesState to other.queuedInlineMessagesState) if (messagesInQueue != other.messagesInQueue) put("messagesInQueue", messagesInQueue to other.messagesInQueue) + if (inboxMessages != other.inboxMessages) put("inboxMessages", inboxMessages to other.inboxMessages) if (shownMessageQueueIds != other.shownMessageQueueIds) put("shownMessageQueueIds", shownMessageQueueIds to other.shownMessageQueueIds) if (sseEnabled != other.sseEnabled) put("sseEnabled", sseEnabled to other.sseEnabled) } diff --git a/messaginginapp/src/main/java/io/customer/messaginginapp/store/InAppPreferenceStore.kt b/messaginginapp/src/main/java/io/customer/messaginginapp/store/InAppPreferenceStore.kt index 7346c5b65..dd240cb0b 100644 --- a/messaginginapp/src/main/java/io/customer/messaginginapp/store/InAppPreferenceStore.kt +++ b/messaginginapp/src/main/java/io/customer/messaginginapp/store/InAppPreferenceStore.kt @@ -5,7 +5,7 @@ import androidx.core.content.edit import io.customer.sdk.data.store.PreferenceStore import io.customer.sdk.data.store.read -interface InAppPreferenceStore { +internal interface InAppPreferenceStore { fun saveNetworkResponse(url: String, response: String) fun getNetworkResponse(url: String): String? fun clearAll() @@ -27,6 +27,11 @@ interface InAppPreferenceStore { fun setAnonymousNextShowTime(messageId: String, nextShowTimeMillis: Long) fun getAnonymousNextShowTime(messageId: String): Long fun isAnonymousInDelayPeriod(messageId: String): Boolean + + // Inbox message opened status caching + fun saveInboxMessageOpenedStatus(queueId: String, opened: Boolean) + fun getInboxMessageOpenedStatus(queueId: String): Boolean? + fun clearInboxMessageOpenedStatus(queueId: String) } internal class InAppPreferenceStoreImpl( @@ -44,6 +49,7 @@ internal class InAppPreferenceStoreImpl( private const val ANONYMOUS_TIMES_SHOWN_PREFIX = "broadcast_times_shown_" private const val ANONYMOUS_DISMISSED_PREFIX = "broadcast_dismissed_" private const val ANONYMOUS_NEXT_SHOW_TIME_PREFIX = "broadcast_next_show_time_" + private const val INBOX_MESSAGE_OPENED_PREFIX = "inbox_message_opened_" } override fun saveNetworkResponse(url: String, response: String) = prefs.edit { @@ -131,4 +137,20 @@ internal class InAppPreferenceStoreImpl( val nextShowTime = getAnonymousNextShowTime(messageId) return nextShowTime > 0 && System.currentTimeMillis() < nextShowTime } + + override fun saveInboxMessageOpenedStatus(queueId: String, opened: Boolean) = prefs.edit { + putBoolean("$INBOX_MESSAGE_OPENED_PREFIX$queueId", opened) + } + + override fun getInboxMessageOpenedStatus(queueId: String): Boolean? = prefs.read { + if (contains("$INBOX_MESSAGE_OPENED_PREFIX$queueId")) { + getBoolean("$INBOX_MESSAGE_OPENED_PREFIX$queueId", false) + } else { + null + } + } + + override fun clearInboxMessageOpenedStatus(queueId: String) = prefs.edit { + remove("$INBOX_MESSAGE_OPENED_PREFIX$queueId") + } } diff --git a/messaginginapp/src/test/java/io/customer/messaginginapp/InAppMessagingStoreTest.kt b/messaginginapp/src/test/java/io/customer/messaginginapp/InAppMessagingStoreTest.kt index 861802df6..0b6535aa6 100644 --- a/messaginginapp/src/test/java/io/customer/messaginginapp/InAppMessagingStoreTest.kt +++ b/messaginginapp/src/test/java/io/customer/messaginginapp/InAppMessagingStoreTest.kt @@ -13,6 +13,7 @@ import io.customer.messaginginapp.state.InAppMessagingManager import io.customer.messaginginapp.state.ModalMessageState import io.customer.messaginginapp.testutils.core.IntegrationTest import io.customer.messaginginapp.testutils.extension.createInAppMessage +import io.customer.messaginginapp.testutils.extension.createInboxMessage import io.customer.messaginginapp.testutils.extension.pageRuleContains import io.customer.messaginginapp.testutils.extension.pageRuleEquals import io.customer.messaginginapp.type.InAppEventListener @@ -41,11 +42,12 @@ class InAppMessagingStoreTest : IntegrationTest() { private var inAppEventListener = mockk(relaxed = true) + private lateinit var module: ModuleMessagingInApp private lateinit var manager: InAppMessagingManager override fun setup(testConfig: TestConfig) { super.setup(testConfig) - ModuleMessagingInApp( + module = ModuleMessagingInApp( config = MessagingInAppModuleConfig.Builder( siteId = TestConstants.Keys.SITE_ID, region = Region.US @@ -640,4 +642,50 @@ class InAppMessagingStoreTest : IntegrationTest() { verify(exactly = 1) { inAppEventListener.messageDismissed(InAppMessage.getFromGistMessage(persistentMessage)) } verify(exactly = 1) { inAppEventListener.messageDismissed(InAppMessage.getFromGistMessage(nonPersistentMessage)) } } + + @Test + fun givenInboxMessages_whenProcessed_thenMessagesAreAvailableViaNotificationInbox() = runTest { + initializeAndSetUser() + + // Create test inbox messages + val message1 = createInboxMessage(deliveryId = "inbox1", priority = 1, opened = false) + val message2 = createInboxMessage(deliveryId = "inbox2", priority = 2, opened = true) + val message3 = createInboxMessage(deliveryId = "inbox3", priority = 3, opened = false) + + // Process inbox messages via action + manager.dispatch(InAppMessagingAction.ProcessInboxMessages(listOf(message1, message2, message3))) + + // Verify NotificationInbox.getMessages() returns correct messages + val notificationInbox = module.inbox() + val retrievedMessages = notificationInbox.getMessages() + retrievedMessages.size shouldBeEqualTo 3 + retrievedMessages shouldContainAll listOf(message1, message2, message3) + } + + @Test + fun givenDuplicateInboxMessages_whenProcessed_thenDuplicatesAreRemovedByQueueId() = runTest { + initializeAndSetUser() + + // Create inbox messages with same queueId but different properties + // This simulates middleware receiving duplicate queueIds with different states + val message1 = createInboxMessage(queueId = "queue1", deliveryId = "delivery1", priority = 1, opened = false) + val message2 = createInboxMessage(queueId = "queue1", deliveryId = "delivery2", priority = 2, opened = true) // Same queueId, different props + val message3 = createInboxMessage(queueId = "queue2", deliveryId = "delivery3", priority = 2, opened = true) + + // Process inbox messages with duplicates + manager.dispatch(InAppMessagingAction.ProcessInboxMessages(listOf(message1, message2, message3))) + + // Verify duplicates are removed by queueId (distinctBy) + // Only the first occurrence of each queueId is kept + val state = manager.getCurrentState() + state.inboxMessages.size shouldBeEqualTo 2 + state.inboxMessages.any { it.queueId == "queue1" } shouldBe true + state.inboxMessages.any { it.queueId == "queue2" } shouldBe true + + // Verify the first occurrence is kept (message1 with opened=false, not message2) + val queue1Message = state.inboxMessages.first { it.queueId == "queue1" } + queue1Message.opened shouldBe false + queue1Message.priority shouldBeEqualTo 1 + queue1Message.deliveryId shouldBeEqualTo "delivery1" + } } diff --git a/messaginginapp/src/test/java/io/customer/messaginginapp/InAppPreferenceStoreTest.kt b/messaginginapp/src/test/java/io/customer/messaginginapp/InAppPreferenceStoreTest.kt index d0db99fa7..c5419ac6d 100644 --- a/messaginginapp/src/test/java/io/customer/messaginginapp/InAppPreferenceStoreTest.kt +++ b/messaginginapp/src/test/java/io/customer/messaginginapp/InAppPreferenceStoreTest.kt @@ -228,4 +228,59 @@ class InAppPreferenceStoreTest : IntegrationTest() { inAppPreferenceStore.isAnonymousInDelayPeriod(messageId) shouldBe false inAppPreferenceStore.getAnonymousNextShowTime(messageId) shouldBeEqualTo 0 } + + // Inbox Message Opened Status Tests + @Test + fun saveInboxMessageOpenedStatus_givenQueueIdAndStatus_expectSavedAndRetrieved() { + val queueId = "queue-123" + + // Initially should return null (not cached) + inAppPreferenceStore.getInboxMessageOpenedStatus(queueId) shouldBe null + + // Save as opened + inAppPreferenceStore.saveInboxMessageOpenedStatus(queueId, true) + inAppPreferenceStore.getInboxMessageOpenedStatus(queueId) shouldBeEqualTo true + + // Save as unopened + inAppPreferenceStore.saveInboxMessageOpenedStatus(queueId, false) + inAppPreferenceStore.getInboxMessageOpenedStatus(queueId) shouldBeEqualTo false + } + + @Test + fun getInboxMessageOpenedStatus_givenNonexistentQueueId_expectNull() { + val queueId = "nonexistent-queue" + + val status = inAppPreferenceStore.getInboxMessageOpenedStatus(queueId) + status shouldBe null + } + + @Test + fun clearInboxMessageOpenedStatus_givenCachedStatus_expectCleared() { + val queueId = "queue-456" + + // Save status + inAppPreferenceStore.saveInboxMessageOpenedStatus(queueId, true) + inAppPreferenceStore.getInboxMessageOpenedStatus(queueId) shouldBeEqualTo true + + // Clear status + inAppPreferenceStore.clearInboxMessageOpenedStatus(queueId) + inAppPreferenceStore.getInboxMessageOpenedStatus(queueId) shouldBe null + } + + @Test + fun saveInboxMessageOpenedStatus_givenMultipleMessages_expectEachTrackedIndependently() { + val queueId1 = "queue-1" + val queueId2 = "queue-2" + val queueId3 = "queue-3" + + // Save different statuses for different messages + inAppPreferenceStore.saveInboxMessageOpenedStatus(queueId1, true) + inAppPreferenceStore.saveInboxMessageOpenedStatus(queueId2, false) + // Don't save anything for queueId3 + + // Verify each is tracked correctly + inAppPreferenceStore.getInboxMessageOpenedStatus(queueId1) shouldBeEqualTo true + inAppPreferenceStore.getInboxMessageOpenedStatus(queueId2) shouldBeEqualTo false + inAppPreferenceStore.getInboxMessageOpenedStatus(queueId3) shouldBe null + } } diff --git a/messaginginapp/src/test/java/io/customer/messaginginapp/gist/data/model/InboxMessageMappingTest.kt b/messaginginapp/src/test/java/io/customer/messaginginapp/gist/data/model/InboxMessageMappingTest.kt new file mode 100644 index 000000000..28ce4ffc6 --- /dev/null +++ b/messaginginapp/src/test/java/io/customer/messaginginapp/gist/data/model/InboxMessageMappingTest.kt @@ -0,0 +1,147 @@ +package io.customer.messaginginapp.gist.data.model + +import io.customer.messaginginapp.gist.data.model.response.InboxMessageFactory +import io.customer.messaginginapp.gist.data.model.response.InboxMessageResponse +import java.util.Date +import org.amshove.kluent.shouldBeEqualTo +import org.amshove.kluent.shouldNotBeNull +import org.junit.Test + +class InboxMessageMappingTest { + + @Test + fun fromResponse_givenValidResponse_expectDomainModel() { + val response = InboxMessageResponse( + queueId = "queue-123", + deliveryId = "delivery-456", + sentAt = Date(), + topics = listOf("promotions", "updates"), + opened = true, + priority = 5 + ) + + val result = requireNotNull(InboxMessageFactory.fromResponse(response)) + + result.queueId shouldBeEqualTo "queue-123" + result.deliveryId shouldBeEqualTo "delivery-456" + result.topics shouldBeEqualTo listOf("promotions", "updates") + result.opened shouldBeEqualTo true + result.priority shouldBeEqualTo 5 + } + + @Test + fun fromResponse_givenMinimalResponse_expectDefaults() { + val response = InboxMessageResponse( + queueId = "queue-123", + deliveryId = "delivery-456", + sentAt = Date(), + topics = null, + opened = null, + priority = null + ) + + val result = requireNotNull(InboxMessageFactory.fromResponse(response)) + + // Verify default values for nullable fields + result.queueId shouldBeEqualTo "queue-123" + result.deliveryId shouldBeEqualTo "delivery-456" + result.topics shouldBeEqualTo emptyList() + result.opened shouldBeEqualTo false + result.priority shouldBeEqualTo null + result.type shouldBeEqualTo "" + result.properties shouldBeEqualTo emptyMap() + } + + @Test + fun fromResponse_givenNullQueueId_expectNull() { + val response = InboxMessageResponse( + queueId = null, + deliveryId = "delivery-456", + sentAt = Date() + ) + + val result = InboxMessageFactory.fromResponse(response) + + result shouldBeEqualTo null + } + + @Test + fun fromResponse_givenNullSentAt_expectNull() { + val response = InboxMessageResponse( + queueId = "queue-123", + sentAt = null + ) + + val result = InboxMessageFactory.fromResponse(response) + + result shouldBeEqualTo null + } + + @Test + fun fromResponse_givenValidProperties_expectPropertiesMapped() { + val response = InboxMessageResponse( + queueId = "queue-123", + sentAt = Date(), + properties = mapOf( + "title" to "Welcome", + "count" to 42.0, + "enabled" to true + ) + ) + + val result = requireNotNull(InboxMessageFactory.fromResponse(response)) + + result.properties.shouldNotBeNull() + result.properties["title"] shouldBeEqualTo "Welcome" + result.properties["count"] shouldBeEqualTo 42.0 + result.properties["enabled"] shouldBeEqualTo true + } + + @Test + fun fromMap_givenValidMap_expectDomainModel() { + val map = mapOf( + "queueId" to "queue-123", + "deliveryId" to "delivery-456", + "sentAt" to System.currentTimeMillis(), + "topics" to listOf("promotions", "updates"), + "type" to "notification", + "opened" to true, + "priority" to 5, + "properties" to mapOf("key" to "value") + ) + + val result = requireNotNull(InboxMessageFactory.fromMap(map)) + + result.queueId shouldBeEqualTo "queue-123" + result.deliveryId shouldBeEqualTo "delivery-456" + result.topics shouldBeEqualTo listOf("promotions", "updates") + result.type shouldBeEqualTo "notification" + result.opened shouldBeEqualTo true + result.priority shouldBeEqualTo 5 + result.properties shouldBeEqualTo mapOf("key" to "value") + } + + @Test + fun fromMap_givenMissingQueueId_expectNull() { + val map = mapOf( + "deliveryId" to "delivery-456", + "sentAt" to System.currentTimeMillis() + ) + + val result = InboxMessageFactory.fromMap(map) + + result shouldBeEqualTo null + } + + @Test + fun fromMap_givenMissingSentAt_expectNull() { + val map = mapOf( + "queueId" to "queue-123", + "deliveryId" to "delivery-456" + ) + + val result = InboxMessageFactory.fromMap(map) + + result shouldBeEqualTo null + } +} diff --git a/messaginginapp/src/test/java/io/customer/messaginginapp/gist/data/model/adapters/Iso8601DateAdapterTest.kt b/messaginginapp/src/test/java/io/customer/messaginginapp/gist/data/model/adapters/Iso8601DateAdapterTest.kt new file mode 100644 index 000000000..20c39472e --- /dev/null +++ b/messaginginapp/src/test/java/io/customer/messaginginapp/gist/data/model/adapters/Iso8601DateAdapterTest.kt @@ -0,0 +1,294 @@ +package io.customer.messaginginapp.gist.data.model.adapters + +import com.google.gson.stream.JsonReader +import com.google.gson.stream.JsonToken +import com.google.gson.stream.JsonWriter +import io.customer.messaginginapp.testutils.core.JUnitTest +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import java.io.StringReader +import java.io.StringWriter +import java.util.Calendar +import java.util.Date +import java.util.TimeZone +import org.amshove.kluent.shouldBeEqualTo +import org.amshove.kluent.shouldBeNull +import org.amshove.kluent.shouldNotBeNull +import org.junit.jupiter.api.Test + +class Iso8601DateAdapterTest : JUnitTest() { + private val adapter = Iso8601DateAdapter() + + @Test + fun testRead_givenValidDateWithMilliseconds_thenReturnsDate() { + val dateString = "2026-01-27T12:30:45.123Z" + val jsonReader = createJsonReader("\"$dateString\"") + + val result = adapter.read(jsonReader) + + result.shouldNotBeNull() + val calendar = utcCalendar(result) + calendar.get(Calendar.YEAR).shouldBeEqualTo(2026) + calendar.get(Calendar.MONTH).shouldBeEqualTo(Calendar.JANUARY) + calendar.get(Calendar.DAY_OF_MONTH).shouldBeEqualTo(27) + calendar.get(Calendar.HOUR_OF_DAY).shouldBeEqualTo(12) + calendar.get(Calendar.MINUTE).shouldBeEqualTo(30) + calendar.get(Calendar.SECOND).shouldBeEqualTo(45) + calendar.get(Calendar.MILLISECOND).shouldBeEqualTo(123) + } + + @Test + fun testRead_givenValidDateWithMicroseconds_thenTruncatesToMilliseconds() { + // Server sends microseconds (6 digits), but Java Date only supports milliseconds + val dateString = "2026-01-27T12:30:45.123456Z" + val jsonReader = createJsonReader("\"$dateString\"") + + val result = adapter.read(jsonReader) + + result.shouldNotBeNull() + val calendar = utcCalendar(result) + calendar.get(Calendar.YEAR).shouldBeEqualTo(2026) + calendar.get(Calendar.MONTH).shouldBeEqualTo(Calendar.JANUARY) + calendar.get(Calendar.DAY_OF_MONTH).shouldBeEqualTo(27) + calendar.get(Calendar.HOUR_OF_DAY).shouldBeEqualTo(12) + calendar.get(Calendar.MINUTE).shouldBeEqualTo(30) + calendar.get(Calendar.SECOND).shouldBeEqualTo(45) + // Microseconds .123456 truncated to milliseconds .123 + calendar.get(Calendar.MILLISECOND).shouldBeEqualTo(123) + } + + @Test + fun testRead_givenValidDateWithSingleDigitFractionalSecond_thenPadsToMilliseconds() { + // Per ISO 8601, .1 = 0.1 seconds = 100 milliseconds + val dateString = "2026-01-27T12:30:45.1Z" + val jsonReader = createJsonReader("\"$dateString\"") + + val result = adapter.read(jsonReader) + + result.shouldNotBeNull() + val calendar = utcCalendar(result) + calendar.get(Calendar.YEAR).shouldBeEqualTo(2026) + calendar.get(Calendar.MONTH).shouldBeEqualTo(Calendar.JANUARY) + calendar.get(Calendar.DAY_OF_MONTH).shouldBeEqualTo(27) + calendar.get(Calendar.HOUR_OF_DAY).shouldBeEqualTo(12) + calendar.get(Calendar.MINUTE).shouldBeEqualTo(30) + calendar.get(Calendar.SECOND).shouldBeEqualTo(45) + // .1 is padded to .100, representing 100 milliseconds + calendar.get(Calendar.MILLISECOND).shouldBeEqualTo(100) + } + + @Test + fun testRead_givenValidDateWithTwoDigitFractionalSecond_thenPadsToMilliseconds() { + // Per ISO 8601, .12 = 0.12 seconds = 120 milliseconds + val dateString = "2026-01-27T12:30:45.12Z" + val jsonReader = createJsonReader("\"$dateString\"") + + val result = adapter.read(jsonReader) + + result.shouldNotBeNull() + val calendar = utcCalendar(result) + calendar.get(Calendar.YEAR).shouldBeEqualTo(2026) + calendar.get(Calendar.MONTH).shouldBeEqualTo(Calendar.JANUARY) + calendar.get(Calendar.DAY_OF_MONTH).shouldBeEqualTo(27) + calendar.get(Calendar.HOUR_OF_DAY).shouldBeEqualTo(12) + calendar.get(Calendar.MINUTE).shouldBeEqualTo(30) + calendar.get(Calendar.SECOND).shouldBeEqualTo(45) + // .12 is padded to .120, representing 120 milliseconds + calendar.get(Calendar.MILLISECOND).shouldBeEqualTo(120) + } + + @Test + fun testRead_givenValidDateWithoutMilliseconds_thenReturnsDate() { + val dateString = "2026-01-27T12:30:45Z" + val jsonReader = createJsonReader("\"$dateString\"") + + val result = adapter.read(jsonReader) + + result.shouldNotBeNull() + val calendar = utcCalendar(result) + calendar.get(Calendar.YEAR).shouldBeEqualTo(2026) + calendar.get(Calendar.MONTH).shouldBeEqualTo(Calendar.JANUARY) + calendar.get(Calendar.DAY_OF_MONTH).shouldBeEqualTo(27) + calendar.get(Calendar.HOUR_OF_DAY).shouldBeEqualTo(12) + calendar.get(Calendar.MINUTE).shouldBeEqualTo(30) + calendar.get(Calendar.SECOND).shouldBeEqualTo(45) + } + + @Test + fun testRead_givenNullToken_thenReturnsNull() { + val jsonReader = mockk(relaxed = true) + every { jsonReader.peek() } returns JsonToken.NULL + + val result = adapter.read(jsonReader) + + result.shouldBeNull() + verify { jsonReader.nextNull() } + } + + @Test + fun testRead_givenBlankString_thenReturnsNull() { + val jsonReader = createJsonReader("\"\"") + + val result = adapter.read(jsonReader) + + result.shouldBeNull() + } + + @Test + fun testRead_givenWhitespaceOnlyString_thenReturnsNull() { + val jsonReader = createJsonReader("\" \"") + + val result = adapter.read(jsonReader) + + result.shouldBeNull() + } + + @Test + fun testRead_givenInvalidDateFormat_thenReturnsNull() { + val jsonReader = createJsonReader("\"not-a-date\"") + + val result = adapter.read(jsonReader) + + result.shouldBeNull() + } + + @Test + fun testRead_givenPartiallyValidDate_thenReturnsNull() { + val jsonReader = createJsonReader("\"2026-01-27\"") + + val result = adapter.read(jsonReader) + + result.shouldBeNull() + } + + @Test + fun testRead_givenMalformedJson_thenReturnsNull() { + val jsonReader = createJsonReader("\"2026-01-27T12:30:45\"") // Missing Z + + val result = adapter.read(jsonReader) + + result.shouldBeNull() + } + + @Test + fun testWrite_givenValidDate_thenWritesWithMilliseconds() { + val date = utcDate( + year = 2026, + month = Calendar.JANUARY, + day = 27, + hour = 12, + minute = 30, + second = 45, + millisecond = 123 + ) + val stringWriter = StringWriter() + val jsonWriter = JsonWriter(stringWriter) + + adapter.write(jsonWriter, date) + + jsonWriter.close() + val result = stringWriter.toString() + result.shouldBeEqualTo("\"2026-01-27T12:30:45.123Z\"") + } + + @Test + fun testWrite_givenDateWithoutMilliseconds_thenWritesWithZeroMilliseconds() { + val date = utcDate( + year = 2026, + month = Calendar.JANUARY, + day = 27, + hour = 12, + minute = 30, + second = 45, + millisecond = 0 + ) + val stringWriter = StringWriter() + val jsonWriter = JsonWriter(stringWriter) + + adapter.write(jsonWriter, date) + + jsonWriter.close() + val result = stringWriter.toString() + result.shouldBeEqualTo("\"2026-01-27T12:30:45.000Z\"") + } + + @Test + fun testWrite_givenNullDate_thenWritesNull() { + val stringWriter = StringWriter() + val jsonWriter = JsonWriter(stringWriter) + + adapter.write(jsonWriter, null) + + jsonWriter.close() + val result = stringWriter.toString() + result.shouldBeEqualTo("null") + } + + @Test + fun testWrite_givenDifferentDate_thenWritesCorrectly() { + val date = utcDate( + year = 2024, + month = Calendar.DECEMBER, + day = 31, + hour = 23, + minute = 59, + second = 59, + millisecond = 999 + ) + val stringWriter = StringWriter() + val jsonWriter = JsonWriter(stringWriter) + + adapter.write(jsonWriter, date) + + jsonWriter.close() + val result = stringWriter.toString() + result.shouldBeEqualTo("\"2024-12-31T23:59:59.999Z\"") + } + + @Test + fun testWrite_givenEpochDate_thenWritesCorrectly() { + val date = utcDate( + year = 1970, + month = Calendar.JANUARY, + day = 1, + hour = 0, + minute = 0, + second = 0, + millisecond = 0 + ) + val stringWriter = StringWriter() + val jsonWriter = JsonWriter(stringWriter) + + adapter.write(jsonWriter, date) + + jsonWriter.close() + val result = stringWriter.toString() + result.shouldBeEqualTo("\"1970-01-01T00:00:00.000Z\"") + } + + private fun createJsonReader(json: String): JsonReader { + return JsonReader(StringReader(json)) + } + + // Creates a Calendar instance in UTC timezone, optionally initialized with given date + private fun utcCalendar(date: Date? = null): Calendar { + return Calendar.getInstance(TimeZone.getTimeZone("UTC")).apply { + date?.let { time = it } + } + } + + // Creates a Date in UTC timezone with specified date/time components + private fun utcDate( + year: Int, + month: Int, + day: Int, + hour: Int = 0, + minute: Int = 0, + second: Int = 0, + millisecond: Int = 0 + ): Date = utcCalendar().apply { + set(year, month, day, hour, minute, second) + set(Calendar.MILLISECOND, millisecond) + }.time +} diff --git a/messaginginapp/src/test/java/io/customer/messaginginapp/gist/data/sse/SseConnectionManagerTest.kt b/messaginginapp/src/test/java/io/customer/messaginginapp/gist/data/sse/SseConnectionManagerTest.kt index b5a2596df..790cdc2b5 100644 --- a/messaginginapp/src/test/java/io/customer/messaginginapp/gist/data/sse/SseConnectionManagerTest.kt +++ b/messaginginapp/src/test/java/io/customer/messaginginapp/gist/data/sse/SseConnectionManagerTest.kt @@ -1,6 +1,7 @@ package io.customer.messaginginapp.gist.data.sse import io.customer.messaginginapp.gist.data.NetworkUtilities +import io.customer.messaginginapp.gist.data.model.InboxMessage import io.customer.messaginginapp.gist.data.model.Message import io.customer.messaginginapp.state.InAppMessagingAction import io.customer.messaginginapp.state.InAppMessagingManager @@ -228,7 +229,7 @@ class SseConnectionManagerTest : JUnitTest() { val messagesJson = """[{"messageId": "msg1"}, {"messageId": "msg2"}]""" every { inAppMessagingManager.getCurrentState() } returns mockState - every { sseDataParser.parseMessages(messagesJson) } returns mockMessages + every { sseDataParser.parseInAppMessages(messagesJson) } returns mockMessages coEvery { sseService.connectSse(any(), any(), any()) } returns flowOf( ServerEvent(ServerEvent.MESSAGES, messagesJson) ) @@ -240,7 +241,7 @@ class SseConnectionManagerTest : JUnitTest() { testScope.advanceUntilIdle() // Then - verify { sseDataParser.parseMessages(messagesJson) } + verify { sseDataParser.parseInAppMessages(messagesJson) } verify { inAppMessagingManager.dispatch(capture(actionSlot)) } actionSlot.captured.messages.shouldBeEqualTo(mockMessages) } @@ -254,7 +255,7 @@ class SseConnectionManagerTest : JUnitTest() { siteId = "test-site" ) every { inAppMessagingManager.getCurrentState() } returns mockState - every { sseDataParser.parseMessages(any()) } returns emptyList() + every { sseDataParser.parseInAppMessages(any()) } returns emptyList() coEvery { sseService.connectSse(any(), any(), any()) } returns flowOf( ServerEvent(ServerEvent.MESSAGES, "[]") ) @@ -326,7 +327,7 @@ class SseConnectionManagerTest : JUnitTest() { siteId = "test-site" ) every { inAppMessagingManager.getCurrentState() } returns mockState - every { sseDataParser.parseMessages(any()) } throws RuntimeException("Parse error") + every { sseDataParser.parseInAppMessages(any()) } throws RuntimeException("Parse error") coEvery { sseService.connectSse(any(), any(), any()) } returns flowOf( ServerEvent(ServerEvent.MESSAGES, "invalid-json") ) @@ -339,6 +340,80 @@ class SseConnectionManagerTest : JUnitTest() { verify(exactly = 0) { inAppMessagingManager.dispatch(any()) } } + @Test + fun testHandleSseEvent_whenInboxMessagesEvent_thenProcessesInboxMessages() = runTest { + // Given + val mockState = InAppMessagingState( + userId = "test-user", + sessionId = "test-session", + siteId = "test-site" + ) + val mockInboxMessages = listOf(mockk(), mockk()) + val inboxMessagesJson = """[{"deliveryId": "inbox1"}, {"deliveryId": "inbox2"}]""" + + every { inAppMessagingManager.getCurrentState() } returns mockState + every { sseDataParser.parseInboxMessages(inboxMessagesJson) } returns mockInboxMessages + coEvery { sseService.connectSse(any(), any(), any()) } returns flowOf( + ServerEvent("inbox_messages", inboxMessagesJson) + ) + + val actionSlot = slot() + + // When + connectionManager.startConnection() + testScope.advanceUntilIdle() + + // Then + verify { sseDataParser.parseInboxMessages(inboxMessagesJson) } + verify { inAppMessagingManager.dispatch(capture(actionSlot)) } + actionSlot.captured.messages.shouldBeEqualTo(mockInboxMessages) + } + + @Test + fun testHandleSseEvent_whenEmptyInboxMessagesEvent_thenLogsDebug() = runTest { + // Given + val mockState = InAppMessagingState( + userId = "test-user", + sessionId = "test-session", + siteId = "test-site" + ) + every { inAppMessagingManager.getCurrentState() } returns mockState + every { sseDataParser.parseInboxMessages(any()) } returns emptyList() + coEvery { sseService.connectSse(any(), any(), any()) } returns flowOf( + ServerEvent("inbox_messages", "[]") + ) + + // When + connectionManager.startConnection() + testScope.advanceUntilIdle() + + // Then + verify { sseLogger.logReceivedEmptyMessagesEvent() } + verify(exactly = 0) { inAppMessagingManager.dispatch(any()) } + } + + @Test + fun testHandleSseEvent_whenInboxMessageParsingFails_thenLogsError() = runTest { + // Given + val mockState = InAppMessagingState( + userId = "test-user", + sessionId = "test-session", + siteId = "test-site" + ) + every { inAppMessagingManager.getCurrentState() } returns mockState + every { sseDataParser.parseInboxMessages(any()) } throws RuntimeException("Parse error") + coEvery { sseService.connectSse(any(), any(), any()) } returns flowOf( + ServerEvent("inbox_messages", "invalid-json") + ) + + // When + connectionManager.startConnection() + testScope.advanceUntilIdle() + + // Then + verify(exactly = 0) { inAppMessagingManager.dispatch(any()) } + } + @Test fun testStartConnection_whenConnectionFails_thenLogsErrorAndSchedulesRetry() = runTest { // Given @@ -414,7 +489,7 @@ class SseConnectionManagerTest : JUnitTest() { siteId = "test-site" ) every { inAppMessagingManager.getCurrentState() } returns mockState - every { sseDataParser.parseMessages(any()) } returns emptyList() + every { sseDataParser.parseInAppMessages(any()) } returns emptyList() coEvery { sseService.connectSse(any(), any(), any()) } returns flowOf( ServerEvent(ServerEvent.MESSAGES, "[]") ) diff --git a/messaginginapp/src/test/java/io/customer/messaginginapp/gist/data/sse/SseDataParserTest.kt b/messaginginapp/src/test/java/io/customer/messaginginapp/gist/data/sse/SseDataParserTest.kt index 289a6ed4d..f622b1469 100644 --- a/messaginginapp/src/test/java/io/customer/messaginginapp/gist/data/sse/SseDataParserTest.kt +++ b/messaginginapp/src/test/java/io/customer/messaginginapp/gist/data/sse/SseDataParserTest.kt @@ -41,7 +41,7 @@ class SseDataParserTest : JUnitTest() { ] """.trimIndent() - val result = parser.parseMessages(json) + val result = parser.parseInAppMessages(json) result.shouldHaveSize(2) result[0].messageId.shouldBeEqualTo("msg1") @@ -54,21 +54,21 @@ class SseDataParserTest : JUnitTest() { fun testParseMessages_givenEmptyArray_thenReturnsEmptyList() { val json = "[]" - val result = parser.parseMessages(json) + val result = parser.parseInAppMessages(json) result.shouldBeEmpty() } @Test fun testParseMessages_givenBlankData_thenReturnsEmptyList() { - val result = parser.parseMessages("") + val result = parser.parseInAppMessages("") result.shouldBeEmpty() } @Test fun testParseMessages_givenWhitespaceOnly_thenReturnsEmptyList() { - val result = parser.parseMessages(" ") + val result = parser.parseInAppMessages(" ") result.shouldBeEmpty() } @@ -77,7 +77,7 @@ class SseDataParserTest : JUnitTest() { fun testParseMessages_givenInvalidJson_thenReturnsEmptyList() { val invalidJson = "{ invalid json }" - val result = parser.parseMessages(invalidJson) + val result = parser.parseInAppMessages(invalidJson) result.shouldBeEmpty() } @@ -86,14 +86,14 @@ class SseDataParserTest : JUnitTest() { fun testParseMessages_givenMalformedJson_thenReturnsEmptyList() { val malformedJson = "[{ messageId: }]" - val result = parser.parseMessages(malformedJson) + val result = parser.parseInAppMessages(malformedJson) result.shouldBeEmpty() } @Test fun testParseMessages_givenNull_thenReturnsEmptyList() { - val result = parser.parseMessages("null") + val result = parser.parseInAppMessages("null") result.shouldBeEmpty() } @@ -115,7 +115,7 @@ class SseDataParserTest : JUnitTest() { ] """.trimIndent() - val result = parser.parseMessages(json) + val result = parser.parseInAppMessages(json) result.shouldHaveSize(1) result[0].messageId.shouldBeEqualTo("single-msg") @@ -198,4 +198,120 @@ class SseDataParserTest : JUnitTest() { result.shouldBeEqualTo(300000L) // 300 seconds * 1000 = 300000ms } + + @Test + fun testParseInboxMessages_givenValidJson_thenReturnsInboxMessages() { + val json = """ + [ + { + "deliveryId": "delivery_Inbox1", + "expiry": "2026-01-30T12:00:00.000000Z", + "sentAt": "2026-01-29T12:00:00.000000Z", + "topics": ["topic1", "topic2"], + "type": "notification", + "opened": false, + "priority": 1, + "properties": { + "body": "Body", + "title": "Title" + }, + "queueId": "queue-abcd-1234-efgh" + }, + { + "deliveryId": "Delivery_inbox2", + "expiry": "2026-02-01T12:00:00.000000Z", + "sentAt": "2026-01-30T12:00:00.000000Z", + "topics": ["topic3"], + "type": "announcement", + "opened": true, + "priority": 2, + "queueId": "queue-pqrs-5678-tuvw" + } + ] + """.trimIndent() + + val result = parser.parseInboxMessages(json) + + result.shouldHaveSize(2) + result[0].deliveryId.shouldBeEqualTo("delivery_Inbox1") + result[0].type.shouldBeEqualTo("notification") + result[0].opened.shouldBeEqualTo(false) + result[1].deliveryId.shouldBeEqualTo("Delivery_inbox2") + result[1].type.shouldBeEqualTo("announcement") + result[1].opened.shouldBeEqualTo(true) + } + + @Test + fun testParseInboxMessages_givenEmptyArray_thenReturnsEmptyList() { + val json = "[]" + + val result = parser.parseInboxMessages(json) + + result.shouldBeEmpty() + } + + @Test + fun testParseInboxMessages_givenBlankData_thenReturnsEmptyList() { + val result = parser.parseInboxMessages("") + + result.shouldBeEmpty() + } + + @Test + fun testParseInboxMessages_givenWhitespaceOnly_thenReturnsEmptyList() { + val result = parser.parseInboxMessages(" ") + + result.shouldBeEmpty() + } + + @Test + fun testParseInboxMessages_givenInvalidJson_thenReturnsEmptyList() { + val invalidJson = "{ invalid json }" + + val result = parser.parseInboxMessages(invalidJson) + + result.shouldBeEmpty() + } + + @Test + fun testParseInboxMessages_givenMalformedJson_thenReturnsEmptyList() { + val malformedJson = "[{ deliveryId: }]" + + val result = parser.parseInboxMessages(malformedJson) + + result.shouldBeEmpty() + } + + @Test + fun testParseInboxMessages_givenNull_thenReturnsEmptyList() { + val result = parser.parseInboxMessages("null") + + result.shouldBeEmpty() + } + + @Test + fun testParseInboxMessages_givenSingleMessage_thenReturnsOneMessage() { + val json = """ + [ + { + "deliveryId": "single-inbox-msg", + "expiry": "2026-02-01T12:00:00.000000Z", + "sentAt": "2026-01-30T12:00:00.000000Z", + "topics": [], + "type": "alert", + "opened": false, + "priority": 5, + "queueId": "queue-ijkl-9012-mnop" + } + ] + """.trimIndent() + + val result = parser.parseInboxMessages(json) + + result.shouldHaveSize(1) + result[0].deliveryId.shouldBeEqualTo("single-inbox-msg") + result[0].type.shouldBeEqualTo("alert") + result[0].opened.shouldBeEqualTo(false) + result[0].priority.shouldBeEqualTo(5) + } } diff --git a/messaginginapp/src/test/java/io/customer/messaginginapp/inbox/NotificationInboxTest.kt b/messaginginapp/src/test/java/io/customer/messaginginapp/inbox/NotificationInboxTest.kt new file mode 100644 index 000000000..087c9cc84 --- /dev/null +++ b/messaginginapp/src/test/java/io/customer/messaginginapp/inbox/NotificationInboxTest.kt @@ -0,0 +1,911 @@ +package io.customer.messaginginapp.inbox + +import io.customer.commontest.config.TestConfig +import io.customer.commontest.config.testConfigurationDefault +import io.customer.commontest.core.TestConstants +import io.customer.commontest.extensions.attachToSDKComponent +import io.customer.commontest.extensions.flushCoroutines +import io.customer.commontest.extensions.random +import io.customer.commontest.util.DispatchersProviderStub +import io.customer.commontest.util.ScopeProviderStub +import io.customer.messaginginapp.MessagingInAppModuleConfig +import io.customer.messaginginapp.ModuleMessagingInApp +import io.customer.messaginginapp.di.inAppMessagingManager +import io.customer.messaginginapp.gist.GistEnvironment +import io.customer.messaginginapp.gist.data.model.InboxMessage +import io.customer.messaginginapp.state.InAppMessagingAction +import io.customer.messaginginapp.state.InAppMessagingManager +import io.customer.messaginginapp.testutils.core.IntegrationTest +import io.customer.messaginginapp.testutils.extension.createInboxMessage +import io.customer.messaginginapp.testutils.extension.dateDaysAgo +import io.customer.messaginginapp.testutils.extension.dateHoursAgo +import io.customer.messaginginapp.testutils.extension.dateNow +import io.customer.sdk.communication.Event +import io.customer.sdk.communication.EventBus +import io.customer.sdk.core.di.SDKComponent +import io.customer.sdk.core.util.DispatchersProvider +import io.customer.sdk.core.util.ScopeProvider +import io.customer.sdk.data.model.Region +import io.customer.sdk.events.Metric +import io.mockk.Runs +import io.mockk.every +import io.mockk.just +import io.mockk.mockk +import io.mockk.verify +import java.util.concurrent.CopyOnWriteArrayList +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit +import kotlin.concurrent.thread +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runTest +import org.amshove.kluent.internal.assertEquals +import org.amshove.kluent.shouldBeEqualTo +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@OptIn(ExperimentalCoroutinesApi::class) +@RunWith(RobolectricTestRunner::class) +class NotificationInboxTest : IntegrationTest() { + + private val scopeProviderStub = ScopeProviderStub.Standard() + private val dispatchersProviderStub = DispatchersProviderStub() + private val mockEventBus: EventBus = mockk(relaxed = true) + + private lateinit var module: ModuleMessagingInApp + private lateinit var manager: InAppMessagingManager + private lateinit var notificationInbox: NotificationInbox + + override fun setup(testConfig: TestConfig) { + super.setup( + testConfigurationDefault { + diGraph { + sdk { + overrideDependency(scopeProviderStub) + overrideDependency(mockEventBus) + overrideDependency(dispatchersProviderStub) + } + } + } + testConfig + ) + module = ModuleMessagingInApp( + config = MessagingInAppModuleConfig.Builder( + siteId = TestConstants.Keys.SITE_ID, + region = Region.US + ).build() + ).attachToSDKComponent() + manager = SDKComponent.inAppMessagingManager + notificationInbox = module.inbox() + } + + private fun initializeAndSetUser() { + manager.dispatch( + InAppMessagingAction.Initialize( + siteId = String.random, + dataCenter = String.random, + environment = GistEnvironment.PROD + ) + ) + manager.dispatch(InAppMessagingAction.SetUserIdentifier(String.random)) + } + + override fun teardown() { + manager.dispatch(InAppMessagingAction.Reset) + super.teardown() + } + + @Test + fun markMessageOpened_givenUnopenedMessage_expectMessageMarkedAsOpened() = runTest { + val queueId = "queue-123" + val message = createInboxMessage(deliveryId = "inbox1", queueId = queueId, opened = false) + + initializeAndSetUser() + manager.dispatch(InAppMessagingAction.ProcessInboxMessages(listOf(message))) + + notificationInbox.markMessageOpened(message) + + val state = manager.getCurrentState() + val updatedMessage = state.inboxMessages.first { it.queueId == queueId } + updatedMessage.opened shouldBeEqualTo true + } + + @Test + fun markMessageUnopened_givenOpenedMessage_expectMessageMarkedAsUnopened() = runTest { + val queueId = "queue-123" + val message = createInboxMessage(deliveryId = "inbox1", queueId = queueId, opened = true) + + initializeAndSetUser() + manager.dispatch(InAppMessagingAction.ProcessInboxMessages(listOf(message))) + + notificationInbox.markMessageUnopened(message) + + val state = manager.getCurrentState() + val updatedMessage = state.inboxMessages.first { it.queueId == queueId } + updatedMessage.opened shouldBeEqualTo false + } + + @Test + fun markMessageOpened_givenMultipleMessages_expectOnlyTargetMessageUpdated() = runTest { + val queueId1 = "queue-123" + val queueId2 = "queue-456" + val message1 = createInboxMessage(deliveryId = "inbox1", queueId = queueId1, opened = false) + val message2 = createInboxMessage(deliveryId = "inbox2", queueId = queueId2, opened = false) + + initializeAndSetUser() + manager.dispatch(InAppMessagingAction.ProcessInboxMessages(listOf(message1, message2))) + + notificationInbox.markMessageOpened(message1) + + val state = manager.getCurrentState() + val updatedMessage = state.inboxMessages.first { it.queueId == queueId1 } + updatedMessage.opened shouldBeEqualTo true + val unchangedMessage = state.inboxMessages.first { it.queueId == queueId2 } + unchangedMessage.opened shouldBeEqualTo false + } + + @Test + fun markMessageUnopened_givenMultipleMessages_expectOnlyTargetMessageUpdated() = runTest { + val queueId1 = "queue-123" + val queueId2 = "queue-456" + val message1 = createInboxMessage(deliveryId = "inbox1", queueId = queueId1, opened = true) + val message2 = createInboxMessage(deliveryId = "inbox2", queueId = queueId2, opened = true) + + initializeAndSetUser() + manager.dispatch(InAppMessagingAction.ProcessInboxMessages(listOf(message1, message2))) + + notificationInbox.markMessageUnopened(message1) + + val state = manager.getCurrentState() + val updatedMessage = state.inboxMessages.first { it.queueId == queueId1 } + updatedMessage.opened shouldBeEqualTo false + val unchangedMessage = state.inboxMessages.first { it.queueId == queueId2 } + unchangedMessage.opened shouldBeEqualTo true + } + + @Test + fun markMessageDeleted_givenMessage_expectMessageRemoved() = runTest { + val queueId = "queue-123" + val message = createInboxMessage(deliveryId = "inbox1", queueId = queueId, opened = false) + + initializeAndSetUser() + manager.dispatch(InAppMessagingAction.ProcessInboxMessages(listOf(message))) + + notificationInbox.markMessageDeleted(message) + + val state = manager.getCurrentState() + state.inboxMessages.size shouldBeEqualTo 0 + } + + @Test + fun markMessageDeleted_givenMultipleMessages_expectOnlyTargetMessageRemoved() = runTest { + val queueId1 = "queue-123" + val queueId2 = "queue-456" + val queueId3 = "queue-789" + val message1 = createInboxMessage(deliveryId = "inbox1", queueId = queueId1, opened = false) + val message2 = createInboxMessage(deliveryId = "inbox2", queueId = queueId2, opened = false) + val message3 = createInboxMessage(deliveryId = "inbox3", queueId = queueId3, opened = true) + + initializeAndSetUser() + manager.dispatch(InAppMessagingAction.ProcessInboxMessages(listOf(message1, message2, message3))) + + notificationInbox.markMessageDeleted(message1) + + val state = manager.getCurrentState() + state.inboxMessages.size shouldBeEqualTo 2 + state.inboxMessages.any { it.queueId == queueId1 } shouldBeEqualTo false + state.inboxMessages.any { it.queueId == queueId2 } shouldBeEqualTo true + state.inboxMessages.any { it.queueId == queueId3 } shouldBeEqualTo true + } + + @Test + fun trackMessageClicked_givenMessageWithActionName_expectMetricEventPublished() = runTest { + val deliveryId = "inbox1" + val message = createInboxMessage(deliveryId = deliveryId, queueId = "queue-123", opened = false) + + initializeAndSetUser() + manager.dispatch(InAppMessagingAction.ProcessInboxMessages(listOf(message))) + + notificationInbox.trackMessageClicked(message, "view_details") + + // Verify TrackInAppMetricEvent with Metric.Clicked was published with actionName param + verify { + mockEventBus.publish( + Event.TrackInAppMetricEvent( + deliveryID = deliveryId, + event = Metric.Clicked, + params = mapOf("actionName" to "view_details") + ) + ) + } + } + + @Test + fun trackMessageClicked_givenMessageWithoutActionName_expectMetricEventPublished() = runTest { + val deliveryId = "inbox1" + val message = createInboxMessage(deliveryId = deliveryId, queueId = "queue-123", opened = false) + + initializeAndSetUser() + manager.dispatch(InAppMessagingAction.ProcessInboxMessages(listOf(message))) + + notificationInbox.trackMessageClicked(message) + + // Verify TrackInAppMetricEvent with Metric.Clicked was published without params + verify { + mockEventBus.publish( + Event.TrackInAppMetricEvent( + deliveryID = deliveryId, + event = Metric.Clicked, + params = emptyMap() + ) + ) + } + } + + @Test + fun addChangeListener_givenNoTopicFilter_expectListenerReceivesAllMessages() = runTest { + initializeAndSetUser() + + // Create test messages with different topics + val message1 = createInboxMessage(deliveryId = "msg1", topics = listOf("promotions")) + val message2 = createInboxMessage(deliveryId = "msg2", topics = listOf("updates")) + val message3 = createInboxMessage(deliveryId = "msg3", topics = listOf("promotions", "special")) + val allMessages = listOf(message1, message2, message3) + + // Create and add listener + val listener = mockk(relaxed = true) + notificationInbox.addChangeListener(listener) + .flushCoroutines(scopeProviderStub.inAppLifecycleScope) + + // Dispatch action to update inbox messages + manager.dispatch(InAppMessagingAction.ProcessInboxMessages(allMessages)) + .flushCoroutines(scopeProviderStub.inAppLifecycleScope) + + // Verify listener received all messages + verify(exactly = 1) { + listener.onMessagesChanged(allMessages) + } + } + + @Test + fun addChangeListener_givenTopicFilter_expectListenerReceivesOnlyMatchingMessages() = runTest { + initializeAndSetUser() + + // Create test messages with different topics + val message1 = createInboxMessage(deliveryId = "msg1", topics = listOf("promotions")) + val message2 = createInboxMessage(deliveryId = "msg2", topics = listOf("updates")) + val message3 = createInboxMessage(deliveryId = "msg3", topics = listOf("promotions", "special")) + val allMessages = listOf(message1, message2, message3) + + // Create and add listener with topic filter + val listener = mockk(relaxed = true) + notificationInbox.addChangeListener(listener, "promotions") + .flushCoroutines(scopeProviderStub.inAppLifecycleScope) + + // Dispatch action to update inbox messages + manager.dispatch(InAppMessagingAction.ProcessInboxMessages(allMessages)) + .flushCoroutines(scopeProviderStub.inAppLifecycleScope) + + // Verify listener received only messages with "promotions" topic + verify(exactly = 1) { + listener.onMessagesChanged( + match { messages -> + messages.size == 2 && + messages.any { it.deliveryId == "msg1" } && + messages.any { it.deliveryId == "msg3" } + } + ) + } + } + + @Test + fun addChangeListener_givenMultipleListenersWithDifferentTopics_expectEachReceivesFilteredMessages() = runTest { + initializeAndSetUser() + + // Create test messages with different topics + val message1 = createInboxMessage(deliveryId = "msg1", topics = listOf("promotions")) + val message2 = createInboxMessage(deliveryId = "msg2", topics = listOf("updates")) + val message3 = createInboxMessage(deliveryId = "msg3", topics = listOf("promotions", "special")) + val allMessages = listOf(message1, message2, message3) + + // Add listeners with different filters + val listener1 = mockk(relaxed = true) + val listener2 = mockk(relaxed = true) + val listener3 = mockk(relaxed = true) + + notificationInbox.addChangeListener(listener1, "promotions") + notificationInbox.addChangeListener(listener2, "updates") + notificationInbox.addChangeListener(listener3) + .flushCoroutines(scopeProviderStub.inAppLifecycleScope) + + // Dispatch action to update inbox messages + manager.dispatch(InAppMessagingAction.ProcessInboxMessages(allMessages)) + .flushCoroutines(scopeProviderStub.inAppLifecycleScope) + + // Verify listener1 received only "promotions" messages + verify(exactly = 1) { + listener1.onMessagesChanged( + match { messages -> + messages.size == 2 && messages.all { it.topics.contains("promotions") } + } + ) + } + + // Verify listener2 received only "updates" messages + verify(exactly = 1) { + listener2.onMessagesChanged( + match { messages -> + messages.size == 1 && messages[0].deliveryId == "msg2" + } + ) + } + + // Verify listener3 received all messages + verify(exactly = 1) { + listener3.onMessagesChanged(match { it.size == 3 }) + } + } + + @Test + fun removeChangeListener_givenListenerExists_expectListenerRemoved() = runTest { + initializeAndSetUser() + + // Create test messages + val message1 = createInboxMessage(deliveryId = "msg1", topics = listOf("promotions")) + val allMessages = listOf(message1) + + // Add listener + val listener = mockk(relaxed = true) + notificationInbox.addChangeListener(listener) + .flushCoroutines(scopeProviderStub.inAppLifecycleScope) + + // Remove listener + notificationInbox.removeChangeListener(listener) + + // Clear previous invocations (initial state notification) + io.mockk.clearMocks(listener, answers = false, recordedCalls = true) + + // Dispatch action to update inbox messages + manager.dispatch(InAppMessagingAction.ProcessInboxMessages(allMessages)) + .flushCoroutines(scopeProviderStub.inAppLifecycleScope) + + // Verify listener was not called after removal + verify(exactly = 0) { + listener.onMessagesChanged(any()) + } + } + + @Test + fun removeChangeListener_givenMultipleRegistrationsOfSameListener_expectAllRemoved() = runTest { + initializeAndSetUser() + + // Add same listener with different topics + val listener = mockk(relaxed = true) + notificationInbox.addChangeListener(listener, "promotions") + notificationInbox.addChangeListener(listener, "updates") + .flushCoroutines(scopeProviderStub.inAppLifecycleScope) + + // Clear previous invocations (initial empty state notifications) + io.mockk.clearMocks(listener, answers = false, recordedCalls = true) + + // Dispatch first state change + val messages = listOf( + createInboxMessage(deliveryId = "msg1", topics = listOf("promotions")), + createInboxMessage(deliveryId = "msg2", topics = listOf("updates")) + ) + manager.dispatch(InAppMessagingAction.ProcessInboxMessages(messages)) + .flushCoroutines(scopeProviderStub.inAppLifecycleScope) + + // Listener should be called twice (once for each registration with filtered messages) + verify(exactly = 2) { + listener.onMessagesChanged(any()) + } + + // Remove listener (should remove all registrations) + notificationInbox.removeChangeListener(listener) + + // Dispatch another state change with a new message + manager.dispatch(InAppMessagingAction.ProcessInboxMessages(messages + createInboxMessage(deliveryId = "msg3", topics = listOf("promotions")))) + .flushCoroutines(scopeProviderStub.inAppLifecycleScope) + + // Verify listener was not called again (still 2 total calls since clearing) + verify(exactly = 2) { + listener.onMessagesChanged(any()) + } + } + + @Test + fun listenerCallback_givenException_expectOtherListenersStillNotified() = runTest { + initializeAndSetUser() + + val badListener = mockk(relaxed = true) + val goodListener = mockk(relaxed = true) + val testException = RuntimeException("Test exception") + + // Make badListener throw an exception + every { badListener.onMessagesChanged(any()) } throws testException + + // Add both listeners + notificationInbox.addChangeListener(badListener) + notificationInbox.addChangeListener(goodListener) + .flushCoroutines(scopeProviderStub.inAppLifecycleScope) + + // Dispatch state change + val messages = listOf(createInboxMessage()) + manager.dispatch(InAppMessagingAction.ProcessInboxMessages(messages)) + .flushCoroutines(scopeProviderStub.inAppLifecycleScope) + + // Verify good listener still received notifications despite bad listener throwing + verify(atLeast = 1) { + goodListener.onMessagesChanged(any()) + } + } + + @Test + fun addChangeListener_givenMultipleListeners_expectEachReceivesImmediateCallbackIndependently() = runTest { + initializeAndSetUser() + + // Set up initial state with messages + val initialMessages = listOf( + createInboxMessage(deliveryId = "msg1", topics = listOf("promotions"), sentAt = dateNow()), + createInboxMessage(deliveryId = "msg2", topics = listOf("updates"), sentAt = dateHoursAgo(1)) + ) + manager.dispatch(InAppMessagingAction.ProcessInboxMessages(initialMessages)) + .flushCoroutines(scopeProviderStub.inAppLifecycleScope) + + // Add first listener + val firstListener = mockk(relaxed = true) + notificationInbox.addChangeListener(firstListener) + .flushCoroutines(scopeProviderStub.inAppLifecycleScope) + + // Verify first listener received initial callback + verify(exactly = 1) { + firstListener.onMessagesChanged(initialMessages) + } + + // Clear invocations to focus on second listener + io.mockk.clearMocks(firstListener, answers = false, recordedCalls = true) + + // Add second listener (each listener gets independent immediate callback) + val secondListener = mockk(relaxed = true) + notificationInbox.addChangeListener(secondListener) + .flushCoroutines(scopeProviderStub.inAppLifecycleScope) + + // Verify second listener also received immediate callback with current state + verify(exactly = 1) { + secondListener.onMessagesChanged(initialMessages) + } + + // Verify first listener was NOT notified again when second listener was added + verify(exactly = 0) { + firstListener.onMessagesChanged(any()) + } + } + + @Test + fun addChangeListener_givenListenerAdded_expectInitialCallbackAndFutureUpdates() = runTest { + initializeAndSetUser() + + // Set up initial state + val initialMessages = listOf(createInboxMessage(deliveryId = "msg1", sentAt = dateHoursAgo(1))) + manager.dispatch(InAppMessagingAction.ProcessInboxMessages(initialMessages)) + .flushCoroutines(scopeProviderStub.inAppLifecycleScope) + + // Add listener + val listener = mockk(relaxed = true) + notificationInbox.addChangeListener(listener) + .flushCoroutines(scopeProviderStub.inAppLifecycleScope) + + // Verify listener received initial callback + verify(exactly = 1) { + listener.onMessagesChanged(initialMessages) + } + + // Update state with new messages + val updatedMessages = listOf(createInboxMessage(deliveryId = "msg2", sentAt = dateNow())) + initialMessages + manager.dispatch(InAppMessagingAction.ProcessInboxMessages(updatedMessages)) + .flushCoroutines(scopeProviderStub.inAppLifecycleScope) + + // Verify listener received callback for updated state + verify(exactly = 1) { + listener.onMessagesChanged(updatedMessages) + } + + // Total should be 2 calls: initial + update + verify(exactly = 2) { + listener.onMessagesChanged(any()) + } + } + + @Test + fun addChangeListener_givenStateUpdatedWithSameMessages_expectNoCallback() = runTest { + initializeAndSetUser() + + // Set up initial state with different sentAt times for deterministic sorting + val message1 = createInboxMessage(deliveryId = "msg1", queueId = "queue1", sentAt = dateHoursAgo(2)) + val message2 = createInboxMessage(deliveryId = "msg2", queueId = "queue2", sentAt = dateHoursAgo(1)) + val messages = listOf(message1, message2) + + manager.dispatch(InAppMessagingAction.ProcessInboxMessages(messages)) + .flushCoroutines(scopeProviderStub.inAppLifecycleScope) + + // Add listener + val listener = mockk(relaxed = true) + notificationInbox.addChangeListener(listener) + .flushCoroutines(scopeProviderStub.inAppLifecycleScope) + + // Verify initial callback (sorted by sentAt descending - message2 first) + verify(exactly = 1) { + listener.onMessagesChanged(listOf(message2, message1)) + } + + // Clear invocations + io.mockk.clearMocks(listener, answers = false, recordedCalls = true) + + // Dispatch the same messages again (state doesn't change) + manager.dispatch(InAppMessagingAction.ProcessInboxMessages(messages)) + .flushCoroutines(scopeProviderStub.inAppLifecycleScope) + + // Verify listener was NOT called because state didn't change (distinctUntilChanged) + verify(exactly = 0) { + listener.onMessagesChanged(any()) + } + + // Now dispatch different messages + val differentMessages = listOf(createInboxMessage(deliveryId = "msg3", queueId = "queue3")) + manager.dispatch(InAppMessagingAction.ProcessInboxMessages(differentMessages)) + .flushCoroutines(scopeProviderStub.inAppLifecycleScope) + + // Verify listener WAS called for actual state change + verify(exactly = 1) { + listener.onMessagesChanged(differentMessages) + } + } + + @Test + fun addChangeListener_givenMultipleListeners_expectEachReceivesInitialAndFutureUpdates() = runTest { + initializeAndSetUser() + + // Set up initial state + val initialMessages = listOf(createInboxMessage(deliveryId = "msg1", sentAt = dateHoursAgo(1))) + manager.dispatch(InAppMessagingAction.ProcessInboxMessages(initialMessages)) + .flushCoroutines(scopeProviderStub.inAppLifecycleScope) + + // Add three listeners sequentially + val listener1 = mockk(relaxed = true) + val listener2 = mockk(relaxed = true) + val listener3 = mockk(relaxed = true) + + notificationInbox.addChangeListener(listener1) + .flushCoroutines(scopeProviderStub.inAppLifecycleScope) + notificationInbox.addChangeListener(listener2) + .flushCoroutines(scopeProviderStub.inAppLifecycleScope) + notificationInbox.addChangeListener(listener3) + .flushCoroutines(scopeProviderStub.inAppLifecycleScope) + + // Verify each listener received initial callback + verify(exactly = 1) { listener1.onMessagesChanged(initialMessages) } + verify(exactly = 1) { listener2.onMessagesChanged(initialMessages) } + verify(exactly = 1) { listener3.onMessagesChanged(initialMessages) } + + // Trigger state update + val updatedMessages = listOf(createInboxMessage(deliveryId = "msg2", sentAt = dateNow())) + initialMessages + manager.dispatch(InAppMessagingAction.ProcessInboxMessages(updatedMessages)) + .flushCoroutines(scopeProviderStub.inAppLifecycleScope) + + // Verify each listener received update callback + verify(exactly = 1) { listener1.onMessagesChanged(updatedMessages) } + verify(exactly = 1) { listener2.onMessagesChanged(updatedMessages) } + verify(exactly = 1) { listener3.onMessagesChanged(updatedMessages) } + + // Verify total: each listener received exactly 2 callbacks (initial + update) + verify(exactly = 2) { listener1.onMessagesChanged(any()) } + verify(exactly = 2) { listener2.onMessagesChanged(any()) } + verify(exactly = 2) { listener3.onMessagesChanged(any()) } + } + + @Test + fun addChangeListener_givenConcurrentAddsAndStateUpdate_expectCorrectCallbacks() = runTest { + initializeAndSetUser() + + // Set up initial state + val initialMessages = listOf(createInboxMessage(deliveryId = "msg1", sentAt = dateHoursAgo(1))) + manager.dispatch(InAppMessagingAction.ProcessInboxMessages(initialMessages)) + .flushCoroutines(scopeProviderStub.inAppLifecycleScope) + + // Set up three listeners with callback tracking + val listeners = listOf( + mockk(relaxed = true), + mockk(relaxed = true), + mockk(relaxed = true) + ) + val callsPerListener = listeners.map { CopyOnWriteArrayList>() } + + listeners.forEachIndexed { index, listener -> + every { listener.onMessagesChanged(capture(callsPerListener[index])) } just Runs + } + + val completionLatch = CountDownLatch(4) + val updatedMessages = listOf(createInboxMessage(deliveryId = "msg2", sentAt = dateNow())) + initialMessages + + // Concurrently: add 3 listeners + trigger state update + // Tests thread safety - listeners should receive correct callbacks without crashes or duplicates + val threads = listeners.map { listener -> + thread(start = false) { + Thread.sleep(1) // Small delay to increase race likelihood + notificationInbox.addChangeListener(listener) + .flushCoroutines(scopeProviderStub.inAppLifecycleScope) + completionLatch.countDown() + } + } + thread(start = false) { + Thread.sleep(1) // Small delay to increase race likelihood + manager.dispatch(InAppMessagingAction.ProcessInboxMessages(updatedMessages)) + .flushCoroutines(scopeProviderStub.inAppLifecycleScope) + completionLatch.countDown() + } + + // Start all threads concurrently + threads.forEach { it.start() } + + // Wait for completion + completionLatch.await(30, TimeUnit.SECONDS) + assertEquals( + expected = 0L, + actual = completionLatch.count, + message = "Threads did not complete - likely crash or deadlock" + ) + + // Verify each listener received correct callbacks without duplicates + callsPerListener.forEach { calls -> + assertListenerCallbackContract(calls, initialMessages, updatedMessages) + } + } + + /** + * Validates that a listener received the correct callbacks based on timing. + * + * Expected behavior: + * - Listener receives current state immediately upon registration (synchronously on main thread) + * - Listener receives notifications for all future state changes + * - No stale state (out-of-order) notifications (guaranteed by @MainThread serialization) + * - Duplicate notifications may occur when listener added during state transition (rare, harmless) + * - Messages are sorted by sentAt (newest first) before delivery + * + * Valid patterns based on when listener was added: + * - Added before state update: receives [initial, updated] (2 calls) + * - Added after state update: receives [updated] (1 call) + * - Added during state update: receives [updated, updated] (2 calls - concurrent duplicate) + */ + private fun assertListenerCallbackContract( + calls: List>, + initial: List, + updated: List + ) { + assertTrue( + "Expected 1-2 calls based on timing, but got ${calls.size}: $calls", + calls.size in 1..2 + ) + + when (calls.size) { + 1 -> { + // Listener added after state update - receives only latest state + assertEquals(updated, calls[0]) + } + + 2 -> { + // Valid patterns: + // [initial, updated] - listener added before state change + // [updated, updated] - listener added during state change (concurrent duplicate) + val isValidPattern = calls == listOf(initial, updated) || + calls == listOf(updated, updated) + assertTrue( + "Expected [initial, updated] or [updated, updated] but got $calls", + isValidPattern + ) + } + } + } + + @Test + fun removeChangeListener_givenConcurrentNotifications_expectThreadSafeOperation() { + initializeAndSetUser() + + val listenersCount = 100 + val emitEventsCount = listenersCount / 5 + val listeners = ArrayList(listenersCount) + val threadsCompletionLatch = CountDownLatch(2) + + // Add listeners + repeat(listenersCount) { + listeners.add(emptyNotificationInboxChangeListener()) + notificationInbox.addChangeListener(listeners.last()) + } + + // Create a thread to remove listeners one by one + val removeListenersThread = thread(start = false) { + repeat(listenersCount) { index -> + notificationInbox.removeChangeListener(listeners[index]) + } + threadsCompletionLatch.countDown() + } + + // Create a thread to emit events + val handleInboxChangeThread = thread(start = false) { + repeat(emitEventsCount) { + // Emit inbox update to trigger listener notifications during concurrent removal + val message = createInboxMessage(deliveryId = "msg-$it") + manager.dispatch( + InAppMessagingAction.ProcessInboxMessages(listOf(message)) + ).flushCoroutines(scopeProviderStub.inAppLifecycleScope) + } + threadsCompletionLatch.countDown() + } + + // Start both threads in parallel + handleInboxChangeThread.start() + removeListenersThread.start() + + // Wait for threads to complete without any exceptions within the timeout + // If there is any exception, the latch will not be decremented + threadsCompletionLatch.await(10, TimeUnit.SECONDS) + + // Assert that threads completed without any exceptions within the timeout + assertEquals( + expected = 0L, + actual = threadsCompletionLatch.count, + message = "Threads did not complete within the timeout" + ) + } + + @Test + fun getMessages_givenNoTopic_expectAllMessages() = runTest { + initializeAndSetUser() + + val message1 = createInboxMessage(deliveryId = "msg1", topics = listOf("promotions")) + val message2 = createInboxMessage(deliveryId = "msg2", topics = listOf("updates")) + val message3 = createInboxMessage(deliveryId = "msg3", topics = listOf("promotions", "alerts")) + + manager.dispatch(InAppMessagingAction.ProcessInboxMessages(listOf(message1, message2, message3))) + + val messages = notificationInbox.getMessages() + + assertEquals(3, messages.size) + } + + @Test + fun getMessages_givenTopicFilter_expectOnlyMatchingMessages() = runTest { + initializeAndSetUser() + + val message1 = createInboxMessage(deliveryId = "msg1", topics = listOf("promotions")) + val message2 = createInboxMessage(deliveryId = "msg2", topics = listOf("updates")) + val message3 = createInboxMessage(deliveryId = "msg3", topics = listOf("promotions", "alerts")) + + manager.dispatch(InAppMessagingAction.ProcessInboxMessages(listOf(message1, message2, message3))) + + val messages = notificationInbox.getMessages("promotions") + + assertEquals(2, messages.size) + assertTrue(messages.all { it.topics.contains("promotions") }) + } + + @Test + fun getMessages_givenTopicFilterCaseInsensitive_expectMatchingMessages() = runTest { + initializeAndSetUser() + + val message1 = createInboxMessage(deliveryId = "msg1", topics = listOf("Promotions")) + val message2 = createInboxMessage(deliveryId = "msg2", topics = listOf("updates")) + + manager.dispatch(InAppMessagingAction.ProcessInboxMessages(listOf(message1, message2))) + + val messages = notificationInbox.getMessages("promotions") + + assertEquals(1, messages.size) + assertEquals("msg1", messages[0].deliveryId) + } + + @Test + fun getMessages_givenTopicFilterNoMatches_expectEmptyList() = runTest { + initializeAndSetUser() + + val message1 = createInboxMessage(deliveryId = "msg1", topics = listOf("promotions")) + val message2 = createInboxMessage(deliveryId = "msg2", topics = listOf("updates")) + + manager.dispatch(InAppMessagingAction.ProcessInboxMessages(listOf(message1, message2))) + + val messages = notificationInbox.getMessages("nonexistent") + + assertEquals(0, messages.size) + } + + @Test + fun getMessages_givenMultipleMessages_expectSortedByNewestFirst() = runTest { + initializeAndSetUser() + + val message1 = createInboxMessage(deliveryId = "msg1", sentAt = dateDaysAgo(2)) + val message2 = createInboxMessage(deliveryId = "msg2", sentAt = dateNow()) + val message3 = createInboxMessage(deliveryId = "msg3", sentAt = dateHoursAgo(1)) + + // Dispatch in random order + manager.dispatch(InAppMessagingAction.ProcessInboxMessages(listOf(message1, message2, message3))) + + val messages = notificationInbox.getMessages() + + assertEquals(3, messages.size) + // Verify sorted newest to oldest + assertEquals("msg2", messages[0].deliveryId) // now + assertEquals("msg3", messages[1].deliveryId) // 1 hour ago + assertEquals("msg1", messages[2].deliveryId) // 2 days ago + } + + @Test + fun getMessages_givenTopicFilter_expectSortedByNewestFirst() = runTest { + initializeAndSetUser() + + val message1 = createInboxMessage(deliveryId = "msg1", sentAt = dateDaysAgo(2), topics = listOf("promotions")) + val message2 = createInboxMessage(deliveryId = "msg2", sentAt = dateNow(), topics = listOf("updates")) + val message3 = createInboxMessage(deliveryId = "msg3", sentAt = dateHoursAgo(1), topics = listOf("promotions")) + + manager.dispatch(InAppMessagingAction.ProcessInboxMessages(listOf(message1, message2, message3))) + + val messages = notificationInbox.getMessages("promotions") + + assertEquals(2, messages.size) + // Verify sorted newest to oldest + assertEquals("msg3", messages[0].deliveryId) // 1 hour ago + assertEquals("msg1", messages[1].deliveryId) // 2 days ago + } + + @Test + fun addChangeListener_givenMultipleMessages_expectSortedByNewestFirst() = runTest { + initializeAndSetUser() + + val message1 = createInboxMessage(deliveryId = "msg1", sentAt = dateDaysAgo(2)) + val message2 = createInboxMessage(deliveryId = "msg2", sentAt = dateNow()) + val message3 = createInboxMessage(deliveryId = "msg3", sentAt = dateHoursAgo(1)) + + // Set up initial state in random order + manager.dispatch(InAppMessagingAction.ProcessInboxMessages(listOf(message1, message2, message3))) + .flushCoroutines(scopeProviderStub.inAppLifecycleScope) + + // Add listener and verify it receives sorted messages (newest first) + val listener = mockk(relaxed = true) + notificationInbox.addChangeListener(listener) + .flushCoroutines(scopeProviderStub.inAppLifecycleScope) + + verify(exactly = 1) { + listener.onMessagesChanged(listOf(message2, message3, message1)) + } + } + + @Test + fun addChangeListener_givenStateUpdate_expectSortedByNewestFirst() = runTest { + initializeAndSetUser() + + val message1 = createInboxMessage(deliveryId = "msg1", sentAt = dateHoursAgo(1)) + + manager.dispatch(InAppMessagingAction.ProcessInboxMessages(listOf(message1))) + .flushCoroutines(scopeProviderStub.inAppLifecycleScope) + + val listener = mockk(relaxed = true) + notificationInbox.addChangeListener(listener) + .flushCoroutines(scopeProviderStub.inAppLifecycleScope) + + // Clear initial callback + io.mockk.clearMocks(listener, answers = false, recordedCalls = true) + + // Add newer message + val message2 = createInboxMessage(deliveryId = "msg2", sentAt = dateNow()) + manager.dispatch(InAppMessagingAction.ProcessInboxMessages(listOf(message1, message2))) + .flushCoroutines(scopeProviderStub.inAppLifecycleScope) + + // Verify listener receives sorted messages (newest first) + verify(exactly = 1) { + listener.onMessagesChanged(listOf(message2, message1)) + } + } + + private fun emptyNotificationInboxChangeListener() = object : NotificationInboxChangeListener { + override fun onMessagesChanged(messages: List) { + } + } +} diff --git a/messaginginapp/src/test/java/io/customer/messaginginapp/state/InAppMessageReducerTest.kt b/messaginginapp/src/test/java/io/customer/messaginginapp/state/InAppMessageReducerTest.kt index 762cbaa23..d39ca90f0 100644 --- a/messaginginapp/src/test/java/io/customer/messaginginapp/state/InAppMessageReducerTest.kt +++ b/messaginginapp/src/test/java/io/customer/messaginginapp/state/InAppMessageReducerTest.kt @@ -6,6 +6,7 @@ import io.customer.messaginginapp.gist.data.model.GistProperties import io.customer.messaginginapp.gist.data.model.Message import io.customer.messaginginapp.gist.data.model.MessagePosition import io.customer.messaginginapp.testutils.core.JUnitTest +import io.customer.messaginginapp.testutils.extension.createInboxMessage import io.mockk.every import io.mockk.mockk import org.junit.jupiter.api.Assertions.assertEquals @@ -82,7 +83,7 @@ class InAppMessageReducerTest : JUnitTest() { val resultState = inAppMessagingReducer(startingState, dismissAction) assertEquals(1, resultState.shownMessageQueueIds.size) - assertTrue(resultState.shownMessageQueueIds.contains(testMessage.queueId!!)) + assertTrue(resultState.shownMessageQueueIds.contains(testMessage.queueId)) val modalState = resultState.modalMessageState as ModalMessageState.Dismissed assertEquals(testMessage, modalState.message) @@ -272,6 +273,75 @@ class InAppMessageReducerTest : JUnitTest() { assertNull(resultState.currentRoute) } + @Test + fun updateInboxMessageOpenedStatus_givenMatchingQueueId_expectMessageUpdated() { + val queueId = "queue-123" + val message1 = createInboxMessage(deliveryId = "inbox1", queueId = queueId, opened = false) + val message2 = createInboxMessage(deliveryId = "inbox2", queueId = "queue-456", opened = false) + + val startingState = initialState.copy( + inboxMessages = setOf(message1, message2) + ) + + val action = InAppMessagingAction.InboxAction.UpdateOpened( + message = message1, + opened = true + ) + val resultState = inAppMessagingReducer(startingState, action) + + assertEquals(2, resultState.inboxMessages.size) + val updatedMessage = resultState.inboxMessages.first { it.queueId == queueId } + assertTrue(updatedMessage.opened) + val unchangedMessage = resultState.inboxMessages.first { it.queueId == "queue-456" } + assertFalse(unchangedMessage.opened) + } + + @Test + fun updateInboxMessageOpenedStatus_givenMarkAsUnopened_expectOpenedSetToFalse() { + val queueId = "queue-123" + val message1 = createInboxMessage(deliveryId = "inbox1", queueId = queueId, opened = true) + val message2 = createInboxMessage(deliveryId = "inbox2", queueId = "queue-456", opened = true) + + val startingState = initialState.copy( + inboxMessages = setOf(message1, message2) + ) + + val action = InAppMessagingAction.InboxAction.UpdateOpened( + message = message1, + opened = false + ) + val resultState = inAppMessagingReducer(startingState, action) + + assertEquals(2, resultState.inboxMessages.size) + val updatedMessage = resultState.inboxMessages.first { it.queueId == queueId } + assertFalse(updatedMessage.opened) + val unchangedMessage = resultState.inboxMessages.first { it.queueId == "queue-456" } + assertTrue(unchangedMessage.opened) + } + + @Test + fun markInboxMessageDeleted_givenMatchingQueueId_expectMessageRemoved() { + val queueId = "queue-123" + val message1 = createInboxMessage(deliveryId = "inbox1", queueId = queueId, opened = false) + val message2 = createInboxMessage(deliveryId = "inbox2", queueId = "queue-456", opened = false) + val message3 = createInboxMessage(deliveryId = "inbox3", queueId = "queue-789", opened = true) + + val startingState = initialState.copy( + inboxMessages = setOf(message1, message2, message3) + ) + + val action = InAppMessagingAction.InboxAction.DeleteMessage(message1) + val resultState = inAppMessagingReducer(startingState, action) + + // Deleted message should be removed + assertEquals(2, resultState.inboxMessages.size) + assertFalse(resultState.inboxMessages.any { it.queueId == queueId }) + + // Other messages should remain unchanged + assertTrue(resultState.inboxMessages.any { it.queueId == "queue-456" }) + assertTrue(resultState.inboxMessages.any { it.queueId == "queue-789" }) + } + /** * Helper method to create a test message with customizable persistence */ diff --git a/messaginginapp/src/test/java/io/customer/messaginginapp/state/InAppMessagingMiddlewaresTest.kt b/messaginginapp/src/test/java/io/customer/messaginginapp/state/InAppMessagingMiddlewaresTest.kt index 6b50d83f3..e2d20e2e4 100644 --- a/messaginginapp/src/test/java/io/customer/messaginginapp/state/InAppMessagingMiddlewaresTest.kt +++ b/messaginginapp/src/test/java/io/customer/messaginginapp/state/InAppMessagingMiddlewaresTest.kt @@ -9,7 +9,11 @@ import io.customer.messaginginapp.gist.presentation.GistSdk import io.customer.messaginginapp.state.MessageBuilderMock.createMessage import io.customer.messaginginapp.testutils.core.JUnitTest import io.customer.messaginginapp.testutils.extension.createInAppMessage +import io.customer.messaginginapp.testutils.extension.createInboxMessage +import io.customer.sdk.communication.Event +import io.customer.sdk.communication.EventBus import io.customer.sdk.core.util.Logger +import io.customer.sdk.events.Metric import io.mockk.every import io.mockk.mockk import io.mockk.slot @@ -29,6 +33,7 @@ class InAppMessagingMiddlewaresTest : JUnitTest() { private val mockGistListener: GistListener = mockk(relaxed = true) private val mockLogger: Logger = mockk(relaxed = true) private val mockGistSdk: GistSdk = mockk(relaxed = true) + private val mockEventBus: EventBus = mockk(relaxed = true) override fun setup(testConfig: TestConfig) { // Configure store state @@ -42,6 +47,7 @@ class InAppMessagingMiddlewaresTest : JUnitTest() { overrideDependency(mockGistQueue) overrideDependency(mockLogger) overrideDependency(mockGistSdk) + overrideDependency(mockEventBus) } } } @@ -470,4 +476,335 @@ class InAppMessagingMiddlewaresTest : JUnitTest() { "Expected EmbedMessages action to be dispatched" } } + + @Test + fun processInboxMessages_givenDuplicateQueueIds_shouldDeduplicateByQueueId() { + // Given multiple messages with duplicate queueIds + val message1 = createInboxMessage(queueId = "queue-1", deliveryId = "delivery-1", opened = false) + val message2 = createInboxMessage(queueId = "queue-1", deliveryId = "delivery-2", opened = true) // Duplicate queueId + val message3 = createInboxMessage(queueId = "queue-2", deliveryId = "delivery-3", opened = false) + + val action = InAppMessagingAction.ProcessInboxMessages(listOf(message1, message2, message3)) + + // When the middleware processes the action + val middleware = processInboxMessages() + middleware(store)(nextFn)(action) + + // Then it should deduplicate by queueId, keeping only the first occurrence + verify { + nextFn( + match { + it.messages.size == 2 && + it.messages[0].queueId == "queue-1" && + it.messages[0].deliveryId == "delivery-1" && // First occurrence kept + it.messages[1].queueId == "queue-2" + } + ) + } + } + + @Test + fun processInboxMessages_givenUpdateOpenedWithOpenedTrue_shouldPublishMetricEvent() { + // Given an inbox message in state that is currently unopened + val deliveryId = String.random + val queueId = String.random + val inboxMessage = createInboxMessage(deliveryId = deliveryId, queueId = queueId, opened = false) + + // Set up store state with the unopened message + val state = InAppMessagingState( + siteId = String.random, + dataCenter = String.random, + inboxMessages = setOf(inboxMessage) + ) + every { store.state } returns state + + val action = InAppMessagingAction.InboxAction.UpdateOpened(inboxMessage, opened = true) + + // When the middleware processes the action + val middleware = processInboxMessages() + middleware(store)(nextFn)(action) + + // Then it should call the API to update opened status + verify { mockGistQueue.logOpenedStatus(inboxMessage, true) } + + // And it should publish a TrackInAppMetricEvent with Metric.Opened + verify { + mockEventBus.publish( + Event.TrackInAppMetricEvent( + deliveryID = deliveryId, + event = Metric.Opened + ) + ) + } + + // And it should pass the action to the next middleware/reducer + verify { nextFn(action) } + } + + @Test + fun processInboxMessages_givenUpdateOpenedWithOpenedFalse_shouldCallApiButNotPublishMetric() { + // Given an inbox message in state that is currently opened + val deliveryId = String.random + val queueId = String.random + val inboxMessage = createInboxMessage(deliveryId = deliveryId, queueId = queueId, opened = true) + + // Set up store state with the opened message + val state = InAppMessagingState( + siteId = String.random, + dataCenter = String.random, + inboxMessages = setOf(inboxMessage) + ) + every { store.state } returns state + + val action = InAppMessagingAction.InboxAction.UpdateOpened(inboxMessage, opened = false) + + // When the middleware processes the action + val middleware = processInboxMessages() + middleware(store)(nextFn)(action) + + // Then it should call the API to update opened status (state is changing) + verify { mockGistQueue.logOpenedStatus(inboxMessage, false) } + + // But it should NOT publish a metric event (only track when opening, not when unopening) + verify(exactly = 0) { + mockEventBus.publish(any()) + } + + // And it should pass the action to the next middleware/reducer + verify { nextFn(action) } + } + + @Test + fun processInboxMessages_givenMessageAlreadyOpened_shouldNotCallApiOrPublishMetric() { + // Given an inbox message that is already opened in state + val deliveryId = String.random + val queueId = String.random + val inboxMessage = createInboxMessage(deliveryId = deliveryId, queueId = queueId, opened = true) + + // Set up store state with the already opened message + val state = InAppMessagingState( + siteId = String.random, + dataCenter = String.random, + inboxMessages = setOf(inboxMessage) + ) + every { store.state } returns state + + val action = InAppMessagingAction.InboxAction.UpdateOpened(inboxMessage, opened = true) + + // When the middleware processes the action + val middleware = processInboxMessages() + middleware(store)(nextFn)(action) + + // Then it should NOT call the API since state is not changing + verify(exactly = 0) { mockGistQueue.logOpenedStatus(any(), any()) } + + // And it should NOT publish a metric event + verify(exactly = 0) { + mockEventBus.publish(any()) + } + + // But it should still pass the action to the next middleware/reducer + verify { nextFn(action) } + } + + @Test + fun processInboxMessages_givenStaleMessageNotInState_shouldNotCallApiOrPublishMetric() { + // Given a stale message object that doesn't exist in current state + val deliveryId = String.random + val queueId = String.random + val staleMessage = createInboxMessage(deliveryId = deliveryId, queueId = queueId, opened = false) + + // Set up store state with different messages (stale message not present) + val state = InAppMessagingState( + siteId = String.random, + dataCenter = String.random, + inboxMessages = setOf( + createInboxMessage(deliveryId = "other-1", queueId = "other-queue-1", opened = false) + ) + ) + every { store.state } returns state + + val action = InAppMessagingAction.InboxAction.UpdateOpened(staleMessage, opened = true) + + // When the middleware processes the action + val middleware = processInboxMessages() + middleware(store)(nextFn)(action) + + // Then it should NOT call the API for stale/unknown message + verify(exactly = 0) { mockGistQueue.logOpenedStatus(any(), any()) } + + // And it should NOT publish a metric event + verify(exactly = 0) { + mockEventBus.publish(any()) + } + + // But it should still pass the action to the next middleware/reducer + verify { nextFn(action) } + } + + @Test + fun processInboxMessages_givenTrackClickedWithActionName_shouldPublishMetricWithParams() { + // Given an inbox message in state + val deliveryId = String.random + val queueId = String.random + val inboxMessage = createInboxMessage(deliveryId = deliveryId, queueId = queueId, opened = false) + + // Set up store state with the message + val state = InAppMessagingState( + siteId = String.random, + dataCenter = String.random, + inboxMessages = setOf(inboxMessage) + ) + every { store.state } returns state + + val actionName = "view_details" + val action = InAppMessagingAction.InboxAction.TrackClicked(inboxMessage, actionName) + + // When the middleware processes the action + val middleware = processInboxMessages() + middleware(store)(nextFn)(action) + + // Then it should publish a TrackInAppMetricEvent with Metric.Clicked and actionName + verify { + mockEventBus.publish( + Event.TrackInAppMetricEvent( + deliveryID = deliveryId, + event = Metric.Clicked, + params = mapOf("actionName" to actionName) + ) + ) + } + + // And it should pass the action to the next middleware/reducer + verify { nextFn(action) } + } + + @Test + fun processInboxMessages_givenTrackClickedWithoutActionName_shouldPublishMetricWithoutParams() { + // Given an inbox message in state + val deliveryId = String.random + val queueId = String.random + val inboxMessage = createInboxMessage(deliveryId = deliveryId, queueId = queueId, opened = false) + + // Set up store state with the message + val state = InAppMessagingState( + siteId = String.random, + dataCenter = String.random, + inboxMessages = setOf(inboxMessage) + ) + every { store.state } returns state + + val action = InAppMessagingAction.InboxAction.TrackClicked(inboxMessage, actionName = null) + + // When the middleware processes the action + val middleware = processInboxMessages() + middleware(store)(nextFn)(action) + + // Then it should publish a TrackInAppMetricEvent with Metric.Clicked and empty params + verify { + mockEventBus.publish( + Event.TrackInAppMetricEvent( + deliveryID = deliveryId, + event = Metric.Clicked, + params = emptyMap() + ) + ) + } + + // And it should pass the action to the next middleware/reducer + verify { nextFn(action) } + } + + @Test + fun processInboxMessages_givenTrackClickedForStaleMessage_shouldNotPublishMetric() { + // Given a stale message object that doesn't exist in current state + val deliveryId = String.random + val queueId = String.random + val staleMessage = createInboxMessage(deliveryId = deliveryId, queueId = queueId, opened = false) + + // Set up store state with different messages (stale message not present) + val state = InAppMessagingState( + siteId = String.random, + dataCenter = String.random, + inboxMessages = setOf( + createInboxMessage(deliveryId = "other-1", queueId = "other-queue-1", opened = false) + ) + ) + every { store.state } returns state + + val action = InAppMessagingAction.InboxAction.TrackClicked(staleMessage, actionName = "some_action") + + // When the middleware processes the action + val middleware = processInboxMessages() + middleware(store)(nextFn)(action) + + // Then it should NOT publish a metric event for stale message + verify(exactly = 0) { + mockEventBus.publish(any()) + } + + // But it should still pass the action to the next middleware/reducer + verify { nextFn(action) } + } + + @Test + fun processInboxMessages_givenUpdateOpenedWithNullDeliveryId_shouldCallApiButNotPublishMetric() { + // Given an inbox message with null deliveryId that is currently unopened + val queueId = String.random + val inboxMessage = createInboxMessage(deliveryId = null, queueId = queueId, opened = false) + + // Set up store state with the unopened message + val state = InAppMessagingState( + siteId = String.random, + dataCenter = String.random, + inboxMessages = setOf(inboxMessage) + ) + every { store.state } returns state + + val action = InAppMessagingAction.InboxAction.UpdateOpened(inboxMessage, opened = true) + + // When the middleware processes the action + val middleware = processInboxMessages() + middleware(store)(nextFn)(action) + + // Then it should call the API to update opened status + verify { mockGistQueue.logOpenedStatus(inboxMessage, true) } + + // But it should NOT publish a metric event since deliveryId is null + verify(exactly = 0) { + mockEventBus.publish(any()) + } + + // And it should pass the action to the next middleware/reducer + verify { nextFn(action) } + } + + @Test + fun processInboxMessages_givenTrackClickedWithNullDeliveryId_shouldNotPublishMetric() { + // Given an inbox message with null deliveryId + val queueId = String.random + val inboxMessage = createInboxMessage(deliveryId = null, queueId = queueId, opened = false) + + // Set up store state with the message + val state = InAppMessagingState( + siteId = String.random, + dataCenter = String.random, + inboxMessages = setOf(inboxMessage) + ) + every { store.state } returns state + + val action = InAppMessagingAction.InboxAction.TrackClicked(inboxMessage, actionName = "some_action") + + // When the middleware processes the action + val middleware = processInboxMessages() + middleware(store)(nextFn)(action) + + // Then it should NOT publish a metric event since deliveryId is null + verify(exactly = 0) { + mockEventBus.publish(any()) + } + + // And it should pass the action to the next middleware/reducer + verify { nextFn(action) } + } } diff --git a/messaginginapp/src/test/java/io/customer/messaginginapp/testutils/extension/GistExtensions.kt b/messaginginapp/src/test/java/io/customer/messaginginapp/testutils/extension/GistExtensions.kt index b458b8da6..da190fc8a 100644 --- a/messaginginapp/src/test/java/io/customer/messaginginapp/testutils/extension/GistExtensions.kt +++ b/messaginginapp/src/test/java/io/customer/messaginginapp/testutils/extension/GistExtensions.kt @@ -1,12 +1,21 @@ package io.customer.messaginginapp.testutils.extension import io.customer.commontest.extensions.random +import io.customer.messaginginapp.gist.data.model.InboxMessage import io.customer.messaginginapp.gist.data.model.Message +import io.customer.messaginginapp.gist.data.model.response.InboxMessageFactory +import io.customer.messaginginapp.gist.data.model.response.InboxMessageResponse import io.customer.messaginginapp.type.InAppMessage import io.customer.messaginginapp.type.getMessage import io.customer.messaginginapp.ui.controller.InAppMessageViewController +import java.util.Date import java.util.UUID +// Date helper functions for testing +internal fun dateNow(): Date = Date() +internal fun dateHoursAgo(hours: Int): Date = Date(Date().time - hours * 3600_000L) +internal fun dateDaysAgo(days: Int): Date = Date(Date().time - days * 86400_000L) + fun getNewRandomMessage(): Message = InAppMessage(String.random, String.random, String.random).getMessage() fun mapToInAppMessage(message: Message): InAppMessage = InAppMessage.getFromGistMessage(gistMessage = message) @@ -42,6 +51,33 @@ fun createInAppMessage( } ) +// Creates an InboxMessage using InboxMessageResponse to leverage the factory mapping logic +fun createInboxMessage( + queueId: String = UUID.randomUUID().toString(), + deliveryId: String? = UUID.randomUUID().toString(), + expiry: Date? = null, + sentAt: Date = Date(), + topics: List = emptyList(), + type: String? = null, + opened: Boolean? = null, + priority: Int? = null, + properties: Map? = null +): InboxMessage = requireNotNull( + InboxMessageFactory.fromResponse( + InboxMessageResponse( + queueId = queueId, + deliveryId = deliveryId, + expiry = expiry, + sentAt = sentAt, + topics = topics, + type = type, + opened = opened, + priority = priority, + properties = properties + ) + ) +) { "Failed to create test InboxMessage - invalid queueId or sentAt" } + fun createGistAction(action: String): String = "gist://$action" internal fun InAppMessageViewController<*>.setMessageAndRouteForTest(message: Message, route: String) { diff --git a/samples/java_layout/build.gradle b/samples/java_layout/build.gradle index e1f7051c4..59151a6b6 100644 --- a/samples/java_layout/build.gradle +++ b/samples/java_layout/build.gradle @@ -55,6 +55,7 @@ dependencies { implementation "androidx.lifecycle:lifecycle-livedata:$lifecycle_version" implementation "androidx.lifecycle:lifecycle-reactivestreams:$lifecycle_version" implementation "androidx.lifecycle:lifecycle-viewmodel:$lifecycle_version" + implementation "androidx.swiperefreshlayout:swiperefreshlayout:1.2.0" implementation "com.google.android.material:material:1.9.0" implementation "io.reactivex.rxjava3:rxandroid:3.0.2" // Because RxAndroid releases are few and far between, it is recommended to diff --git a/samples/java_layout/src/main/AndroidManifest.xml b/samples/java_layout/src/main/AndroidManifest.xml index 9045b1e2d..356ff3ec4 100644 --- a/samples/java_layout/src/main/AndroidManifest.xml +++ b/samples/java_layout/src/main/AndroidManifest.xml @@ -130,6 +130,10 @@ android:name=".ui.inline.InlineExamplesActivity" android:exported="false" android:label="@string/label_inline_examples_activity" /> + { startInlineExamplesActivity(); }); + binding.inboxMessagesButton.setOnClickListener(view -> { + startInboxMessagesActivity(); + }); binding.logoutButton.setOnClickListener(view -> { authViewModel.clearLoggedInUser(); }); @@ -205,6 +209,11 @@ private void startInlineExamplesActivity() { startActivity(intent); } + private void startInboxMessagesActivity() { + Intent intent = new Intent(DashboardActivity.this, InboxMessagesActivity.class); + startActivity(intent); + } + private void requestNotificationPermission() { if (isNotificationPermissionGranted()) { // Ask for notification permission if not granted diff --git a/samples/java_layout/src/main/java/io/customer/android/sample/java_layout/ui/inbox/InboxMessagesActivity.kt b/samples/java_layout/src/main/java/io/customer/android/sample/java_layout/ui/inbox/InboxMessagesActivity.kt new file mode 100644 index 000000000..9ed78e3a1 --- /dev/null +++ b/samples/java_layout/src/main/java/io/customer/android/sample/java_layout/ui/inbox/InboxMessagesActivity.kt @@ -0,0 +1,159 @@ +package io.customer.android.sample.java_layout.ui.inbox + +import android.view.View +import android.widget.EditText +import android.widget.Toast +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import io.customer.android.sample.java_layout.R +import io.customer.android.sample.java_layout.databinding.ActivityInboxMessagesBinding +import io.customer.android.sample.java_layout.ui.core.BaseActivity +import io.customer.messaginginapp.di.inAppMessaging +import io.customer.messaginginapp.gist.data.model.InboxMessage +import io.customer.messaginginapp.inbox.NotificationInboxChangeListener +import io.customer.sdk.CustomerIO + +class InboxMessagesActivity : BaseActivity() { + private lateinit var adapter: InboxMessagesAdapter + private val notificationInbox by lazy { CustomerIO.instance().inAppMessaging().inbox() } + private var notificationInboxChangeListener: NotificationInboxChangeListener? = null + + override fun inflateViewBinding(): ActivityInboxMessagesBinding { + return ActivityInboxMessagesBinding.inflate(layoutInflater) + } + + override fun setupContent() { + setupToolbar() + setupRecyclerView() + setupSwipeRefresh() + setupInbox() + } + + private fun setupToolbar() { + binding.toolbar.setNavigationOnClickListener { finish() } + } + + private fun setupRecyclerView() { + adapter = InboxMessagesAdapter( + onToggleReadClick = { message -> + if (message.opened) { + notificationInbox.markMessageUnopened(message) + } else { + notificationInbox.markMessageOpened(message) + } + }, + onTrackClickClick = { message -> + showTrackClickDialog(message) + }, + onDeleteClick = { message -> + showDeleteConfirmationDialog(message) + } + ) + binding.recyclerView.adapter = adapter + } + + private fun setupSwipeRefresh() { + binding.swipeRefreshLayout.setOnRefreshListener { fetchMessages() } + } + + private fun setupInbox() { + notificationInboxChangeListener = object : NotificationInboxChangeListener { + override fun onMessagesChanged(messages: List) { + updateMessages(messages) + hideLoading() + binding.swipeRefreshLayout.isRefreshing = false + } + } + + notificationInboxChangeListener?.let { notificationInbox.addChangeListener(it) } + } + + private fun fetchMessages() { + showLoading() + notificationInbox.fetchMessages { result -> + runOnUiThread { + result.onSuccess { messages -> + updateMessages(messages) + hideLoading() + }.onFailure { error -> + Toast.makeText( + this, + getString(R.string.inbox_fetch_error) + ": ${error.localizedMessage}", + Toast.LENGTH_SHORT + ).show() + updateMessages(emptyList()) + hideLoading() + } + binding.swipeRefreshLayout.isRefreshing = false + } + } + } + + private fun updateMessages(messages: List) { + if (messages.isEmpty()) { + binding.recyclerView.visibility = View.GONE + binding.emptyStateTextView.visibility = View.VISIBLE + } else { + binding.recyclerView.visibility = View.VISIBLE + binding.emptyStateTextView.visibility = View.GONE + } + adapter.setMessages(messages) + } + + private fun showLoading() { + binding.progressBar.visibility = View.VISIBLE + binding.recyclerView.visibility = View.GONE + binding.emptyStateTextView.visibility = View.GONE + } + + private fun hideLoading() { + binding.progressBar.visibility = View.GONE + } + + private fun showTrackClickDialog(message: InboxMessage) { + val input = EditText(this).apply { + hint = getString(R.string.inbox_track_click_dialog_hint) + setPadding(64, 32, 64, 32) + } + + MaterialAlertDialogBuilder(this) + .setTitle(R.string.inbox_track_click_dialog_title) + .setMessage(R.string.inbox_track_click_dialog_message) + .setView(input) + .setPositiveButton(android.R.string.ok) { _, _ -> + val actionName = input.text.toString().trim() + if (actionName.isEmpty()) { + notificationInbox.trackMessageClicked(message) + } else { + notificationInbox.trackMessageClicked(message, actionName) + } + Toast.makeText( + this, + getString(R.string.inbox_track_click_success), + Toast.LENGTH_SHORT + ).show() + } + .setNegativeButton(android.R.string.cancel, null) + .show() + } + + private fun showDeleteConfirmationDialog(message: InboxMessage) { + MaterialAlertDialogBuilder(this) + .setTitle(R.string.inbox_delete_dialog_title) + .setMessage(R.string.inbox_delete_dialog_message) + .setPositiveButton(R.string.inbox_delete_confirm) { _, _ -> + notificationInbox.markMessageDeleted(message) + Toast.makeText( + this, + getString(R.string.inbox_message_deleted), + Toast.LENGTH_SHORT + ).show() + } + .setNegativeButton(R.string.inbox_delete_cancel, null) + .show() + } + + override fun onDestroy() { + notificationInboxChangeListener?.let { notificationInbox.removeChangeListener(it) } + super.onDestroy() + } +} diff --git a/samples/java_layout/src/main/java/io/customer/android/sample/java_layout/ui/inbox/InboxMessagesAdapter.kt b/samples/java_layout/src/main/java/io/customer/android/sample/java_layout/ui/inbox/InboxMessagesAdapter.kt new file mode 100644 index 000000000..7acc661b0 --- /dev/null +++ b/samples/java_layout/src/main/java/io/customer/android/sample/java_layout/ui/inbox/InboxMessagesAdapter.kt @@ -0,0 +1,218 @@ +package io.customer.android.sample.java_layout.ui.inbox + +import android.text.format.DateUtils +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.RecyclerView +import io.customer.android.sample.java_layout.R +import io.customer.android.sample.java_layout.databinding.ItemInboxMessageBinding +import io.customer.messaginginapp.gist.data.model.InboxMessage +import java.text.SimpleDateFormat +import java.util.Locale + +class InboxMessagesAdapter( + private val onToggleReadClick: (InboxMessage) -> Unit, + private val onTrackClickClick: (InboxMessage) -> Unit, + private val onDeleteClick: (InboxMessage) -> Unit +) : RecyclerView.Adapter() { + + private val messages: MutableList = mutableListOf() + + fun setMessages(newMessages: List) { + val diffCallback = InboxMessageDiffCallback(messages, newMessages) + val diffResult = DiffUtil.calculateDiff(diffCallback) + + messages.clear() + messages.addAll(newMessages) + diffResult.dispatchUpdatesTo(this) + } + + private class InboxMessageDiffCallback( + private val oldList: List, + private val newList: List + ) : DiffUtil.Callback() { + override fun getOldListSize(): Int = oldList.size + + override fun getNewListSize(): Int = newList.size + + override fun areItemsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean { + // Use queueId for identity since it's the guaranteed unique, non-nullable identifier + return oldList[oldItemPosition].queueId == newList[newItemPosition].queueId + } + + override fun areContentsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean { + // InboxMessage is a data class with generated equals() that compares all fields + // This ensures all displayed fields are checked for changes + return oldList[oldItemPosition] == newList[newItemPosition] + } + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): InboxMessageViewHolder { + val binding = ItemInboxMessageBinding.inflate( + LayoutInflater.from(parent.context), + parent, + false + ) + return InboxMessageViewHolder(binding, onToggleReadClick, onTrackClickClick, onDeleteClick) + } + + override fun onBindViewHolder(holder: InboxMessageViewHolder, position: Int) { + holder.bind(messages[position]) + } + + override fun getItemCount(): Int = messages.size + + class InboxMessageViewHolder( + private val binding: ItemInboxMessageBinding, + private val onToggleReadClick: (InboxMessage) -> Unit, + private val onTrackClickClick: (InboxMessage) -> Unit, + private val onDeleteClick: (InboxMessage) -> Unit + ) : RecyclerView.ViewHolder(binding.root) { + + private val dateFormatter = SimpleDateFormat("MMM d, yyyy h:mm a", Locale.getDefault()) + + fun bind(message: InboxMessage) { + val context = binding.root.context + + binding.deliveryIdTextView.text = message.deliveryId ?: "N/A - (${message.queueId})" + + val backgroundColorAttr = if (message.opened) { + com.google.android.material.R.attr.colorSurfaceContainerLowest + } else { + com.google.android.material.R.attr.colorSurfaceContainerHighest + } + binding.cardView.setCardBackgroundColor( + android.util.TypedValue().let { typedValue -> + context.theme.resolveAttribute(backgroundColorAttr, typedValue, true) + typedValue.data + } + ) + + // Compact metadata line: "sentAt • Priority X • topics" + val metadataParts = mutableListOf() + + // Add sent date + metadataParts.add(formatDate(message.sentAt)) + + // Add priority if present + message.priority?.let { metadataParts.add("Priority $it") } + + // Add topics + if (message.topics.isNotEmpty()) { + metadataParts.add(message.topics.joinToString(", ")) + } + + binding.metadataTextView.text = metadataParts.joinToString(" • ") + + // Properties (show only if present) + val properties = message.properties + if (properties.isNotEmpty()) { + binding.propertiesTextView.text = formatProperties(properties) + binding.propertiesTextView.visibility = android.view.View.VISIBLE + } else { + binding.propertiesTextView.visibility = android.view.View.GONE + binding.propertiesTextView.text = "" + } + + // Show only the relevant button based on message state + if (message.opened) { + // Read message - show mark as unread button + binding.markReadButton.visibility = android.view.View.GONE + binding.markUnreadButton.visibility = android.view.View.VISIBLE + + binding.markUnreadButton.setOnClickListener { + onToggleReadClick(message) + } + + binding.markUnreadButton.setOnLongClickListener { + android.widget.Toast.makeText( + context, + context.getString(R.string.inbox_mark_as_unread), + android.widget.Toast.LENGTH_SHORT + ).show() + true + } + } else { + // Unread message - show mark as read button + binding.markReadButton.visibility = android.view.View.VISIBLE + binding.markUnreadButton.visibility = android.view.View.GONE + + binding.markReadButton.setOnClickListener { + onToggleReadClick(message) + } + + binding.markReadButton.setOnLongClickListener { + android.widget.Toast.makeText( + context, + context.getString(R.string.inbox_mark_as_read), + android.widget.Toast.LENGTH_SHORT + ).show() + true + } + } + + // Track click button (always visible) + binding.trackClickButton.setOnClickListener { + onTrackClickClick(message) + } + + binding.trackClickButton.setOnLongClickListener { + android.widget.Toast.makeText( + context, + context.getString(R.string.inbox_track_click), + android.widget.Toast.LENGTH_SHORT + ).show() + true + } + + // Delete button (always visible) + binding.deleteButton.setOnClickListener { + onDeleteClick(message) + } + + binding.deleteButton.setOnLongClickListener { + android.widget.Toast.makeText( + context, + context.getString(R.string.inbox_delete), + android.widget.Toast.LENGTH_SHORT + ).show() + true + } + } + + private fun formatDate(date: java.util.Date): String { + val now = System.currentTimeMillis() + val dateTime = date.time + + // Use relative time if within the last week + return if (now - dateTime < DateUtils.WEEK_IN_MILLIS) { + DateUtils.getRelativeTimeSpanString( + dateTime, + now, + DateUtils.MINUTE_IN_MILLIS, + DateUtils.FORMAT_ABBREV_RELATIVE + ).toString() + } else { + // Otherwise show formatted date + dateFormatter.format(date) + } + } + + private fun formatProperties(properties: Map): String { + if (properties.isEmpty()) return "{}" + + return properties.entries.joinToString( + separator = ", ", + prefix = "{", + postfix = "}" + ) { (key, value) -> + val formattedValue = when (value) { + is String -> "\"$value\"" + else -> value.toString() + } + "\"$key\": $formattedValue" + } + } + } +} diff --git a/samples/java_layout/src/main/res/drawable/ic_delete_forever_24dp.xml b/samples/java_layout/src/main/res/drawable/ic_delete_forever_24dp.xml new file mode 100644 index 000000000..ed9275b48 --- /dev/null +++ b/samples/java_layout/src/main/res/drawable/ic_delete_forever_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/samples/java_layout/src/main/res/drawable/ic_delivery_24dp.xml b/samples/java_layout/src/main/res/drawable/ic_delivery_24dp.xml new file mode 100644 index 000000000..674cda33f --- /dev/null +++ b/samples/java_layout/src/main/res/drawable/ic_delivery_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/samples/java_layout/src/main/res/drawable/ic_mark_chat_read_24dp.xml b/samples/java_layout/src/main/res/drawable/ic_mark_chat_read_24dp.xml new file mode 100644 index 000000000..1603a5a4c --- /dev/null +++ b/samples/java_layout/src/main/res/drawable/ic_mark_chat_read_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/samples/java_layout/src/main/res/drawable/ic_mark_chat_unread_24dp.xml b/samples/java_layout/src/main/res/drawable/ic_mark_chat_unread_24dp.xml new file mode 100644 index 000000000..0766d2fd0 --- /dev/null +++ b/samples/java_layout/src/main/res/drawable/ic_mark_chat_unread_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/samples/java_layout/src/main/res/drawable/ic_track_changes_24dp.xml b/samples/java_layout/src/main/res/drawable/ic_track_changes_24dp.xml new file mode 100644 index 000000000..a0a09aa2e --- /dev/null +++ b/samples/java_layout/src/main/res/drawable/ic_track_changes_24dp.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 b552542f6..5389a97cb 100644 --- a/samples/java_layout/src/main/res/layout/activity_dashboard.xml +++ b/samples/java_layout/src/main/res/layout/activity_dashboard.xml @@ -207,12 +207,25 @@ android:layout_height="wrap_content" android:layout_marginTop="@dimen/margin_default" android:text="@string/inline_examples_tabbed" - app:layout_constraintBottom_toTopOf="@id/logout_button" + app:layout_constraintBottom_toTopOf="@id/inbox_messages_button" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toBottomOf="@id/view_logs_button" app:layout_constraintWidth_max="@dimen/material_button_max_width" /> +