Skip to content
Open
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
13 changes: 11 additions & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
Expand Down
50 changes: 34 additions & 16 deletions README.md
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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")
}
```

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

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

Expand Down Expand Up @@ -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
Expand All @@ -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<String>, grantResults: IntArray) {
override fun onRequestPermissionsResult(
requestCode: Int,
permissions: Array<String>,
grantResults: IntArray
) {
if (requestCode == PERMISSION_REQUEST_CODE) {
val granted = grantResults.isNotEmpty() && grantResults[0] == PackageManager.PERMISSION_GRANTED

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

Expand All @@ -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.
68 changes: 40 additions & 28 deletions clix/src/main/kotlin/so/clix/core/ClixNotification.kt
Original file line number Diff line number Diff line change
Expand Up @@ -225,46 +225,58 @@ object ClixNotification {
}
}

internal suspend fun handleIncomingPayload(
internal suspend fun handleNotificationReceived(
notificationData: Map<String, Any?>,
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)
}
Comment on lines +271 to +279
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Recovery block unreachable due to exception swallowing in handlePushReceived.

The try-catch at lines 276-279 intends to recover the messageId if handlePushReceived fails. However, handlePushReceived in NotificationService.kt catches all exceptions internally (lines 77-79) and never rethrows them. This means:

  1. The catch block here will never execute for handlePushReceived failures
  2. If showNotification or trackPushNotificationReceivedEvent fails, the messageId remains marked as processed
  3. Subsequent retries or duplicate deliveries of that message will be skipped as duplicates

This effectively makes transient failures permanent—the notification is never shown and the receive event is never tracked, with no retry possible.

To fix this, either:

  1. Remove the try-catch in handlePushReceived so exceptions propagate (recommended)
  2. Have handlePushReceived return a success/failure result that the caller can check

Based on learnings, side effects should be handled explicitly within service classes, but error propagation should allow callers to manage recovery.

}

private fun isAppInForeground(): Boolean {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ open class ClixMessagingService : FirebaseMessagingService() {
}

Clix.coroutineScope.launch {
Clix.Notification.handleIncomingPayload(
Clix.Notification.handleNotificationReceived(
notificationData = notificationData,
payload = payload,
)
Expand Down
39 changes: 13 additions & 26 deletions clix/src/main/kotlin/so/clix/services/NotificationService.kt
Original file line number Diff line number Diff line change
Expand Up @@ -57,39 +57,25 @@ internal class NotificationService(
createNotificationChannel()
}

suspend fun handleNotificationReceived(
suspend fun handlePushReceived(
payload: ClixPushNotificationPayload,
notificationData: Map<String, Any?>,
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)
}
Comment on lines 77 to 79

Choose a reason for hiding this comment

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

P1 Badge Propagate push handling failures for dedup recovery

The new handlePushReceived swallows all exceptions (logging at lines 77-79), but ClixNotification.handleNotificationReceived now records the messageId before delegating (lines 231-235) and only clears it in its own catch (275-277). If showNotification or trackPushNotificationReceivedEvent throws, the exception is absorbed here so the caller never recovers the recorded messageId, leaving it marked as processed. Any retry or duplicate delivery of that messageId is then skipped as a duplicate, meaning transient notification/rendering failures now permanently drop the notification and receive event instead of retrying as before.

Useful? React with 👍 / 👎.

}

Expand Down Expand Up @@ -191,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)
Expand Down Expand Up @@ -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<String>(lastReceivedMessageIdKey)
if (previous == messageId) {
Expand All @@ -250,7 +236,7 @@ internal class NotificationService(
}
}

private fun recoverReceivedMessageId(messageId: String) {
internal fun recoverReceivedMessageId(messageId: String) {
synchronized(this) {
val previous = storageService.get<String>(lastReceivedMessageIdKey)
if (previous == messageId) {
Expand All @@ -268,6 +254,7 @@ internal class NotificationService(
description = descriptionText
enableVibration(true)
enableLights(true)
setShowBadge(true)
}
notificationManager.createNotificationChannel(channel)
}
Expand Down
2 changes: 1 addition & 1 deletion gradle/libs.versions.toml
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
[versions]
clix = "1.3.2"
clix = "1.3.3"
# Plugins
android-gradle-plugin = "8.9.2"
gms = "4.4.2"
Expand Down
2 changes: 1 addition & 1 deletion samples/basic-app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down