Entwickler · API v1.0.0

iOS-Integration (Einrichtung und Objective-C)

Dieser Leitfaden beschreibt Schritt für Schritt die Einrichtung auf Apple-Seite für native iOS-Apps und enthält Objective-C-Beispielcode. Wenn Sie Swift verwenden, folgen Sie hier den Einrichtungsschritten und wechseln Sie für den Code zum Swift-Leitfaden. Die allgemeine API-Referenz finden Sie in der Überblick.

Unterstützte Versionen

Komponente Minimum Empfohlen
iOS (Deployment Target) 13.0 15.0+
Xcode 15 aktuell
Objective-C ARC aktiviert –

Voraussetzungen: Kostenpflichtige Mitgliedschaft im Apple Developer Program, physisches iPhone/iPad (Push-Tests im Simulator sind nicht zuverlässig).

Wie funktioniert es?

NotiPilot 1.0.0 stellt Benachrichtigungen über den Expo Push Service zu:

  1. Die App erhält von Apple den APNs Device Token.
  2. Dieser Token wird beim Expo-Token-Service gegen einen Expo Push Token eingetauscht.
  3. Der Expo Push Token wird per register-device bei NotiPilot registriert.
  4. Eine aus dem Dashboard gesendete Benachrichtigung gelangt über Expo → APNs → Gerät zum Nutzer.

Ihre App muss nicht mit Expo geschrieben sein; Expo dient ausschließlich als Zustellinfrastruktur.

1. Apple-Developer-Einrichtung

1.1 App ID und Push Notifications

  1. Öffnen Sie unter developer.apple.com → Identifiers die App ID Ihrer App (oder legen Sie sie an).
  2. Aktivieren Sie in der Liste Capabilities das Kästchen Push Notifications und speichern Sie.
  3. Erstellen Sie Ihre Provisioning Profiles neu (bei „Automatically manage signing“ in Xcode geschieht das automatisch).

1.2 APNs Auth Key (.p8)

  1. Erstellen Sie unter developer.apple.com → Keys über + einen neuen Schlüssel.
  2. Aktivieren Sie die Option Apple Push Notifications service (APNs).
  3. Laden Sie die .p8-Datei herunter (sie kann nur einmal heruntergeladen werden, bewahren Sie sie sicher auf). Notieren Sie die Key ID und die Team ID Ihres Accounts.

Ein einziger .p8-Schlüssel kann für alle Apps Ihres Accounts sowie sowohl in der Sandbox- als auch in der Production-Umgebung verwendet werden. Die alten .p12-Zertifikate werden nicht benötigt.

1.3 Expo-Projekt

  1. Erstellen Sie auf expo.dev ein Projekt und notieren Sie die Projekt-ID (UUID).
  2. expo.dev → Projekt → Credentials → iOS → Bundle ID hinzufügen → im Bereich Push Key die .p8-Datei zusammen mit Key ID und Team ID hochladen.

1.4 NotiPilot-Dashboard

Fügen Sie Ihre App hinzu: Expo Project ID = Expo-Projekt-ID, Expo Access Token = expo.dev → Access Tokens, iOS Bundle ID = Bundle Identifier Ihrer App.

2. Xcode-Einstellungen

Target → Signing & Capabilities:

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

Dadurch wird Ihrer .entitlements-Datei der Schlüssel aps-environment hinzugefügt. In Debug-Builds lautet der Wert development (APNs-Sandbox), in TestFlight-/App-Store-Builds production.

3. Objective-C-Code

3.1 NotiPilot.h

Objective-C
#import <Foundation/Foundation.h>

NS_ASSUME_NONNULL_BEGIN

@interface NotiPilot : NSObject

+ (instancetype)shared;

/// In application:didRegisterForRemoteNotificationsWithDeviceToken: aufrufen.
- (void)registerDeviceWithAPNsToken:(NSData *)deviceToken
                    extraAttributes:(nullable NSDictionary<NSString *, id> *)extra
                         completion:(nullable void (^)(NSDictionary *_Nullable response, NSError *_Nullable error))completion;

/// Aufrufen, wenn sich der Nutzer anmeldet.
- (void)identifyWithExternalId:(NSString *)externalId
                    attributes:(nullable NSDictionary<NSString *, id> *)attributes
                    completion:(nullable void (^)(NSDictionary *_Nullable response, NSError *_Nullable error))completion;

@end

NS_ASSUME_NONNULL_END

3.2 NotiPilot.m

Objective-C
#import "NotiPilot.h"
#import <Security/Security.h>
#import <UIKit/UIKit.h>

static NSString *const kBaseURL = @"https://app.notipilot.com/api/v1";
static NSString *const kAppId   = @"IHRE-EXPO-PROJEKT-ID"; // "Expo Project ID" im Dashboard
static NSString *const kUidKey  = @"notipilot_device_uid";

@implementation NotiPilot

+ (instancetype)shared {
    static NotiPilot *instance;
    static dispatch_once_t once;
    dispatch_once(&once, ^{ instance = [NotiPilot new]; });
    return instance;
}

#pragma mark - device_uid (Keychain)

- (NSString *)deviceUid {
    NSDictionary *query = @{ (__bridge id)kSecClass: (__bridge id)kSecClassGenericPassword,
                             (__bridge id)kSecAttrAccount: kUidKey,
                             (__bridge id)kSecReturnData: @YES,
                             (__bridge id)kSecMatchLimit: (__bridge id)kSecMatchLimitOne };
    CFTypeRef result = NULL;
    if (SecItemCopyMatching((__bridge CFDictionaryRef)query, &result) == errSecSuccess && result) {
        NSData *data = (__bridge_transfer NSData *)result;
        return [[NSString alloc] initWithData:data encoding:NSUTF8StringEncoding];
    }

    NSString *uid = [[NSUUID UUID].UUIDString lowercaseString];
    NSDictionary *item = @{ (__bridge id)kSecClass: (__bridge id)kSecClassGenericPassword,
                            (__bridge id)kSecAttrAccount: kUidKey,
                            (__bridge id)kSecValueData: [uid dataUsingEncoding:NSUTF8StringEncoding],
                            (__bridge id)kSecAttrAccessible: (__bridge id)kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly };
    SecItemAdd((__bridge CFDictionaryRef)item, NULL);
    return uid;
}

#pragma mark - Public API

- (void)registerDeviceWithAPNsToken:(NSData *)deviceToken
                    extraAttributes:(NSDictionary<NSString *, id> *)extra
                         completion:(void (^)(NSDictionary *, NSError *))completion {
    NSMutableString *hex = [NSMutableString stringWithCapacity:deviceToken.length * 2];
    const unsigned char *bytes = deviceToken.bytes;
    for (NSUInteger i = 0; i < deviceToken.length; i++) [hex appendFormat:@"%02x", bytes[i]];

    [self exchangeAPNsToken:hex completion:^(NSString *expoToken, NSError *error) {
        if (!expoToken) { if (completion) completion(nil, error); return; }

        NSMutableDictionary *attributes = [@{
            @"locale": [NSLocale currentLocale].languageCode ?: [NSNull null],
            @"country": [NSLocale currentLocale].countryCode ?: [NSNull null],
            @"app_version": [NSBundle mainBundle].infoDictionary[@"CFBundleShortVersionString"] ?: [NSNull null],
            @"os_version": [UIDevice currentDevice].systemVersion,
        } mutableCopy];
        if (extra) [attributes addEntriesFromDictionary:extra];

        [self post:@"register-device" body:@{
            @"app_id": kAppId,
            @"device_uid": [self deviceUid],
            @"token": expoToken,
            @"platform": @"ios",
            @"provider": @"expo",
            @"attributes": attributes,
        } attempt:0 completion:completion];
    }];
}

- (void)identifyWithExternalId:(NSString *)externalId
                    attributes:(NSDictionary<NSString *, id> *)attributes
                    completion:(void (^)(NSDictionary *, NSError *))completion {
    NSMutableDictionary *body = [@{ @"app_id": kAppId,
                                    @"device_uid": [self deviceUid],
                                    @"external_id": externalId } mutableCopy];
    if (attributes) body[@"attributes"] = attributes;
    [self post:@"identify-device" body:body attempt:0 completion:completion];
}

#pragma mark - Expo-Token-Austausch

- (void)exchangeAPNsToken:(NSString *)hexToken completion:(void (^)(NSString *, NSError *))completion {
#if DEBUG
    BOOL development = YES;  // Debug-Builds verwenden die APNs-Sandbox
#else
    BOOL development = NO;
#endif
    NSDictionary *body = @{ @"type": @"apns",
                            @"deviceId": [self deviceUid],
                            @"development": @(development),
                            @"appId": [NSBundle mainBundle].bundleIdentifier ?: @"",
                            @"deviceToken": hexToken,
                            @"projectId": kAppId };

    NSMutableURLRequest *req = [NSMutableURLRequest requestWithURL:
        [NSURL URLWithString:@"https://exp.host/--/api/v2/push/getExpoPushToken"]];
    req.HTTPMethod = @"POST";
    [req setValue:@"application/json" forHTTPHeaderField:@"Content-Type"];
    req.HTTPBody = [NSJSONSerialization dataWithJSONObject:body options:0 error:nil];

    [[[NSURLSession sharedSession] dataTaskWithRequest:req
                                     completionHandler:^(NSData *data, NSURLResponse *resp, NSError *err) {
        NSInteger status = [(NSHTTPURLResponse *)resp statusCode];
        NSDictionary *json = data ? [NSJSONSerialization JSONObjectWithData:data options:0 error:nil] : nil;
        NSString *token = [json isKindOfClass:NSDictionary.class] ? json[@"data"][@"expoPushToken"] : nil;
        if (status == 200 && [token isKindOfClass:NSString.class]) {
            completion(token, nil);
        } else {
            completion(nil, err ?: [NSError errorWithDomain:@"NotiPilot" code:status userInfo:@{
                NSLocalizedDescriptionKey: @"Expo token exchange failed" }]);
        }
    }] resume];
}

#pragma mark - HTTP

- (void)post:(NSString *)path body:(NSDictionary *)body attempt:(NSInteger)attempt
  completion:(void (^)(NSDictionary *, NSError *))completion {
    NSMutableURLRequest *req = [NSMutableURLRequest requestWithURL:
        [NSURL URLWithString:[NSString stringWithFormat:@"%@/%@", kBaseURL, path]]];
    req.HTTPMethod = @"POST";
    [req setValue:@"application/json" forHTTPHeaderField:@"Content-Type"];
    [req setValue:@"application/json" forHTTPHeaderField:@"Accept"];
    req.HTTPBody = [NSJSONSerialization dataWithJSONObject:body options:0 error:nil];

    [[[NSURLSession sharedSession] dataTaskWithRequest:req
                                     completionHandler:^(NSData *data, NSURLResponse *resp, NSError *err) {
        NSInteger status = [(NSHTTPURLResponse *)resp statusCode];
        NSDictionary *json = data ? [NSJSONSerialization JSONObjectWithData:data options:0 error:nil] : nil;

        if ((status == 429 || status >= 500) && attempt < 3) {
            double wait = [json[@"retry_after"] doubleValue] ?: pow(2, attempt);
            dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(wait * NSEC_PER_SEC)),
                           dispatch_get_global_queue(QOS_CLASS_UTILITY, 0), ^{
                [self post:path body:body attempt:attempt + 1 completion:completion];
            });
            return;
        }
        if (completion) completion(json, err);
    }] resume];
}

@end

3.3 AppDelegate.m

Objective-C
#import "AppDelegate.h"
#import "NotiPilot.h"
#import <UserNotifications/UserNotifications.h>

@interface AppDelegate () <UNUserNotificationCenterDelegate>
@end

@implementation AppDelegate

- (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions {
    UNUserNotificationCenter *center = [UNUserNotificationCenter currentNotificationCenter];
    center.delegate = self;
    [center requestAuthorizationWithOptions:(UNAuthorizationOptionAlert | UNAuthorizationOptionSound | UNAuthorizationOptionBadge)
                          completionHandler:^(BOOL granted, NSError *error) {
        if (granted) {
            dispatch_async(dispatch_get_main_queue(), ^{ [application registerForRemoteNotifications]; });
        }
    }];
    return YES;
}

- (void)application:(UIApplication *)application didRegisterForRemoteNotificationsWithDeviceToken:(NSData *)deviceToken {
    [[NotiPilot shared] registerDeviceWithAPNsToken:deviceToken
                                    extraAttributes:@{ @"city": @"Istanbul" }
                                         completion:^(NSDictionary *res, NSError *err) {
        if (err) NSLog(@"[NotiPilot] register failed: %@", err);
    }];
}

- (void)application:(UIApplication *)application didFailToRegisterForRemoteNotificationsWithError:(NSError *)error {
    NSLog(@"[NotiPilot] APNs registration failed: %@", error);
}

// Benachrichtigung anzeigen, während die App im Vordergrund ist
- (void)userNotificationCenter:(UNUserNotificationCenter *)center
       willPresentNotification:(UNNotification *)notification
         withCompletionHandler:(void (^)(UNNotificationPresentationOptions))completionHandler {
    if (@available(iOS 14.0, *)) {
        completionHandler(UNNotificationPresentationOptionBanner | UNNotificationPresentationOptionList | UNNotificationPresentationOptionSound);
    } else {
        completionHandler(UNNotificationPresentationOptionAlert | UNNotificationPresentationOptionSound);
    }
}

// Benachrichtigung angetippt — aus dem Dashboard gesendete benutzerdefinierte Daten liegen unter dem Schlüssel `body`
- (void)userNotificationCenter:(UNUserNotificationCenter *)center
didReceiveNotificationResponse:(UNNotificationResponse *)response
         withCompletionHandler:(void (^)(void))completionHandler {
    id raw = response.notification.request.content.userInfo[@"body"];
    NSDictionary *data = nil;
    if ([raw isKindOfClass:NSDictionary.class]) {
        data = raw;
    } else if ([raw isKindOfClass:NSString.class]) {
        data = [NSJSONSerialization JSONObjectWithData:[raw dataUsingEncoding:NSUTF8StringEncoding] options:0 error:nil];
    }
    NSString *screen = data[@"screen"];
    // Anhand von screen navigieren
    completionHandler();
}

@end

Wenn sich der Nutzer anmeldet:

Objective-C
[[NotiPilot shared] identifyWithExternalId:user.userId attributes:nil completion:nil];

Checkliste

  • Push-Notifications-Capability ist in der App ID aktiviert
  • APNs-Schlüssel (.p8) mit Key ID + Team ID ins Expo-Projekt hochgeladen
  • Expo Project ID im Dashboard = kAppId, iOS Bundle ID = Bundle Identifier der App
  • In Xcode sind Push Notifications + Background Modes → Remote notifications aktiviert
  • Im Debug-Build development = YES, in TestFlight/App Store NO
  • 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.
Fehler no valid "aps-environment" entitlement Das Provisioning Profile wurde nach dem Hinzufügen der Capability nicht erneuert.
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.