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