Entwickler · API v1.0.0

Swift-Integration (iOS)

Dieser Leitfaden richtet sich an native iOS-Apps, die mit Xcode in Swift (UIKit oder SwiftUI) entwickelt werden. Wenn Sie Objective-C verwenden oder die Einrichtung von Apple Developer / APNs Schritt für Schritt sehen möchten, lesen Sie den iOS-Leitfaden. Die allgemeine API-Referenz finden Sie in der Überblick.

Unterstützte Versionen

Komponente Minimum Empfohlen
iOS (Deployment Target) 13.0 15.0+
Swift 5.9 6.x
Xcode 15 aktuell

Push-Benachrichtigungen lassen sich im iOS-Simulator nicht zuverlässig testen; verwenden Sie ein physisches Gerät. Ein kostenpflichtiger Apple-Developer-Account ist erforderlich.

Wie funktioniert es?

NotiPilot 1.0.0 stellt Benachrichtigungen über den Expo Push Service zu. Ihre native iOS-App:

  1. erhält von Apple den APNs Device Token,
  2. tauscht diesen Token beim Token-Service von Expo gegen einen Expo Push Token ein,
  3. registriert den Expo Push Token per register-device bei NotiPilot.

Der Expo Push Service leitet die Benachrichtigungen über APNs an das Gerät weiter; in der App ist kein zusätzliches SDK erforderlich.

1. Vorbereitung (einmalig)

  1. Erstellen Sie unter Apple Developer → Keys einen Schlüssel (.p8) mit Berechtigung für Apple Push Notifications service (APNs). Notieren Sie Key ID und Team ID.
  2. Erstellen Sie mit einem Expo-Account ein EAS-Projekt (expo.dev → Create project) und notieren Sie die Projekt-ID (UUID).
  3. Laden Sie den .p8-Schlüssel in Ihr Expo-Projekt hoch: expo.dev → Projekt → Credentials → iOS → Push Key (oder eas credentials). Stellen Sie sicher, dass die Bundle ID mit Ihrer App übereinstimmt.
  4. Fügen Sie Ihre App im NotiPilot-Dashboard hinzu: Expo Project ID = Expo-Projekt-ID, Expo Access Token = auf expo.dev erstellter Token, iOS Bundle ID = Bundle Identifier Ihrer App.

2. Xcode-Einstellungen

Target → Signing & Capabilities:

  1. + Capability → Push Notifications
  2. + Capability → Background Modes → Remote notifications aktivieren

3. NotiPilot.swift

Swift
import Foundation
import Security
import UIKit

enum NotiPilotConfig {
    static let baseURL = URL(string: "https://app.notipilot.com/api/v1")!
    /// "Expo Project ID" im NotiPilot-Dashboard (Expo-Projekt-ID)
    static let appId = "IHRE-EXPO-PROJEKT-ID"
}

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

    // MARK: - device_uid (dauerhaft in der Keychain)

    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

    /// In `didRegisterForRemoteNotificationsWithDeviceToken` aufrufen.
    @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,
        ])
    }

    /// Aufrufen, wenn sich der Nutzer anmeldet.
    @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-Austausch

    private func exchangeForExpoToken(apnsHexToken: String) async throws -> String {
        #if DEBUG
        let development = true   // Debug-Builds verwenden die APNs-Sandbox-Umgebung
        #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: - Minimaler Keychain-Helfer

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 erhalten (wird bei jedem Start und bei Token-Änderungen erneut aufgerufen)
    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)")
    }

    // Benachrichtigung anzeigen, während die App im Vordergrund ist
    func userNotificationCenter(_ center: UNUserNotificationCenter,
                                willPresent notification: UNNotification) async -> UNNotificationPresentationOptions {
        [.banner, .list, .sound]
    }

    // Benachrichtigung angetippt
    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 {
            // Navigation: z. B. screen == "product" → data["product_id"]
            print("Open screen: \(screen)")
        }
    }
}

enum NotiPilotPayload {
    /// Die aus dem Dashboard gesendeten benutzerdefinierten `data` werden von Expo unter dem Schlüssel `body` übermittelt.
    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 [:]
    }
}

Wenn Sie SwiftUI verwenden

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

Entfernen Sie in diesem Fall die Markierung @main aus der Klasse AppDelegate.

5. Anmeldung / Abmeldung

Swift
// Nach der Anmeldung
Task { try? await NotiPilot.shared.identify(externalId: user.id, attributes: ["gender": "female"]) }

Um die external_id-Zuordnung bei der Abmeldung aufzuheben, können Sie register-device erneut aufrufen und der Anfrage "external_id": NSNull() hinzufügen.

Checkliste

  • APNs-Schlüssel (.p8) ins Expo-Projekt hochgeladen
  • Expo Project ID im Dashboard = NotiPilotConfig.appId
  • iOS Bundle ID im Dashboard = Bundle Identifier der App
  • Push Notifications + Background Modes → Remote notifications aktiviert
  • Im Debug-Build development = true, im App-Store-/TestFlight-Build false
  • country und nach Möglichkeit city werden als Attributes gesendet

Häufige Probleme

Symptom Lösung
didRegisterForRemoteNotifications wird nie aufgerufen Push-Notifications-Capability und Provisioning Profile prüfen; ein physisches Gerät verwenden.
Token-Austausch liefert 4xx Werte von projectId und appId (Bundle ID) prüfen.
In TestFlight kommen keine Benachrichtigungen an, im Debug schon (oder umgekehrt) Der Wert development verweist auf die falsche Umgebung.
404 unknown_app app_id ist im Dashboard nicht registriert.