Developers · API v1.0.0

Kotlin Integration (Android)

This guide is for native Android apps built with Kotlin (classic View system or Jetpack Compose). If you use Java, see the Android (Java) guide. For the general API reference, see the Overview.

Supported versions

Component Minimum Recommended
Android 6.0 (API 23, minSdk 23) targetSdk / compileSdk 35+
Kotlin 1.9 2.x
Coroutines 1.7 latest
Android Gradle Plugin 8.0 latest
Firebase BoM 33.x latest

How does it work?

NotiPilot 1.0.0 delivers notifications through the Expo Push Service. Your app gets an FCM token, exchanges it for an Expo Push Token and registers it with NotiPilot. Notifications reach the device through FCM.

1. Prerequisites (one-time)

  1. Add your Android app in the Firebase console and place the google-services.json file in the app/ folder.
  2. Create a project on expo.dev and note its project ID (UUID). Your app doesn't need to be built with Expo.
  3. In Firebase → Project Settings → Service Accounts, click Generate new private key, then upload the downloaded JSON on expo.dev → Project → Credentials → Android → FCM V1 service account key.
  4. In the NotiPilot dashboard: Expo Project ID = your Expo project ID, Expo Access Token = expo.dev → Access Tokens, Android Package = applicationId.

2. Gradle (Kotlin DSL)

build.gradle.kts (project):

Kotlin
plugins {
    id("com.google.gms.google-services") version "4.4.2" apply false
}

app/build.gradle.kts:

Kotlin
plugins {
    id("com.android.application")
    id("org.jetbrains.kotlin.android")
    id("com.google.gms.google-services")
}

android {
    defaultConfig {
        minSdk = 23
        buildConfigField("String", "NOTIPILOT_BASE_URL", "\"https://app.notipilot.com/api/v1\"")
        buildConfigField("String", "NOTIPILOT_APP_ID", "\"YOUR-EXPO-PROJECT-ID\"")
    }
    buildFeatures { buildConfig = true }
}

dependencies {
    implementation(platform("com.google.firebase:firebase-bom:33.7.0"))
    implementation("com.google.firebase:firebase-messaging")
    implementation("com.squareup.okhttp3:okhttp:4.12.0")
    implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.9.0")
    implementation("org.jetbrains.kotlinx:kotlinx-coroutines-play-services:1.9.0")
}

3. AndroidManifest.xml

XML
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />

<application ...>
    <service
        android:name=".push.NotiPilotMessagingService"
        android:exported="false">
        <intent-filter>
            <action android:name="com.google.firebase.MESSAGING_EVENT" />
        </intent-filter>
    </service>

    <meta-data
        android:name="com.google.firebase.messaging.default_notification_channel_id"
        android:value="default" />
</application>

4. NotiPilot.kt

Kotlin
package com.example.app.push

import android.content.Context
import android.os.Build
import com.example.app.BuildConfig
import com.google.firebase.messaging.FirebaseMessaging
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.tasks.await
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.util.Locale
import java.util.UUID

object NotiPilot {
    private val http = OkHttpClient()
    private val JSON = "application/json; charset=utf-8".toMediaType()

    fun deviceUid(context: Context): String {
        val prefs = context.getSharedPreferences("notipilot", Context.MODE_PRIVATE)
        return prefs.getString("device_uid", null) ?: UUID.randomUUID().toString().also {
            prefs.edit().putString("device_uid", it).apply()
        }
    }

    /** Call on app launch (after permission is granted) and in onNewToken. */
    suspend fun registerDevice(
        context: Context,
        fcmToken: String? = null,
        extraAttributes: Map<String, Any?> = emptyMap(),
    ): JSONObject = withContext(Dispatchers.IO) {
        val uid = deviceUid(context)
        val token = fcmToken ?: FirebaseMessaging.getInstance().token.await()
        val expoToken = exchangeForExpoToken(context, uid, token)

        val attributes = JSONObject().apply {
            put("locale", Locale.getDefault().language)   // "tr"
            put("country", Locale.getDefault().country)   // "TR"
            put("app_version", appVersion(context))
            put("os_version", Build.VERSION.RELEASE)
            extraAttributes.forEach { (k, v) -> put(k, v ?: JSONObject.NULL) }
        }

        post("/register-device", JSONObject().apply {
            put("app_id", BuildConfig.NOTIPILOT_APP_ID)
            put("device_uid", uid)
            put("token", expoToken)
            put("platform", "android")
            put("provider", "expo")
            put("attributes", attributes)
        })
    }

    /** Call when the user signs in. */
    suspend fun identify(context: Context, externalId: String, attributes: Map<String, Any?>? = null) =
        withContext(Dispatchers.IO) {
            post("/identify-device", JSONObject().apply {
                put("app_id", BuildConfig.NOTIPILOT_APP_ID)
                put("device_uid", deviceUid(context))
                put("external_id", externalId)
                attributes?.let { put("attributes", JSONObject(it)) }
            })
        }

    /** Exchanges the FCM token for an Expo Push Token. */
    private fun exchangeForExpoToken(context: Context, uid: String, fcmToken: String): String {
        val body = JSONObject().apply {
            put("type", "fcm")
            put("deviceId", uid.lowercase())
            put("development", false)
            put("appId", context.packageName)
            put("deviceToken", fcmToken)
            put("projectId", BuildConfig.NOTIPILOT_APP_ID)
        }
        val request = Request.Builder()
            .url("https://exp.host/--/api/v2/push/getExpoPushToken")
            .post(body.toString().toRequestBody(JSON))
            .build()

        http.newCall(request).execute().use { res ->
            val json = JSONObject(res.body?.string().orEmpty().ifBlank { "{}" })
            check(res.isSuccessful) { "Expo token exchange failed: ${res.code} $json" }
            return json.getJSONObject("data").getString("expoPushToken")
        }
    }

    private suspend fun post(path: String, body: JSONObject, attempt: Int = 0): JSONObject {
        val request = Request.Builder()
            .url(BuildConfig.NOTIPILOT_BASE_URL + path)
            .post(body.toString().toRequestBody(JSON))
            .build()

        val (code, json) = http.newCall(request).execute().use { res ->
            res.code to JSONObject(res.body?.string().orEmpty().ifBlank { "{}" })
        }

        if ((code == 429 || code >= 500) && attempt < 3) {
            delay(json.optLong("retry_after", 1L shl attempt) * 1000)
            return post(path, body, attempt + 1)
        }
        return json
    }

    private fun appVersion(context: Context): String =
        context.packageManager.getPackageInfo(context.packageName, 0).versionName ?: ""
}

5. NotiPilotMessagingService.kt

Kotlin
package com.example.app.push

import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.os.Build
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import com.example.app.MainActivity
import com.example.app.R
import com.google.firebase.messaging.FirebaseMessagingService
import com.google.firebase.messaging.RemoteMessage
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
import org.json.JSONObject

class NotiPilotMessagingService : FirebaseMessagingService() {
    private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)

    override fun onNewToken(token: String) {
        scope.launch { runCatching { NotiPilot.registerDevice(applicationContext, token) } }
    }

    override fun onMessageReceived(message: RemoteMessage) {
        // Expo may send the content in the `notification` block or inside `data` (title / message / body).
        val title = message.notification?.title ?: message.data["title"] ?: return
        val body = message.notification?.body ?: message.data["message"].orEmpty()
        // Custom data sent from the dashboard arrives as a JSON string in `data["body"]`.
        val custom = message.data["body"]?.let { runCatching { JSONObject(it) }.getOrNull() }

        show(this, title, body, custom)
    }

    companion object {
        const val CHANNEL_ID = "default"

        fun createChannel(context: Context) {
            if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
                val channel = NotificationChannel(CHANNEL_ID, "General", NotificationManager.IMPORTANCE_HIGH)
                context.getSystemService(NotificationManager::class.java).createNotificationChannel(channel)
            }
        }

        fun show(context: Context, title: String, body: String, data: JSONObject?) {
            createChannel(context)
            val intent = Intent(context, MainActivity::class.java).apply {
                flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP
                data?.keys()?.forEach { key -> putExtra(key, data.optString(key)) }
            }
            val id = System.currentTimeMillis().toInt()
            val pending = PendingIntent.getActivity(
                context, id, intent,
                PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
            )
            val notification = NotificationCompat.Builder(context, CHANNEL_ID)
                .setSmallIcon(R.drawable.ic_notification)
                .setContentTitle(title)
                .setContentText(body)
                .setStyle(NotificationCompat.BigTextStyle().bigText(body))
                .setPriority(NotificationCompat.PRIORITY_HIGH)
                .setAutoCancel(true)
                .setContentIntent(pending)
                .build()

            val manager = NotificationManagerCompat.from(context)
            if (manager.areNotificationsEnabled()) manager.notify(id, notification)
        }
    }
}

6. Permission and registration

Jetpack Compose

Kotlin
@Composable
fun NotiPilotSetup() {
    val context = LocalContext.current
    val scope = rememberCoroutineScope()

    fun register() = scope.launch {
        runCatching { NotiPilot.registerDevice(context, extraAttributes = mapOf("city" to "Istanbul")) }
            .onFailure { Log.w("NotiPilot", "register failed", it) }
    }

    val launcher = rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { granted ->
        if (granted) register()
    }

    LaunchedEffect(Unit) {
        NotiPilotMessagingService.createChannel(context)
        if (Build.VERSION.SDK_INT >= 33 &&
            ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS)
            != PackageManager.PERMISSION_GRANTED
        ) {
            launcher.launch(Manifest.permission.POST_NOTIFICATIONS)
        } else {
            register()
        }
    }
}

Activity (View system)

Kotlin
class MainActivity : AppCompatActivity() {

    private val permissionLauncher =
        registerForActivityResult(ActivityResultContracts.RequestPermission()) { granted ->
            if (granted) register()
        }

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        NotiPilotMessagingService.createChannel(this)

        if (Build.VERSION.SDK_INT >= 33 &&
            checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED
        ) {
            permissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS)
        } else {
            register()
        }

        // If the app was opened by tapping a notification, custom data is in the intent extras
        intent.getStringExtra("screen")?.let { /* navigation */ }
    }

    private fun register() = lifecycleScope.launch {
        runCatching { NotiPilot.registerDevice(this@MainActivity) }
            .onFailure { Log.w("NotiPilot", "register failed", it) }
    }
}

When the user signs in: NotiPilot.identify(context, user.id) (inside a coroutine).

Checklist

  • google-services.json added, FCM V1 service account key uploaded to the Expo project
  • Expo Project ID in the dashboard = NOTIPILOT_APP_ID
  • Android Package in the dashboard = applicationId
  • POST_NOTIFICATIONS permission is requested on Android 13+
  • default notification channel is created
  • registerDevice is called in onNewToken
  • country and, if possible, city are sent as attributes

For troubleshooting, see the table in the Android guide.