Skip to content

Commit 7bc46da

Browse files
authored
feat: support for notification inbox (#660)
2 parents 4344acb + b5cf24d commit 7bc46da

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

46 files changed

+3703
-80
lines changed

messaginginapp/api/messaginginapp.api

Lines changed: 48 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ public final class io/customer/messaginginapp/ModuleMessagingInApp : io/customer
2121
public fun getModuleConfig ()Lio/customer/messaginginapp/MessagingInAppModuleConfig;
2222
public synthetic fun getModuleConfig ()Lio/customer/sdk/core/module/CustomerIOModuleConfig;
2323
public fun getModuleName ()Ljava/lang/String;
24+
public final fun inbox ()Lio/customer/messaginginapp/inbox/NotificationInbox;
2425
public fun initialize ()V
2526
public static final fun instance ()Lio/customer/messaginginapp/ModuleMessagingInApp;
2627
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 {
5758
public final class io/customer/messaginginapp/gist/data/NetworkUtilities$Companion {
5859
}
5960

60-
public abstract interface class io/customer/messaginginapp/gist/data/listeners/GistQueue {
61-
public abstract fun fetchUserMessages ()V
62-
public abstract fun logView (Lio/customer/messaginginapp/gist/data/model/Message;)V
63-
}
64-
65-
public abstract interface class io/customer/messaginginapp/gist/data/listeners/GistQueueService {
66-
public abstract fun fetchMessagesForUser (Ljava/lang/Object;Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
67-
public abstract fun logMessageView (Ljava/lang/String;Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
68-
public abstract fun logUserMessageView (Ljava/lang/String;Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
69-
}
70-
71-
public final class io/customer/messaginginapp/gist/data/listeners/GistQueueService$DefaultImpls {
72-
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;
73-
}
74-
75-
public final class io/customer/messaginginapp/gist/data/listeners/Queue : io/customer/messaginginapp/gist/data/listeners/GistQueue {
76-
public fun <init> ()V
77-
public fun fetchUserMessages ()V
78-
public fun logView (Lio/customer/messaginginapp/gist/data/model/Message;)V
79-
}
80-
8161
public final class io/customer/messaginginapp/gist/data/model/BroadcastFrequency {
8262
public fun <init> (IIZ)V
8363
public synthetic fun <init> (IIZILkotlin/jvm/internal/DefaultConstructorMarker;)V
@@ -128,6 +108,33 @@ public final class io/customer/messaginginapp/gist/data/model/GistProperties {
128108
public fun toString ()Ljava/lang/String;
129109
}
130110

111+
public final class io/customer/messaginginapp/gist/data/model/InboxMessage {
112+
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
113+
public final fun component1 ()Ljava/lang/String;
114+
public final fun component2 ()Ljava/lang/String;
115+
public final fun component3 ()Ljava/util/Date;
116+
public final fun component4 ()Ljava/util/Date;
117+
public final fun component5 ()Ljava/util/List;
118+
public final fun component6 ()Ljava/lang/String;
119+
public final fun component7 ()Z
120+
public final fun component8 ()Ljava/lang/Integer;
121+
public final fun component9 ()Ljava/util/Map;
122+
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;
123+
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;
124+
public fun equals (Ljava/lang/Object;)Z
125+
public final fun getDeliveryId ()Ljava/lang/String;
126+
public final fun getExpiry ()Ljava/util/Date;
127+
public final fun getOpened ()Z
128+
public final fun getPriority ()Ljava/lang/Integer;
129+
public final fun getProperties ()Ljava/util/Map;
130+
public final fun getQueueId ()Ljava/lang/String;
131+
public final fun getSentAt ()Ljava/util/Date;
132+
public final fun getTopics ()Ljava/util/List;
133+
public final fun getType ()Ljava/lang/String;
134+
public fun hashCode ()I
135+
public fun toString ()Ljava/lang/String;
136+
}
137+
131138
public final class io/customer/messaginginapp/gist/data/model/Message {
132139
public fun <init> ()V
133140
public fun <init> (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 {
200207
public final fun start (Ljava/lang/String;)V
201208
}
202209

203-
public abstract interface class io/customer/messaginginapp/store/InAppPreferenceStore {
204-
public abstract fun clearAll ()V
205-
public abstract fun clearAllAnonymousData ()V
206-
public abstract fun clearAnonymousTracking (Ljava/lang/String;)V
207-
public abstract fun getAnonymousMessages ()Ljava/lang/String;
208-
public abstract fun getAnonymousNextShowTime (Ljava/lang/String;)J
209-
public abstract fun getAnonymousTimesShown (Ljava/lang/String;)I
210-
public abstract fun getNetworkResponse (Ljava/lang/String;)Ljava/lang/String;
211-
public abstract fun incrementAnonymousTimesShown (Ljava/lang/String;)V
212-
public abstract fun isAnonymousDismissed (Ljava/lang/String;)Z
213-
public abstract fun isAnonymousInDelayPeriod (Ljava/lang/String;)Z
214-
public abstract fun isAnonymousMessagesExpired ()Z
215-
public abstract fun saveAnonymousMessages (Ljava/lang/String;J)V
216-
public abstract fun saveNetworkResponse (Ljava/lang/String;Ljava/lang/String;)V
217-
public abstract fun setAnonymousDismissed (Ljava/lang/String;Z)V
218-
public abstract fun setAnonymousNextShowTime (Ljava/lang/String;J)V
210+
public final class io/customer/messaginginapp/inbox/NotificationInbox {
211+
public final fun addChangeListener (Lio/customer/messaginginapp/inbox/NotificationInboxChangeListener;)V
212+
public final fun addChangeListener (Lio/customer/messaginginapp/inbox/NotificationInboxChangeListener;Ljava/lang/String;)V
213+
public static synthetic fun addChangeListener$default (Lio/customer/messaginginapp/inbox/NotificationInbox;Lio/customer/messaginginapp/inbox/NotificationInboxChangeListener;Ljava/lang/String;ILjava/lang/Object;)V
214+
public final fun fetchMessages (Ljava/lang/String;Lkotlin/jvm/functions/Function1;)V
215+
public final fun fetchMessages (Lkotlin/jvm/functions/Function1;)V
216+
public final fun getMessages (Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
217+
public final fun getMessages (Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
218+
public static synthetic fun getMessages$default (Lio/customer/messaginginapp/inbox/NotificationInbox;Ljava/lang/String;Lkotlin/coroutines/Continuation;ILjava/lang/Object;)Ljava/lang/Object;
219+
public final fun markMessageDeleted (Lio/customer/messaginginapp/gist/data/model/InboxMessage;)V
220+
public final fun markMessageOpened (Lio/customer/messaginginapp/gist/data/model/InboxMessage;)V
221+
public final fun markMessageUnopened (Lio/customer/messaginginapp/gist/data/model/InboxMessage;)V
222+
public final fun removeChangeListener (Lio/customer/messaginginapp/inbox/NotificationInboxChangeListener;)V
223+
public final fun trackMessageClicked (Lio/customer/messaginginapp/gist/data/model/InboxMessage;)V
224+
public final fun trackMessageClicked (Lio/customer/messaginginapp/gist/data/model/InboxMessage;Ljava/lang/String;)V
225+
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
226+
}
227+
228+
public abstract interface class io/customer/messaginginapp/inbox/NotificationInboxChangeListener {
229+
public abstract fun onMessagesChanged (Ljava/util/List;)V
219230
}
220231

221232
public abstract interface class io/customer/messaginginapp/type/InAppEventListener {

messaginginapp/src/main/java/io/customer/messaginginapp/ModuleMessagingInApp.kt

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,11 @@ package io.customer.messaginginapp
22

33
import io.customer.messaginginapp.di.gistCustomAttributes
44
import io.customer.messaginginapp.di.gistProvider
5+
import io.customer.messaginginapp.di.notificationInbox
56
import io.customer.messaginginapp.gist.data.model.Message
67
import io.customer.messaginginapp.gist.presentation.GistListener
78
import io.customer.messaginginapp.gist.presentation.GistProvider
9+
import io.customer.messaginginapp.inbox.NotificationInbox
810
import io.customer.messaginginapp.type.InAppMessage
911
import io.customer.sdk.communication.Event
1012
import io.customer.sdk.communication.subscribe
@@ -23,6 +25,15 @@ class ModuleMessagingInApp(
2325
get() = SDKComponent.gistProvider
2426
private val logger = SDKComponent.logger
2527

28+
/**
29+
* Access the inbox messages instance for managing user inbox messages.
30+
*
31+
* @return [NotificationInbox] instance for inbox operations
32+
*/
33+
fun inbox(): NotificationInbox {
34+
return SDKComponent.notificationInbox
35+
}
36+
2637
fun dismissMessage() {
2738
gistProvider.dismissMessage()
2839
}

messaginginapp/src/main/java/io/customer/messaginginapp/di/DIGraphMessagingInApp.kt

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import io.customer.messaginginapp.gist.presentation.SseLifecycleManager
2020
import io.customer.messaginginapp.gist.utilities.ModalMessageGsonParser
2121
import io.customer.messaginginapp.gist.utilities.ModalMessageParser
2222
import io.customer.messaginginapp.gist.utilities.ModalMessageParserDefault
23+
import io.customer.messaginginapp.inbox.NotificationInbox
2324
import io.customer.messaginginapp.state.InAppMessagingManager
2425
import io.customer.messaginginapp.store.InAppPreferenceStore
2526
import io.customer.messaginginapp.store.InAppPreferenceStoreImpl
@@ -134,3 +135,16 @@ fun CustomerIOInstance.inAppMessaging(): ModuleMessagingInApp = SDKComponent.inA
134135

135136
internal val SDKComponent.inAppMessaging: ModuleMessagingInApp
136137
get() = ModuleMessagingInApp.instance()
138+
139+
/**
140+
* Provides singleton instance of [NotificationInbox] for managing inbox messages.
141+
*/
142+
internal val SDKComponent.notificationInbox: NotificationInbox
143+
get() = singleton {
144+
NotificationInbox(
145+
logger = logger,
146+
coroutineScope = scopeProvider.inAppLifecycleScope,
147+
dispatchersProvider = dispatchersProvider,
148+
inAppMessagingManager = inAppMessagingManager
149+
)
150+
}

messaginginapp/src/main/java/io/customer/messaginginapp/gist/data/listeners/Queue.kt

Lines changed: 103 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,12 @@ import io.customer.messaginginapp.di.inAppPreferenceStore
77
import io.customer.messaginginapp.di.inAppSseLogger
88
import io.customer.messaginginapp.gist.data.AnonymousMessageManager
99
import io.customer.messaginginapp.gist.data.NetworkUtilities
10+
import io.customer.messaginginapp.gist.data.model.InboxMessage
1011
import io.customer.messaginginapp.gist.data.model.Message
1112
import io.customer.messaginginapp.gist.data.model.isMessageAnonymous
13+
import io.customer.messaginginapp.gist.data.model.response.InboxMessageFactory
14+
import io.customer.messaginginapp.gist.data.model.response.QueueMessagesResponse
15+
import io.customer.messaginginapp.gist.data.model.response.toLogString
1216
import io.customer.messaginginapp.state.InAppMessagingAction
1317
import io.customer.messaginginapp.state.InAppMessagingState
1418
import io.customer.messaginginapp.store.InAppPreferenceStore
@@ -25,27 +29,33 @@ import retrofit2.Response
2529
import retrofit2.Retrofit
2630
import retrofit2.converter.gson.GsonConverterFactory
2731
import retrofit2.http.Body
32+
import retrofit2.http.PATCH
2833
import retrofit2.http.POST
2934
import retrofit2.http.Path
3035
import retrofit2.http.Query
3136

32-
interface GistQueueService {
33-
@POST("/api/v3/users")
34-
suspend fun fetchMessagesForUser(@Body body: Any = Object(), @Query("sessionId") sessionId: String): Response<List<Message>>
37+
internal interface GistQueueService {
38+
@POST("/api/v4/users")
39+
suspend fun fetchMessagesForUser(@Body body: Any = Object(), @Query("sessionId") sessionId: String): Response<QueueMessagesResponse>
3540

3641
@POST("/api/v1/logs/message/{messageId}")
3742
suspend fun logMessageView(@Path("messageId") messageId: String, @Query("sessionId") sessionId: String)
3843

3944
@POST("/api/v1/logs/queue/{queueId}")
4045
suspend fun logUserMessageView(@Path("queueId") queueId: String, @Query("sessionId") sessionId: String)
46+
47+
@PATCH("/api/v1/messages/{queueId}")
48+
suspend fun logInboxMessageOpened(@Path("queueId") queueId: String, @Query("sessionId") sessionId: String, @Body body: Map<String, Boolean>)
4149
}
4250

43-
interface GistQueue {
51+
internal interface GistQueue {
4452
fun fetchUserMessages()
4553
fun logView(message: Message)
54+
fun logOpenedStatus(message: InboxMessage, opened: Boolean)
55+
fun logDeleted(message: InboxMessage)
4656
}
4757

48-
class Queue : GistQueue {
58+
internal class Queue : GistQueue {
4959

5060
private val inAppMessagingManager = SDKComponent.inAppMessagingManager
5161
private val state: InAppMessagingState
@@ -108,12 +118,16 @@ class Queue : GistQueue {
108118
return response
109119
}
110120

121+
// Populates 304 response body with cached data and converts to 200 for Retrofit compatibility.
122+
// Retrofit only populates body() for 2xx responses, so we must convert 304->200.
123+
// Adds custom header to track that this was originally a 304 response.
111124
private fun interceptNotModifiedResponse(response: okhttp3.Response, originalRequest: okhttp3.Request): okhttp3.Response {
112125
val cachedResponse = inAppPreferenceStore.getNetworkResponse(originalRequest.url.toString())
113126
return cachedResponse?.let {
114127
response.newBuilder()
115128
.body(it.toResponseBody(null))
116129
.code(200)
130+
.header(HEADER_FROM_CACHE, "true")
117131
.build()
118132
} ?: response
119133
}
@@ -125,9 +139,14 @@ class Queue : GistQueue {
125139
val latestMessagesResponse = gistQueueService.fetchMessagesForUser(sessionId = state.sessionId)
126140

127141
val code = latestMessagesResponse.code()
142+
val fromCache = latestMessagesResponse.headers()[HEADER_FROM_CACHE] == "true"
128143
when {
129144
(code == 204 || code == 304) -> handleNoContent(code)
130-
latestMessagesResponse.isSuccessful -> handleSuccessfulFetch(latestMessagesResponse.body())
145+
latestMessagesResponse.isSuccessful -> handleSuccessfulFetch(
146+
responseBody = latestMessagesResponse.body(),
147+
fromCache = fromCache
148+
)
149+
131150
else -> handleFailedFetch(code)
132151
}
133152

@@ -141,34 +160,64 @@ class Queue : GistQueue {
141160

142161
private fun handleNoContent(responseCode: Int) {
143162
logger.debug("No messages found for user with response code: $responseCode")
144-
inAppMessagingManager.dispatch(InAppMessagingAction.ClearMessageQueue)
163+
inAppMessagingManager.dispatch(InAppMessagingAction.ClearMessageQueue(isContentEmpty = true))
145164
}
146165

147-
private fun handleSuccessfulFetch(responseBody: List<Message>?) {
148-
logger.debug("Found ${responseBody?.count()} messages for user")
149-
responseBody?.let { messages ->
166+
// For cached responses (304), apply locally cached opened status to preserve user's changes.
167+
// For fresh responses (200), clear cached status and use server's data.
168+
private fun handleSuccessfulFetch(responseBody: QueueMessagesResponse?, fromCache: Boolean) {
169+
if (responseBody == null) {
170+
logger.error("Received null response body for successful fetch")
171+
return
172+
}
173+
174+
responseBody.let { response ->
175+
// Process in-app messages first
176+
val inAppMessages = response.inAppMessages
177+
logger.debug("Found ${inAppMessages.count()} in-app messages for user")
150178
// Store anonymous messages locally for frequency management
151-
anonymousMessageManager.updateAnonymousMessagesLocalStore(messages)
179+
anonymousMessageManager.updateAnonymousMessagesLocalStore(inAppMessages)
152180

153181
// Get eligible anonymous messages from local storage (respects frequency/dismissal rules)
154182
val eligibleAnonymousMessages = anonymousMessageManager.getEligibleAnonymousMessages()
155183

156184
// Filter out anonymous messages from server response (we use local ones instead)
157-
val regularMessages = messages.filter { !it.isMessageAnonymous() }
185+
val regularMessages = inAppMessages.filter { !it.isMessageAnonymous() }
158186

159187
// Combine regular messages with eligible anonymous messages
160188
val allMessages = regularMessages + eligibleAnonymousMessages
161189

162190
logger.debug("Processing ${regularMessages.size} regular messages and ${eligibleAnonymousMessages.size} eligible anonymous messages")
163191

164-
// Process all messages through the normal queue
192+
// Process all in-app messages through the normal queue
165193
inAppMessagingManager.dispatch(InAppMessagingAction.ProcessMessageQueue(allMessages))
194+
195+
// Process inbox messages next
196+
val inboxMessages = response.inboxMessages
197+
logger.debug("Found ${inboxMessages.count()} inbox messages for user")
198+
val inboxMessagesMapped = inboxMessages.mapNotNull { item ->
199+
InboxMessageFactory.fromResponse(item)?.let { message ->
200+
if (fromCache) {
201+
// 304: apply cached opened status if available
202+
val cachedOpenedStatus = inAppPreferenceStore.getInboxMessageOpenedStatus(message.queueId)
203+
return@let cachedOpenedStatus?.let { message.copy(opened = it) } ?: message
204+
} else {
205+
// 200: clear cached status and use server's data
206+
inAppPreferenceStore.clearInboxMessageOpenedStatus(message.queueId)
207+
return@let message
208+
}
209+
}
210+
}
211+
if (inboxMessagesMapped.size < inboxMessages.size) {
212+
logger.debug("Filtered out ${inboxMessages.size - inboxMessagesMapped.size} invalid inbox message(s)")
213+
}
214+
inAppMessagingManager.dispatch(InAppMessagingAction.ProcessInboxMessages(inboxMessagesMapped))
166215
}
167216
}
168217

169218
private fun handleFailedFetch(responseCode: Int) {
170219
logger.error("Failed to fetch messages: $responseCode")
171-
inAppMessagingManager.dispatch(InAppMessagingAction.ClearMessageQueue)
220+
inAppMessagingManager.dispatch(InAppMessagingAction.ClearMessageQueue(isContentEmpty = false))
172221
}
173222

174223
private fun updatePollingInterval(headers: Headers) {
@@ -207,4 +256,44 @@ class Queue : GistQueue {
207256
}
208257
}
209258
}
259+
260+
override fun logOpenedStatus(message: InboxMessage, opened: Boolean) {
261+
scope.launch {
262+
try {
263+
logger.debug("Updating inbox message ${message.toLogString()} opened status to: $opened")
264+
// Log updated opened status to server
265+
gistQueueService.logInboxMessageOpened(
266+
queueId = message.queueId,
267+
sessionId = state.sessionId,
268+
body = mapOf("opened" to opened)
269+
)
270+
// Cache the opened status locally for 304 responses
271+
inAppPreferenceStore.saveInboxMessageOpenedStatus(message.queueId, opened)
272+
} catch (e: Exception) {
273+
logger.error("Failed to update inbox message ${message.toLogString()} opened status: ${e.message}")
274+
}
275+
}
276+
}
277+
278+
override fun logDeleted(message: InboxMessage) {
279+
scope.launch {
280+
try {
281+
logger.debug("Deleting inbox message: ${message.toLogString()}")
282+
// Log deletion event to server
283+
gistQueueService.logUserMessageView(
284+
queueId = message.queueId,
285+
sessionId = state.sessionId
286+
)
287+
// Clear any cached opened status for deleted message
288+
inAppPreferenceStore.clearInboxMessageOpenedStatus(message.queueId)
289+
} catch (e: Exception) {
290+
logger.error("Failed to delete inbox message ${message.toLogString()}: ${e.message}")
291+
}
292+
}
293+
}
294+
295+
companion object {
296+
// Custom header to distinguish 304 responses (converted to 200) from genuine 200 responses
297+
private const val HEADER_FROM_CACHE = "X-CIO-MOBILE-SDK-Cache"
298+
}
210299
}

0 commit comments

Comments
 (0)