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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
85 changes: 48 additions & 37 deletions messaginginapp/api/messaginginapp.api
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 <init> ()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 <init> (IIZ)V
public synthetic fun <init> (IIZILkotlin/jvm/internal/DefaultConstructorMarker;)V
Expand Down Expand Up @@ -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 <init> (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 <init> ()V
public fun <init> (Ljava/lang/String;Ljava/lang/Integer;Ljava/lang/String;Ljava/util/Map;)V
Expand Down Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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()
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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<List<Message>>
internal interface GistQueueService {
@POST("/api/v4/users")
suspend fun fetchMessagesForUser(@Body body: Any = Object(), @Query("sessionId") sessionId: String): Response<QueueMessagesResponse>

@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<String, Boolean>)
}

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
Expand Down Expand Up @@ -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
}
Expand All @@ -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)
}

Expand All @@ -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<Message>?) {
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) {
Expand Down Expand Up @@ -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}")
}
}
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Delete inbox message calls view-logging endpoint instead

High Severity

The logDeleted method calls gistQueueService.logUserMessageView() which POSTs to /api/v1/logs/queue/{queueId} — the same endpoint used by logView for logging that a user viewed a message. No actual delete endpoint (e.g., @DELETE or a PATCH with a deletion flag) is defined in GistQueueService. By contrast, logOpenedStatus correctly uses a dedicated @PATCH("/api/v1/messages/{queueId}") endpoint. Deleting an inbox message will instead log a "view" event on the server, meaning the message won't actually be deleted and will reappear on the next sync.

Additional Locations (1)

Fix in Cursor Fix in Web

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's intentional. The same API is used for marking an in app message as viewed and for deleting an inbox message. I'll think of more descriptive name and rename the method later to minimize confusion.


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"
}
}
Loading
Loading