Push Notifications for Android
This guide walks you through the entire Android push notification integration — from Firebase setup to handling notification taps and deep links.
Prerequisites
- A Firebase project
- Android Studio Arctic Fox (2020.3.1) or later
- Android API 21+ (Android 5.0) minimum SDK
- A Zyphr account with push notifications enabled
- FCM provider configured in Zyphr (see Provider Setup)
1. Firebase Project Setup
Create a Firebase Project
- Go to the Firebase Console
- Click Add project (or select an existing project)
- Follow the setup wizard
Add Your Android App
- In the Firebase Console, click the Android icon to add an Android app
- Enter your app's package name (e.g.,
com.yourapp.android) - Download the
google-services.jsonfile - Place it in your app's module root directory:
app/google-services.json
Get the Service Account Key
- In Firebase Console: Project Settings → Service Accounts
- Click Generate New Private Key
- This downloads a JSON file containing
project_id,client_email, andprivate_key - Enter these values in Zyphr under Push → Providers → Firebase Cloud Messaging
2. Gradle Configuration
Project-level build.gradle.kts
plugins {
id("com.google.gms.google-services") version "4.4.2" apply false
}
App-level build.gradle.kts
plugins {
id("com.android.application")
id("org.jetbrains.kotlin.android")
id("com.google.gms.google-services")
}
dependencies {
// Firebase BoM (manages all Firebase library versions)
implementation(platform("com.google.firebase:firebase-bom:33.7.0"))
implementation("com.google.firebase:firebase-messaging-ktx")
// Zyphr SDK
implementation("dev.zyphr:zyphr-sdk:0.1.0")
}
Sync your project after adding these dependencies.
3. AndroidManifest Configuration
Required Permissions
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- Required for push notifications -->
<uses-permission android:name="android.permission.INTERNET" />
<!-- Required for Android 13+ notification permission -->
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<application ...>
<!-- Firebase Messaging Service -->
<service
android:name=".push.ZyphrMessagingService"
android:exported="false">
<intent-filter>
<action android:name="com.google.firebase.MESSAGING_EVENT" />
</intent-filter>
</service>
<!-- Default notification channel (Android 8.0+) -->
<meta-data
android:name="com.google.firebase.messaging.default_notification_channel_id"
android:value="zyphr_default" />
<!-- Default notification icon -->
<meta-data
android:name="com.google.firebase.messaging.default_notification_icon"
android:resource="@drawable/ic_notification" />
<!-- Default notification color -->
<meta-data
android:name="com.google.firebase.messaging.default_notification_color"
android:resource="@color/notification_accent" />
</application>
</manifest>
4. Request Notification Permission (Android 13+)
Starting with Android 13 (API 33), apps must request the POST_NOTIFICATIONS runtime permission.
import android.Manifest
import android.content.pm.PackageManager
import android.os.Build
import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AppCompatActivity
import androidx.core.content.ContextCompat
class MainActivity : AppCompatActivity() {
private val requestPermissionLauncher = registerForActivityResult(
ActivityResultContracts.RequestPermission()
) { isGranted: Boolean ->
if (isGranted) {
// Permission granted — FCM token is already available
retrieveFcmToken()
} else {
// Permission denied — notifications won't show
}
}
private fun checkNotificationPermission() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
when {
ContextCompat.checkSelfPermission(
this, Manifest.permission.POST_NOTIFICATIONS
) == PackageManager.PERMISSION_GRANTED -> {
// Already granted
retrieveFcmToken()
}
shouldShowRequestPermissionRationale(Manifest.permission.POST_NOTIFICATIONS) -> {
// Show explanation UI, then request
requestPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS)
}
else -> {
requestPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS)
}
}
} else {
// Pre-Android 13: no runtime permission needed
retrieveFcmToken()
}
}
}
Jetpack Compose
@Composable
fun NotificationPermissionRequest() {
val launcher = rememberLauncherForActivityResult(
ActivityResultContracts.RequestPermission()
) { isGranted ->
if (isGranted) {
// Retrieve FCM token
}
}
LaunchedEffect(Unit) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
launcher.launch(Manifest.permission.POST_NOTIFICATIONS)
}
}
}
5. Get the FCM Token
import com.google.firebase.messaging.FirebaseMessaging
fun retrieveFcmToken() {
FirebaseMessaging.getInstance().token.addOnCompleteListener { task ->
if (!task.isSuccessful) {
Log.w("FCM", "Token retrieval failed", task.exception)
return@addOnCompleteListener
}
val token = task.result
Log.d("FCM", "FCM token: $token")
// Register with Zyphr
registerDeviceWithZyphr(token)
}
}
With Coroutines
import com.google.firebase.messaging.FirebaseMessaging
import kotlinx.coroutines.tasks.await
suspend fun getFcmToken(): String? {
return try {
FirebaseMessaging.getInstance().token.await()
} catch (e: Exception) {
Log.e("FCM", "Failed to get token", e)
null
}
}
6. Register the Device with Zyphr
Device registration authenticates with your secret API key (zy_live_* /
zy_test_*). Anything shipped in an APK is extractable, so the key must stay on
your server. Post the FCM token to your own backend and let it call Zyphr,
deriving user_id from the authenticated session rather than the request body —
otherwise any caller can register a device against another user's account.
See Device Management for the full lifecycle.
Step 1 — send the token to your backend (app)
fun registerDeviceWithZyphr(token: String) {
viewModelScope.launch {
try {
yourApi.postPushToken(
pushToken = token,
appVersion = BuildConfig.VERSION_NAME
)
Log.d("Zyphr", "Token sent to backend")
} catch (e: Exception) {
Log.e("Zyphr", "Registration failed", e)
}
}
}
Step 2 — call Zyphr from your backend
import { Zyphr } from '@zyphr-dev/node-sdk';
const zyphr = new Zyphr({ apiKey: process.env.ZYPHR_API_KEY });
app.post('/push-token', requireAuth, async (req, res) => {
await zyphr.devices.registerDevice({
userId: req.user.id, // from YOUR session — never the request body
platform: 'android',
token: req.body.pushToken,
metadata: { appVersion: req.body.appVersion },
});
res.json({ ok: true });
});
Registration is an upsert keyed on (project, token), so calling it on every
app launch is safe. Re-registering an existing token under a different userId
reassigns the device to that user.
7. Implement FirebaseMessagingService
This is the core of your push integration. This service receives messages and handles token refresh.
package com.yourapp.push
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Intent
import android.graphics.BitmapFactory
import android.os.Build
import androidx.core.app.NotificationCompat
import com.google.firebase.messaging.FirebaseMessagingService
import com.google.firebase.messaging.RemoteMessage
import java.net.URL
class ZyphrMessagingService : FirebaseMessagingService() {
override fun onNewToken(token: String) {
super.onNewToken(token)
// Token has been refreshed — re-register with Zyphr
registerDeviceWithZyphr(token)
}
override fun onMessageReceived(message: RemoteMessage) {
super.onMessageReceived(message)
// Data payload (always available)
val data = message.data
// Notification payload (only when sent as notification message)
val notification = message.notification
val title = notification?.title ?: data["title"] ?: "Notification"
val body = notification?.body ?: data["body"] ?: ""
val imageUrl = notification?.imageUrl?.toString() ?: data["image_url"]
showNotification(title, body, imageUrl, data)
}
private fun showNotification(
title: String,
body: String,
imageUrl: String?,
data: Map<String, String>
) {
val channelId = data["channel_id"] ?: "zyphr_default"
createNotificationChannel(channelId)
// Build the tap intent with notification data
val intent = Intent(this, MainActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP
data.forEach { (key, value) -> putExtra(key, value) }
}
val pendingIntent = PendingIntent.getActivity(
this, System.currentTimeMillis().toInt(), intent,
PendingIntent.FLAG_ONE_SHOT or PendingIntent.FLAG_IMMUTABLE
)
val builder = NotificationCompat.Builder(this, channelId)
.setSmallIcon(R.drawable.ic_notification)
.setContentTitle(title)
.setContentText(body)
.setAutoCancel(true)
.setContentIntent(pendingIntent)
.setPriority(NotificationCompat.PRIORITY_HIGH)
// Add image if present
if (imageUrl != null) {
try {
val bitmap = URL(imageUrl).openStream().use {
BitmapFactory.decodeStream(it)
}
builder.setStyle(
NotificationCompat.BigPictureStyle().bigPicture(bitmap)
)
builder.setLargeIcon(bitmap)
} catch (e: Exception) {
// Image download failed — show text-only notification
}
}
val manager = getSystemService(NOTIFICATION_SERVICE) as NotificationManager
manager.notify(System.currentTimeMillis().toInt(), builder.build())
}
private fun createNotificationChannel(channelId: String) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val channel = NotificationChannel(
channelId,
"Notifications",
NotificationManager.IMPORTANCE_HIGH
).apply {
description = "Zyphr push notifications"
enableVibration(true)
}
val manager = getSystemService(NOTIFICATION_SERVICE) as NotificationManager
manager.createNotificationChannel(channel)
}
}
}
FCM Message Types
| Type | onMessageReceived called? | Behavior |
|---|---|---|
| Notification message (foreground) | Yes | You handle display |
| Notification message (background) | No | System displays automatically |
| Data message (any state) | Yes | You always handle display |
Zyphr sends data messages by default, giving you full control over notification display in all app states.
8. Handle Notification Taps
Extract Data from Intent
class MainActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
handleNotificationIntent(intent)
}
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
handleNotificationIntent(intent)
}
private fun handleNotificationIntent(intent: Intent) {
val extras = intent.extras ?: return
val type = extras.getString("type") ?: return
when (type) {
"message" -> {
val messageId = extras.getString("message_id") ?: return
navigateToMessage(messageId)
}
"order" -> {
val orderId = extras.getString("order_id") ?: return
navigateToOrder(orderId)
}
}
}
}
Deep Links with Jetpack Navigation
// Define deep link in nav graph
<fragment
android:id="@+id/messageFragment"
android:name="com.yourapp.MessageFragment">
<deepLink app:uri="yourapp://messages/{messageId}" />
</fragment>
private fun handleNotificationIntent(intent: Intent) {
val type = intent.getStringExtra("type") ?: return
val id = intent.getStringExtra("message_id") ?: return
when (type) {
"message" -> {
val deepLink = Uri.parse("yourapp://messages/$id")
navController.navigate(deepLink)
}
}
}
Jetpack Compose Navigation
@Composable
fun AppNavigation(notificationData: Map<String, String>?) {
val navController = rememberNavController()
LaunchedEffect(notificationData) {
notificationData?.let { data ->
when (data["type"]) {
"message" -> navController.navigate("messages/${data["message_id"]}")
"order" -> navController.navigate("orders/${data["order_id"]}")
}
}
}
NavHost(navController, startDestination = "home") {
composable("messages/{messageId}") { backStackEntry ->
MessageScreen(backStackEntry.arguments?.getString("messageId") ?: "")
}
}
}
9. Notification Channels (Android 8.0+)
Android 8.0+ requires notification channels. Create channels at app startup so users can customize notification behavior per category.
object NotificationChannels {
const val MESSAGES = "messages"
const val PROMOTIONS = "promotions"
const val SYSTEM = "system"
fun createAll(context: Context) {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
val manager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
val channels = listOf(
NotificationChannel(MESSAGES, "Messages", NotificationManager.IMPORTANCE_HIGH).apply {
description = "New message notifications"
enableVibration(true)
},
NotificationChannel(PROMOTIONS, "Promotions", NotificationManager.IMPORTANCE_DEFAULT).apply {
description = "Promotional offers and updates"
},
NotificationChannel(SYSTEM, "System", NotificationManager.IMPORTANCE_LOW).apply {
description = "System alerts and updates"
},
)
manager.createNotificationChannels(channels)
}
}
// Call in Application.onCreate()
class MyApp : Application() {
override fun onCreate() {
super.onCreate()
NotificationChannels.createAll(this)
}
}
When sending from Zyphr, include the channel in the data payload:
await zyphr.push.send({
user_id: 'user_123',
title: 'Flash Sale!',
body: '50% off everything today',
data: {
channel_id: 'promotions',
},
});
10. Action Buttons
Add interactive buttons to notifications:
private fun showNotificationWithActions(
title: String,
body: String,
data: Map<String, String>
) {
val channelId = "zyphr_default"
createNotificationChannel(channelId)
val viewIntent = Intent(this, NotificationActionReceiver::class.java).apply {
action = "ACTION_VIEW"
data.forEach { (key, value) -> putExtra(key, value) }
}
val viewPending = PendingIntent.getBroadcast(
this, 0, viewIntent,
PendingIntent.FLAG_ONE_SHOT or PendingIntent.FLAG_IMMUTABLE
)
val dismissIntent = Intent(this, NotificationActionReceiver::class.java).apply {
action = "ACTION_DISMISS"
}
val dismissPending = PendingIntent.getBroadcast(
this, 1, dismissIntent,
PendingIntent.FLAG_ONE_SHOT or PendingIntent.FLAG_IMMUTABLE
)
val notification = NotificationCompat.Builder(this, channelId)
.setSmallIcon(R.drawable.ic_notification)
.setContentTitle(title)
.setContentText(body)
.addAction(R.drawable.ic_view, "View", viewPending)
.addAction(R.drawable.ic_dismiss, "Dismiss", dismissPending)
.setAutoCancel(true)
.build()
val manager = getSystemService(NOTIFICATION_SERVICE) as NotificationManager
manager.notify(System.currentTimeMillis().toInt(), notification)
}
class NotificationActionReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
when (intent.action) {
"ACTION_VIEW" -> {
val messageId = intent.getStringExtra("message_id")
// Launch activity with deep link
}
"ACTION_DISMISS" -> {
// Notification auto-dismissed
}
}
}
}
Register the receiver in AndroidManifest.xml:
<receiver
android:name=".push.NotificationActionReceiver"
android:exported="false" />
11. Silent / Data-Only Push
Handle background data sync without showing a notification:
override fun onMessageReceived(message: RemoteMessage) {
val data = message.data
if (data["content_available"] == "true") {
// Silent push — perform background work
handleSilentPush(data)
return
}
// Normal notification — show to user
showNotification(...)
}
private fun handleSilentPush(data: Map<String, String>) {
when (data["type"]) {
"sync" -> {
// Sync data in background
val resource = data["resource"]
syncResource(resource)
}
"badge_update" -> {
// Update app shortcut badge
val count = data["count"]?.toIntOrNull() ?: 0
updateBadgeCount(count)
}
}
}
12. Token Refresh
FCM tokens can change when:
- The app is restored on a new device
- The user uninstalls/reinstalls the app
- The user clears app data
Handle this in your FirebaseMessagingService:
override fun onNewToken(token: String) {
super.onNewToken(token)
Log.d("FCM", "New token: $token")
// Re-register with Zyphr — it handles deduplication
viewModelScope.launch {
registerDeviceWithZyphr(token)
}
}
13. Unregister on Logout
When a user logs out, unregister their device to stop receiving notifications:
suspend fun onUserLogout() {
// Ask YOUR backend to unregister — it calls zyphr.devices.deleteDevice()
// with the secret key. Never call Zyphr directly from the app.
try {
yourApi.deletePushToken(currentPushToken)
} catch (e: Exception) {
Log.e("Zyphr", "Failed to unregister device", e)
}
// Delete the FCM token locally
FirebaseMessaging.getInstance().deleteToken().await()
}
Deleting on logout is defence-in-depth rather than a correctness requirement — re-registering the same token under a new user reassigns it. It matters when nobody logs in afterwards, since the token stays mapped to the previous user until something overwrites it. See Logging out.
Troubleshooting
| Issue | Cause | Solution |
|---|---|---|
| No token received | Missing google-services.json | Verify file is in app/ directory and plugin is applied |
| Token received but no notifications | FCM provider not configured | Check Zyphr provider config has correct project_id, client_email, private_key |
| Notifications not showing (Android 13+) | Missing runtime permission | Request POST_NOTIFICATIONS permission |
| Notifications show but no sound/vibration | Wrong channel importance | Set IMPORTANCE_HIGH on the notification channel |
onMessageReceived not called (background) | Sent as notification message | Ensure Zyphr sends data messages (default) |
| Image not showing | Download failed or too slow | Image download happens synchronously — use small images |
| Duplicate notifications | Multiple FirebaseMessagingService declarations | Ensure only one service with MESSAGING_EVENT filter |
Complete Example
Minimal but complete integration:
// Application class
class MyApp : Application() {
override fun onCreate() {
super.onCreate()
NotificationChannels.createAll(this)
}
}
// Messaging service
class ZyphrMessagingService : FirebaseMessagingService() {
override fun onNewToken(token: String) {
CoroutineScope(Dispatchers.IO).launch {
registerDeviceWithZyphr(token)
}
}
override fun onMessageReceived(message: RemoteMessage) {
val data = message.data
if (data["content_available"] == "true") return
showNotification(
title = data["title"] ?: "Notification",
body = data["body"] ?: "",
imageUrl = data["image_url"],
data = data
)
}
}
// In your login flow
suspend fun onUserLogin(userId: String) {
val token = FirebaseMessaging.getInstance().token.await()
registerDeviceWithZyphr(token)
}
Next Steps
- Push Notifications API Reference — Full API parameters and options
- Kotlin SDK Reference — Complete SDK documentation (server-side)
- Device Management — Managing registered devices
- Topics — Subscribe devices to notification topics