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:
- erhält von Apple den APNs Device Token,
- tauscht diesen Token beim Token-Service von Expo gegen einen Expo Push Token ein,
- registriert den Expo Push Token per
register-devicebei 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)
- 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. - Erstellen Sie mit einem Expo-Account ein EAS-Projekt (expo.dev → Create project) und notieren Sie die Projekt-ID (UUID).
- Laden Sie den
.p8-Schlüssel in Ihr Expo-Projekt hoch: expo.dev → Projekt → Credentials → iOS → Push Key (odereas credentials). Stellen Sie sicher, dass die Bundle ID mit Ihrer App übereinstimmt. - 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:
- + Capability → Push Notifications
- + Capability → Background Modes → Remote notifications aktivieren
3. NotiPilot.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
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
@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
// 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-Buildfalse -
countryund nach Möglichkeitcitywerden 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. |