From 0f2bfc5c35e70f4e97adc1dba44c2de3767a7642 Mon Sep 17 00:00:00 2001 From: Minkyu Cho Date: Thu, 18 Dec 2025 14:02:44 +0900 Subject: [PATCH 1/3] refactor: improve push notification processing logic and deduplication handling --- .../kotlin/so/clix/core/ClixNotification.kt | 71 +++++++++++-------- .../clix/notification/ClixMessagingService.kt | 2 +- .../so/clix/services/NotificationService.kt | 36 +++------- 3 files changed, 53 insertions(+), 56 deletions(-) diff --git a/clix/src/main/kotlin/so/clix/core/ClixNotification.kt b/clix/src/main/kotlin/so/clix/core/ClixNotification.kt index e1655c0..728ebc5 100644 --- a/clix/src/main/kotlin/so/clix/core/ClixNotification.kt +++ b/clix/src/main/kotlin/so/clix/core/ClixNotification.kt @@ -53,8 +53,7 @@ object ClixNotification { } ClixLogger.debug( - "ClixNotification.configure(autoRequestPermission: $autoRequestPermission, " + - "autoHandleLandingURL: $autoHandleLandingURL)" + "ClixNotification.configure(autoRequestPermission: $autoRequestPermission, autoHandleLandingURL: $autoHandleLandingURL)" ) this.autoHandleLandingURL = autoHandleLandingURL @@ -225,46 +224,58 @@ object ClixNotification { } } - internal suspend fun handleIncomingPayload( + internal suspend fun handleNotificationReceived( notificationData: Map, payload: ClixPushNotificationPayload, ) { + val shouldProcess = Clix.notificationService.recordReceivedMessageId(payload.messageId) + if (!shouldProcess) { + ClixLogger.debug("Duplicate message ignored for messageId: ${payload.messageId}") + return + } + val isInForeground = isAppInForeground() - if (isInForeground) { - // Foreground: call onMessage handler (can suppress notification) - val handler = messageHandler - if (handler != null) { - val shouldDisplay = + try { + if (isInForeground) { + // Foreground: call onMessage handler (can suppress notification) + val handler = messageHandler + if (handler != null) { + val shouldDisplay = + try { + handler(notificationData) + } catch (e: Exception) { + ClixLogger.error("Foreground message handler failed", e) + true + } + if (!shouldDisplay) { + ClixLogger.debug( + "Foreground handler suppressed notification for ${payload.messageId}" + ) + Clix.notificationService.recoverReceivedMessageId(payload.messageId) + return + } + } + } else { + // Background: call onBackgroundMessage handler (notification always displayed) + backgroundMessageHandler?.let { handler -> try { handler(notificationData) } catch (e: Exception) { - ClixLogger.error("Foreground message handler failed", e) - true + ClixLogger.error("Background message handler failed", e) } - if (!shouldDisplay) { - ClixLogger.debug( - "Foreground handler suppressed notification for ${payload.messageId}" - ) - return } } - } else { - // Background: call onBackgroundMessage handler (notification always displayed) - backgroundMessageHandler?.let { handler -> - try { - handler(notificationData) - } catch (e: Exception) { - ClixLogger.error("Background message handler failed", e) - } - } - } - Clix.notificationService.handleNotificationReceived( - payload = payload, - notificationData = notificationData, - autoHandleLandingURL = autoHandleLandingURL, - ) + Clix.notificationService.handlePushReceived( + payload = payload, + notificationData = notificationData, + autoHandleLandingURL = autoHandleLandingURL, + ) + } catch (e: Exception) { + ClixLogger.error("Failed to handle notification received", e) + Clix.notificationService.recoverReceivedMessageId(payload.messageId) + } } private fun isAppInForeground(): Boolean { diff --git a/clix/src/main/kotlin/so/clix/notification/ClixMessagingService.kt b/clix/src/main/kotlin/so/clix/notification/ClixMessagingService.kt index b513cae..0d3f60f 100644 --- a/clix/src/main/kotlin/so/clix/notification/ClixMessagingService.kt +++ b/clix/src/main/kotlin/so/clix/notification/ClixMessagingService.kt @@ -49,7 +49,7 @@ open class ClixMessagingService : FirebaseMessagingService() { } Clix.coroutineScope.launch { - Clix.Notification.handleIncomingPayload( + Clix.Notification.handleNotificationReceived( notificationData = notificationData, payload = payload, ) diff --git a/clix/src/main/kotlin/so/clix/services/NotificationService.kt b/clix/src/main/kotlin/so/clix/services/NotificationService.kt index 07028a0..653c4c4 100644 --- a/clix/src/main/kotlin/so/clix/services/NotificationService.kt +++ b/clix/src/main/kotlin/so/clix/services/NotificationService.kt @@ -57,39 +57,25 @@ internal class NotificationService( createNotificationChannel() } - suspend fun handleNotificationReceived( + suspend fun handlePushReceived( payload: ClixPushNotificationPayload, notificationData: Map, autoHandleLandingURL: Boolean = true, ) { try { if (hasNotificationPermission(context)) { - val shouldTrack = recordReceivedMessageId(payload.messageId) - if (!shouldTrack) { - val eventName = NotificationEvent.PUSH_NOTIFICATION_RECEIVED.name - ClixLogger.debug( - "Skipping duplicate $eventName for messageId: ${payload.messageId}" - ) - return - } - - try { - showNotification(payload, notificationData, autoHandleLandingURL) - trackPushNotificationReceivedEvent( - payload.messageId, - payload.userJourneyId, - payload.userJourneyNodeId, - ) - ClixLogger.debug("Message received, notification sent to Clix SDK") - } catch (e: Exception) { - recoverReceivedMessageId(payload.messageId) - throw e - } + showNotification(payload, notificationData, autoHandleLandingURL) + trackPushNotificationReceivedEvent( + payload.messageId, + payload.userJourneyId, + payload.userJourneyNodeId, + ) + ClixLogger.debug("Message received, notification sent to Clix SDK") } else { ClixLogger.warn("Notification permission not granted, cannot show notification") } } catch (e: Exception) { - ClixLogger.error("Failed to handle notification received", e) + ClixLogger.error("Failed to handle push received", e) } } @@ -239,7 +225,7 @@ internal class NotificationService( ) } - private fun recordReceivedMessageId(messageId: String): Boolean { + internal fun recordReceivedMessageId(messageId: String): Boolean { synchronized(this) { val previous = storageService.get(lastReceivedMessageIdKey) if (previous == messageId) { @@ -250,7 +236,7 @@ internal class NotificationService( } } - private fun recoverReceivedMessageId(messageId: String) { + internal fun recoverReceivedMessageId(messageId: String) { synchronized(this) { val previous = storageService.get(lastReceivedMessageIdKey) if (previous == messageId) { From 92f4cd6dd2a14fbed68dcb01d711460c636f6940 Mon Sep 17 00:00:00 2001 From: Minkyu Cho Date: Thu, 18 Dec 2025 14:05:42 +0900 Subject: [PATCH 2/3] docs: update version to 1.3.3 and add changelog entry --- CHANGELOG.md | 13 ++++++-- README.md | 50 ++++++++++++++++++++---------- gradle/libs.versions.toml | 2 +- samples/basic-app/build.gradle.kts | 2 +- 4 files changed, 47 insertions(+), 20 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b56c394..be7e4c5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,13 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [1.3.3] - 2025-12-18 + +### Fixed + +- **Push Notifications** + - Fixed duplicate `PUSH_NOTIFICATION_RECEIVED` event tracking when app is in foreground + ## [1.3.2] - 2025-12-17 ### Changed @@ -20,7 +27,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed - **Push Notifications** - - Fixed crash when receiving push notifications while app process is killed (UninitializedPropertyAccessException) + - Fixed crash when receiving push notifications while app process is killed ( + UninitializedPropertyAccessException) - Added automatic SDK initialization from stored configuration on cold start ## [1.3.0] - 2025-11-24 @@ -41,7 +49,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - **Event Tracking** - - Normalized event property serialization: numeric values remain numeric, and date/time inputs are formatted to ISO + - Normalized event property serialization: numeric values remain numeric, and date/time inputs are + formatted to ISO 8601. ## [1.1.2] - 2025-09-23 diff --git a/README.md b/README.md index 36f7c20..51b59c4 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,7 @@ # Clix Android SDK -Clix Android SDK is a powerful tool for managing push notifications and user events in your Android application. It +Clix Android SDK is a powerful tool for managing push notifications and user events in your Android +application. It provides a simple and intuitive interface for user engagement and analytics. ## Installation @@ -19,7 +20,7 @@ Add the dependency to your app's `build.gradle.kts`: ```kotlin dependencies { - implementation("so.clix:clix-android-sdk:1.3.2") + implementation("so.clix:clix-android-sdk:1.3.3") } ``` @@ -38,7 +39,8 @@ dependencies { ### Initialization -Initialize the SDK with a ClixConfig object. The config is required and contains your project settings. +Initialize the SDK with a ClixConfig object. The config is required and contains your project +settings. ```kotlin import so.clix.core.Clix @@ -189,15 +191,20 @@ class MyApplication : Application() { } ``` -**Important:** All `Clix.Notification` methods must be called **after** `Clix.initialize()`. Calling them before +**Important:** All `Clix.Notification` methods must be called **after** `Clix.initialize()`. Calling +them before initialization will result in an error. ##### About `notificationData` -- The `notificationData` map is the full FCM payload as delivered to the device; it mirrors iOS’s `userInfo` dictionary. -- Every Clix notification callback (`onMessage`, `onBackgroundMessage`, `onNotificationOpened`) passes this map through - untouched, so you can inspect both the serialized `"clix"` block and any custom keys your backend adds. -- `notificationData["clix"]` holds the Clix metadata JSON, while all other keys represent app-specific data. +- The `notificationData` map is the full FCM payload as delivered to the device; it mirrors iOS’s + `userInfo` dictionary. +- Every Clix notification callback (`onMessage`, `onBackgroundMessage`, `onNotificationOpened`) + passes this map through + untouched, so you can inspect both the serialized `"clix"` block and any custom keys your backend + adds. +- `notificationData["clix"]` holds the Clix metadata JSON, while all other keys represent + app-specific data. Or request permission manually: @@ -246,7 +253,8 @@ class MyMessagingService : ClixMessagingService() { - Push notification event tracking - Duplicate notification prevention - Deep linking support (automatic landing URL handling) -- Use `Clix.Notification.configure(autoHandleLandingURL = false)` to disable automatic landing URL handling +- Use `Clix.Notification.configure(autoHandleLandingURL = false)` to disable automatic landing URL + handling #### Deep Link Handling @@ -315,19 +323,22 @@ try { ## Thread Safety -The SDK is thread-safe and all operations can be called from any thread. Coroutine-based operations will automatically +The SDK is thread-safe and all operations can be called from any thread. Coroutine-based operations +will automatically wait for SDK initialization to complete. ## Troubleshooting ### Push Permission Status Not Updating -The `autoRequestPermission` parameter defaults to **`false`**. If you're not using automatic permission requests, you +The `autoRequestPermission` parameter defaults to **`false`**. If you're not using automatic +permission requests, you must manually notify Clix when users grant or deny push permissions. #### Update Permission Status -When using `autoRequestPermission = false` (the default), call `Clix.Notification.setPermissionGranted()` after +When using `autoRequestPermission = false` (the default), call +`Clix.Notification.setPermissionGranted()` after requesting push permissions in your app: ```kotlin @@ -336,7 +347,11 @@ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { } // In your permission result callback -override fun onRequestPermissionsResult(requestCode: Int, permissions: Array, grantResults: IntArray) { +override fun onRequestPermissionsResult( + requestCode: Int, + permissions: Array, + grantResults: IntArray +) { if (requestCode == PERMISSION_REQUEST_CODE) { val granted = grantResults.isNotEmpty() && grantResults[0] == PackageManager.PERMISSION_GRANTED @@ -376,7 +391,8 @@ If push notifications aren't working, verify: 1. ✅ `google-services.json` is added to your app module 2. ✅ Firebase Cloud Messaging is properly configured 3. ✅ `ClixMessagingService` is declared in `AndroidManifest.xml` -4. ✅ `Clix.Notification.setPermissionGranted()` is called after requesting permissions (when not using auto-request) +4. ✅ `Clix.Notification.setPermissionGranted()` is called after requesting permissions (when not + using auto-request) 5. ✅ Testing on a real device or emulator with Google Play Services 6. ✅ Debug logs show "New token received" message 7. ✅ Use `onFcmTokenError()` handler to catch token registration errors @@ -389,7 +405,8 @@ If you continue to experience issues: 2. Check Logcat for Clix log messages 3. Verify your device appears in the Clix console Users page 4. Check if `push_token` field is populated for your device -5. Create an issue on [GitHub](https://github.com/clix-so/clix-android-sdk/issues) with logs and configuration details +5. Create an issue on [GitHub](https://github.com/clix-so/clix-android-sdk/issues) with logs and + configuration details ## Proguard @@ -410,5 +427,6 @@ See the full release history and changes in the [CHANGELOG.md](CHANGELOG.md) fil ## Contributing -We welcome contributions! Please read the [CONTRIBUTING.md](CONTRIBUTING.md) guide before submitting issues or pull +We welcome contributions! Please read the [CONTRIBUTING.md](CONTRIBUTING.md) guide before submitting +issues or pull requests. diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 4849154..472cdb0 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,5 +1,5 @@ [versions] -clix = "1.3.2" +clix = "1.3.3" # Plugins android-gradle-plugin = "8.9.2" gms = "4.4.2" diff --git a/samples/basic-app/build.gradle.kts b/samples/basic-app/build.gradle.kts index 0b8ed91..fd6b6f0 100644 --- a/samples/basic-app/build.gradle.kts +++ b/samples/basic-app/build.gradle.kts @@ -38,7 +38,7 @@ android { dependencies { implementation(project(":clix")) - // implementation("so.clix:clix-android-sdk:1.3.2") + // implementation("so.clix:clix-android-sdk:1.3.3") implementation(libs.androidx.core.ktx) implementation(libs.androidx.appcompat) implementation(libs.androidx.activity.compose) From e78c9f4f8a2c28d92bf39aa63659247da156211f Mon Sep 17 00:00:00 2001 From: Minkyu Cho Date: Thu, 18 Dec 2025 14:21:32 +0900 Subject: [PATCH 3/3] feat: enhance notification priority and channel settings --- clix/src/main/kotlin/so/clix/core/ClixNotification.kt | 3 ++- clix/src/main/kotlin/so/clix/services/NotificationService.kt | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/clix/src/main/kotlin/so/clix/core/ClixNotification.kt b/clix/src/main/kotlin/so/clix/core/ClixNotification.kt index 728ebc5..3267fab 100644 --- a/clix/src/main/kotlin/so/clix/core/ClixNotification.kt +++ b/clix/src/main/kotlin/so/clix/core/ClixNotification.kt @@ -53,7 +53,8 @@ object ClixNotification { } ClixLogger.debug( - "ClixNotification.configure(autoRequestPermission: $autoRequestPermission, autoHandleLandingURL: $autoHandleLandingURL)" + "ClixNotification.configure(autoRequestPermission: $autoRequestPermission, " + + "autoHandleLandingURL: $autoHandleLandingURL)" ) this.autoHandleLandingURL = autoHandleLandingURL diff --git a/clix/src/main/kotlin/so/clix/services/NotificationService.kt b/clix/src/main/kotlin/so/clix/services/NotificationService.kt index 653c4c4..66eb2d1 100644 --- a/clix/src/main/kotlin/so/clix/services/NotificationService.kt +++ b/clix/src/main/kotlin/so/clix/services/NotificationService.kt @@ -177,7 +177,7 @@ internal class NotificationService( .setStyle(NotificationCompat.BigTextStyle().bigText(payload.body.orEmpty())) .setTicker(payload.body.orEmpty()) .setAutoCancel(true) - .setPriority(NotificationCompat.PRIORITY_HIGH) + .setPriority(NotificationCompat.PRIORITY_MAX) .setCategory(NotificationCompat.CATEGORY_MESSAGE) .setVisibility(NotificationCompat.VISIBILITY_PUBLIC) .setDefaults(NotificationCompat.DEFAULT_ALL) @@ -254,6 +254,7 @@ internal class NotificationService( description = descriptionText enableVibration(true) enableLights(true) + setShowBadge(true) } notificationManager.createNotificationChannel(channel) }