From 10ec82b74c0cb51a668dead25118720c596806a2 Mon Sep 17 00:00:00 2001 From: Maxime Crampon Date: Tue, 25 Jan 2022 18:06:26 +0100 Subject: [PATCH] feat(plugin): add ios expo plugin (#72) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(plugin): add ios expo plugin * ✏️ (expo) rename iOSApiKey to iosApiKey * ♻️ (expo) import Batch earlier in AppDelegate * :memo: (expo) update android installation doc * :memo: (expo) add ios installation doc * :memo: (expo) update ios warning on AppDelegate * :memo: (expo) delete ios comment about previous version --- ...withReactNativeBatchAppBuildGradle.test.ts | 3 +- .../withReactNativeBatchAppDelegate.test.ts | 13 + plugin/src/fixtures/appDelegate.ts | 319 ++++++++++++++++++ plugin/src/fixtures/buildGradle.ts | 2 +- plugin/src/withReactNativeBatch.ts | 8 +- .../src/withReactNativeBatchAppBuildGradle.ts | 2 +- plugin/src/withReactNativeBatchAppDelegate.ts | 31 ++ plugin/src/withReactNativeBatchInfoPlist.ts | 20 ++ readme/expo.md | 63 +++- 9 files changed, 441 insertions(+), 20 deletions(-) create mode 100644 plugin/src/__tests__/withReactNativeBatchAppDelegate.test.ts create mode 100644 plugin/src/fixtures/appDelegate.ts create mode 100644 plugin/src/withReactNativeBatchAppDelegate.ts create mode 100644 plugin/src/withReactNativeBatchInfoPlist.ts diff --git a/plugin/src/__tests__/withReactNativeBatchAppBuildGradle.test.ts b/plugin/src/__tests__/withReactNativeBatchAppBuildGradle.test.ts index 8b3ff0e..2e64116 100644 --- a/plugin/src/__tests__/withReactNativeBatchAppBuildGradle.test.ts +++ b/plugin/src/__tests__/withReactNativeBatchAppBuildGradle.test.ts @@ -7,7 +7,8 @@ import { describe(pushDependencies, () => { it('should push depedencies in the App ProjetGradle file', () => { const result = pushDependencies(buildGradleFixture, { - apiKey: 'FAKE_API_KEY', + iosApiKey: 'FAKE_IOS_API_KEY', + androidApiKey: 'FAKE_ANDROID_API_KEY', }); expect(result).toEqual(buildGradleExpectedFixture); diff --git a/plugin/src/__tests__/withReactNativeBatchAppDelegate.test.ts b/plugin/src/__tests__/withReactNativeBatchAppDelegate.test.ts new file mode 100644 index 0000000..e4ba05e --- /dev/null +++ b/plugin/src/__tests__/withReactNativeBatchAppDelegate.test.ts @@ -0,0 +1,13 @@ +import { modifyAppDelegate } from '../withReactNativeBatchAppDelegate'; +import { + appDelegateExpectedFixture, + appDelegateFixture, +} from '../fixtures/appDelegate'; + +describe(modifyAppDelegate, () => { + it('should modify the AppDelegate', () => { + const result = modifyAppDelegate(appDelegateFixture); + + expect(result).toEqual(appDelegateExpectedFixture); + }); +}); diff --git a/plugin/src/fixtures/appDelegate.ts b/plugin/src/fixtures/appDelegate.ts new file mode 100644 index 0000000..c8f86e2 --- /dev/null +++ b/plugin/src/fixtures/appDelegate.ts @@ -0,0 +1,319 @@ +export const appDelegateFixture = `#import "AppDelegate.h" + +#if defined(EX_DEV_MENU_ENABLED) +@import EXDevMenu; +#endif + +#if defined(EX_DEV_LAUNCHER_ENABLED) +#include +#import +#endif + +#import +#import +#import +#import + +#import +#import +#import +#import +#import + +#if defined(FB_SONARKIT_ENABLED) && __has_include() +#import +#import +#import +#import +#import +#import + +static void InitializeFlipper(UIApplication *application) { + FlipperClient *client = [FlipperClient sharedClient]; + SKDescriptorMapper *layoutDescriptorMapper = [[SKDescriptorMapper alloc] initWithDefaults]; + [client addPlugin:[[FlipperKitLayoutPlugin alloc] initWithRootNode:application withDescriptorMapper:layoutDescriptorMapper]]; + [client addPlugin:[[FKUserDefaultsPlugin alloc] initWithSuiteName:nil]]; + [client addPlugin:[FlipperKitReactPlugin new]]; + [client addPlugin:[[FlipperKitNetworkPlugin alloc] initWithNetworkAdapter:[SKIOSNetworkAdapter new]]]; + [client start]; +} +#endif + +@interface AppDelegate () + +@property (nonatomic, strong) UMModuleRegistryAdapter *moduleRegistryAdapter; +@property (nonatomic, strong) NSDictionary *launchOptions; + +@end + +@implementation AppDelegate + +- (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions +{ +#if defined(FB_SONARKIT_ENABLED) && __has_include() + InitializeFlipper(application); +#endif + + self.moduleRegistryAdapter = [[UMModuleRegistryAdapter alloc] initWithModuleRegistryProvider:[[UMModuleRegistryProvider alloc] init]]; + self.launchOptions = launchOptions; + self.window = [[UIWindow alloc] initWithFrame:[UIScreen mainScreen].bounds]; + #ifdef DEBUG + #if defined(EX_DEV_LAUNCHER_ENABLED) + EXDevLauncherController *controller = [EXDevLauncherController sharedInstance]; + controller.updatesInterface = [EXUpdatesDevLauncherController sharedInstance]; + [controller startWithWindow:self.window delegate:(id)self launchOptions:launchOptions]; + #else + [self initializeReactNativeApp]; + #endif + #else + EXUpdatesAppController *controller = [EXUpdatesAppController sharedInstance]; + controller.delegate = self; + [controller startAndShowLaunchScreen:self.window]; + #endif + + [super application:application didFinishLaunchingWithOptions:launchOptions]; + + return YES; +} + +- (RCTBridge *)initializeReactNativeApp +{ + #if defined(EX_DEV_LAUNCHER_ENABLED) + NSDictionary *launchOptions = [EXDevLauncherController.sharedInstance getLaunchOptions]; + #else + NSDictionary *launchOptions = self.launchOptions; + #endif + + RCTBridge *bridge = [[RCTBridge alloc] initWithDelegate:self launchOptions:launchOptions]; + RCTRootView *rootView = [[RCTRootView alloc] initWithBridge:bridge moduleName:@"main" initialProperties:nil]; + rootView.backgroundColor = [[UIColor alloc] initWithRed:1.0f green:1.0f blue:1.0f alpha:1]; + + UIViewController *rootViewController = [UIViewController new]; + rootViewController.view = rootView; + self.window.rootViewController = rootViewController; + [self.window makeKeyAndVisible]; + + return bridge; + } + +- (NSArray> *)extraModulesForBridge:(RCTBridge *)bridge +{ + NSArray> *extraModules = [_moduleRegistryAdapter extraModulesForBridge:bridge]; + // If you'd like to export some custom RCTBridgeModules that are not Expo modules, add them here! + return extraModules; +} + +- (NSURL *)sourceURLForBridge:(RCTBridge *)bridge { + #ifdef DEBUG + #if defined(EX_DEV_LAUNCHER_ENABLED) + return [[EXDevLauncherController sharedInstance] sourceUrl]; + #else + return [[RCTBundleURLProvider sharedSettings] jsBundleURLForBundleRoot:@"index" fallbackResource:nil]; + #endif + #else + return [[EXUpdatesAppController sharedInstance] launchAssetUrl]; + #endif +} + +- (void)appController:(EXUpdatesAppController *)appController didStartWithSuccess:(BOOL)success { + appController.bridge = [self initializeReactNativeApp]; + EXSplashScreenService *splashScreenService = (EXSplashScreenService *)[UMModuleRegistryProvider getSingletonModuleForClass:[EXSplashScreenService class]]; + [splashScreenService showSplashScreenFor:self.window.rootViewController]; +} + +// Linking API +- (BOOL)application:(UIApplication *)application openURL:(NSURL *)url options:(NSDictionary *)options { + #if defined(EX_DEV_LAUNCHER_ENABLED) + if ([EXDevLauncherController.sharedInstance onDeepLink:url options:options]) { + return true; + } + #endif + return [RCTLinkingManager application:application openURL:url options:options]; +} + +// Universal Links +- (BOOL)application:(UIApplication *)application continueUserActivity:(nonnull NSUserActivity *)userActivity restorationHandler:(nonnull void (^)(NSArray> * _Nullable))restorationHandler { + return [RCTLinkingManager application:application + continueUserActivity:userActivity + restorationHandler:restorationHandler]; +} + +@end + +#if defined(EX_DEV_LAUNCHER_ENABLED) +@implementation AppDelegate (EXDevLauncherControllerDelegate) + +- (void)devLauncherController:(EXDevLauncherController *)developmentClientController + didStartWithSuccess:(BOOL)success +{ + developmentClientController.appBridge = [self initializeReactNativeApp]; + EXSplashScreenService *splashScreenService = (EXSplashScreenService *)[UMModuleRegistryProvider getSingletonModuleForClass:[EXSplashScreenService class]]; + [splashScreenService showSplashScreenFor:self.window.rootViewController]; +} + +@end +#endif +`; + +export const appDelegateExpectedFixture = `#import "AppDelegate.h" + +#import + +#if defined(EX_DEV_MENU_ENABLED) +@import EXDevMenu; +#endif + +#if defined(EX_DEV_LAUNCHER_ENABLED) +#include +#import +#endif + +#import +#import +#import +#import + +#import +#import +#import +#import +#import + +#if defined(FB_SONARKIT_ENABLED) && __has_include() +#import +#import +#import +#import +#import +#import + +static void InitializeFlipper(UIApplication *application) { + FlipperClient *client = [FlipperClient sharedClient]; + SKDescriptorMapper *layoutDescriptorMapper = [[SKDescriptorMapper alloc] initWithDefaults]; + [client addPlugin:[[FlipperKitLayoutPlugin alloc] initWithRootNode:application withDescriptorMapper:layoutDescriptorMapper]]; + [client addPlugin:[[FKUserDefaultsPlugin alloc] initWithSuiteName:nil]]; + [client addPlugin:[FlipperKitReactPlugin new]]; + [client addPlugin:[[FlipperKitNetworkPlugin alloc] initWithNetworkAdapter:[SKIOSNetworkAdapter new]]]; + [client start]; +} +#endif + +@interface AppDelegate () + +@property (nonatomic, strong) UMModuleRegistryAdapter *moduleRegistryAdapter; +@property (nonatomic, strong) NSDictionary *launchOptions; + +@end + +@implementation AppDelegate + +- (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions +{ +[RNBatch start]; +[BatchUNUserNotificationCenterDelegate registerAsDelegate]; +[BatchUNUserNotificationCenterDelegate sharedInstance].showForegroundNotifications = true; + +#if defined(FB_SONARKIT_ENABLED) && __has_include() + InitializeFlipper(application); +#endif + + self.moduleRegistryAdapter = [[UMModuleRegistryAdapter alloc] initWithModuleRegistryProvider:[[UMModuleRegistryProvider alloc] init]]; + self.launchOptions = launchOptions; + self.window = [[UIWindow alloc] initWithFrame:[UIScreen mainScreen].bounds]; + #ifdef DEBUG + #if defined(EX_DEV_LAUNCHER_ENABLED) + EXDevLauncherController *controller = [EXDevLauncherController sharedInstance]; + controller.updatesInterface = [EXUpdatesDevLauncherController sharedInstance]; + [controller startWithWindow:self.window delegate:(id)self launchOptions:launchOptions]; + #else + [self initializeReactNativeApp]; + #endif + #else + EXUpdatesAppController *controller = [EXUpdatesAppController sharedInstance]; + controller.delegate = self; + [controller startAndShowLaunchScreen:self.window]; + #endif + + [super application:application didFinishLaunchingWithOptions:launchOptions]; + + return YES; +} + +- (RCTBridge *)initializeReactNativeApp +{ + #if defined(EX_DEV_LAUNCHER_ENABLED) + NSDictionary *launchOptions = [EXDevLauncherController.sharedInstance getLaunchOptions]; + #else + NSDictionary *launchOptions = self.launchOptions; + #endif + + RCTBridge *bridge = [[RCTBridge alloc] initWithDelegate:self launchOptions:launchOptions]; + RCTRootView *rootView = [[RCTRootView alloc] initWithBridge:bridge moduleName:@"main" initialProperties:nil]; + rootView.backgroundColor = [[UIColor alloc] initWithRed:1.0f green:1.0f blue:1.0f alpha:1]; + + UIViewController *rootViewController = [UIViewController new]; + rootViewController.view = rootView; + self.window.rootViewController = rootViewController; + [self.window makeKeyAndVisible]; + + return bridge; + } + +- (NSArray> *)extraModulesForBridge:(RCTBridge *)bridge +{ + NSArray> *extraModules = [_moduleRegistryAdapter extraModulesForBridge:bridge]; + // If you'd like to export some custom RCTBridgeModules that are not Expo modules, add them here! + return extraModules; +} + +- (NSURL *)sourceURLForBridge:(RCTBridge *)bridge { + #ifdef DEBUG + #if defined(EX_DEV_LAUNCHER_ENABLED) + return [[EXDevLauncherController sharedInstance] sourceUrl]; + #else + return [[RCTBundleURLProvider sharedSettings] jsBundleURLForBundleRoot:@"index" fallbackResource:nil]; + #endif + #else + return [[EXUpdatesAppController sharedInstance] launchAssetUrl]; + #endif +} + +- (void)appController:(EXUpdatesAppController *)appController didStartWithSuccess:(BOOL)success { + appController.bridge = [self initializeReactNativeApp]; + EXSplashScreenService *splashScreenService = (EXSplashScreenService *)[UMModuleRegistryProvider getSingletonModuleForClass:[EXSplashScreenService class]]; + [splashScreenService showSplashScreenFor:self.window.rootViewController]; +} + +// Linking API +- (BOOL)application:(UIApplication *)application openURL:(NSURL *)url options:(NSDictionary *)options { + #if defined(EX_DEV_LAUNCHER_ENABLED) + if ([EXDevLauncherController.sharedInstance onDeepLink:url options:options]) { + return true; + } + #endif + return [RCTLinkingManager application:application openURL:url options:options]; +} + +// Universal Links +- (BOOL)application:(UIApplication *)application continueUserActivity:(nonnull NSUserActivity *)userActivity restorationHandler:(nonnull void (^)(NSArray> * _Nullable))restorationHandler { + return [RCTLinkingManager application:application + continueUserActivity:userActivity + restorationHandler:restorationHandler]; +} + +@end + +#if defined(EX_DEV_LAUNCHER_ENABLED) +@implementation AppDelegate (EXDevLauncherControllerDelegate) + +- (void)devLauncherController:(EXDevLauncherController *)developmentClientController + didStartWithSuccess:(BOOL)success +{ + developmentClientController.appBridge = [self initializeReactNativeApp]; + EXSplashScreenService *splashScreenService = (EXSplashScreenService *)[UMModuleRegistryProvider getSingletonModuleForClass:[EXSplashScreenService class]]; + [splashScreenService showSplashScreenFor:self.window.rootViewController]; +} + +@end +#endif +`; diff --git a/plugin/src/fixtures/buildGradle.ts b/plugin/src/fixtures/buildGradle.ts index e8ad310..0b662c2 100644 --- a/plugin/src/fixtures/buildGradle.ts +++ b/plugin/src/fixtures/buildGradle.ts @@ -154,7 +154,7 @@ android { targetSdkVersion rootProject.ext.targetSdkVersion versionCode 1 versionName "1.0" - resValue "string", "BATCH_API_KEY", "FAKE_API_KEY" + resValue "string", "BATCH_API_KEY", "FAKE_ANDROID_API_KEY" } splits { abi { diff --git a/plugin/src/withReactNativeBatch.ts b/plugin/src/withReactNativeBatch.ts index 770910a..8b795df 100644 --- a/plugin/src/withReactNativeBatch.ts +++ b/plugin/src/withReactNativeBatch.ts @@ -7,13 +7,15 @@ import { import { withReactNativeBatchMainActivity } from './withReactNativeBatchMainActivity'; import { withReactNativeBatchAppBuildGradle } from './withReactNativeBatchAppBuildGradle'; import { withReactNativeBatchProjectBuildGradle } from './withReactNativeBatchProjectBuildGradle'; +import { withReactNativeBatchInfoPlist } from './withReactNativeBatchInfoPlist'; +import { withReactNativeBatchAppDelegate } from './withReactNativeBatchAppDelegate'; -export type Props = { apiKey: string }; +export type Props = { androidApiKey: string; iosApiKey: string }; /** * Apply react-native-batch configuration for Expo SDK 42 projects. */ const withReactNativeBatch: ConfigPlugin = (config, props) => { - const _props = props || { apiKey: '' }; + const _props = props || { androidApiKey: '', iosApiKey: '' }; let newConfig = withGoogleServicesFile(config); newConfig = withClassPath(newConfig); @@ -21,6 +23,8 @@ const withReactNativeBatch: ConfigPlugin = (config, props) => { newConfig = withReactNativeBatchAppBuildGradle(newConfig, _props); newConfig = withReactNativeBatchMainActivity(newConfig); newConfig = withReactNativeBatchProjectBuildGradle(newConfig); + newConfig = withReactNativeBatchInfoPlist(newConfig, _props); + newConfig = withReactNativeBatchAppDelegate(newConfig); // Return the modified config. return newConfig; }; diff --git a/plugin/src/withReactNativeBatchAppBuildGradle.ts b/plugin/src/withReactNativeBatchAppBuildGradle.ts index c34195c..73d45a2 100644 --- a/plugin/src/withReactNativeBatchAppBuildGradle.ts +++ b/plugin/src/withReactNativeBatchAppBuildGradle.ts @@ -20,7 +20,7 @@ export const pushDependencies = (contents: string, props: Props): string => { newContents = start + defaultConfigContents[0] + - ` resValue "string", "BATCH_API_KEY", "${props.apiKey}"` + + ` resValue "string", "BATCH_API_KEY", "${props.androidApiKey}"` + '\n ' + end; } diff --git a/plugin/src/withReactNativeBatchAppDelegate.ts b/plugin/src/withReactNativeBatchAppDelegate.ts new file mode 100644 index 0000000..899d1fd --- /dev/null +++ b/plugin/src/withReactNativeBatchAppDelegate.ts @@ -0,0 +1,31 @@ +import { ConfigPlugin, withAppDelegate } from '@expo/config-plugins'; +import { Props } from './withReactNativeBatch'; + +const DID_FINISH_LAUNCHING_WITH_OPTIONS_DECLARATION = + '- (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions\n{'; + +const IMPORT_BATCH = '\n\n#import \n'; +const REGISTER_BATCH = + '\n[RNBatch start];\n[BatchUNUserNotificationCenterDelegate registerAsDelegate];\n[BatchUNUserNotificationCenterDelegate sharedInstance].showForegroundNotifications = true;\n'; + +export const modifyAppDelegate = (contents: string) => { + contents = contents.replace('\n', IMPORT_BATCH); + + const [beforeDeclaration, afterDeclaration] = contents.split( + DID_FINISH_LAUNCHING_WITH_OPTIONS_DECLARATION + ); + + const newAfterDeclaration = DID_FINISH_LAUNCHING_WITH_OPTIONS_DECLARATION.concat( + REGISTER_BATCH + ).concat(afterDeclaration); + + contents = beforeDeclaration.concat(newAfterDeclaration); + return contents; +}; + +export const withReactNativeBatchAppDelegate: ConfigPlugin<{} | void> = config => { + return withAppDelegate(config, config => { + config.modResults.contents = modifyAppDelegate(config.modResults.contents); + return config; + }); +}; diff --git a/plugin/src/withReactNativeBatchInfoPlist.ts b/plugin/src/withReactNativeBatchInfoPlist.ts new file mode 100644 index 0000000..9d9ee4b --- /dev/null +++ b/plugin/src/withReactNativeBatchInfoPlist.ts @@ -0,0 +1,20 @@ +import { ConfigPlugin, withInfoPlist, InfoPlist } from '@expo/config-plugins'; +import { Props } from './withReactNativeBatch'; + +export const modifyInfoPlist = ( + infoPlist: InfoPlist, + props: Props +): InfoPlist => { + infoPlist.BatchAPIKey = props.iosApiKey; + return infoPlist; +}; + +export const withReactNativeBatchInfoPlist: ConfigPlugin = ( + config, + props +) => { + return withInfoPlist(config, config => { + config.modResults = modifyInfoPlist(config.modResults, props); + return config; + }); +}; diff --git a/readme/expo.md b/readme/expo.md index a647dd5..2f4dfe6 100644 --- a/readme/expo.md +++ b/readme/expo.md @@ -1,42 +1,75 @@ -**Warning: This plugin is available only for Android for the moment. iOS version in progress...** +**Warning: The iOS part of this plugin may not be compatible with a native Firebase module cohabitation, or other third-party libraries that may swizzle your application delegate file (ios/AppDelegate.m). To understand more or if you want to extend this plugin to cover the "manual integration" of Batch Push, read [this documentation](https://doc.batch.com/ios/advanced/manual-integration)** + +**Currently only supporting React Native >= 0.60.0. You should also use Expo SDK >= 42** # Installation +## Common steps (Android & iOS) + 1. Install using `yarn add @bam.tech/react-native-batch` or `npm i @bam.tech/react-native-batch` -2. Copy your google-services.json file at the root of your project (get it from the Firebase Console) -3. In the app.json file add the plugin: +2. In the app.json/app.config.js/app.config.ts file add the plugin: -``` +```json { "plugins": [ [ "@bam.tech/react-native-batch", { - "apiKey": + "androidApiKey": , + "iosApiKey": } ] ] } ``` -4. Create a react-native.config.js file at the root of your project and/or add the following lines: +## Additional Android steps -``` +1. Copy your google-services.json file at the root of your project (get it from the Firebase Console) and link it in your app.json/app.config.js/app.config.ts under the key `googleServicesFile` of the android section. + +2. Create a react-native.config.js file at the root of your project and/or add the following lines: + +```js module.exports = { dependencies: { - "@bam.tech/react-native-batch": { - platforms: { - android: { - packageInstance: "new RNBatchPackage(this.getApplication())", - }, + '@bam.tech/react-native-batch': { + platforms: { + android: { + packageInstance: 'new RNBatchPackage(this.getApplication())', }, }, + }, }, }; +``` + +## Additional iOS steps +Add the following in your app code to enable push notifications, ideally the first view a user sees when opening the app: + +```js +import { BatchPush } from '@bam.tech/react-native-batch'; + +... + +// Ask for the permission to display notifications +// The push token will automatically be fetched by the SDK +BatchPush.requestNotificationAuthorization(); + +// Alternatively, you can call requestNotificationAuthorization later +// But, you should always refresh your token on each application start +// This will make sure that even if your user's token changes, you still get notifications +// BatchPush.refreshToken(); ``` -5. Prepare your custom Expo client: `expo prebuild --clean` -6. Build your custom Expo client for Android: `expo run:android` for development +# Build and run locally + +1. Prepare your custom Expo client: `expo prebuild --clean`. This can be useful to debug and verify the plugin has executed correctly (compare with bare React Native configuration from the Batch doc) + +2. Build your custom Expo client for Android: `expo run:android` for development; or for iOS: `expo run:ios`. To force starting on physical device instead of a simulator, add the `-d` option. + +# Build with EAS + +When you are ready to go to production or to provide a new develoment client (for internal testing) containing your newly added custom native code: [build your app with custom native code with EAS](https://docs.expo.dev/workflow/customizing/#releasing-apps-with-custom-native-code-to) -When you are ready to go to production : [build your app with custom native code with EAS](https://docs.expo.dev/workflow/customizing/#releasing-apps-with-custom-native-code-to) +You will have to register every iOS device you plan on testing on with `eas device:create` (it has to be done before the build)