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

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

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

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

```kotlin title="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` 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](/docs/push/credentials). A token from a different Firebase project answers `SENDER_ID_MISMATCH`.

## 5. Send and verify

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

`"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](/docs/push/sending).

Before a real blast, prove one device with [`test-send`](/docs/push/verification) (`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](/docs/push/registration)).
