Skip to content

Commit 770c954

Browse files
authored
Merge pull request #312 from customerio/feature/message-inbox-mvp
feat: support for notification inbox
2 parents cf7c4b4 + b7d56be commit 770c954

19 files changed

+1251
-21
lines changed

android/build.gradle

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,7 @@ android {
5959
dependencies {
6060
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version"
6161
// Customer.io SDK
62-
def cioVersion = "4.15.2"
62+
def cioVersion = "4.16.0"
6363
implementation "io.customer.android:datapipelines:$cioVersion"
6464
implementation "io.customer.android:messaging-push-fcm:$cioVersion"
6565
implementation "io.customer.android:messaging-in-app:$cioVersion"

android/src/main/kotlin/io/customer/customer_io/messaginginapp/CustomerIOInAppMessaging.kt

Lines changed: 168 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,17 +2,21 @@ package io.customer.customer_io.messaginginapp
22

33
import android.app.Activity
44
import io.customer.customer_io.bridge.NativeModuleBridge
5+
import io.customer.customer_io.bridge.nativeMapArgs
56
import io.customer.customer_io.bridge.nativeNoArgs
6-
import io.customer.customer_io.messaginginapp.InlineInAppMessageViewFactory
77
import io.customer.customer_io.utils.getAs
88
import io.customer.messaginginapp.MessagingInAppModuleConfig
99
import io.customer.messaginginapp.ModuleMessagingInApp
1010
import io.customer.messaginginapp.di.inAppMessaging
11+
import io.customer.messaginginapp.gist.data.model.InboxMessage
12+
import io.customer.messaginginapp.gist.data.model.response.InboxMessageFactory
13+
import io.customer.messaginginapp.inbox.NotificationInbox
1114
import io.customer.messaginginapp.type.InAppEventListener
1215
import io.customer.messaginginapp.type.InAppMessage
1316
import io.customer.sdk.CustomerIO
1417
import io.customer.sdk.CustomerIOBuilder
1518
import io.customer.sdk.core.di.SDKComponent
19+
import io.customer.sdk.core.util.Logger
1620
import io.customer.sdk.data.model.Region
1721
import io.flutter.embedding.engine.plugins.FlutterPlugin
1822
import io.flutter.embedding.engine.plugins.activity.ActivityAware
@@ -31,13 +35,33 @@ internal class CustomerIOInAppMessaging(
3135
override val moduleName: String = "InAppMessaging"
3236
override val flutterCommunicationChannel: MethodChannel =
3337
MethodChannel(pluginBinding.binaryMessenger, "customer_io_messaging_in_app")
38+
private val logger: Logger = SDKComponent.logger
39+
private val inAppMessagingModule: ModuleMessagingInApp?
40+
get() = runCatching { CustomerIO.instance().inAppMessaging() }.getOrNull()
3441
private var activity: WeakReference<Activity>? = null
3542
private val binaryMessenger = pluginBinding.binaryMessenger
3643
private val platformViewRegistry = pluginBinding.platformViewRegistry
3744

45+
// Dedicated lock for inbox listener setup to avoid blocking other operations
46+
private val inboxListenerLock = Any()
47+
private val inboxChangeListener = FlutterNotificationInboxChangeListener.instance
48+
private var isInboxChangeListenerSetup = false
49+
50+
/**
51+
* Returns NotificationInbox instance if available, null otherwise, logging error on failure.
52+
* Note: Notification Inbox is only available after SDK is initialized.
53+
*/
54+
private fun requireInboxInstance(): NotificationInbox? {
55+
val inbox = inAppMessagingModule?.inbox()
56+
if (inbox == null) {
57+
logger.error("Notification Inbox is not available. Ensure CustomerIO SDK is initialized.")
58+
}
59+
return inbox
60+
}
61+
3862
override fun onAttachedToEngine() {
3963
super.onAttachedToEngine()
40-
64+
4165
// Register the platform view factory for inline in-app messages
4266
platformViewRegistry.registerViewFactory(
4367
"customer_io_inline_in_app_message_view",
@@ -61,15 +85,26 @@ internal class CustomerIOInAppMessaging(
6185
this.activity = null
6286
}
6387

88+
override fun onDetachedFromEngine() {
89+
clearInboxChangeListener()
90+
super.onDetachedFromEngine()
91+
}
92+
6493
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
6594
when (call.method) {
6695
"dismissMessage" -> call.nativeNoArgs(result, ::dismissMessage)
96+
"subscribeToInboxMessages" -> setupInboxChangeListener(call, result)
97+
"getInboxMessages" -> getInboxMessages(call, result)
98+
"markInboxMessageOpened" -> call.nativeMapArgs(result, ::markInboxMessageOpened)
99+
"markInboxMessageUnopened" -> call.nativeMapArgs(result, ::markInboxMessageUnopened)
100+
"markInboxMessageDeleted" -> call.nativeMapArgs(result, ::markInboxMessageDeleted)
101+
"trackInboxMessageClicked" -> call.nativeMapArgs(result, ::trackInboxMessageClicked)
67102
else -> super.onMethodCall(call, result)
68103
}
69104
}
70105

71106
private fun dismissMessage() {
72-
CustomerIO.instance().inAppMessaging().dismissMessage()
107+
inAppMessagingModule?.dismissMessage()
73108
}
74109

75110
/**
@@ -102,6 +137,135 @@ internal class CustomerIOInAppMessaging(
102137
)
103138
builder.addCustomerIOModule(module)
104139
}
140+
141+
/**
142+
* Sets up the inbox change listener to receive real-time updates.
143+
* This method can be called multiple times safely and will only set up the listener once.
144+
* Note: Inbox must be available (SDK initialized) before this can succeed.
145+
*/
146+
private fun setupInboxChangeListener(call: MethodCall, result: MethodChannel.Result) {
147+
synchronized(inboxListenerLock) {
148+
// Only set up once to avoid duplicate listeners
149+
if (isInboxChangeListenerSetup) {
150+
result.success(true)
151+
return
152+
}
153+
154+
val inbox = requireInboxInstance() ?: run {
155+
result.error("INBOX_NOT_AVAILABLE", "Notification Inbox is not available. Ensure CustomerIO SDK is initialized.", null)
156+
return
157+
}
158+
159+
inboxChangeListener.setEventEmitter(
160+
emitter = { data ->
161+
runOnMainThread {
162+
flutterCommunicationChannel.invokeMethod("inboxMessagesChanged", data)
163+
}
164+
}
165+
)
166+
inbox.addChangeListener(inboxChangeListener)
167+
isInboxChangeListenerSetup = true
168+
logger.debug("NotificationInboxChangeListener set up successfully")
169+
result.success(true)
170+
}
171+
}
172+
173+
private fun clearInboxChangeListener() {
174+
synchronized(inboxListenerLock) {
175+
if (!isInboxChangeListenerSetup) {
176+
return
177+
}
178+
requireInboxInstance()?.removeChangeListener(inboxChangeListener)
179+
inboxChangeListener.clearEventEmitter()
180+
isInboxChangeListenerSetup = false
181+
}
182+
}
183+
184+
private fun getInboxMessages(call: MethodCall, result: MethodChannel.Result) {
185+
val inbox = requireInboxInstance() ?: run {
186+
result.error("INBOX_NOT_AVAILABLE", "Notification Inbox is not available. Ensure CustomerIO SDK is initialized.", null)
187+
return
188+
}
189+
190+
// Extract topic parameter if provided
191+
val args = call.arguments as? Map<String, Any>
192+
val topic = args?.get("topic") as? String
193+
194+
// Fetch messages with topic filter
195+
// Using async callback avoids blocking main thread (prevents ANR/deadlocks)
196+
inbox.fetchMessages(topic) { fetchResult ->
197+
runOnMainThread {
198+
fetchResult.onSuccess { messages ->
199+
result.success(messages.map { it.toMap() })
200+
}.onFailure { error ->
201+
logger.error("Failed to get inbox messages: ${error.message}")
202+
result.error("FETCH_ERROR", error.message, null)
203+
}
204+
}
205+
}
206+
}
207+
208+
private fun markInboxMessageOpened(params: Map<String, Any>) {
209+
val message = params.getAs<Map<String, Any>>("message")
210+
211+
performInboxMessageAction(message) { inbox, inboxMessage ->
212+
inbox.markMessageOpened(inboxMessage)
213+
}
214+
}
215+
216+
private fun markInboxMessageUnopened(params: Map<String, Any>) {
217+
val message = params.getAs<Map<String, Any>>("message")
218+
219+
performInboxMessageAction(message) { inbox, inboxMessage ->
220+
inbox.markMessageUnopened(inboxMessage)
221+
}
222+
}
223+
224+
private fun markInboxMessageDeleted(params: Map<String, Any>) {
225+
val message = params.getAs<Map<String, Any>>("message")
226+
227+
performInboxMessageAction(message) { inbox, inboxMessage ->
228+
inbox.markMessageDeleted(inboxMessage)
229+
}
230+
}
231+
232+
private fun trackInboxMessageClicked(params: Map<String, Any>) {
233+
val message = params.getAs<Map<String, Any>>("message")
234+
val actionName = params.getAs<String>("actionName")
235+
236+
performInboxMessageAction(message) { inbox, inboxMessage ->
237+
inbox.trackMessageClicked(inboxMessage, actionName)
238+
}
239+
}
240+
241+
/**
242+
* Runs block on main/UI thread. Uses activity's runOnUiThread if available,
243+
* otherwise falls back to Handler(Looper.getMainLooper()).post
244+
*/
245+
private fun runOnMainThread(block: () -> Unit) {
246+
val currentActivity = activity?.get()
247+
if (currentActivity != null) {
248+
currentActivity.runOnUiThread(block)
249+
} else {
250+
// Activity is null - use main looper directly
251+
android.os.Handler(android.os.Looper.getMainLooper()).post(block)
252+
}
253+
}
254+
255+
/**
256+
* Helper to validate inbox instance and message data before performing a message action.
257+
* Throws exceptions if inbox is unavailable or message data is invalid.
258+
*/
259+
private fun performInboxMessageAction(
260+
message: Map<String, Any>?,
261+
action: (NotificationInbox, InboxMessage) -> Unit,
262+
) {
263+
val inbox = requireInboxInstance()
264+
?: throw IllegalStateException("Notification Inbox is not available. Ensure CustomerIO SDK is initialized.")
265+
val inboxMessage = message?.let { InboxMessageFactory.fromMap(it) }
266+
?: throw IllegalArgumentException("Invalid message data: $message")
267+
action(inbox, inboxMessage)
268+
}
105269
}
106270

107271
class CustomerIOInAppEventListener(private val invokeMethod: (String, Any?) -> Unit) :
@@ -142,4 +306,4 @@ class CustomerIOInAppEventListener(private val invokeMethod: (String, Any?) -> U
142306
)
143307
)
144308
}
145-
}
309+
}
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
package io.customer.customer_io.messaginginapp
2+
3+
import io.customer.messaginginapp.gist.data.model.InboxMessage
4+
import io.customer.messaginginapp.inbox.NotificationInboxChangeListener
5+
6+
class FlutterNotificationInboxChangeListener private constructor() :
7+
NotificationInboxChangeListener {
8+
9+
// Event emitter function to send events to Flutter layer
10+
private var eventEmitter: ((Map<String, Any?>) -> Unit)? = null
11+
12+
// Sets the event emitter function and message converter
13+
internal fun setEventEmitter(emitter: ((Map<String, Any?>) -> Unit)?) {
14+
this.eventEmitter = emitter
15+
}
16+
17+
// Clears the event emitter to prevent memory leaks
18+
internal fun clearEventEmitter() {
19+
this.eventEmitter = null
20+
}
21+
22+
private fun emitMessagesUpdate(messages: List<InboxMessage>) {
23+
// Get the emitter and converter, return early if not set
24+
val emitter = eventEmitter ?: return
25+
26+
val payload = mapOf("messages" to messages.map { it.toMap() })
27+
emitter.invoke(payload)
28+
}
29+
30+
override fun onMessagesChanged(messages: List<InboxMessage>) {
31+
emitMessagesUpdate(messages)
32+
}
33+
34+
companion object {
35+
// Singleton instance with public visibility for direct access
36+
val instance: FlutterNotificationInboxChangeListener by lazy { FlutterNotificationInboxChangeListener() }
37+
}
38+
}
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
package io.customer.customer_io.messaginginapp
2+
3+
import io.customer.messaginginapp.gist.data.model.InboxMessage
4+
5+
// Extension functions for InboxMessage serialization
6+
internal fun InboxMessage.toMap(): Map<String, Any?> {
7+
return mapOf(
8+
"queueId" to queueId,
9+
"deliveryId" to deliveryId,
10+
"expiry" to expiry?.time,
11+
"sentAt" to sentAt.time,
12+
"topics" to topics,
13+
"type" to type,
14+
"opened" to opened,
15+
"priority" to priority,
16+
"properties" to properties
17+
)
18+
}

apps/amiapp_flutter/lib/src/app.dart

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import 'data/screen.dart';
1010
import 'screens/attributes.dart';
1111
import 'screens/dashboard.dart';
1212
import 'screens/events.dart';
13+
import 'screens/inbox_messages.dart';
1314
import 'screens/inline_messages.dart';
1415
import 'screens/login.dart';
1516
import 'screens/settings.dart';
@@ -144,6 +145,11 @@ class _AmiAppState extends State<AmiApp> {
144145
path: Screen.inlineMessages.path,
145146
builder: (context, state) => const InlineMessagesScreen(),
146147
),
148+
GoRoute(
149+
name: Screen.inboxMessages.name,
150+
path: Screen.inboxMessages.path,
151+
builder: (context, state) => const InboxMessagesScreen(),
152+
),
147153
],
148154
),
149155
],

apps/amiapp_flutter/lib/src/data/screen.dart

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,8 @@ enum Screen {
1111
deviceAttributes(name: 'Custom Device Attribute', path: 'attributes/device'),
1212
profileAttributes(
1313
name: 'Custom Profile Attribute', path: 'attributes/profile'),
14-
inlineMessages(name: 'Inline Messages Test', path: 'inline-messages');
14+
inlineMessages(name: 'Inline Messages Test', path: 'inline-messages'),
15+
inboxMessages(name: 'Inbox Messages', path: 'inbox-messages');
1516

1617
const Screen({
1718
required this.name,

apps/amiapp_flutter/lib/src/screens/dashboard.dart

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -303,6 +303,7 @@ enum _ActionItem {
303303
deviceAttributes,
304304
profileAttributes,
305305
inlineMessages,
306+
inboxMessages,
306307
showPushPrompt,
307308
showLocalPush,
308309
signOut,
@@ -321,6 +322,8 @@ extension _ActionNames on _ActionItem {
321322
return 'Set Profile Attribute';
322323
case _ActionItem.inlineMessages:
323324
return 'Test Inline Messages';
325+
case _ActionItem.inboxMessages:
326+
return 'Inbox Messages';
324327
case _ActionItem.showPushPrompt:
325328
return 'Show Push Prompt';
326329
case _ActionItem.showLocalPush:
@@ -342,6 +345,8 @@ extension _ActionNames on _ActionItem {
342345
return 'Profile Attribute Button';
343346
case _ActionItem.inlineMessages:
344347
return 'Inline Messages Button';
348+
case _ActionItem.inboxMessages:
349+
return 'Inbox Messages Button';
345350
case _ActionItem.showPushPrompt:
346351
return 'Show Push Prompt Button';
347352
case _ActionItem.showLocalPush:
@@ -363,6 +368,8 @@ extension _ActionNames on _ActionItem {
363368
return Screen.profileAttributes;
364369
case _ActionItem.inlineMessages:
365370
return Screen.inlineMessages;
371+
case _ActionItem.inboxMessages:
372+
return Screen.inboxMessages;
366373
case _ActionItem.showPushPrompt:
367374
return null;
368375
case _ActionItem.showLocalPush:

0 commit comments

Comments
 (0)