From 8d75b36f485af07ecfa653192ca56f761d0cc5b7 Mon Sep 17 00:00:00 2001 From: Mike Hardy Date: Wed, 25 Jan 2023 13:02:12 -0500 Subject: [PATCH] feat(messaging, ios): new setAPNSToken API / getToken works on M1 Simulator --- packages/messaging/e2e/messaging.e2e.js | 195 +++++++++++++++++- .../RNFBMessaging+NSNotificationCenter.m | 8 +- .../ios/RNFBMessaging/RNFBMessagingModule.m | 49 ++++- .../RNFBMessaging/RNFBMessagingSerializer.h | 2 + .../RNFBMessaging/RNFBMessagingSerializer.m | 18 ++ packages/messaging/lib/index.d.ts | 35 +++- packages/messaging/lib/index.js | 22 ++ packages/messaging/modular/index.js | 27 +++ 8 files changed, 333 insertions(+), 23 deletions(-) diff --git a/packages/messaging/e2e/messaging.e2e.js b/packages/messaging/e2e/messaging.e2e.js index c6a59a7a0d..2e224e100b 100644 --- a/packages/messaging/e2e/messaging.e2e.js +++ b/packages/messaging/e2e/messaging.e2e.js @@ -15,7 +15,21 @@ * */ -describe('messaging() modular', function () { +async function isSimulator() { + return await DeviceInfo.isEmulator(); +} + +async function isAPNSCapableSimulator() { + supportedAbis = await DeviceInfo.supportedAbis(); // looking for an ARM Simulator implying M1 host + iosVersionMajor = DeviceInfo.getSystemVersion().split('.')[0]; // looking for iOS16+ + macOSVersionMajor = require('os').release().split('.')[0]; // host macOS13+ has Darwin kernel 22+ + if (macOSVersionMajor >= 22 && supportedAbis.includes('ARM64E') && iosVersionMajor >= 16) { + return true; + } + return false; +} + +describe('messaging()', function () { describe('firebase v8 compatibility', function () { describe('namespace', function () { it('accessible from firebase.app()', function () { @@ -74,9 +88,10 @@ describe('messaging() modular', function () { }); it('successfully unregisters on ios', async function () { if (device.getPlatform() === 'ios') { - should.equal(firebase.messaging().isDeviceRegisteredForRemoteMessages, true); await firebase.messaging().unregisterDeviceForRemoteMessages(); should.equal(firebase.messaging().isDeviceRegisteredForRemoteMessages, false); + await firebase.messaging().registerDeviceForRemoteMessages(); + should.equal(firebase.messaging().isDeviceRegisteredForRemoteMessages, true); } else { this.skip(); } @@ -116,9 +131,84 @@ describe('messaging() modular', function () { this.skip(); } }); - it('resolves null on ios if using simulator', async function () { + it('resolves on ios with token on supported simulators', async function () { + // Make sure we are registered for remote notifications, else no token + await firebase.messaging().registerDeviceForRemoteMessages(); + if (device.getPlatform() === 'ios') { - should.equal(await firebase.messaging().getAPNSToken(), null); + apnsToken = await firebase.messaging().getAPNSToken(); + + simulator = await isSimulator(); + aPNSCapableSimulator = await isAPNSCapableSimulator(); + + if (!simulator || (simulator && aPNSCapableSimulator)) { + apnsToken.should.be.a.String(); + } else { + // unsupported iOS Simulator returns null (typically, + // can attempt-but-fail if M1 Simulator but not macOS13+/ios16+, rare combo) + if (apnsToken !== null) { + apnsToken.should.be.a.String(); + } + } + } else { + this.skip(); + } + }); + }); + + describe('setAPNSToken', function () { + it('requires a token parameter', async function () { + try { + firebase.messaging().setAPNSToken(); + return Promise.reject(new Error('Did not throw Error.')); + } catch (e) { + e.message.should.containEql("'token' expected a string value"); + } + try { + firebase.messaging().setAPNSToken(123); + return Promise.reject(new Error('Did not throw Error.')); + } catch (e) { + e.message.should.containEql("'token' expected a string value"); + return Promise.resolve(); + } + }); + + it('verifies type parameter is valid if specified', async function () { + try { + firebase.messaging().setAPNSToken('typeparamtest', 123); + return Promise.reject(new Error('Did not throw Error.')); + } catch (e) { + e.message.should.containEql("'type' expected one of 'prod', 'sandbox', or 'unknown'"); + } + try { + firebase.messaging().setAPNSToken('typeparamtest', 'bogus'); + return Promise.reject(new Error('Did not throw Error.')); + } catch (e) { + e.message.should.containEql("'type' expected one of 'prod', 'sandbox', or 'unknown'"); + } + }); + + it('resolves on android', async function () { + if (device.getPlatform() === 'android') { + should.equal(await firebase.messaging().setAPNSToken('foo'), null); + } else { + this.skip(); + } + }); + + it('correctly sets new token on ios', async function () { + if (device.getPlatform() === 'ios') { + originalAPNSToken = await firebase.messaging().getAPNSToken(); + // 74657374696E67746F6B656E is hex for "testingtoken" + await firebase.messaging().setAPNSToken('74657374696E67746F6B656E', 'unknown'); + newAPNSToken = await firebase.messaging().getAPNSToken(); + newAPNSToken.should.eql('74657374696E67746F6B656E'); + newAPNSToken.should.not.eql(originalAPNSToken); + if (originalAPNSToken !== null) { + await firebase.messaging().setAPNSToken(originalAPNSToken); + } + } else { + this.skip(); } }); }); @@ -367,7 +457,7 @@ describe('messaging() modular', function () { }); }); - describe('modular', function () { + describe('firebase v9 modular API', function () { describe('getMessaging', function () { it('pass app as argument', function () { const { getMessaging } = messagingModular; @@ -418,7 +508,7 @@ describe('messaging() modular', function () { }); }); - describe('isDeviceRegisteredForRemoteMessages', function () { + describe('isDeviceRegisteredForRemoteMessages default state', function () { it('returns true on android', function () { const { getMessaging, isDeviceRegisteredForRemoteMessages } = messagingModular; @@ -430,7 +520,7 @@ describe('messaging() modular', function () { }); }); - describe('unregisterDeviceForRemoteMessages', function () { + describe('remote message device register / unregister', function () { it('resolves on android, remains registered', async function () { const { getMessaging, @@ -450,12 +540,14 @@ describe('messaging() modular', function () { getMessaging, unregisterDeviceForRemoteMessages, isDeviceRegisteredForRemoteMessages, + registerDeviceForRemoteMessages, } = messagingModular; if (device.getPlatform() === 'ios') { - should.equal(isDeviceRegisteredForRemoteMessages(getMessaging()), true); await unregisterDeviceForRemoteMessages(getMessaging()); should.equal(isDeviceRegisteredForRemoteMessages(getMessaging()), false); + await registerDeviceForRemoteMessages(getMessaging()); + should.equal(isDeviceRegisteredForRemoteMessages(getMessaging()), true); } else { this.skip(); } @@ -499,10 +591,91 @@ describe('messaging() modular', function () { this.skip(); } }); - it('resolves null on ios if using simulator', async function () { - const { getMessaging, getAPNSToken } = messagingModular; + it('resolves on ios with token on supported simulators', async function () { + // Make sure we are registered for remote notifications, else no token + const { getMessaging, getAPNSToken, registerDeviceForRemoteMessages } = messagingModular; + await registerDeviceForRemoteMessages(getMessaging()); + if (device.getPlatform() === 'ios') { - should.equal(await getAPNSToken(getMessaging()), null); + apnsToken = await getAPNSToken(getMessaging()); + + simulator = await isSimulator(); + aPNSCapableSimulator = await isAPNSCapableSimulator(); + + if (!simulator || (simulator && aPNSCapableSimulator)) { + apnsToken.should.be.a.String(); + } else { + // unsupported iOS Simulator returns null (typically, + // can attempt-but-fail if M1 Simulator but not macOS13+/ios16+, rare combo) + if (apnsToken !== null) { + apnsToken.should.be.a.String(); + } + } + } else { + this.skip(); + } + }); + }); + + describe('setAPNSToken', function () { + it('requires a token parameter', async function () { + const { getMessaging, setAPNSToken } = messagingModular; + try { + setAPNSToken(getMessaging()); + return Promise.reject(new Error('Did not throw Error.')); + } catch (e) { + e.message.should.containEql("'token' expected a string value"); + } + try { + setAPNSToken(getMessaging(), 123); + return Promise.reject(new Error('Did not throw Error.')); + } catch (e) { + e.message.should.containEql("'token' expected a string value"); + return Promise.resolve(); + } + }); + + it('verifies type parameter is valid if specified', async function () { + const { getMessaging, setAPNSToken } = messagingModular; + try { + setAPNSToken(getMessaging(), 'typeparamtest', 123); + return Promise.reject(new Error('Did not throw Error.')); + } catch (e) { + e.message.should.containEql("'type' expected one of 'prod', 'sandbox', or 'unknown'"); + } + try { + setAPNSToken(getMessaging(), 'typeparamtest', 'bogus'); + return Promise.reject(new Error('Did not throw Error.')); + } catch (e) { + e.message.should.containEql("'type' expected one of 'prod', 'sandbox', or 'unknown'"); + } + }); + + it('resolves on android', async function () { + const { getMessaging, setAPNSToken } = messagingModular; + if (device.getPlatform() === 'android') { + should.equal(await setAPNSToken(getMessaging(), 'foo'), null); + } else { + this.skip(); + } + }); + + it('correctly sets new token on ios', async function () { + const { getMessaging, getAPNSToken, setAPNSToken } = messagingModular; + if (device.getPlatform() === 'ios') { + originalAPNSToken = await getAPNSToken(getMessaging()); + // 74657374696E67746F6B656E6D6F64756C6172 is hex for "testingtokenmodular" + await firebase + .messaging() + .setAPNSToken('74657374696E67746F6B656E6D6F64756C6172', 'unknown'); + newAPNSToken = await firebase.messaging().getAPNSToken(); + newAPNSToken.should.eql('74657374696E67746F6B656E6D6F64756C6172'); + newAPNSToken.should.not.eql(originalAPNSToken); + if (originalAPNSToken !== null) { + await setAPNSToken(getMessaging(), originalAPNSToken); + } + } else { + this.skip(); } }); }); diff --git a/packages/messaging/ios/RNFBMessaging/RNFBMessaging+NSNotificationCenter.m b/packages/messaging/ios/RNFBMessaging/RNFBMessaging+NSNotificationCenter.m index 47fdc153ff..67e27dbc93 100644 --- a/packages/messaging/ios/RNFBMessaging/RNFBMessaging+NSNotificationCenter.m +++ b/packages/messaging/ios/RNFBMessaging/RNFBMessaging+NSNotificationCenter.m @@ -87,12 +87,12 @@ - (void)application_onDidFinishLaunchingNotification:(nonnull NSNotification *)n (RCTRootView *)[UIApplication sharedApplication].delegate.window.rootViewController.view; } -#if !(TARGET_IPHONE_SIMULATOR) + // #if !(TARGET_IPHONE_SIMULATOR) if ([[RNFBJSON shared] getBooleanValue:@"messaging_ios_auto_register_for_remote_messages" defaultValue:YES]) { [[UIApplication sharedApplication] registerForRemoteNotifications]; } -#endif + // #endif if (notification.userInfo[UIApplicationLaunchOptionsRemoteNotificationKey]) { if ([UIApplication sharedApplication].applicationState == UIApplicationStateBackground) { @@ -108,7 +108,7 @@ - (void)application_onDidFinishLaunchingNotification:(nonnull NSNotification *)n } } -#if !(TARGET_IPHONE_SIMULATOR) + // #if !(TARGET_IPHONE_SIMULATOR) // When an app launches in the background (BG mode) and is launched with the notification // launch option the app delegate method // application:didReceiveRemoteNotification:fetchCompletionHandler: will not get called unless @@ -118,7 +118,7 @@ - (void)application_onDidFinishLaunchingNotification:(nonnull NSNotification *)n // `messaging_ios_auto_register_for_remote_messages` as this is most likely an app launching // as a result of a remote notification - so has been registered previously [[UIApplication sharedApplication] registerForRemoteNotifications]; -#endif + // #endif } else { if (rctRootView != nil) { isHeadless = NO; diff --git a/packages/messaging/ios/RNFBMessaging/RNFBMessagingModule.m b/packages/messaging/ios/RNFBMessaging/RNFBMessagingModule.m index 4bca950321..b8877d0287 100644 --- a/packages/messaging/ios/RNFBMessaging/RNFBMessagingModule.m +++ b/packages/messaging/ios/RNFBMessaging/RNFBMessagingModule.m @@ -114,7 +114,7 @@ - (NSDictionary *)constantsToExport { : (NSString *)senderId : (RCTPromiseResolveBlock)resolve : (RCTPromiseRejectBlock)reject) { -#if !(TARGET_IPHONE_SIMULATOR) + // #if !(TARGET_IPHONE_SIMULATOR) if ([UIApplication sharedApplication].isRegisteredForRemoteNotifications == NO) { [RNFBSharedUtils rejectPromiseWithUserInfo:reject @@ -126,7 +126,7 @@ - (NSDictionary *)constantsToExport { }]; return; } -#endif + // #endif [[FIRMessaging messaging] retrieveFCMTokenForSenderID:senderId @@ -160,7 +160,17 @@ - (NSDictionary *)constantsToExport { if (apnsToken) { resolve([RNFBMessagingSerializer APNSTokenFromNSData:apnsToken]); } else { -#if !(TARGET_IPHONE_SIMULATOR) +#if TARGET_IPHONE_SIMULATOR +#if !TARGET_CPU_ARM64 + DLog(@"RNFBMessaging getAPNSToken - Simulator without APNS support detected, with no token " + @"set. Use setAPNSToken with an arbitrary string if needed for testing.") + resolve([NSNull null]); + return; +#endif + DLog(@"RNFBMessaging getAPNSToken - ARM64 Simulator detected, but no APNS token set. Assuming " + @"APNS token is possible. macOS13+ / iOS16+ / M1 mac required for assumption to be valid. " + @"Use setAPNSToken in testing if needed."); +#endif if ([UIApplication sharedApplication].isRegisteredForRemoteNotifications == NO) { [RNFBSharedUtils rejectPromiseWithUserInfo:reject @@ -172,11 +182,28 @@ - (NSDictionary *)constantsToExport { }]; return; } -#endif - resolve([NSNull null]); } } +RCT_EXPORT_METHOD(setAPNSToken + : (NSString *)token + : (NSString *)type + : (RCTPromiseResolveBlock)resolve + : (RCTPromiseRejectBlock)reject) { + // Default to unknown (determined by provisioning profile) type, but user may have passed type as + // param + FIRMessagingAPNSTokenType tokenType = FIRMessagingAPNSTokenTypeUnknown; + if (type != nil && [@"prod" isEqualToString:type]) { + tokenType = FIRMessagingAPNSTokenTypeProd; + } else if (type != nil && [@"sandbox" isEqualToString:type]) { + tokenType = FIRMessagingAPNSTokenTypeSandbox; + } + + [[FIRMessaging messaging] setAPNSToken:[RNFBMessagingSerializer APNSTokenDataFromNSString:token] + type:tokenType]; + resolve([NSNull null]); +} + RCT_EXPORT_METHOD(getIsHeadless : (RCTPromiseResolveBlock)resolve : (RCTPromiseRejectBlock)reject) { RNFBMessagingNSNotificationCenter *notifCenter = [RNFBMessagingNSNotificationCenter sharedInstance]; @@ -267,12 +294,19 @@ - (NSDictionary *)constantsToExport { : (RCTPromiseResolveBlock)resolve : (RCTPromiseRejectBlock)reject) { #if TARGET_IPHONE_SIMULATOR +#if !TARGET_CPU_ARM64 + // Do the registration on this unsupported simulator, but don't set up to wait for a token that + // won't arrive + [[UIApplication sharedApplication] registerForRemoteNotifications]; resolve(@([RCTConvert BOOL:@(YES)])); return; +#endif + DLog(@"RNFBMessaging registerForRemoteNotifications ARM64 Simulator detected, attempting real " + @"registration. macOS13+ / iOS16+ / M1 mac required or will timeout.") #endif #pragma clang diagnostic push #pragma clang diagnostic ignored "-Wunreachable-code" - if (@available(iOS 10.0, *)) { + if (@available(iOS 10.0, *)) { #pragma pop if ([UIApplication sharedApplication].isRegisteredForRemoteNotifications == YES) { resolve(@([RCTConvert BOOL:@(YES)])); @@ -286,7 +320,8 @@ - (NSDictionary *)constantsToExport { dispatch_async(dispatch_get_main_queue(), ^{ [[UIApplication sharedApplication] registerForRemoteNotifications]; }); - } else { + } + else { [RNFBSharedUtils rejectPromiseWithUserInfo:reject userInfo:[@{ diff --git a/packages/messaging/ios/RNFBMessaging/RNFBMessagingSerializer.h b/packages/messaging/ios/RNFBMessaging/RNFBMessagingSerializer.h index 6be8cfd3ba..502da9aecf 100644 --- a/packages/messaging/ios/RNFBMessaging/RNFBMessagingSerializer.h +++ b/packages/messaging/ios/RNFBMessaging/RNFBMessagingSerializer.h @@ -21,6 +21,8 @@ @interface RNFBMessagingSerializer : NSObject ++ (NSData *)APNSTokenDataFromNSString:(NSString *)token; + + (NSString *)APNSTokenFromNSData:(NSData *)tokenData; + (NSDictionary *)notificationToDict:(UNNotification *)notification; diff --git a/packages/messaging/ios/RNFBMessaging/RNFBMessagingSerializer.m b/packages/messaging/ios/RNFBMessaging/RNFBMessagingSerializer.m index 7bcdb41e41..7d75995102 100644 --- a/packages/messaging/ios/RNFBMessaging/RNFBMessagingSerializer.m +++ b/packages/messaging/ios/RNFBMessaging/RNFBMessagingSerializer.m @@ -20,6 +20,24 @@ @implementation RNFBMessagingSerializer ++ (NSData *)APNSTokenDataFromNSString:(NSString *)token { + NSString *string = [token lowercaseString]; + NSMutableData *data = [NSMutableData new]; + unsigned char whole_byte; + char byte_chars[3] = {'\0', '\0', '\0'}; + NSUInteger i = 0; + NSUInteger length = string.length; + while (i < length - 1) { + char c = [string characterAtIndex:i++]; + if (c < '0' || (c > '9' && c < 'a') || c > 'f') continue; + byte_chars[0] = c; + byte_chars[1] = [string characterAtIndex:i++]; + whole_byte = strtol(byte_chars, NULL, 16); + [data appendBytes:&whole_byte length:1]; + } + return data; +} + + (NSString *)APNSTokenFromNSData:(NSData *)tokenData { const char *data = [tokenData bytes]; diff --git a/packages/messaging/lib/index.d.ts b/packages/messaging/lib/index.d.ts index a118bd1cba..56d100b8db 100644 --- a/packages/messaging/lib/index.d.ts +++ b/packages/messaging/lib/index.d.ts @@ -881,10 +881,43 @@ export namespace FirebaseMessagingTypes { * } * ``` * - * @ios + * @platform ios */ getAPNSToken(): Promise; + /** + * On iOS, This method is used to set the APNs Token received by the application delegate. + * Note that the token is expected to be a hexadecimal string, as it is an NSData type in + * the underlying native firebase SDK, and raw data may only be passed as a string if it is + * hex encoded. Calling code is responsible for correct encoding, you should verify by comparing + * the results of `getAPNSToken()` with your token parameter to make sure they are equivalent + * + * Messaging uses method swizzling to ensure that the APNs token is set automatically. + * However, if you have disabled swizzling by setting FirebaseAppDelegateProxyEnabled to NO + * in your app’s Info.plist, you should manually set the APNs token in your application + * delegate’s application(_:didRegisterForRemoteNotificationsWithDeviceToken:) method. + * + * If you would like to set the type of the APNs token, rather than relying on automatic + * detection, provide a type of either 'prod', 'sandbox'. Omitting the type parameter + * or specifying 'unknown' will rely on automatic type detection based on provisioning profile. + * + * At a native level you may also call objective-c `[FIRMessaging setAPNSToken];` as needed + * + * > You can safely call this method on Android without platform checks. It's a no-op on Android and will promise resolve `null`. + * + * #### Example + * + * ```js + * let myAPNSToken = someOthermodule.someWayToGetAPNSToken(); + * await firebase.messaging().setAPNSToken(myAPNSToken); + * ``` + * + * @param token a hexadecimal string representing your APNS token + * @param type optional string specifying 'prod', 'sandbox' or 'unknown' token type + * @platform ios + */ + setAPNSToken(token: string, type?: string): Promise; + /** * Returns a `AuthorizationStatus` as to whether the user has messaging permission for this app. * diff --git a/packages/messaging/lib/index.js b/packages/messaging/lib/index.js index 453ddd538b..661498aaa2 100644 --- a/packages/messaging/lib/index.js +++ b/packages/messaging/lib/index.js @@ -51,6 +51,7 @@ export { isDeviceRegisteredForRemoteMessages, unregisterDeviceForRemoteMessages, getAPNSToken, + setAPNSToken, hasPermission, onDeletedMessages, onMessageSent, @@ -340,6 +341,27 @@ class FirebaseMessagingModule extends FirebaseModule { return this.native.getAPNSToken(); } + /** + * @platform ios + */ + setAPNSToken(token, type) { + if (isUndefined(token) || !isString(token)) { + throw new Error("firebase.messaging().setAPNSToken(*) 'token' expected a string value."); + } + + if (!isUndefined(type) && (!isString(type) || !['prod', 'sandbox', 'unknown'].includes(type))) { + throw new Error( + "firebase.messaging().setAPNSToken(*) 'type' expected one of 'prod', 'sandbox', or 'unknown'.", + ); + } + + if (isAndroid) { + return Promise.resolve(null); + } + + return this.native.setAPNSToken(token, type); + } + hasPermission() { return this.native.hasPermission(); } diff --git a/packages/messaging/modular/index.js b/packages/messaging/modular/index.js index ba0d63a576..5c99d8f685 100644 --- a/packages/messaging/modular/index.js +++ b/packages/messaging/modular/index.js @@ -176,6 +176,33 @@ export function getAPNSToken(messaging) { return messaging.getAPNSToken(); } +/** + * On iOS, This method is used to set the APNs Token received by the application delegate. + * Note that the token is expected to be a hexadecimal string, as it is an NSData type in + * the underlying native firebase SDK, and raw data may only be passed as a string if it is + * hex encoded. Calling code is responsible for correct encoding, you should verify by comparing + * the results of `getAPNSToken()` with your token parameter to make sure they are equivalent + * + * Messaging uses method swizzling to ensure that the APNs token is set automatically. + * However, if you have disabled swizzling by setting FirebaseAppDelegateProxyEnabled to NO + * in your app’s Info.plist, you should manually set the APNs token in your application + * delegate’s application(_:didRegisterForRemoteNotificationsWithDeviceToken:) method. + * + * If you would like to set the type of the APNs token, rather than relying on automatic + * detection, provide a type of either 'prod', 'sandbox'. Omitting the type parameter + * or specifying 'unknown' will rely on automatic type detection based on provisioning profile. + * + * At a native level you may also call objective-c `[FIRMessaging setAPNSToken];` as needed + * + * @param messaging Messaging instance. + * @param {string} token a hexadecimal string representing your APNS token + * @param {string?} type specifying 'prod', 'sandbox' or 'unknown' token type + * @returns {Promise} + */ +export function setAPNSToken(messaging, token, type) { + return messaging.setAPNSToken(token, type); +} + /** * Returns a `AuthorizationStatus` as to whether the user has messaging permission for this app. * @param messaging Messaging instance.