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
93 changes: 67 additions & 26 deletions Docs/PushNotifications.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ The easiest way to support push notifications in your app.
<tr width="600px">
<td align="left">
<a href="https://github.com/trycourier/courier-android/blob/master/Docs/PushNotifications.md#manual-token-syncing">
<code>Manual Token Managment</code>
<code>Manual Token Management</code>
</a>
</td>
<td align="left">
Expand Down Expand Up @@ -193,40 +193,79 @@ Select which push notification provider you would like Courier to route push not

## 2. Sync Push Notification Tokens

### Automatic Token Syncing (FCM Only)
## Automatic Token Syncing (FCM Only)

To track new push notifications when they arrive and to automatically sync push notification tokens, create a new file, name it what you'd like and paste the following code in it. (Kotlin example shown below)

### 1. Add the Firebase Messaging Dependency to your `app/build.gradle`

```gradle
..
dependencies {

..

// Firebase
implementation platform('com.google.firebase:firebase-bom:XXXX')
implementation "com.google.firebase:firebase-messaging"
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

nit: These could use consistent syntax:

implementation platform("com.google.firebase:firebase-bom:X.Y.Z")
implementation("com.google.firebase:firebase-messaging")

The Android docs use double quotes here, too, if we want to stay consistent with those examples: https://developer.android.com/develop/ui/compose/bom#kts


}
```

You can find more details in the official Firebase documentation:

🔗 [Set up Firebase for Android](https://firebase.google.com/docs/android/setup)

### 2. Create a new Service File to handle push notification delivery and tokens

```kotlin
import android.annotation.SuppressLint
package your.app.package

import com.courier.android.Courier
import com.courier.android.notifications.CourierPushNotificationIntent
import com.courier.android.notifications.presentNotification
import com.courier.android.service.CourierService
import com.google.firebase.messaging.FirebaseMessagingService
import com.google.firebase.messaging.RemoteMessage

// This is safe. `CourierService` will automatically handle token refreshes.
@SuppressLint("MissingFirebaseInstanceTokenRefresh")
class YourNotificationService: CourierService() {
class CourierPushNotificationService: FirebaseMessagingService() {

override fun showNotification(message: RemoteMessage) {
super.showNotification(message)
override fun onMessageReceived(message: RemoteMessage) {
super.onMessageReceived(message)

// TODO: This is where you will customize the notification that is shown to your users
// `message.presentNotification(...)` is used to get started quickly and not for production use.
// More information on how to customize an Android notification here:
// https://developer.android.com/develop/ui/views/notifications/build-notification
// Notify the Courier SDK that a push was delivered
Courier.onMessageReceived(message.data)

message.presentNotification(
// Create the PendingIntent that runs when the user taps the notification
// This intent targets your Activity and carries the original message payload
val notificationIntent = CourierPushNotificationIntent(
context = this,
handlingClass = MainActivity::class.java,
icon = android.R.drawable.ic_dialog_info
target = MainActivity::class.java,
payload = message
)

// Show the notification to the user.
// Prefer data-only FCM so this service runs even in background/killed state.
// Fall back to notification fields if data keys are missing.
notificationIntent.presentNotification(
title = message.data["title"] ?: message.notification?.title,
body = message.data["body"] ?: message.notification?.body,
)

}

override fun onNewToken(token: String) {
super.onNewToken(token)

// Register/refresh this device’s FCM token with Courier.
// The SDK caches and updates the token automatically and links it to the current user.
Courier.onNewToken(token)

}

}
```

Next, add the `CourierService` entry in your `AndroidManifest.xml` file
### 3. Register the `Service` in your `AndroidManifest.xml` file

```xml
<manifest>
Expand All @@ -238,7 +277,7 @@ Next, add the `CourierService` entry in your `AndroidManifest.xml` file

// Add this 👇
<service
android:name=".YourNotificationService"
android:name=".CourierPushNotificationService"
android:exported="false">
<intent-filter>
<action android:name="com.google.firebase.MESSAGING_EVENT" />
Expand All @@ -252,25 +291,27 @@ Next, add the `CourierService` entry in your `AndroidManifest.xml` file
</manifest>
```

Finally, add the `CourierActivity`. This will allow you to handle when users get push notifications delivered and when they click on the push notifications. You will likely want to extend your `MainActivity` but your use case may be slightly different.
### 4. Add the `CourierActivity`

This will allow you to handle when users get push notifications delivered and when they click on the push notifications. You will likely want to extend your `MainActivity` but your use case may be slightly different.

```kotlin
class MainActivity : CourierActivity() {

..

override fun onPushNotificationClicked(message: RemoteMessage) {
Toast.makeText(this, "Message clicked:\n${message.data}", Toast.LENGTH_LONG).show()
override fun onPushNotificationDelivered(pushNotification: Map<String, String>) {
Toast.makeText(this, "Message delivered:\n${pushNotification}", Toast.LENGTH_LONG).show()
}

override fun onPushNotificationDelivered(message: RemoteMessage) {
Toast.makeText(this, "Message delivered:\n${message.data}", Toast.LENGTH_LONG).show()
override fun onPushNotificationClicked(pushNotification: Map<String, String>) {
Toast.makeText(this, "Message clicked:\n${pushNotification}", Toast.LENGTH_LONG).show()
}

}
```

### Manual Token Syncing
## Manual Token Syncing

If you do not want to use `CourierService` and `CourierActivity`, you can manually sync push notification tokens with the following code.

Expand Down Expand Up @@ -299,7 +340,7 @@ lifecycleScope.launch {
}
```

### Manual Notification Tracking
## Manual Notification Tracking

This is how you can tell Courier when a notification has been delivered or clicked in your app. If you are using `CourierService` and `CourierActivity`, this is done automatically.

Expand Down
2 changes: 1 addition & 1 deletion android/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -62,9 +62,9 @@ dependencies {
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-core:1.6.4'
implementation 'com.squareup.okhttp3:okhttp:4.10.0'
implementation 'com.google.code.gson:gson:2.11.0'
implementation 'com.google.firebase:firebase-messaging-ktx:24.1.2'

// Exportable APIs
api 'com.google.firebase:firebase-messaging-ktx:23.4.1'
api 'androidx.recyclerview:recyclerview:1.3.2'
api 'com.google.android.material:material:1.12.0'

Expand Down
4 changes: 2 additions & 2 deletions android/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

How come this is removed?


<manifest>
<!-- Empty -->
</manifest>
57 changes: 54 additions & 3 deletions android/src/main/java/com/courier/android/Courier.kt
Original file line number Diff line number Diff line change
Expand Up @@ -10,17 +10,26 @@ import com.courier.android.client.CourierClient
import com.courier.android.models.CourierAgent
import com.courier.android.models.CourierAuthenticationListener
import com.courier.android.models.CourierException
import com.courier.android.models.CourierPushNotificationEvent
import com.courier.android.models.CourierTrackingEvent
import com.courier.android.modules.InboxModule
import com.courier.android.modules.linkInbox
import com.courier.android.modules.refreshFcmToken
import com.courier.android.modules.setFcmToken
import com.courier.android.modules.unlinkInbox
import com.courier.android.utils.NotificationEventBus
import com.courier.android.utils.broadcastPushNotification
import com.courier.android.utils.error
import com.courier.android.utils.log
import com.courier.android.utils.trackPushNotification
import com.courier.android.utils.trackingUrl
import com.courier.android.utils.warn
import com.google.firebase.FirebaseApp
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch

/**
Expand Down Expand Up @@ -56,9 +65,6 @@ class Courier private constructor(val context: Context) : Application.ActivityLi
// Inbox
private const val SOCKET_CLEANUP_DURATION = 60_000L * 10 // 10 minutes

// Push
internal const val COURIER_PENDING_NOTIFICATION_KEY = "courier_pending_notification_key"

// Eventing
val eventBus by lazy { NotificationEventBus() }

Expand Down Expand Up @@ -125,6 +131,51 @@ class Courier private constructor(val context: Context) : Application.ActivityLi
}
}

// Broadcasts and tracks the message in Courier
fun onMessageReceived(data: Map<String, String>) = coroutineScope.launch(Dispatchers.IO) {
try {

val trackingEvent = CourierTrackingEvent.DELIVERED

// Broadcast the message to the app
broadcastPushNotification(
trackingEvent = trackingEvent,
data = data
)

// Track the push notification delivery
data.trackingUrl?.let { trackingUrl ->
trackPushNotification(
trackingEvent = trackingEvent,
trackingUrl = trackingUrl
)
}

} catch (e: Exception) {
CourierClient.default.error(e.toString())
}
}

// Saves the notification token to Courier token management
fun onNewToken(token: String) {
try {
shared.setFcmToken(
token = token,
onSuccess = { shared.client?.log("Courier FCM token updated") },
onFailure = { shared.client?.error(it.toString()) }
)
} catch (e: Exception) {
CourierClient.default.error(e.toString())
}
}

// Returns the last message that was delivered via the event bus
fun onPushNotificationEvent(onEvent: (event: CourierPushNotificationEvent) -> Unit) = coroutineScope.launch(Dispatchers.Main) {
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

We may want to change this to use a listener of some sort in the future. Not a huge deal for now

eventBus.events.collectLatest {
onEvent(it)
}
}

}

// Inbox
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,13 @@ package com.courier.android.activity
import android.content.Intent
import android.os.Bundle
import androidx.appcompat.app.AppCompatActivity
import androidx.lifecycle.lifecycleScope
import com.courier.android.Courier
import com.courier.android.models.CourierTrackingEvent
import com.courier.android.utils.onPushNotificationEvent
import com.courier.android.utils.trackPushNotificationClick
import com.google.firebase.messaging.RemoteMessage
import com.courier.android.utils.getPushNotificationData
import com.courier.android.utils.trackPushNotification
import com.courier.android.utils.trackingUrl
import kotlinx.coroutines.launch

open class CourierActivity : AppCompatActivity() {

Expand All @@ -17,13 +19,16 @@ open class CourierActivity : AppCompatActivity() {
// Init Courier if needed
Courier.initialize(context = this)

// See if there is a pending click event
// Handle initial push
checkIntentForPushNotificationClick(intent)

// Handle delivered messages on the main thread
Courier.shared.onPushNotificationEvent { event ->
if (event.trackingEvent == CourierTrackingEvent.DELIVERED) {
onPushNotificationDelivered(event.remoteMessage)
Courier.onPushNotificationEvent { event ->
when (event.trackingEvent) {
CourierTrackingEvent.DELIVERED -> {
onPushNotificationDelivered(event.data)
}
else -> Unit
}
}

Expand All @@ -34,14 +39,27 @@ open class CourierActivity : AppCompatActivity() {
checkIntentForPushNotificationClick(intent)
}

private fun checkIntentForPushNotificationClick(intent: Intent?) {
intent?.trackPushNotificationClick { message ->
onPushNotificationClicked(message)
private fun checkIntentForPushNotificationClick(intent: Intent?) = lifecycleScope.launch {

// Get the notification and tracking event
val remoteMessage = intent?.getPushNotificationData() ?: return@launch
val trackingEvent = CourierTrackingEvent.CLICKED

// Broadcast the message
onPushNotificationClicked(remoteMessage)

// Track the message
remoteMessage.trackingUrl?.let { trackingUrl ->
trackPushNotification(
trackingEvent = trackingEvent,
trackingUrl = trackingUrl
)
}

}

open fun onPushNotificationClicked(message: RemoteMessage) {}
open fun onPushNotificationClicked(pushNotification: Map<String, String>) {}

open fun onPushNotificationDelivered(message: RemoteMessage) {}
open fun onPushNotificationDelivered(pushNotification: Map<String, String>) {}
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

We can no longer expose the public class at this point. Using a simple map is more ideal.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Thinking: since we're now taking an implementation dependency on Firebase Messaging, we could reference RemoteMessage in the Courier SDK, however we avoid referencing Firebase classes in Courier's public API since they're not exposed through the Courier SDK


}
Original file line number Diff line number Diff line change
@@ -1,8 +1,6 @@
package com.courier.android.models

import com.google.firebase.messaging.RemoteMessage

data class CourierPushNotificationEvent(
val trackingEvent: CourierTrackingEvent,
val remoteMessage: RemoteMessage
val data: Map<String, String>
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

I'd consider creating a class for the message data (ex. CourierMessage or something) that's more descriptive to pass around than a plain Map and let's us extend functionality down the line

)
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,4 @@ package com.courier.android.models
enum class CourierTrackingEvent(val value: String) {
CLICKED("CLICKED"),
DELIVERED("DELIVERED"),
OPENED("OPENED"),
READ("READ"),
UNREAD("UNREAD")
}
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,8 @@ import com.courier.android.models.CourierException
import com.courier.android.models.CourierPushProvider
import com.courier.android.utils.error
import com.courier.android.utils.log
import com.google.firebase.ktx.Firebase
import com.google.firebase.messaging.ktx.messaging
import com.google.firebase.Firebase
import com.google.firebase.messaging.messaging
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.tasks.await
Expand Down Expand Up @@ -254,6 +254,10 @@ fun Courier.setToken(provider: CourierPushProvider, token: String, onSuccess: ()
}
}

/**
* Permission Handlers
*/

fun Courier.requestNotificationPermission(activity: Activity, requestCode: Int = 1) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
ActivityCompat.requestPermissions(activity, arrayOf(Manifest.permission.POST_NOTIFICATIONS), requestCode)
Expand Down
Loading