Geliştiriciler · API v1.0.0

Swift Entegrasyonu (iOS)

Bu rehber Xcode ile Swift (UIKit veya SwiftUI) kullanılarak geliştirilen native iOS uygulamaları içindir. Objective-C kullanıyorsanız veya iOS tarafındaki Apple Developer / APNs kurulumunu adım adım görmek istiyorsanız iOS rehberine bakın. Genel API referansı için Genel bakış.

Desteklenen sürümler

Bileşen Minimum Önerilen
iOS (deployment target) 13.0 15.0+
Swift 5.9 6.x
Xcode 15 güncel

Push bildirimleri iOS Simülatöründe güvenilir şekilde test edilemez; fiziksel cihaz kullanın. Ücretli bir Apple Developer hesabı gereklidir.

Nasıl çalışır?

NotiPilot 1.0.0, bildirimleri Expo Push Service üzerinden teslim eder. Native iOS uygulamanız:

  1. Apple'dan APNs device token'ı alır,
  2. Bu token'ı Expo'nun token servisinde bir Expo Push Token'a dönüştürür,
  3. Expo Push Token'ı NotiPilot'a register-device ile kaydeder.

Expo Push Service bildirimleri APNs üzerinden cihaza iletir; uygulama tarafında ek bir SDK gerekmez.

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

  1. Apple Developer → Keys → Apple Push Notifications service (APNs) yetkili bir anahtar (.p8) oluşturun. Key ID ve Team ID'yi not edin.
  2. Bir Expo hesabı ile EAS projesi oluşturun (expo.dev → Create project) ve proje ID'sini (UUID) not edin.
  3. .p8 anahtarını Expo projenize yükleyin: expo.dev → Proje → Credentials → iOS → Push Key (veya eas credentials). Bundle ID'nin uygulamanızla aynı olduğundan emin olun.
  4. NotiPilot panelinde uygulamanızı ekleyin: Expo Project ID = Expo proje ID'si, Expo Access Token = expo.dev'den oluşturduğunuz token, iOS Bundle ID = uygulamanızın bundle identifier'ı.

2. Xcode ayarları

Target → Signing & Capabilities:

  1. + Capability → Push Notifications
  2. + Capability → Background Modes → Remote notifications işaretleyin

3. NotiPilot.swift

Swift
import Foundation
import Security
import UIKit

enum NotiPilotConfig {
    static let baseURL = URL(string: "https://app.notipilot.com/api/v1")!
    /// NotiPilot panelindeki "Expo Project ID" (Expo proje ID'si)
    static let appId = "EXPO-PROJE-ID-NIZ"
}

final class NotiPilot {
    static let shared = NotiPilot()
    private init() {}

    // MARK: - device_uid (Keychain'de kalıcı)

    var deviceUid: String {
        if let existing = Keychain.read("notipilot_device_uid") { return existing }
        let uid = UUID().uuidString.lowercased()
        Keychain.save("notipilot_device_uid", uid)
        return uid
    }

    // MARK: - Public API

    /// `didRegisterForRemoteNotificationsWithDeviceToken` içinde çağırın.
    @discardableResult
    func registerDevice(apnsToken: Data, extraAttributes: [String: Any?] = [:]) async throws -> [String: Any] {
        let hexToken = apnsToken.map { String(format: "%02x", $0) }.joined()
        let expoToken = try await exchangeForExpoToken(apnsHexToken: hexToken)

        var attributes: [String: Any] = [
            "locale": Locale.current.languageCode ?? NSNull(),   // "tr"
            "country": Locale.current.regionCode ?? NSNull(),    // "TR"
            "app_version": Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? NSNull(),
            "os_version": UIDevice.current.systemVersion,
        ]
        for (key, value) in extraAttributes { attributes[key] = value ?? NSNull() }

        return try await post("register-device", body: [
            "app_id": NotiPilotConfig.appId,
            "device_uid": deviceUid,
            "token": expoToken,
            "platform": "ios",
            "provider": "expo",
            "attributes": attributes,
        ])
    }

    /// Kullanıcı giriş yaptığında çağırın.
    @discardableResult
    func identify(externalId: String, attributes: [String: Any]? = nil) async throws -> [String: Any] {
        var body: [String: Any] = [
            "app_id": NotiPilotConfig.appId,
            "device_uid": deviceUid,
            "external_id": externalId,
        ]
        if let attributes { body["attributes"] = attributes }
        return try await post("identify-device", body: body)
    }

    // MARK: - Expo token dönüşümü

    private func exchangeForExpoToken(apnsHexToken: String) async throws -> String {
        #if DEBUG
        let development = true   // Debug build'ler APNs sandbox ortamını kullanır
        #else
        let development = false
        #endif

        var request = URLRequest(url: URL(string: "https://exp.host/--/api/v2/push/getExpoPushToken")!)
        request.httpMethod = "POST"
        request.setValue("application/json", forHTTPHeaderField: "Content-Type")
        request.httpBody = try JSONSerialization.data(withJSONObject: [
            "type": "apns",
            "deviceId": deviceUid,
            "development": development,
            "appId": Bundle.main.bundleIdentifier ?? "",
            "deviceToken": apnsHexToken,
            "projectId": NotiPilotConfig.appId,
        ])

        let (data, response) = try await URLSession.shared.data(for: request)
        guard (response as? HTTPURLResponse)?.statusCode == 200,
              let json = try JSONSerialization.jsonObject(with: data) as? [String: Any],
              let payload = json["data"] as? [String: Any],
              let token = payload["expoPushToken"] as? String
        else {
            throw NSError(domain: "NotiPilot", code: 1,
                          userInfo: [NSLocalizedDescriptionKey: "Expo token exchange failed: \(String(decoding: data, as: UTF8.self))"])
        }
        return token // "ExponentPushToken[...]"
    }

    // MARK: - HTTP

    private func post(_ path: String, body: [String: Any], attempt: Int = 0) async throws -> [String: Any] {
        var request = URLRequest(url: NotiPilotConfig.baseURL.appendingPathComponent(path))
        request.httpMethod = "POST"
        request.setValue("application/json", forHTTPHeaderField: "Content-Type")
        request.setValue("application/json", forHTTPHeaderField: "Accept")
        request.httpBody = try JSONSerialization.data(withJSONObject: body)

        let (data, response) = try await URLSession.shared.data(for: request)
        let status = (response as? HTTPURLResponse)?.statusCode ?? 0
        let json = (try? JSONSerialization.jsonObject(with: data) as? [String: Any]) ?? [:]

        if (status == 429 || status >= 500) && attempt < 3 {
            let wait = (json["retry_after"] as? Double) ?? pow(2, Double(attempt))
            try await Task.sleep(nanoseconds: UInt64(wait * 1_000_000_000))
            return try await post(path, body: body, attempt: attempt + 1)
        }
        if !(200..<300).contains(status) {
            print("[NotiPilot] \(status) \(json["error"] ?? "") \(json["errors"] ?? json["message"] ?? "")")
        }
        return json
    }
}

// MARK: - Minimal Keychain yardımcısı

enum Keychain {
    static func save(_ key: String, _ value: String) {
        let base: [String: Any] = [kSecClass as String: kSecClassGenericPassword,
                                   kSecAttrAccount as String: key]
        SecItemDelete(base as CFDictionary)
        var item = base
        item[kSecValueData as String] = Data(value.utf8)
        item[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly
        SecItemAdd(item as CFDictionary, nil)
    }

    static func read(_ key: String) -> String? {
        let query: [String: Any] = [kSecClass as String: kSecClassGenericPassword,
                                    kSecAttrAccount as String: key,
                                    kSecReturnData as String: true,
                                    kSecMatchLimit as String: kSecMatchLimitOne]
        var result: AnyObject?
        guard SecItemCopyMatching(query as CFDictionary, &result) == errSecSuccess,
              let data = result as? Data else { return nil }
        return String(data: data, encoding: .utf8)
    }
}

4. AppDelegate.swift

Swift
import UIKit
import UserNotifications

@main
class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterDelegate {

    func application(_ application: UIApplication,
                     didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
        UNUserNotificationCenter.current().delegate = self
        UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .sound, .badge]) { granted, _ in
            guard granted else { return }
            DispatchQueue.main.async { application.registerForRemoteNotifications() }
        }
        return true
    }

    // APNs token alındı (her açılışta ve token değiştiğinde tekrar çağrılır)
    func application(_ application: UIApplication,
                     didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) {
        Task {
            do {
                try await NotiPilot.shared.registerDevice(apnsToken: deviceToken,
                                                          extraAttributes: ["city": "Istanbul"])
            } catch {
                print("[NotiPilot] register failed: \(error)")
            }
        }
    }

    func application(_ application: UIApplication,
                     didFailToRegisterForRemoteNotificationsWithError error: Error) {
        print("[NotiPilot] APNs registration failed: \(error)")
    }

    // Uygulama ön plandayken bildirimi göster
    func userNotificationCenter(_ center: UNUserNotificationCenter,
                                willPresent notification: UNNotification) async -> UNNotificationPresentationOptions {
        [.banner, .list, .sound]
    }

    // Bildirime tıklandı
    func userNotificationCenter(_ center: UNUserNotificationCenter,
                                didReceive response: UNNotificationResponse) async {
        let data = NotiPilotPayload.customData(from: response.notification.request.content.userInfo)
        if let screen = data["screen"] as? String {
            // yönlendirme: örn. screen == "product" → data["product_id"]
            print("Open screen: \(screen)")
        }
    }
}

enum NotiPilotPayload {
    /// Panelden gönderilen özel `data`, Expo tarafından `body` anahtarı altında iletilir.
    static func customData(from userInfo: [AnyHashable: Any]) -> [String: Any] {
        if let dict = userInfo["body"] as? [String: Any] { return dict }
        if let string = userInfo["body"] as? String,
           let data = string.data(using: .utf8),
           let dict = try? JSONSerialization.jsonObject(with: data) as? [String: Any] { return dict }
        return [:]
    }
}

SwiftUI kullanıyorsanız

Swift
@main
struct MyApp: App {
    @UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate
    var body: some Scene { WindowGroup { ContentView() } }
}

Bu durumda AppDelegate sınıfından @main işaretini kaldırın.

5. Giriş / çıkış

Swift
// Giriş sonrası
Task { try? await NotiPilot.shared.identify(externalId: user.id, attributes: ["gender": "female"]) }

Çıkışta external_id eşleştirmesini kaldırmak için register-device isteğine "external_id": NSNull() ekleyerek tekrar çağırabilirsiniz.

Kontrol listesi

  • APNs .p8 anahtarı Expo projesine yüklendi
  • Paneldeki Expo Project ID = NotiPilotConfig.appId
  • Paneldeki iOS Bundle ID = uygulamanın bundle identifier'ı
  • Push Notifications + Background Modes → Remote notifications açık
  • Debug build'de development = true, App Store/TestFlight build'inde false
  • country ve mümkünse city attributes olarak gönderiliyor

Sık karşılaşılan sorunlar

Belirti Çözüm
didRegisterForRemoteNotifications hiç çağrılmıyor Push Notifications capability'si ve provisioning profile'ı kontrol edin; fiziksel cihaz kullanın.
Token dönüşümü 4xx dönüyor projectId ve appId (bundle ID) değerlerini kontrol edin.
TestFlight'ta bildirim gelmiyor, debug'da geliyor (veya tersi) development değeri yanlış ortamı gösteriyor.
404 unknown_app app_id panelde kayıtlı değil.