# 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):

```kotlin
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`:

```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

```kotlin title="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

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:

```kotlin title="MainActivity.kt"
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:

```kotlin title="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

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:

```kotlin title="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)
}
```

Channel creation, big-picture images and the full field list are in the [Kotlin recipe](/docs/push/recipes/kotlin#2-register-the-token-and-show-incoming-pushes).

## 5. Credentials, then send

Save the **same Firebase project's** service-account JSON as the app's credentials in Native Notify — [Push Credentials](/docs/push/credentials). A token from a different Firebase project answers `SENDER_ID_MISMATCH`.

```bash
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`](/docs/push/verification) — 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.
