Platform guides

Jetpack Compose recipe (Android)

Register a Jetpack Compose app's FCM token with Native Notify, including the Android 13+ permission request.

Before you start

  • A Firebase project (console.firebase.google.com, free) with your app registered under its exact applicationId, and its google-services.json in the app module folder.
  • The service-account key, saved in Native Notify as Push credentials.
  • Where it runs: a phone, or an emulator whose system image includes Google Play services.

1. Set up the project in Android Studio

Versions come from the Firebase and Compose BoMs you already use.

app/build.gradle.kts
dependencies {
    // Firebase Cloud Messaging — the token and the incoming pushes.
    implementation(platform("com.google.firebase:firebase-bom:34.0.0"))
    implementation("com.google.firebase:firebase-messaging")

    // The registration call (any HTTP client works — this is the one used below).
    implementation("com.squareup.okhttp3:okhttp:4.12.0")

    // Compose + lifecycle (already in a Compose project): activity-compose for
    // rememberLauncherForActivityResult, lifecycle-runtime-compose for collectAsStateWithLifecycle.
    implementation("androidx.activity:activity-compose:1.9.2")
    implementation("androidx.lifecycle:lifecycle-runtime-compose:2.8.5")
    implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.9.0")
}

Also add google-services.json, apply the com.google.gms.google-services plugin, and register the messaging service:

AndroidManifest.xml
<service
    android:name=".PushService"
    android:exported="false">
    <intent-filter>
        <action android:name="com.google.firebase.MESSAGING_EVENT" />
    </intent-filter>
</service>

2. Add the registration call

UniversalPush.kt
import android.annotation.SuppressLint
import android.content.Context
import android.provider.Settings
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import org.json.JSONObject
import java.io.IOException
import java.util.UUID

const val NN_APP_ID = 123
const val NN_APP_TOKEN = "yourAppToken" // keep it in your build config

object UniversalPush {
    private val client = OkHttpClient()
    private val json = "application/json; charset=utf-8".toMediaType()

    /** Registers this device's FCM token. true = the server answered 201. */
    suspend fun register(context: Context, fcmToken: String, subscriberId: String? = null): Boolean =
        post(context, "register", JSONObject().apply {
            put("appId", NN_APP_ID)
            put("appToken", NN_APP_TOKEN)
            put("deviceId", DeviceIdentity.get(context))
            put("platform", "android")
            if (subscriberId != null) put("subscriberId", subscriberId) // omit for an anonymous device
            put("tokens", JSONObject().put("fcmToken", fcmToken))
        }) == 201

    /** Logout: removes this device (a hard, idempotent delete). true = 200. */
    suspend fun deregister(context: Context): Boolean =
        post(context, "deregister", JSONObject().apply {
            put("appId", NN_APP_ID)
            put("appToken", NN_APP_TOKEN)
            put("deviceId", DeviceIdentity.get(context))
        }) == 200

    private suspend fun post(context: Context, action: String, body: JSONObject): Int =
        withContext(Dispatchers.IO) { // never call this on the main thread
            val request = Request.Builder()
                .url("https://app.nativenotify.com/api/universal/device/$action")
                .post(body.toString().toRequestBody(json))
                .build()
            try {
                client.newCall(request).execute().use { response -> response.code }
            } catch (e: IOException) {
                -1 // offline — the next launch registers again
            }
        }
}

object DeviceIdentity {
    /** ANDROID_ID is stable for your app on this device and survives reinstalls. */
    @SuppressLint("HardwareIds")
    fun get(context: Context): String {
        val androidId = Settings.Secure.getString(context.contentResolver, Settings.Secure.ANDROID_ID)
        if (!androidId.isNullOrBlank()) return androidId
        val prefs = context.getSharedPreferences("native_notify", Context.MODE_PRIVATE)
        return prefs.getString("deviceId", null)
            ?: UUID.randomUUID().toString().also { prefs.edit().putString("deviceId", it).apply() }
    }
}

3. Ask for permission and register from a Compose screen

Launch the Android 13+ POST_NOTIFICATIONS request with rememberLauncherForActivityResult, then register — the token is issued whether or not it's granted:

MainActivity.kt
import android.Manifest
import android.os.Build
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.compose.setContent
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import com.google.firebase.messaging.FirebaseMessaging
import kotlinx.coroutines.launch

class MainActivity : ComponentActivity() {

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        // Opened from a notification? Your pushData JSON is in this extra.
        val pushData = intent.getStringExtra("nn_push_data")

        setContent {
            MaterialTheme {
                Surface(Modifier.fillMaxSize()) {
                    HomeScreen(pushData = pushData)
                }
            }
        }
    }

    @Composable
    private fun HomeScreen(pushData: String?) {
        val scope = rememberCoroutineScope()
        val context = LocalContext.current

        // Register on every launch: an idempotent upsert that also refreshes lastSeenAt.
        fun register() {
            FirebaseMessaging.getInstance().token.addOnSuccessListener { token ->
                scope.launch { UniversalPush.register(context, token) } // pass a subscriberId when logged in
            }
        }

        val askNotifications = rememberLauncherForActivityResult(
            ActivityResultContracts.RequestPermission()
        ) { register() } // granted or not: the token still registers

        LaunchedEffect(Unit) {
            if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
                askNotifications.launch(Manifest.permission.POST_NOTIFICATIONS)
            } else {
                register()
            }
        }

        Column(Modifier.padding(16.dp)) {
            Text("Push notifications are on for this device.", style = MaterialTheme.typography.bodyLarge)
            Spacer(Modifier.height(8.dp))
            if (pushData != null) Text("Opened from a notification: $pushData")
        }
    }
}

On every token rotation FCM calls onNewToken. Register the new token under the same deviceId:

PushService.kt
class PushService : FirebaseMessagingService() {
    override fun onNewToken(token: String) {
        // Runs on a background thread; runBlocking is fine here.
        kotlinx.coroutines.runBlocking { UniversalPush.register(applicationContext, token) }
    }
}

4. Show the push

FCM data messages aren't displayed for you. Post the notification in onMessageReceived, in the same PushService class:

PushService.kt — display side
override fun onMessageReceived(message: RemoteMessage) {
    val data = message.data
    val title = data["title"] ?: return
    val manager = getSystemService(NotificationManager::class.java)

    val tap = packageManager.getLaunchIntentForPackage(packageName)?.let { launch ->
        launch.putExtra("nn_push_data", data["body"]) // your pushData, read in MainActivity above
        PendingIntent.getActivity(this, 0, launch, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)
    }

    val notification = NotificationCompat.Builder(this, "default")
        .setSmallIcon(android.R.drawable.ic_dialog_info) // use your own monochrome icon
        .setContentTitle(title)
        .setContentText(data["message"].orEmpty())
        .setContentIntent(tap)
        .setAutoCancel(true)
        .build()

    manager.notify(System.currentTimeMillis().toInt(), notification)
}

Create the default channel first — Android 8+ shows nothing without it. The Kotlin recipe has the channel code, the picture of a bigPictureURL send and every data field, and Show images has the manifest defaults Firebase needs to draw that picture while the app is closed.

5. Save the service account in Native Notify

In Native Notify, open your project's Settings → Push credentials and save the same Firebase project's service-account JSON. See Push credentials. A token from another Firebase project answers SENDER_ID_MISMATCH.

6. Send a push with the API

Check one device first with a test send; accepted means FCM took the message, not that your app showed it. This call reaches every registered device:

curl -X POST https://app.nativenotify.com/api/universal/notifications/send \
  -H "Content-Type: application/json" \
  -d '{"appId":123,"appToken":"yourAppToken","title":"Hello Android","message":"Delivered over FCM.","audience":{"type":"all"}}'