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, thecom.google.gms.google-servicesGradle plugin, andcom.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)
}
}
registerruns onDispatchers.IO(a blocking call on the main thread would crash withNetworkOnMainThreadException) and returnstruefor the server's201.onNewTokenre-registers whenever FCM rotates the token — samedeviceId, new token (an idempotent upsert).- The data message carries
title,message,body(yourpushDataas a JSON string, withnn_notification_id/nn_source), and — when the send set them —subtitle,badge,sound,channelIdandcategoryId, all as strings. - A send with
bigPictureURLalso carries a regular notification block: while the app is in the background Android shows that one itself (with the image) instead of callingonMessageReceived.
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).