Skip to main content

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

  1. Go to the Firebase Console
  2. Click Add project (or select an existing project)
  3. Follow the setup wizard

Add Your Android App

  1. In the Firebase Console, click the Android icon to add an Android app
  2. Enter your app's package name (e.g., com.yourapp.android)
  3. Download the google-services.json file
  4. Place it in your app's module root directory: app/google-services.json

Get the Service Account Key

  1. In Firebase Console: Project Settings → Service Accounts
  2. Click Generate New Private Key
  3. This downloads a JSON file containing project_id, client_email, and private_key
  4. 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

Using the Android SDK

import dev.zyphr.sdk.api.DevicesApi
import dev.zyphr.sdk.infrastructure.ApiClient
import dev.zyphr.sdk.models.RegisterDeviceRequest

fun registerDeviceWithZyphr(token: String) {
val client = ApiClient(apiKey = "zy_live_xxx")
val devices = DevicesApi(client)

viewModelScope.launch {
try {
devices.registerDevice(
RegisterDeviceRequest(
subscriberId = "user_123", // Your user's ID in Zyphr
token = token,
platform = RegisterDeviceRequest.Platform.android,
appVersion = BuildConfig.VERSION_NAME,
osVersion = Build.VERSION.RELEASE
)
)
Log.d("Zyphr", "Device registered")
} catch (e: Exception) {
Log.e("Zyphr", "Registration failed", e)
}
}
}

Using the REST API

import java.net.HttpURLConnection
import java.net.URL

suspend fun registerDeviceWithZyphr(token: String) = withContext(Dispatchers.IO) {
val url = URL("https://api.zyphr.dev/v1/devices")
val connection = url.openConnection() as HttpURLConnection

try {
connection.requestMethod = "POST"
connection.setRequestProperty("X-API-Key", "zy_live_xxx")
connection.setRequestProperty("Content-Type", "application/json")
connection.doOutput = true

val body = """
{
"user_id": "user_123",
"token": "$token",
"platform": "android",
"app_version": "${BuildConfig.VERSION_NAME}",
"os_version": "${Build.VERSION.RELEASE}"
}
""".trimIndent()

connection.outputStream.use { it.write(body.toByteArray()) }
Log.d("Zyphr", "Registration status: ${connection.responseCode}")
} finally {
connection.disconnect()
}
}

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

TypeonMessageReceived called?Behavior
Notification message (foreground)YesYou handle display
Notification message (background)NoSystem displays automatically
Data message (any state)YesYou 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)
}
}
}
}
// 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() {
// Unregister from Zyphr
val client = ApiClient(apiKey = "zy_live_xxx")
val devices = DevicesApi(client)
try {
devices.deleteDevice(deviceId)
} catch (e: Exception) {
Log.e("Zyphr", "Failed to unregister device", e)
}

// Delete the FCM token locally
FirebaseMessaging.getInstance().deleteToken().await()
}

Troubleshooting

IssueCauseSolution
No token receivedMissing google-services.jsonVerify file is in app/ directory and plugin is applied
Token received but no notificationsFCM provider not configuredCheck Zyphr provider config has correct project_id, client_email, private_key
Notifications not showing (Android 13+)Missing runtime permissionRequest POST_NOTIFICATIONS permission
Notifications show but no sound/vibrationWrong channel importanceSet IMPORTANCE_HIGH on the notification channel
onMessageReceived not called (background)Sent as notification messageEnsure Zyphr sends data messages (default)
Image not showingDownload failed or too slowImage download happens synchronously — use small images
Duplicate notificationsMultiple FirebaseMessagingService declarationsEnsure 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