Kindly Android SDK — Push Notifications Reference
Deep reference for wiring Firebase Cloud Messaging (FCM) push notifications to Kindly on Android. Companion to SKILL.md.
Push notifications power "agent has replied" alerts when the chat is in the background. The SDK needs two things from you, both delivered through your FirebaseMessagingService:
- The FCM token when Firebase issues one (onNewToken)
- Incoming RemoteMessages that originate from Kindly (onMessageReceived)
How you wire #2 depends on whether Kindly is the only push provider in your app.
Both calls work before start(). A push that arrives after the process was killed is decrypted with the key stored at registration and shown, and tapping it opens the chat once start() runs (see Process death and cold start).
Prerequisites
- Firebase added to your project (google-services.json in app/, Google Services Gradle plugin applied).
- firebase-messaging dependency in app/build.gradle:
implementation("com.google.firebase:firebase-messaging-ktx:24.0.0")- A Firebase Server Key uploaded in the Kindly admin panel (under your bot's Mobile settings).
- POST_NOTIFICATIONS runtime permission requested (Android 13+).
1. Create a FirebaseMessagingService
import com.google.firebase.messaging.FirebaseMessagingService
import com.google.firebase.messaging.RemoteMessage
import no.kindly.chatsdk.chat.KindlySDK
class AppMessagingService : FirebaseMessagingService() {
override fun onNewToken(token: String) {
super.onNewToken(token)
KindlySDK.saveNotificationToken(token)
}
override fun onMessageReceived(remoteMessage: RemoteMessage) {
super.onMessageReceived(remoteMessage)
if (KindlySDK.isKindlyNotification(remoteMessage)) {
KindlySDK.handleNotification(remoteMessage)
return
}
// not from Kindly — handle elsewhere
}
}Register the service in AndroidManifest.xml:
<service
android:name=".AppMessagingService"
android:exported="false">
<intent-filter>
<action android:name="com.google.firebase.MESSAGING_EVENT" />
</intent-filter>
</service>2. Coexist with another provider
If you already have a FirebaseMessagingService (Braze, Airship, OneSignal, your own), keep it and forward to Kindly only when the message is from Kindly:
class AppMessagingService : FirebaseMessagingService() {
override fun onNewToken(token: String) {
super.onNewToken(token)
KindlySDK.saveNotificationToken(token)
OtherProvider.registerToken(token)
}
override fun onMessageReceived(remoteMessage: RemoteMessage) {
super.onMessageReceived(remoteMessage)
if (KindlySDK.isKindlyNotification(remoteMessage)) {
KindlySDK.handleNotification(remoteMessage)
return
}
OtherProvider.handleMessage(remoteMessage)
}
}Only one FirebaseMessagingService can be registered per app — every provider has to flow through this single class.
3. Intercept and observe via KindlySDKInteraction
For every Kindly push the SDK successfully decrypts while it is started, it calls shouldHandleNotification(notification) on KindlySDKInteraction before any display gate is applied. Use it to run analytics, log to a custom inbox, or take over presentation entirely with your own UI. Before start() the SDK still asks it when your interface is set in this process (for example after kill()); in a fresh process there is none and the SDK posts the banner itself.
Display rule (after the callback returns true)
App state | Active screen | SDK posts a system notification? |
|---|---|---|
Background | any | ✅ Yes |
Foreground | Chat conversation on screen | ❌ No (the user can already see the message) |
Foreground | Settings, language, image preview, host-app screens, etc. | ✅ Yes |
"On screen" means the chat activity is started and visible. Once the user backs out of the chat, or another activity covers it, banners are shown again.
The interface callback fires regardless of this rule — it runs for every successfully decrypted Kindly push, so it is the right hook for analytics that must run even while the chat is open.
Payload
The callback receives a fully-decrypted ExternalNotification:
data class ExternalNotification(
val id: String, // decrypted chat_id
val title: String, // decrypted title
val body: String, // decrypted body
val data: Map<String, String>, // original RemoteMessage.data
)Implementation
import no.kindly.chatsdk.chat.common.KindlySDKInteraction
import no.kindly.chatsdk.chat.domain.entites.ExternalNotification
KindlySDK.`interface` = object : KindlySDKInteraction {
override fun didPressButton(chatButton: ButtonChat, chatLog: List<MessageChat>) { /* ... */ }
override fun shouldHandleNotification(notification: ExternalNotification): Boolean {
Analytics.track("kindly_notification", notification.id)
if (isShowingCustomInbox) {
customInbox.add(notification)
return false // SDK skips the system notification
}
return true // let the SDK post the system notification (subject to the display rule)
}
}Polarity
Same as shouldHandleLink(url):
- true (default) → SDK handles. Subject to the display rule.
- false → host handles. SDK skips the system notification.
Returning false only suppresses the system notification — the underlying message has already been delivered to the chat session via the websocket / /latest endpoint, so it will appear in chat history when the user opens it.
Process death and cold start
FCM delivers Kindly's data-only messages to onMessageReceived even when the app process is not running (unless the user force-stopped the app). In that process nothing has called KindlySDK.start(...) yet, and the SDK handles it on its own:
- isKindlyNotification is a pure check on the payload
- handleNotification decrypts with the key persisted when the chat registered the device, creates the channel if needed and posts the banner. shouldHandleNotification is asked when your interface is set in this process (after kill()), a fresh process has none
- the banner's tap launches your launcher activity and the chat opens once start() has run
You do not need to call start() from the messaging service. A device that never registered (the chat was never opened while a token was known) has no key, the push is logged as no_keys and dropped.
API Reference
KindlySDK.saveNotificationToken(token: String)
Stores the FCM token in memory and uploads it to Kindly on every connect. Safe to call before or after start(); a token given before start() is applied when the SDK starts. The token is not persisted, so provide it again after a process restart (the FirebaseMessaging.getInstance().token call at app start in the pattern below does this).
KindlySDK.handleNotification(remoteMessage: RemoteMessage)
Tell the SDK that a Kindly notification arrived. The SDK:
- Decrypts the payload with the key the device received when the chat registered for pushes
- Offers the decrypted ExternalNotification to shouldHandleNotification when the SDK is started
- Skips the banner while the chat conversation is on screen (see the display rule above)
- Creates the kindly_channel channel if needed and posts the system notification
Works before start() too, see Process death and cold start.
KindlySDK.isKindlyNotification(remoteMessage: RemoteMessage): Boolean
Returns true if the RemoteMessage.data carries Kindly's notification_source marker. A pure check on the payload, it works before start(). Use this to route between providers.
KindlySDK.`interface`
Type: KindlySDKInteraction?. Set to receive shouldHandleNotification(notification) (and the existing shouldHandleLink(url) and didPressButton(...)) callbacks. The interface's shouldHandleNotification has a default implementation that returns true, so you only need to override it if you want to observe or intercept notifications.
ExternalNotification
A data class carrying the decrypted notification (id, title, body) plus the original RemoteMessage.data map for any extra fields the backend attached. Passed to shouldHandleNotification(notification).
Notification tap opens the chat
Tapping a Kindly banner opens the chat, with nothing to wire on your side:
- SDK started: the chat opens directly
- SDK not started (the app was killed, or the tap launched it): the SDK starts your launcher activity, and the chat opens as soon as your Application.onCreate() has called KindlySDK.start(...). A tap older than 60 seconds is dropped
Set KindlySDK.openChatOnNotificationTap = false to keep the launch but not open the chat, and read the tap yourself:
import no.kindly.chatsdk.chat.KindlyNotificationIntent
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
KindlyNotificationIntent.read(intent)?.let { tap -> /* tap.id (chat id), tap.title, tap.body, tap.data */ }
}
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
setIntent(intent)
KindlyNotificationIntent.read(intent)?.let { tap -> /* ... */ }
}KindlyNotificationIntent.read(intent) returns the decrypted ExternalNotification behind the tap, or null for any other intent; isKindlyNotificationTap(intent) is the boolean form. The extras are hints for your UI, not authenticated data: treat them like any other extra an exported launcher activity can receive.
End-to-End Pattern
// AppMessagingService.kt
class AppMessagingService : FirebaseMessagingService() {
override fun onNewToken(token: String) {
super.onNewToken(token)
KindlySDK.saveNotificationToken(token)
}
override fun onMessageReceived(remoteMessage: RemoteMessage) {
super.onMessageReceived(remoteMessage)
if (KindlySDK.isKindlyNotification(remoteMessage)) {
KindlySDK.handleNotification(remoteMessage)
}
}
}
// App.kt
class App : Application() {
override fun onCreate() {
super.onCreate()
KindlySDK.start(application = this, botKey = "YOUR_BOT_KEY", market = "YOUR_MARKET")
// Bootstrap the FCM token at app start (covers app-update and reinstall cases)
FirebaseMessaging.getInstance().token.addOnSuccessListener { token ->
KindlySDK.saveNotificationToken(token)
}
}
}
// MainActivity.kt — optional: see which notification opened the app (the SDK opens the chat by itself)
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
KindlyNotificationIntent.read(intent)?.let { Analytics.track("kindly_notification_opened", it.id) }
}
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
setIntent(intent)
KindlyNotificationIntent.read(intent)?.let { Analytics.track("kindly_notification_opened", it.id) }
}
}<!-- AndroidManifest.xml -->
<service android:name=".AppMessagingService" android:exported="false">
<intent-filter>
<action android:name="com.google.firebase.MESSAGING_EVENT" />
</intent-filter>
</service>
<!-- singleTask: a tap while the app is already running arrives in onNewIntent -->
<activity android:name=".MainActivity" android:exported="true" android:launchMode="singleTask">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>Debugging
Symptom | Likely cause |
|---|---|
No notifications arriving | (a) Server Key not configured in Kindly admin. (b) Application ID mismatch with google-services.json. (c) The token was never provided in this process (it is kept in memory only, so provide it again at app start). (d) The chat was never opened with a token on this device, so it has no decryption key (logcat shows no_keys). |
ClassNotFoundException: FirebaseMessagingService | Add firebase-messaging to your app/build.gradle dependencies. |
Tap on notification doesn't open chat | KindlySDK.openChatOnNotificationTap is false, or the app was killed and KindlySDK.start(...) did not run within 60 seconds of the tap (call it in Application.onCreate()). |
Push arrived while the app was killed but nothing was shown | Check logcat for permission_denied / notifications_disabled (notifications are off for the app) or decrypt_failed (the key on the device does not match the chat, for example after app data was restored from another device). |
Notifications missing on Android 13+ | The user denied POST_NOTIFICATIONS; request it via ActivityResultContracts.RequestPermission(). |
Enable verbose logging to see push handshake messages:
KindlySDK.setVerboseLogging(true)Notes & Gotchas
- Only one FirebaseMessagingService can be registered per app. If you already have one, don't add a second — extend the existing one to call into Kindly.
- onNewToken fires on token rotation, not on every app launch. To catch the case where the user reinstalled the app between launches and token rotation hasn't happened yet, fetch the token at app start with FirebaseMessaging.getInstance().token.
- The SDK posts the system notification itself when handleNotification is called — you don't need to call NotificationManagerCompat.notify(...) yourself for Kindly messages.
- The SDK's display gate suppresses the system notification only when the app is in the foreground and the chat conversation screen is actually on screen. Sub-screens of the SDK (settings, language, image preview, etc.) and host-app screens still receive notifications; backgrounded apps always receive them.
- Use shouldHandleNotification(notification) for analytics or custom presentation that must run on every Kindly push — it fires for every successfully decrypted notification, regardless of foreground/background state or active screen.
- The system notification is posted on the single kindly_channel channel (IMPORTANCE_HIGH, default sound), shown to the user as "Chat messages" in the system settings, translated into the device's language where the SDK ships one (23 languages, English otherwise). SDK versions before 2.0.15 created one channel per notification; the first push handled by this version removes those.
- A tap always opens the chat at the latest message; there is no per-message routing.
- Banners are posted with VISIBILITY_PUBLIC, so the decrypted text also shows on a locked screen. If your content must stay hidden there, return false from shouldHandleNotification and post your own notification.