Kotlin Recipe (native Android)

Register a native Android app's FCM token with Native Notify's push API from Kotlin — FirebaseMessaging, a stable deviceId, the registration call, and showing the data messages in onMessageReceived.

Android apps get their token from Firebase Cloud Messaging, register it with one HTTPS call — and show the notification themselves: Native Notify's Android pushes are FCM data messages (the same title / message / body format Expo apps read), so a native app renders them in onMessageReceived.

1. Project setup

  • Firebase: add the app's google-services.json, the com.google.gms.google-services Gradle plugin, and com.google.firebase:firebase-messaging (through the Firebase BoM).
  • Libraries used below: com.squareup.okhttp3:okhttp (4.x), androidx.core:core-ktx, androidx.activity:activity-ktx, androidx.lifecycle:lifecycle-runtime-ktx.
  • 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. Register the token and show incoming pushes

import android.annotation.SuppressLint
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Context
import android.os.Build
import android.provider.Settings
import androidx.core.app.NotificationCompat
import com.google.firebase.messaging.FirebaseMessagingService
import com.google.firebase.messaging.RemoteMessage
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.runBlocking
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" // read it from your build config

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

    /** Registers this device's FCM token (runs on Dispatchers.IO). true = 201 from the server. */
    suspend fun register(context: Context, fcmToken: String, subscriberId: String? = null): Boolean =
        post("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)
            put("tokens", JSONObject().put("fcmToken", fcmToken))
        }) == 201

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

    private suspend fun post(action: String, body: JSONObject): Int = withContext(Dispatchers.IO) {
        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 (Android 8+), survives
     * reinstalls, and a backup restore never copies it to another phone. A
     * stored UUID is the fallback.
     */
    @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() }
    }
}

/** Native Notify's Android pushes are FCM *data* messages — the app shows them itself. */
class NativeNotifyMessagingService : FirebaseMessagingService() {
    // Runs on a background thread whenever FCM rotates the token.
    override fun onNewToken(token: String) {
        runBlocking { UniversalPush.register(applicationContext, token) }
    }

    override fun onMessageReceived(message: RemoteMessage) {
        val data = message.data // every value is a String
        val title = data["title"] ?: return
        val text = data["message"].orEmpty()
        val channelId = data["channelId"] ?: "default"

        val manager = getSystemService(NotificationManager::class.java)
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && manager.getNotificationChannel(channelId) == null) {
            manager.createNotificationChannel(
                NotificationChannel(channelId, "Notifications", NotificationManager.IMPORTANCE_HIGH)
            )
        }

        // A tap opens the app with your pushData as JSON (your keys + nn_notification_id / nn_source).
        val tap = packageManager.getLaunchIntentForPackage(packageName)?.let { launch ->
            launch.putExtra("nn_push_data", data["body"])
            PendingIntent.getActivity(
                this, 0, launch, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
            )
        }

        val notification = NotificationCompat.Builder(this, channelId)
            .setSmallIcon(android.R.drawable.ic_dialog_info) // use your own monochrome icon
            .setContentTitle(title)
            .setContentText(text)
            .setContentIntent(tap)
            .setAutoCancel(true)
            .build()
        manager.notify(System.currentTimeMillis().toInt(), notification)
    }
}
  • register runs on Dispatchers.IO (a blocking call on the main thread would crash with NetworkOnMainThreadException) and returns true for the server's 201.
  • onNewToken re-registers whenever FCM rotates the token — same deviceId, new token (an idempotent upsert).
  • The data message carries title, message, body (your pushData as a JSON string, with nn_notification_id / nn_source), and — when the send set them — subtitle, badge, sound, channelId and categoryId, all as strings.
  • A send with bigPictureURL also carries a regular notification block: while the app is in the background Android shows that one itself (with the image) instead of calling onMessageReceived.

3. Ask for permission and register on launch

import android.Manifest
import android.os.Build
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.result.contract.ActivityResultContracts
import androidx.lifecycle.lifecycleScope
import com.google.firebase.messaging.FirebaseMessaging
import kotlinx.coroutines.launch

class MainActivity : ComponentActivity() {
    // Android 13+: without this permission nothing is shown (the token still works).
    private val askNotifications = registerForActivityResult(ActivityResultContracts.RequestPermission()) { }

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
            askNotifications.launch(Manifest.permission.POST_NOTIFICATIONS)
        }
        // Register on every launch (an idempotent upsert); onNewToken covers rotations.
        FirebaseMessaging.getInstance().token.addOnSuccessListener { token ->
            lifecycleScope.launch { UniversalPush.register(this@MainActivity, token) }
        }
        // Opened from a notification? intent.getStringExtra("nn_push_data") holds its pushData JSON.
    }
}

Pass subscriberId to register when a user is logged in; omit it for an anonymous device.

4. Credentials the app needs

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

5. Send and verify

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

"all" reaches every registered device, with or without a subscriberId; to reach one logged-in user's devices, send {"type":"subscribers","subscriberIds":["user_8241"]} — see Send Notifications.

Before a real blast, prove one device with test-send (deviceId) — FCM's answer is immediate: accepted, or a per-token reason like UNREGISTERED. accepted means FCM took the message; if nothing appears, check that onMessageReceived posts the notification and that the permission was granted.

Logout: call UniversalPush.deregister(context) — a hard, idempotent delete (Device Registration).