diff --git a/README.md b/README.md index 85365b0..e2cf747 100644 --- a/README.md +++ b/README.md @@ -152,7 +152,15 @@ BluememeUtils.requestPushPermission(function (permission) { }, error); ``` -iOS 上 FCM token 依赖 APNs device token。插件会在 `getFirebaseToken` 内等待 Cordova 收到 APNs token 后再向 Firebase 请求 FCM token;如果 APNs token 超时或注册失败,会返回 `APNS_TOKEN_NOT_AVAILABLE` 或 `APNS_TOKEN_FAILED`。 +iOS 上 FCM token 依赖 APNs device token。必须等待 `requestPushPermission` 的 success callback 返回后再调用 `getFirebaseToken`,不要把 `requestPushPermission()` 当成同步调用。 + +插件会在 `getFirebaseToken` 内等待 APNs token 后再向 Firebase 请求 FCM token。iOS 侧会同时使用三种来源降低宿主 AppDelegate 未转发回调造成的问题: + +- 直接桥接 `application:didRegisterForRemoteNotificationsWithDeviceToken:` +- 监听 Cordova 的 `CDVRemoteNotification` +- 轮询 Firebase Messaging 已缓存的 `APNSToken` + +如果 APNs token 超时或注册失败,会返回 `APNS_TOKEN_NOT_AVAILABLE` 或 `APNS_TOKEN_FAILED`。返回结果会包含 `apnsTokenSource`、`cachedAPNSTokenAvailable`、`firebaseAPNSTokenAvailable` 等诊断字段。 返回示例: diff --git a/package.json b/package.json index 2f39926..9c4190b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "bluememe-utils", - "version": "0.1.12", + "version": "0.1.13", "description": "Cordova utilities for WiFi, Firebase Cloud Messaging, and biometric unlock for OutSystems 11 apps.", "license": "MIT", "files": [ diff --git a/plugin.xml b/plugin.xml index 5083ce0..c258afe 100644 --- a/plugin.xml +++ b/plugin.xml @@ -2,7 +2,7 @@ + version="0.1.13"> bluememe-utils Cordova utilities for WiFi, Firebase Cloud Messaging, and biometric unlock. MIT diff --git a/src/ios/CDVBluememeUtils.m b/src/ios/CDVBluememeUtils.m index dbfdfde..1178ea6 100644 --- a/src/ios/CDVBluememeUtils.m +++ b/src/ios/CDVBluememeUtils.m @@ -6,12 +6,61 @@ #import #import #import +#import static NSString * const BluememeDeviceKeyDefaultsKey = @"bluememe_utils_device_key"; -static NSString * const BluememeUtilsPluginVersion = @"0.1.12"; +static NSString * const BluememeUtilsPluginVersion = @"0.1.13"; static NSString * const BluememeCordovaRemoteNotificationName = @"CDVRemoteNotification"; static NSString * const BluememeCordovaRemoteNotificationErrorName = @"CDVRemoteNotificationError"; static NSTimeInterval const BluememeAPNSTokenTimeoutSeconds = 10.0; +static NSTimeInterval const BluememeAPNSTokenPollIntervalSeconds = 0.25; +static char BluememeRemoteNotificationBridgeInstalledKey; + +static SEL BluememeRemoteNotificationBridgeSelectorForSelector(SEL selector) +{ + NSString *selectorName = NSStringFromSelector(selector); + return NSSelectorFromString([@"bluememe_" stringByAppendingString:selectorName]); +} + +static void BluememePostRemoteNotificationToken(NSData *deviceToken) +{ + if (deviceToken.length == 0) { + return; + } + [[NSNotificationCenter defaultCenter] postNotificationName:BluememeCordovaRemoteNotificationName + object:deviceToken + userInfo:@{@"deviceToken": deviceToken}]; +} + +static void BluememePostRemoteNotificationError(NSError *error) +{ + NSDictionary *userInfo = error == nil ? @{} : @{@"error": error}; + [[NSNotificationCenter defaultCenter] postNotificationName:BluememeCordovaRemoteNotificationErrorName + object:error + userInfo:userInfo]; +} + +static void BluememeApplicationDidRegisterForRemoteNotifications(id self, SEL _cmd, UIApplication *application, NSData *deviceToken) +{ + BluememePostRemoteNotificationToken(deviceToken); + + SEL originalSelector = BluememeRemoteNotificationBridgeSelectorForSelector(_cmd); + if ([self respondsToSelector:originalSelector]) { + IMP originalImplementation = [self methodForSelector:originalSelector]; + ((void (*)(id, SEL, UIApplication *, NSData *))originalImplementation)(self, originalSelector, application, deviceToken); + } +} + +static void BluememeApplicationDidFailToRegisterForRemoteNotifications(id self, SEL _cmd, UIApplication *application, NSError *error) +{ + BluememePostRemoteNotificationError(error); + + SEL originalSelector = BluememeRemoteNotificationBridgeSelectorForSelector(_cmd); + if ([self respondsToSelector:originalSelector]) { + IMP originalImplementation = [self methodForSelector:originalSelector]; + ((void (*)(id, SEL, UIApplication *, NSError *))originalImplementation)(self, originalSelector, application, error); + } +} @interface CDVBluememeUtils () @property (nonatomic, copy) NSString *notificationCallbackId; @@ -29,6 +78,7 @@ static NSTimeInterval const BluememeAPNSTokenTimeoutSeconds = 10.0; if ([self ensureFirebaseConfigured:nil]) { [FIRMessaging messaging].delegate = self; } + [self installRemoteNotificationDelegateBridgeIfNeeded]; if (@available(iOS 10.0, *)) { UNUserNotificationCenter *center = [UNUserNotificationCenter currentNotificationCenter]; @@ -183,8 +233,7 @@ static NSTimeInterval const BluememeAPNSTokenTimeoutSeconds = 10.0; dispatch_async(dispatch_get_main_queue(), ^{ NSMutableDictionary *result = [[self dictionaryFromNotificationSettings:settings] mutableCopy]; result[@"granted"] = @(granted); - result[@"registeredForRemoteNotifications"] = @([[UIApplication sharedApplication] isRegisteredForRemoteNotifications]); - result[@"apnsTokenAvailable"] = @(self.apnsDeviceToken.length > 0); + [result addEntriesFromDictionary:[self apnsStateDetails]]; [self sendSuccessForCommand:command dictionary:result]; }); }]; @@ -194,16 +243,16 @@ static NSTimeInterval const BluememeAPNSTokenTimeoutSeconds = 10.0; UIUserNotificationSettings *settings = [UIUserNotificationSettings settingsForTypes:(UIUserNotificationTypeAlert | UIUserNotificationTypeBadge | UIUserNotificationTypeSound) categories:nil]; [[UIApplication sharedApplication] registerUserNotificationSettings:settings]; [[UIApplication sharedApplication] registerForRemoteNotifications]; - [self sendSuccessForCommand:command dictionary:@{ + NSMutableDictionary *result = [[self apnsStateDetails] mutableCopy]; + [result addEntriesFromDictionary:@{ @"type": @"notification", @"platform": @"iOS", @"enabled": @YES, @"granted": @YES, @"authorizationStatus": @"authorized", - @"registeredForRemoteNotifications": @([[UIApplication sharedApplication] isRegisteredForRemoteNotifications]), - @"apnsTokenAvailable": @(self.apnsDeviceToken.length > 0), @"missing": @[] }]; + [self sendSuccessForCommand:command dictionary:result]; } } @@ -218,7 +267,8 @@ static NSTimeInterval const BluememeAPNSTokenTimeoutSeconds = 10.0; } else { UIUserNotificationSettings *settings = [[UIApplication sharedApplication] currentUserNotificationSettings]; BOOL enabled = settings.types != UIUserNotificationTypeNone; - [self sendSuccessForCommand:command dictionary:@{ + NSMutableDictionary *result = [[self apnsStateDetails] mutableCopy]; + [result addEntriesFromDictionary:@{ @"type": @"notification", @"platform": @"iOS", @"enabled": @(enabled), @@ -227,10 +277,9 @@ static NSTimeInterval const BluememeAPNSTokenTimeoutSeconds = 10.0; @"alertEnabled": @((settings.types & UIUserNotificationTypeAlert) != 0), @"soundEnabled": @((settings.types & UIUserNotificationTypeSound) != 0), @"badgeEnabled": @((settings.types & UIUserNotificationTypeBadge) != 0), - @"registeredForRemoteNotifications": @([[UIApplication sharedApplication] isRegisteredForRemoteNotifications]), - @"apnsTokenAvailable": @(self.apnsDeviceToken.length > 0), @"missing": enabled ? @[] : @[@"notification"] }]; + [self sendSuccessForCommand:command dictionary:result]; } } @@ -248,8 +297,7 @@ static NSTimeInterval const BluememeAPNSTokenTimeoutSeconds = 10.0; dispatch_async(dispatch_get_main_queue(), ^{ if (![self notificationAuthorizationAllowsFirebaseToken:settings.authorizationStatus]) { NSMutableDictionary *details = [[self dictionaryFromNotificationSettings:settings] mutableCopy]; - details[@"apnsTokenAvailable"] = @(self.apnsDeviceToken.length > 0); - details[@"registeredForRemoteNotifications"] = @([[UIApplication sharedApplication] isRegisteredForRemoteNotifications]); + [details addEntriesFromDictionary:[self apnsStateDetails]]; [self sendErrorForCommand:command code:@"PERMISSION_REQUIRED" message:@"Call requestPushPermission and wait for notification authorization before getFirebaseToken." @@ -267,63 +315,80 @@ static NSTimeInterval const BluememeAPNSTokenTimeoutSeconds = 10.0; - (void)registerForRemoteNotificationsAndFetchFirebaseTokenForCommand:(CDVInvokedUrlCommand *)command { - if (self.apnsDeviceToken.length > 0) { - [FIRMessaging messaging].APNSToken = self.apnsDeviceToken; + NSData *tokenData = [self availableAPNSToken]; + if (tokenData.length > 0) { + [FIRMessaging messaging].APNSToken = tokenData; [self fetchFirebaseTokenForCommand:command]; return; } if (self.pendingFirebaseTokenCommand != nil) { + NSMutableDictionary *details = [[self apnsStateDetails] mutableCopy]; + details[@"apnsTokenAvailable"] = @NO; [self sendErrorForCommand:self.pendingFirebaseTokenCommand code:@"FCM_TOKEN_REQUEST_REPLACED" message:@"A newer getFirebaseToken request replaced this pending request." - details:@{ - @"apnsTokenAvailable": @NO, - @"registeredForRemoteNotifications": @([[UIApplication sharedApplication] isRegisteredForRemoteNotifications]) - }]; + details:details]; } self.pendingFirebaseTokenCommand = command; self.apnsRegistrationError = nil; [[UIApplication sharedApplication] registerForRemoteNotifications]; - dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(BluememeAPNSTokenTimeoutSeconds * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{ - if (self.pendingFirebaseTokenCommand != command) { - return; - } + NSDate *deadline = [NSDate dateWithTimeIntervalSinceNow:BluememeAPNSTokenTimeoutSeconds]; + [self waitForAPNSTokenAndFetchFirebaseTokenForCommand:command deadline:deadline]; +} +- (void)waitForAPNSTokenAndFetchFirebaseTokenForCommand:(CDVInvokedUrlCommand *)command deadline:(NSDate *)deadline +{ + if (self.pendingFirebaseTokenCommand != command) { + return; + } + + NSData *tokenData = [self availableAPNSToken]; + if (tokenData.length > 0) { self.pendingFirebaseTokenCommand = nil; - if (self.apnsRegistrationError != nil) { - [self sendErrorForCommand:command - code:@"APNS_TOKEN_FAILED" - message:(self.apnsRegistrationError.localizedDescription ? self.apnsRegistrationError.localizedDescription : @"APNs registration failed.") - details:[self detailsForNativeError:self.apnsRegistrationError includeAPNSState:YES]]; - return; - } + [FIRMessaging messaging].APNSToken = tokenData; + [self fetchFirebaseTokenForCommand:command]; + return; + } + if (self.apnsRegistrationError != nil) { + self.pendingFirebaseTokenCommand = nil; + [self sendErrorForCommand:command + code:@"APNS_TOKEN_FAILED" + message:(self.apnsRegistrationError.localizedDescription ? self.apnsRegistrationError.localizedDescription : @"APNs registration failed.") + details:[self detailsForNativeError:self.apnsRegistrationError includeAPNSState:YES]]; + return; + } + + if ([deadline timeIntervalSinceNow] <= 0) { + self.pendingFirebaseTokenCommand = nil; + NSMutableDictionary *details = [[self apnsStateDetails] mutableCopy]; + details[@"timeoutSeconds"] = @(BluememeAPNSTokenTimeoutSeconds); [self sendErrorForCommand:command code:@"APNS_TOKEN_NOT_AVAILABLE" - message:@"APNs device token was not received before fetching the FCM token. Check Push Notifications capability, aps-environment entitlement, provisioning profile, and Firebase APNs key configuration." - details:@{ - @"apnsTokenAvailable": @NO, - @"registeredForRemoteNotifications": @([[UIApplication sharedApplication] isRegisteredForRemoteNotifications]), - @"timeoutSeconds": @(BluememeAPNSTokenTimeoutSeconds) - }]; + message:@"APNs device token was not received before fetching the FCM token. Check Push Notifications capability, aps-environment entitlement, provisioning profile, Firebase APNs key configuration, or AppDelegate remote notification forwarding." + details:details]; + return; + } + + dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(BluememeAPNSTokenPollIntervalSeconds * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{ + [self waitForAPNSTokenAndFetchFirebaseTokenForCommand:command deadline:deadline]; }); } - (void)fetchFirebaseTokenForCommand:(CDVInvokedUrlCommand *)command { - if (self.apnsDeviceToken.length > 0) { - [FIRMessaging messaging].APNSToken = self.apnsDeviceToken; + NSData *tokenData = [self availableAPNSToken]; + if (tokenData.length > 0) { + [FIRMessaging messaging].APNSToken = tokenData; } [[FIRMessaging messaging] tokenWithCompletion:^(NSString *token, NSError *error) { dispatch_async(dispatch_get_main_queue(), ^{ if (error != nil || token.length == 0) { - NSMutableDictionary *details = [NSMutableDictionary dictionary]; - details[@"apnsTokenAvailable"] = @(self.apnsDeviceToken.length > 0); - details[@"registeredForRemoteNotifications"] = @([[UIApplication sharedApplication] isRegisteredForRemoteNotifications]); + NSMutableDictionary *details = [[self apnsStateDetails] mutableCopy]; if (error != nil) { [details addEntriesFromDictionary:[self detailsForNativeError:error includeAPNSState:NO]]; } @@ -333,12 +398,10 @@ static NSTimeInterval const BluememeAPNSTokenTimeoutSeconds = 10.0; details:details]; return; } - [self sendSuccessForCommand:command dictionary:@{ - @"token": token, - @"platform": @"iOS", - @"apnsTokenAvailable": @(self.apnsDeviceToken.length > 0), - @"registeredForRemoteNotifications": @([[UIApplication sharedApplication] isRegisteredForRemoteNotifications]) - }]; + NSMutableDictionary *result = [[self apnsStateDetails] mutableCopy]; + result[@"token"] = token; + result[@"platform"] = @"iOS"; + [self sendSuccessForCommand:command dictionary:result]; [self sendEvent:@{@"type": @"token", @"token": token}]; }); }]; @@ -835,7 +898,8 @@ static NSTimeInterval const BluememeAPNSTokenTimeoutSeconds = 10.0; BOOL badgeEnabled = settings.badgeSetting == UNNotificationSettingEnabled; NSArray *missing = enabled ? @[] : @[@"notification"]; - return @{ + NSMutableDictionary *result = [[self apnsStateDetails] mutableCopy]; + [result addEntriesFromDictionary:@{ @"type": @"notification", @"platform": @"iOS", @"enabled": @(enabled), @@ -844,10 +908,9 @@ static NSTimeInterval const BluememeAPNSTokenTimeoutSeconds = 10.0; @"alertEnabled": @(alertEnabled), @"soundEnabled": @(soundEnabled), @"badgeEnabled": @(badgeEnabled), - @"registeredForRemoteNotifications": @([[UIApplication sharedApplication] isRegisteredForRemoteNotifications]), - @"apnsTokenAvailable": @(self.apnsDeviceToken.length > 0), @"missing": missing - }; + }]; + return result; } - (NSString *)authorizationStatusName:(UNAuthorizationStatus)status API_AVAILABLE(ios(10.0)) @@ -894,6 +957,103 @@ static NSTimeInterval const BluememeAPNSTokenTimeoutSeconds = 10.0; return NO; } +- (NSData *)firebaseAPNSToken +{ + if ([FIRApp defaultApp] == nil) { + return nil; + } + return [FIRMessaging messaging].APNSToken; +} + +- (NSData *)availableAPNSToken +{ + if (self.apnsDeviceToken.length > 0) { + return self.apnsDeviceToken; + } + + NSData *firebaseToken = [self firebaseAPNSToken]; + if (firebaseToken.length > 0) { + self.apnsDeviceToken = firebaseToken; + self.apnsRegistrationError = nil; + return firebaseToken; + } + return nil; +} + +- (NSDictionary *)apnsStateDetails +{ + NSData *cachedToken = self.apnsDeviceToken; + NSData *firebaseToken = [self firebaseAPNSToken]; + BOOL cachedTokenAvailable = cachedToken.length > 0; + BOOL firebaseTokenAvailable = firebaseToken.length > 0; + NSString *source = @"none"; + if (cachedTokenAvailable) { + source = @"plugin"; + } else if (firebaseTokenAvailable) { + source = @"firebase"; + } + + return @{ + @"apnsTokenAvailable": @(cachedTokenAvailable || firebaseTokenAvailable), + @"cachedAPNSTokenAvailable": @(cachedTokenAvailable), + @"firebaseAPNSTokenAvailable": @(firebaseTokenAvailable), + @"apnsTokenSource": source, + @"registeredForRemoteNotifications": @([[UIApplication sharedApplication] isRegisteredForRemoteNotifications]) + }; +} + +- (void)installRemoteNotificationDelegateBridgeIfNeeded +{ + id appDelegate = [UIApplication sharedApplication].delegate; + if (appDelegate == nil) { + return; + } + + Class appDelegateClass = [appDelegate class]; + if ([objc_getAssociatedObject(appDelegateClass, &BluememeRemoteNotificationBridgeInstalledKey) boolValue]) { + return; + } + + [self installRemoteNotificationBridgeForClass:appDelegateClass + selector:@selector(application:didRegisterForRemoteNotificationsWithDeviceToken:) + implementation:(IMP)BluememeApplicationDidRegisterForRemoteNotifications + typeEncoding:"v@:@@"]; + [self installRemoteNotificationBridgeForClass:appDelegateClass + selector:@selector(application:didFailToRegisterForRemoteNotificationsWithError:) + implementation:(IMP)BluememeApplicationDidFailToRegisterForRemoteNotifications + typeEncoding:"v@:@@"]; + objc_setAssociatedObject(appDelegateClass, &BluememeRemoteNotificationBridgeInstalledKey, @YES, OBJC_ASSOCIATION_RETAIN_NONATOMIC); +} + +- (void)installRemoteNotificationBridgeForClass:(Class)delegateClass + selector:(SEL)selector + implementation:(IMP)implementation + typeEncoding:(const char *)fallbackTypeEncoding +{ + Method originalMethod = class_getInstanceMethod(delegateClass, selector); + const char *originalTypeEncoding = originalMethod == NULL ? NULL : method_getTypeEncoding(originalMethod); + const char *typeEncoding = originalTypeEncoding == NULL ? fallbackTypeEncoding : originalTypeEncoding; + + if (originalMethod == NULL) { + class_addMethod(delegateClass, selector, implementation, typeEncoding); + return; + } + + SEL bridgeSelector = BluememeRemoteNotificationBridgeSelectorForSelector(selector); + if (class_addMethod(delegateClass, selector, implementation, typeEncoding)) { + class_addMethod(delegateClass, bridgeSelector, method_getImplementation(originalMethod), typeEncoding); + return; + } + + if (!class_addMethod(delegateClass, bridgeSelector, implementation, typeEncoding)) { + return; + } + + Method bridgeMethod = class_getInstanceMethod(delegateClass, bridgeSelector); + originalMethod = class_getInstanceMethod(delegateClass, selector); + method_exchangeImplementations(originalMethod, bridgeMethod); +} + - (void)remoteNotificationRegistered:(NSNotification *)notification { NSData *tokenData = [self dataFromAPNSTokenObject:notification.object]; @@ -992,8 +1152,7 @@ static NSTimeInterval const BluememeAPNSTokenTimeoutSeconds = 10.0; { NSMutableDictionary *details = [NSMutableDictionary dictionary]; if (includeAPNSState) { - details[@"apnsTokenAvailable"] = @(self.apnsDeviceToken.length > 0); - details[@"registeredForRemoteNotifications"] = @([[UIApplication sharedApplication] isRegisteredForRemoteNotifications]); + [details addEntriesFromDictionary:[self apnsStateDetails]]; } if (error.domain.length > 0) { details[@"nativeDomain"] = error.domain;