Geliştiriciler · API v1.0.0

Kotlin Entegrasyonu (Android)

Bu rehber Kotlin ile geliştirilen native Android uygulamaları içindir (klasik View sistemi veya Jetpack Compose). Java kullanıyorsanız Android (Java) rehberine bakın. Genel API referansı için Genel bakış.

Desteklenen sürümler

Bileşen Minimum Önerilen
Android 6.0 (API 23, minSdk 23) targetSdk / compileSdk 35+
Kotlin 1.9 2.x
Coroutines 1.7 güncel
Android Gradle Plugin 8.0 güncel
Firebase BoM 33.x güncel

Nasıl çalışır?

NotiPilot 1.0.0 bildirimleri Expo Push Service üzerinden teslim eder. Uygulamanız FCM token'ını alır, bunu bir Expo Push Token'a dönüştürür ve NotiPilot'a kaydeder. Bildirimler FCM üzerinden cihaza ulaşır.

1. Ön hazırlık (bir kez)

  1. Firebase konsolunda Android uygulamanızı ekleyin, google-services.json dosyasını app/ klasörüne koyun.
  2. expo.dev üzerinde bir proje oluşturun ve proje ID'sini (UUID) not edin. Uygulamanızın Expo ile yazılmış olması gerekmez.
  3. Firebase → Proje Ayarları → Hizmet Hesapları → Yeni özel anahtar oluştur ile indirdiğiniz JSON'u expo.dev → Proje → Credentials → Android → FCM V1 service account key alanına yükleyin.
  4. NotiPilot panelinde: Expo Project ID = Expo proje ID'si, Expo Access Token = expo.dev → Access Tokens, Android Package = applicationId.

2. Gradle (Kotlin DSL)

build.gradle.kts (proje):

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", "\"EXPO-PROJE-ID-NIZ\"")
    }
    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()
        }
    }

    /** Uygulama açılışında (izin verildikten sonra) ve onNewToken'da çağırın. */
    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)
        })
    }

    /** Kullanıcı giriş yaptığında çağırın. */
    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)) }
            })
        }

    /** FCM token'ını Expo Push Token'a dönüştürür. */
    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 içeriği `notification` bloğunda veya `data` içinde (title / message / body) gönderebilir.
        val title = message.notification?.title ?: message.data["title"] ?: return
        val body = message.notification?.body ?: message.data["message"].orEmpty()
        // Panelden gönderilen özel veri `data["body"]` alanında JSON string olarak gelir.
        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, "Genel", 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. İzin ve kayıt

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 sistemi)

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

        // Bildirime tıklanarak açıldıysa özel veriler intent extras içindedir
        intent.getStringExtra("screen")?.let { /* yönlendirme */ }
    }

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

Kullanıcı giriş yaptığında: NotiPilot.identify(context, user.id) (bir coroutine içinde).

Kontrol listesi

  • google-services.json eklendi, FCM V1 servis hesabı anahtarı Expo projesine yüklendi
  • Paneldeki Expo Project ID = NOTIPILOT_APP_ID
  • Paneldeki Android Package = applicationId
  • Android 13+ için POST_NOTIFICATIONS izni isteniyor
  • default bildirim kanalı oluşturuluyor
  • onNewToken içinde registerDevice çağrılıyor
  • country ve mümkünse city attributes olarak gönderiliyor

Sorun giderme için Android rehberindeki tabloya bakın.