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)
- Add your Android app in the Firebase console and place the
google-services.jsonfile in theapp/folder. - Create a project on expo.dev and note its project ID (UUID). Your app doesn't need to be built with Expo.
- 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.
- 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):
plugins {
id("com.google.gms.google-services") version "4.4.2" apply false
}
app/build.gradle.kts:
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
<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
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
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
@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)
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.jsonadded, 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_NOTIFICATIONSpermission is requested on Android 13+ -
defaultnotification channel is created -
registerDeviceis called inonNewToken -
countryand, if possible,cityare sent as attributes
For troubleshooting, see the table in the Android guide.