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 itsgoogle-services.jsonin 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.
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:
<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
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:
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:
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:
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"}}'