Entwickler · API v1.0.0

Kotlin-Integration (Android)

Dieser Leitfaden richtet sich an native Android-Apps, die in Kotlin entwickelt werden (klassisches View-System oder Jetpack Compose). Wenn Sie Java verwenden, lesen Sie den Android-Leitfaden (Java). Die allgemeine API-Referenz finden Sie in der Überblick.

Unterstützte Versionen

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

Wie funktioniert es?

NotiPilot 1.0.0 stellt Benachrichtigungen über den Expo Push Service zu. Ihre App ruft den FCM-Token ab, tauscht ihn gegen einen Expo Push Token ein und registriert diesen bei NotiPilot. Die Benachrichtigungen erreichen das Gerät über FCM.

1. Vorbereitung (einmalig)

  1. Fügen Sie Ihre Android-App in der Firebase-Konsole hinzu und legen Sie die Datei google-services.json im Ordner app/ ab.
  2. Erstellen Sie auf expo.dev ein Projekt und notieren Sie die Projekt-ID (UUID). Ihre App muss nicht mit Expo geschrieben sein.
  3. Laden Sie die JSON-Datei, die Sie unter Firebase → Projekteinstellungen → Dienstkonten → Neuen privaten Schlüssel generieren heruntergeladen haben, unter expo.dev → Projekt → Credentials → Android → FCM V1 service account key hoch.
  4. Im NotiPilot-Dashboard: Expo Project ID = Expo-Projekt-ID, Expo Access Token = expo.dev → Access Tokens, Android Package = applicationId.

2. Gradle (Kotlin DSL)

build.gradle.kts (Projekt):

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", "\"IHRE-EXPO-PROJEKT-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()
        }
    }

    /** Beim App-Start (nach erteilter Berechtigung) und in onNewToken aufrufen. */
    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)
        })
    }

    /** Aufrufen, wenn sich der Nutzer anmeldet. */
    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)) }
            })
        }

    /** Tauscht den FCM-Token gegen einen Expo Push Token ein. */
    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 kann den Inhalt im `notification`-Block oder in `data` (title / message / body) senden.
        val title = message.notification?.title ?: message.data["title"] ?: return
        val body = message.notification?.body ?: message.data["message"].orEmpty()
        // Aus dem Dashboard gesendete benutzerdefinierte Daten kommen als JSON-String im Feld `data["body"]` an.
        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, "Allgemein", 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. Berechtigung und Registrierung

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()
        }

        // Wurde die App durch Tippen auf eine Benachrichtigung geöffnet, stehen die benutzerdefinierten Daten in den Intent-Extras
        intent.getStringExtra("screen")?.let { /* Navigation */ }
    }

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

Wenn sich der Nutzer anmeldet: NotiPilot.identify(context, user.id) (innerhalb einer Coroutine).

Checkliste

  • google-services.json hinzugefügt, Schlüssel des FCM-V1-Dienstkontos ins Expo-Projekt hochgeladen
  • Expo Project ID im Dashboard = NOTIPILOT_APP_ID
  • Android Package im Dashboard = applicationId
  • Ab Android 13 wird die Berechtigung POST_NOTIFICATIONS angefragt
  • Der Benachrichtigungskanal default wird angelegt
  • registerDevice wird in onNewToken aufgerufen
  • country und nach Möglichkeit city werden als Attributes gesendet

Zur Fehlerbehebung siehe die Tabelle im Android-Leitfaden.