Jetpack Compose Recipe (Android)

Register a Jetpack Compose Android app's FCM token with Native Notify's push API — the Gradle packages, the Android 13+ notification permission in Compose, a stable device id, and showing the data messages.

A Jetpack Compose app uses the same FCM token and the same registration endpoint as any other Android app — the Compose-specific parts are the permission request and where you kick off registration. Native Notify's Android pushes are FCM data messages, so the app posts the notification itself.

1. Packages to install

app/build.gradle.kts (versions through the Firebase BoM and the Compose BoM your project already uses):

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 Gradle plugin, and register the messaging service in AndroidManifest.xml:

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

2. The registration call

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

Android 13+ needs POST_NOTIFICATIONS before anything is displayed (the token is issued either way). In Compose, launch the request with rememberLauncherForActivityResult and register once it settles:

import android.Manifest
import android.os.Build
import android.os.Bundle
import androidx.activity.ComponentActivity
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.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("Universal push is 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 — re-register the same deviceId with the new value:

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

Native Notify's Android pushes are data messages (title, message, body = your pushData as JSON, plus channelId, subtitle, … when the send set them). Firebase does not display them — post a notification yourself in onMessageReceived, from the same service class you registered in the manifest:

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)
}

Channel creation, big-picture images and the full field list are in the Kotlin recipe.

5. Credentials, then send

Save the same Firebase project's service-account JSON as the app's credentials in Native Notify — Push Credentials. A token from a different Firebase project answers SENDER_ID_MISMATCH.

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"}}'

Prove one device first with test-send — FCM's answer is immediate, and accepted means FCM took the message. If nothing appears, check that onMessageReceived posts the notification and that the permission was granted.