xref: /expo/ios/Exponent/Kernel/Core/EXKernel.m (revision 8ef929c3)
1// Copyright 2015-present 650 Industries. All rights reserved.
2
3#import "EXAnalytics.h"
4#import "EXAppState.h"
5#import "EXAppViewController.h"
6#import "EXBuildConstants.h"
7#import "EXKernel.h"
8#import "EXAppLoader.h"
9#import "EXKernelAppRecord.h"
10#import "EXKernelLinkingManager.h"
11#import "EXLinkingManager.h"
12#import "EXVersions.h"
13
14#import <React/RCTBridge+Private.h>
15#import <React/RCTEventDispatcher.h>
16#import <React/RCTModuleData.h>
17#import <React/RCTUtils.h>
18
19NS_ASSUME_NONNULL_BEGIN
20
21NSString *kEXKernelErrorDomain = @"EXKernelErrorDomain";
22NSString *kEXKernelShouldForegroundTaskEvent = @"foregroundTask";
23NSString * const kEXDeviceInstallUUIDKey = @"EXDeviceInstallUUIDKey";
24NSString * const kEXKernelClearJSCacheUserDefaultsKey = @"EXKernelClearJSCacheUserDefaultsKey";
25
26const NSUInteger kEXErrorCodeAppForbidden = 424242;
27
28@interface EXKernel () <EXKernelAppRegistryDelegate>
29
30@end
31
32// Protocol that should be implemented by all versions of EXAppState class.
33@protocol EXAppStateProtocol
34
35@property (nonatomic, strong, readonly) NSString *lastKnownState;
36
37- (void)setState:(NSString *)state;
38
39@end
40
41@implementation EXKernel
42
43+ (instancetype)sharedInstance
44{
45  static EXKernel *theKernel;
46  static dispatch_once_t once;
47  dispatch_once(&once, ^{
48    if (!theKernel) {
49      theKernel = [[EXKernel alloc] init];
50    }
51  });
52  return theKernel;
53}
54
55- (instancetype)init
56{
57  if (self = [super init]) {
58    // init app registry: keep track of RN bridges we are running
59    _appRegistry = [[EXKernelAppRegistry alloc] init];
60    _appRegistry.delegate = self;
61
62    // init service registry: classes which manage shared resources among all bridges
63    _serviceRegistry = [[EXKernelServiceRegistry alloc] init];
64
65    for (NSString *name in @[UIApplicationDidBecomeActiveNotification,
66                             UIApplicationDidEnterBackgroundNotification,
67                             UIApplicationDidFinishLaunchingNotification,
68                             UIApplicationWillResignActiveNotification,
69                             UIApplicationWillEnterForegroundNotification]) {
70
71      [[NSNotificationCenter defaultCenter] addObserver:self
72                                               selector:@selector(_handleAppStateDidChange:)
73                                                   name:name
74                                                 object:nil];
75    }
76    NSLog(@"Expo iOS Runtime Version %@", [EXBuildConstants sharedInstance].expoRuntimeVersion);
77  }
78  return self;
79}
80
81- (void)dealloc
82{
83  [[NSNotificationCenter defaultCenter] removeObserver:self];
84}
85
86#pragma mark - Misc
87
88+ (NSString *)deviceInstallUUID
89{
90  NSString *uuid = [[NSUserDefaults standardUserDefaults] stringForKey:kEXDeviceInstallUUIDKey];
91  if (!uuid) {
92    uuid = [[NSUUID UUID] UUIDString];
93    [[NSUserDefaults standardUserDefaults] setObject:uuid forKey:kEXDeviceInstallUUIDKey];
94    [[NSUserDefaults standardUserDefaults] synchronize];
95  }
96  return uuid;
97}
98
99- (void)logAnalyticsEvent:(NSString *)eventId forAppRecord:(EXKernelAppRecord *)appRecord
100{
101  if (_appRegistry.homeAppRecord && appRecord == _appRegistry.homeAppRecord) {
102    return;
103  }
104  NSString *validatedSdkVersion = [[EXVersions sharedInstance] availableSdkVersionForManifest:appRecord.appLoader.manifest];
105  NSDictionary *props = (validatedSdkVersion) ? @{ @"SDK_VERSION": validatedSdkVersion } : @{};
106  [[EXAnalytics sharedInstance] logEvent:eventId
107                             manifestUrl:appRecord.appLoader.manifestUrl
108                         eventProperties:props];
109}
110
111#pragma mark - bridge registry delegate
112
113- (void)appRegistry:(EXKernelAppRegistry *)registry didRegisterAppRecord:(EXKernelAppRecord *)appRecord
114{
115  // forward to service registry
116  [_serviceRegistry appRegistry:registry didRegisterAppRecord:appRecord];
117}
118
119- (void)appRegistry:(EXKernelAppRegistry *)registry willUnregisterAppRecord:(EXKernelAppRecord *)appRecord
120{
121  // forward to service registry
122  [_serviceRegistry appRegistry:registry willUnregisterAppRecord:appRecord];
123}
124
125#pragma mark - Interfacing with JS
126
127- (void)sendUrl:(NSString *)urlString toAppRecord:(EXKernelAppRecord *)app
128{
129  // fire a Linking url event on this (possibly versioned) bridge
130  EXReactAppManager *appManager = app.appManager;
131  id linkingModule = [self nativeModuleForAppManager:appManager named:@"LinkingManager"];
132  if (!linkingModule) {
133    DDLogError(@"Could not find the Linking module to open URL (%@)", urlString);
134  } else if ([linkingModule respondsToSelector:@selector(dispatchOpenUrlEvent:)]) {
135    [linkingModule dispatchOpenUrlEvent:[NSURL URLWithString:urlString]];
136  } else {
137    DDLogError(@"Linking module doesn't support the API we use to open URL (%@)", urlString);
138  }
139  [self _moveAppToVisible:app];
140}
141
142- (id)nativeModuleForAppManager:(EXReactAppManager *)appManager named:(NSString *)moduleName
143{
144  id destinationBridge = appManager.reactBridge;
145
146  if ([destinationBridge respondsToSelector:@selector(batchedBridge)]) {
147    id batchedBridge = [destinationBridge batchedBridge];
148    id moduleData = [batchedBridge moduleDataForName:moduleName];
149
150    // React Native before SDK 11 didn't strip the "RCT" prefix from module names
151    if (!moduleData && ![moduleName hasPrefix:@"RCT"]) {
152      moduleData = [batchedBridge moduleDataForName:[@"RCT" stringByAppendingString:moduleName]];
153    }
154
155    if (moduleData) {
156      return [moduleData instance];
157    }
158  } else {
159    // bridge can be null if the record is in an error state and never created a bridge.
160    if (destinationBridge) {
161      DDLogError(@"Bridge does not support the API we use to get its underlying batched bridge");
162    }
163  }
164  return nil;
165}
166
167- (void)sendNotification:(NSDictionary *)notifBody
168      toExperienceWithId:(NSString *)destinationExperienceId
169          fromBackground:(BOOL)isFromBackground
170                isRemote:(BOOL)isRemote
171{
172  EXKernelAppRecord *destinationApp = [_appRegistry newestRecordWithExperienceId:destinationExperienceId];
173  NSDictionary *bodyWithOrigin = [self _notificationPropsWithBody:notifBody isFromBackground:isFromBackground isRemote:isRemote];
174  if (destinationApp) {
175    // send the body to the already-open experience
176    [self _dispatchJSEvent:@"Exponent.notification" body:bodyWithOrigin toApp:destinationApp];
177    [self _moveAppToVisible:destinationApp];
178  } else {
179    // no app is currently running for this experience id.
180    // if we're Expo Client, we can query Home for a past experience in the user's history, and route the notification there.
181    if (_browserController) {
182      __weak typeof(self) weakSelf = self;
183      [_browserController getHistoryUrlForExperienceId:destinationExperienceId completion:^(NSString *urlString) {
184        if (urlString) {
185          NSURL *url = [NSURL URLWithString:urlString];
186          if (url) {
187            [weakSelf createNewAppWithUrl:url initialProps:@{ @"notification": bodyWithOrigin }];
188          }
189        }
190      }];
191    }
192  }
193}
194
195/**
196 *  If the bridge has a batchedBridge or parentBridge selector, posts the notification on that object as well.
197 */
198- (void)_postNotificationName: (NSNotificationName)name onAbstractBridge: (id)bridge
199{
200  [[NSNotificationCenter defaultCenter] postNotificationName:name object:bridge];
201  if ([bridge respondsToSelector:@selector(batchedBridge)]) {
202    [[NSNotificationCenter defaultCenter] postNotificationName:name object:[bridge batchedBridge]];
203  } else if ([bridge respondsToSelector:@selector(parentBridge)]) {
204    [[NSNotificationCenter defaultCenter] postNotificationName:name object:[bridge parentBridge]];
205  }
206}
207
208- (void)_dispatchJSEvent:(NSString *)eventName body:(NSDictionary *)eventBody toApp:(EXKernelAppRecord *)appRecord
209{
210  [appRecord.appManager.reactBridge enqueueJSCall:@"RCTDeviceEventEmitter.emit"
211                                             args:eventBody ? @[eventName, eventBody] : @[eventName]];
212}
213
214#pragma mark - App props
215
216- (NSDictionary *)initialAppPropsFromLaunchOptions:(NSDictionary *)launchOptions
217{
218  NSMutableDictionary *initialProps = [NSMutableDictionary dictionary];
219
220  NSDictionary *remoteNotification = [launchOptions objectForKey:UIApplicationLaunchOptionsRemoteNotificationKey];
221  if (remoteNotification) {
222    initialProps[@"notification"] = [self _notificationPropsWithBody:remoteNotification[@"body"] isFromBackground:YES isRemote:YES];
223  }
224  UILocalNotification *localNotification = [launchOptions objectForKey:UIApplicationLaunchOptionsLocalNotificationKey];
225  if (localNotification) {
226    initialProps[@"notification"] = [self _notificationPropsWithBody:localNotification.userInfo[@"body"] isFromBackground:YES isRemote:NO];
227  }
228  return initialProps;
229}
230
231- (NSDictionary *)_notificationPropsWithBody:(NSDictionary *)notifBody isFromBackground:(BOOL)isFromBackground isRemote:(BOOL)isRemote
232{
233  // if the notification came from the background, in most but not all cases, this means the user acted on an iOS notification
234  // and caused the app to launch.
235  // From SO:
236  // > Note that "App opened from Notification" will be a false positive if the notification is sent while the user is on a different
237  // > screen (for example, if they pull down the status bar and then receive a notification from your app).
238  if (!notifBody) {
239    notifBody = @{};
240  }
241  return @{
242    @"origin": (isFromBackground) ? @"selected" : @"received",
243    @"remote": @(isRemote),
244    @"data": notifBody,
245  };
246}
247
248#pragma mark - App State
249
250- (EXKernelAppRecord *)createNewAppWithUrl:(NSURL *)url initialProps:(nullable NSDictionary *)initialProps
251{
252  NSString *recordId = [_appRegistry registerAppWithManifestUrl:url initialProps:initialProps];
253  EXKernelAppRecord *record = [_appRegistry recordForId:recordId];
254  [self _moveAppToVisible:record];
255  return record;
256}
257
258- (void)switchTasks
259{
260  if (!_browserController) {
261    return;
262  }
263
264  if (_visibleApp != _appRegistry.homeAppRecord) {
265    [EXUtil performSynchronouslyOnMainThread:^{
266      [self->_browserController toggleMenuWithCompletion:nil];
267    }];
268  } else {
269    EXKernelAppRegistry *appRegistry = [EXKernel sharedInstance].appRegistry;
270    for (NSString *recordId in appRegistry.appEnumerator) {
271      EXKernelAppRecord *record = [appRegistry recordForId:recordId];
272      // foreground the first thing we find
273      [self _moveAppToVisible:record];
274    }
275  }
276}
277
278- (void)reloadAppWithExperienceId:(NSString *)experienceId
279{
280  EXKernelAppRecord *appRecord = [_appRegistry newestRecordWithExperienceId:experienceId];
281  if (_browserController) {
282    [self createNewAppWithUrl:appRecord.appLoader.manifestUrl initialProps:nil];
283  } else if (_appRegistry.standaloneAppRecord && appRecord == _appRegistry.standaloneAppRecord) {
284    [appRecord.viewController refresh];
285  }
286}
287
288- (void)reloadAppFromCacheWithExperienceId:(NSString *)experienceId
289{
290  EXKernelAppRecord *appRecord = [_appRegistry newestRecordWithExperienceId:experienceId];
291  [appRecord.viewController reloadFromCache];
292}
293
294- (void)viewController:(__unused EXViewController *)vc didNavigateAppToVisible:(EXKernelAppRecord *)appRecord
295{
296  EXKernelAppRecord *appRecordPreviouslyVisible = _visibleApp;
297  if (appRecord != appRecordPreviouslyVisible) {
298    if (appRecordPreviouslyVisible) {
299      [appRecordPreviouslyVisible.viewController appStateDidBecomeInactive];
300      [self _postNotificationName:kEXKernelBridgeDidBackgroundNotification onAbstractBridge:appRecordPreviouslyVisible.appManager.reactBridge];
301      id<EXAppStateProtocol> appStateModule = [self nativeModuleForAppManager:appRecordPreviouslyVisible.appManager named:@"AppState"];
302      if (appStateModule != nil) {
303        [appStateModule setState:@"background"];
304      }
305    }
306    if (appRecord) {
307      [appRecord.viewController appStateDidBecomeActive];
308      [self _postNotificationName:kEXKernelBridgeDidForegroundNotification onAbstractBridge:appRecord.appManager.reactBridge];
309      id<EXAppStateProtocol> appStateModule = [self nativeModuleForAppManager:appRecord.appManager named:@"AppState"];
310      if (appStateModule != nil) {
311        [appStateModule setState:@"active"];
312      }
313      _visibleApp = appRecord;
314      [[EXAnalytics sharedInstance] logAppVisibleEvent];
315    } else {
316      _visibleApp = nil;
317    }
318
319    if (_visibleApp && _visibleApp != _appRegistry.homeAppRecord) {
320      [self _unregisterUnusedAppRecords];
321    }
322  }
323}
324
325- (void)_unregisterUnusedAppRecords
326{
327  for (NSString *recordId in _appRegistry.appEnumerator) {
328    EXKernelAppRecord *record = [_appRegistry recordForId:recordId];
329    if (record && record != _visibleApp) {
330      [_appRegistry unregisterAppWithRecordId:recordId];
331      break;
332    }
333  }
334}
335
336- (void)_handleAppStateDidChange:(NSNotification *)notification
337{
338  NSString *newState;
339
340  if ([notification.name isEqualToString:UIApplicationWillResignActiveNotification]) {
341    newState = @"inactive";
342  } else if ([notification.name isEqualToString:UIApplicationWillEnterForegroundNotification]) {
343    newState = @"background";
344  } else {
345    switch (RCTSharedApplication().applicationState) {
346      case UIApplicationStateActive:
347        newState = @"active";
348        break;
349      case UIApplicationStateBackground: {
350        newState = @"background";
351        break;
352      }
353      default: {
354        newState = @"unknown";
355        break;
356      }
357    }
358  }
359
360  if (_visibleApp) {
361    EXReactAppManager *appManager = _visibleApp.appManager;
362    id<EXAppStateProtocol> appStateModule = [self nativeModuleForAppManager:appManager named:@"AppState"];
363    NSString *lastKnownState;
364    if (appStateModule != nil) {
365      lastKnownState = [appStateModule lastKnownState];
366      [appStateModule setState:newState];
367    }
368    if (!lastKnownState || ![newState isEqualToString:lastKnownState]) {
369      if ([newState isEqualToString:@"active"]) {
370        [_visibleApp.viewController appStateDidBecomeActive];
371        [self _postNotificationName:kEXKernelBridgeDidForegroundNotification onAbstractBridge:appManager.reactBridge];
372      } else if ([newState isEqualToString:@"background"]) {
373        [_visibleApp.viewController appStateDidBecomeInactive];
374        [self _postNotificationName:kEXKernelBridgeDidBackgroundNotification onAbstractBridge:appManager.reactBridge];
375      }
376    }
377  }
378}
379
380- (void)_moveAppToVisible:(EXKernelAppRecord *)appRecord
381{
382  if (_browserController) {
383    [EXUtil performSynchronouslyOnMainThread:^{
384      [self->_browserController moveAppToVisible:appRecord];
385    }];
386  }
387}
388
389@end
390
391NS_ASSUME_NONNULL_END
392