Platform guides

Kotlin recipe (native Android)

Register an Android app's FCM token with Native Notify from Kotlin, and show the data messages it receives.

1. Set up the project

Add google-services.json, the com.google.gms.google-services Gradle plugin and com.google.firebase:firebase-messaging (through the Firebase BoM), plus com.squareup.okhttp3:okhttp (4.x), androidx.core:core-ktx, androidx.activity:activity-ktx and androidx.lifecycle:lifecycle-runtime-ktx. Register the service:

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

Android pushes are FCM data messages, so your app shows them itself.

NativeNotifyPush.kt
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)
    }
}

A message carries title, message and body (your pushData as a JSON string), plus subtitle, badge, sound, channelId and categoryId when the send set them. A send with bigPictureURL also carries a notification block, which Android shows itself in the background, skipping onMessageReceived.

3. Ask for permission and register on launch

MainActivity.kt
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 for a logged-in user.

4. Save the service account

Save the same Firebase project's service-account JSON under App Settings → Push credentials. See Push credentials. A token from another Firebase project answers SENDER_ID_MISMATCH.

5. Send a push

Check one device first with a test send; accepted means FCM took the message, not that your app showed it. For other audiences, see Send notifications. 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"}}'

Deregister on logout

Call UniversalPush.deregister(context). See Device registration.