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:
- Apple'dan APNs device token'ı alır,
- Bu token'ı Expo'nun token servisinde bir Expo Push Token'a dönüştürür,
- Expo Push Token'ı NotiPilot'a
register-deviceile kaydeder.
Expo Push Service bildirimleri APNs üzerinden cihaza iletir; uygulama tarafında ek bir SDK gerekmez.
1. Ön hazırlık (bir kez)
- Apple Developer → Keys → Apple Push Notifications service (APNs) yetkili bir anahtar (
.p8) oluşturun. Key ID ve Team ID'yi not edin. - Bir Expo hesabı ile EAS projesi oluşturun (expo.dev → Create project) ve proje ID'sini (UUID) not edin.
.p8anahtarını Expo projenize yükleyin: expo.dev → Proje → Credentials → iOS → Push Key (veyaeas credentials). Bundle ID'nin uygulamanızla aynı olduğundan emin olun.- 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:
- + Capability → Push Notifications
- + Capability → Background Modes → Remote notifications işaretleyin
3. NotiPilot.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
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
@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ış
// 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
.p8anahtarı 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'indefalse -
countryve mümkünsecityattributes 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. |