Register an Android app's FCM token with Native Notify from Kotlin, show the data messages it receives, and show images.
Before you start
- A Firebase project (console.firebase.google.com, free) with your app registered under its exact
applicationId. google-services.json(Firebase console → Project settings → Your apps) in the app module folder, then a Gradle sync.- 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 (a "Google Play" or "Google APIs" image). Run it from Android Studio; there is nothing to build or install first.
1. Set up the project in Android Studio
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:
<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.
import android.annotation.SuppressLint
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Context
import android.graphics.Bitmap
import android.graphics.BitmapFactory
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.net.HttpURLConnection
import java.net.URL
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() }
}
}
/** Android 8+ shows nothing without a channel. Create `default` at launch too: Firebase draws a closed-app push on it. */
fun ensureChannel(context: Context, channelId: String) {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
val manager = context.getSystemService(NotificationManager::class.java)
if (manager.getNotificationChannel(channelId) == null) {
manager.createNotificationChannel(
NotificationChannel(channelId, "Notifications", NotificationManager.IMPORTANCE_HIGH)
)
}
}
/** 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)
ensureChannel(this, channelId)
// 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 builder = NotificationCompat.Builder(this, channelId)
.setSmallIcon(android.R.drawable.ic_dialog_info) // use your own monochrome icon
.setContentTitle(title)
.setContentText(text)
.setContentIntent(tap)
.setAutoCancel(true)
// A send with bigPictureURL also carries a notification block with the image URL.
loadPicture(message.notification?.imageUrl?.toString())?.let { picture ->
builder.setLargeIcon(picture)
builder.setStyle(NotificationCompat.BigPictureStyle().bigPicture(picture).bigLargeIcon(null as Bitmap?))
}
manager.notify(System.currentTimeMillis().toInt(), builder.build())
}
/** This runs on a background thread, so a plain download is fine. https only; keep images under 1 MB. */
private fun loadPicture(url: String?): Bitmap? {
if (url == null || !url.startsWith("https://")) return null
return try {
val connection = URL(url).openConnection() as HttpURLConnection
connection.connectTimeout = 8000
connection.readTimeout = 8000
connection.inputStream.use { BitmapFactory.decodeStream(it) }
} catch (e: IOException) {
null
}
}
}
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. Android shows that itself while the app is closed or in the background, skipping onMessageReceived. See Show images.
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)
ensureChannel(this, "default")
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? Its pushData JSON is in "nn_push_data", or in "body" when Firebase drew the notification itself.
val pushData = intent.getStringExtra("nn_push_data") ?: intent.getStringExtra("body")
}
}
Pass subscriberId to register for a logged-in user.
4. 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.
5. 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. 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"}}'
Show images
Firebase draws a push's picture itself while the app is closed or in the background, on the channel and with the icon your manifest names. Add them inside <application>:
<meta-data
android:name="com.google.firebase.messaging.default_notification_channel_id"
android:value="default" />
<meta-data
android:name="com.google.firebase.messaging.default_notification_icon"
android:resource="@android:drawable/ic_dialog_info" />
Use your own monochrome icon instead of ic_dialog_info. onMessageReceived above draws the picture while the app is open. Send a push with a bigPictureURL: an https JPEG or PNG under 1 MB.
Deregister on logout
Call UniversalPush.deregister(context). See Device registration.