1// Copyright 2015-present 650 Industries. All rights reserved.
2
3@import UIKit;
4
5#import "EXAnalytics.h"
6#import "EXAppLoader.h"
7#import "EXAppLoadingView.h"
8#import "EXAppViewController.h"
9#import "EXEnvironment.h"
10#import "EXErrorRecoveryManager.h"
11#import "EXErrorView.h"
12#import "EXFileDownloader.h"
13#import "EXKernel.h"
14#import "EXKernelUtil.h"
15#import "EXReactAppManager.h"
16#import "EXScreenOrientationManager.h"
17#import "EXVersions.h"
18#import "EXUpdatesManager.h"
19#import "EXUtil.h"
20#import <UMCore/UMModuleRegistryProvider.h>
21
22#if __has_include(<EXScreenOrientation/EXScreenOrientationRegistry.h>)
23#import <EXScreenOrientation/EXScreenOrientationRegistry.h>
24#endif
25
26#import <React/RCTUtils.h>
27
28#define EX_INTERFACE_ORIENTATION_USE_MANIFEST 0
29
30// when we encounter an error and auto-refresh, we may actually see a series of errors.
31// we only want to trigger refresh once, so we debounce refresh on a timer.
32const CGFloat kEXAutoReloadDebounceSeconds = 0.1;
33
34// in development only, some errors can happen before we even start loading
35// (e.g. certain packager errors, such as an invalid bundle url)
36// and we want to make sure not to cover the error with a loading view or other chrome.
37const CGFloat kEXDevelopmentErrorCoolDownSeconds = 0.1;
38
39NS_ASSUME_NONNULL_BEGIN
40
41@interface EXAppViewController ()
42  <EXReactAppManagerUIDelegate, EXAppLoaderDelegate, EXErrorViewDelegate>
43
44@property (nonatomic, assign) BOOL isLoading;
45@property (nonatomic, assign) BOOL isBridgeAlreadyLoading;
46@property (nonatomic, weak) EXKernelAppRecord *appRecord;
47@property (nonatomic, strong) EXAppLoadingView *loadingView;
48@property (nonatomic, strong) EXErrorView *errorView;
49@property (nonatomic, assign) UIInterfaceOrientationMask supportedInterfaceOrientations; // override super
50@property (nonatomic, strong) NSTimer *tmrAutoReloadDebounce;
51@property (nonatomic, strong) NSDate *dtmLastFatalErrorShown;
52@property (nonatomic, strong) NSMutableArray<UIViewController *> *backgroundedControllers;
53
54@end
55
56@implementation EXAppViewController
57
58@synthesize supportedInterfaceOrientations = _supportedInterfaceOrientations;
59
60#pragma mark - Lifecycle
61
62- (instancetype)initWithAppRecord:(EXKernelAppRecord *)record
63{
64  if (self = [super init]) {
65    _appRecord = record;
66    _supportedInterfaceOrientations = EX_INTERFACE_ORIENTATION_USE_MANIFEST;
67  }
68  return self;
69}
70
71- (void)dealloc
72{
73  [self _invalidateRecoveryTimer];
74  [[NSNotificationCenter defaultCenter] removeObserver:self];
75}
76
77- (void)viewDidLoad
78{
79  [super viewDidLoad];
80
81  self.view.backgroundColor = [UIColor whiteColor];
82  _loadingView = [[EXAppLoadingView alloc] initWithAppRecord:_appRecord];
83  [self.view addSubview:_loadingView];
84  _appRecord.appManager.delegate = self;
85  self.isLoading = YES;
86}
87
88- (void)viewDidAppear:(BOOL)animated
89{
90  [super viewDidAppear:animated];
91  if (_appRecord && _appRecord.status == kEXKernelAppRecordStatusNew) {
92    _appRecord.appLoader.delegate = self;
93    _appRecord.appLoader.dataSource = _appRecord.appManager;
94    [self refresh];
95  }
96}
97
98- (BOOL)shouldAutorotate
99{
100  return YES;
101}
102
103- (void)viewWillLayoutSubviews
104{
105  [super viewWillLayoutSubviews];
106  if (_loadingView) {
107    _loadingView.frame = CGRectMake(0, 0, self.view.frame.size.width, self.view.frame.size.height);
108  }
109  if (_contentView) {
110    _contentView.frame = CGRectMake(0, 0, self.view.frame.size.width, self.view.frame.size.height);
111  }
112}
113
114/**
115 * Force presented view controllers to use the same user interface style.
116 */
117- (void)presentViewController:(UIViewController *)viewControllerToPresent animated: (BOOL)flag completion:(void (^ __nullable)(void))completion
118{
119  [super presentViewController:viewControllerToPresent animated:flag completion:completion];
120  [self _overrideUserInterfaceStyleOf:viewControllerToPresent];
121}
122
123/**
124 * Force child view controllers to use the same user interface style.
125 */
126- (void)addChildViewController:(UIViewController *)childController
127{
128  [super addChildViewController:childController];
129  [self _overrideUserInterfaceStyleOf:childController];
130}
131
132#pragma mark - Public
133
134- (void)maybeShowError:(NSError *)error
135{
136  self.isLoading = NO;
137  if ([self _willAutoRecoverFromError:error]) {
138    return;
139  }
140  if (error && ![error isKindOfClass:[NSError class]]) {
141#if DEBUG
142    NSAssert(NO, @"AppViewController error handler was called on an object that isn't an NSError");
143#endif
144    return;
145  }
146
147  // we don't ever want to show any Expo UI in a production standalone app, so hard crash
148  if ([EXEnvironment sharedEnvironment].isDetached && ![_appRecord.appManager enablesDeveloperTools]) {
149    NSException *e = [NSException exceptionWithName:@"ExpoFatalError"
150                                             reason:[NSString stringWithFormat:@"Expo encountered a fatal error: %@", [error localizedDescription]]
151                                           userInfo:@{NSUnderlyingErrorKey: error}];
152    @throw e;
153  }
154
155  NSString *domain = (error && error.domain) ? error.domain : @"";
156  BOOL isNetworkError = ([domain isEqualToString:(NSString *)kCFErrorDomainCFNetwork] || [domain isEqualToString:EXNetworkErrorDomain]);
157
158  if (isNetworkError) {
159    // show a human-readable reachability error
160    dispatch_async(dispatch_get_main_queue(), ^{
161      [self _showErrorWithType:kEXFatalErrorTypeLoading error:error];
162    });
163  } else if ([domain isEqualToString:@"JSServer"] && [_appRecord.appManager enablesDeveloperTools]) {
164    // RCTRedBox already handled this
165  } else if ([domain rangeOfString:RCTErrorDomain].length > 0 && [_appRecord.appManager enablesDeveloperTools]) {
166    // RCTRedBox already handled this
167  } else {
168    dispatch_async(dispatch_get_main_queue(), ^{
169      [self _showErrorWithType:kEXFatalErrorTypeException error:error];
170    });
171  }
172}
173
174- (void)_rebuildBridge
175{
176  [self _invalidateRecoveryTimer];
177  [[EXKernel sharedInstance] logAnalyticsEvent:@"LOAD_EXPERIENCE" forAppRecord:_appRecord];
178  [_appRecord.appManager rebuildBridge];
179}
180
181- (void)refresh
182{
183  self.isLoading = YES;
184  self.isBridgeAlreadyLoading = NO;
185  [self _invalidateRecoveryTimer];
186  [_appRecord.appLoader request];
187}
188
189- (void)reloadFromCache
190{
191  self.isLoading = YES;
192  self.isBridgeAlreadyLoading = NO;
193  [self _invalidateRecoveryTimer];
194  [_appRecord.appLoader requestFromCache];
195}
196
197- (void)appStateDidBecomeActive
198{
199  dispatch_async(dispatch_get_main_queue(), ^{
200    [self _enforceDesiredDeviceOrientation];
201
202    // Reset the root view background color and window color if we switch between Expo home and project
203    [self _setBackgroundColor:self.view];
204  });
205  [_appRecord.appManager appStateDidBecomeActive];
206}
207
208- (void)appStateDidBecomeInactive
209{
210  [_appRecord.appManager appStateDidBecomeInactive];
211}
212
213- (void)_rebuildBridgeWithLoadingViewManifest:(NSDictionary *)manifest
214{
215  if (!self.isBridgeAlreadyLoading) {
216    self.isBridgeAlreadyLoading = YES;
217    dispatch_async(dispatch_get_main_queue(), ^{
218      self->_loadingView.manifest = manifest;
219      [self _overrideUserInterfaceStyleOf:self];
220      [self _enforceDesiredDeviceOrientation];
221      [self _rebuildBridge];
222    });
223  }
224}
225
226- (void)foregroundControllers
227{
228  if (_backgroundedControllers != nil) {
229    __block UIViewController *parentController = self;
230
231    [_backgroundedControllers enumerateObjectsUsingBlock:^(UIViewController * _Nonnull viewController, NSUInteger idx, BOOL * _Nonnull stop) {
232      [parentController presentViewController:viewController animated:NO completion:nil];
233      parentController = viewController;
234    }];
235
236    _backgroundedControllers = nil;
237  }
238}
239
240- (void)backgroundControllers
241{
242  UIViewController *childController = [self presentedViewController];
243
244  if (childController != nil) {
245    if (_backgroundedControllers == nil) {
246      _backgroundedControllers = [NSMutableArray new];
247    }
248
249    while (childController != nil) {
250      [_backgroundedControllers addObject:childController];
251      childController = childController.presentedViewController;
252    }
253  }
254}
255
256#pragma mark - EXAppLoaderDelegate
257
258- (void)appLoader:(EXAppLoader *)appLoader didLoadOptimisticManifest:(NSDictionary *)manifest
259{
260  if ([EXKernel sharedInstance].browserController) {
261    [[EXKernel sharedInstance].browserController addHistoryItemWithUrl:appLoader.manifestUrl manifest:manifest];
262  }
263  [self _rebuildBridgeWithLoadingViewManifest:manifest];
264}
265
266- (void)appLoader:(EXAppLoader *)appLoader didLoadBundleWithProgress:(EXLoadingProgress *)progress
267{
268  dispatch_async(dispatch_get_main_queue(), ^{
269    [self->_loadingView updateStatusWithProgress:progress];
270  });
271}
272
273- (void)appLoader:(EXAppLoader *)appLoader didFinishLoadingManifest:(NSDictionary *)manifest bundle:(NSData *)data
274{
275  [self _rebuildBridgeWithLoadingViewManifest:manifest];
276  if (self->_appRecord.appManager.status == kEXReactAppManagerStatusBridgeLoading) {
277    [self->_appRecord.appManager appLoaderFinished];
278  }
279}
280
281- (void)appLoader:(EXAppLoader *)appLoader didFailWithError:(NSError *)error
282{
283  if (_appRecord.appManager.status == kEXReactAppManagerStatusBridgeLoading) {
284    [_appRecord.appManager appLoaderFailedWithError:error];
285  }
286  [self maybeShowError:error];
287}
288
289- (void)appLoader:(EXAppLoader *)appLoader didResolveUpdatedBundleWithManifest:(NSDictionary * _Nullable)manifest isFromCache:(BOOL)isFromCache error:(NSError * _Nullable)error
290{
291  [[EXKernel sharedInstance].serviceRegistry.updatesManager notifyApp:_appRecord ofDownloadWithManifest:manifest isNew:!isFromCache error:error];
292}
293
294#pragma mark - EXReactAppManagerDelegate
295
296- (void)reactAppManagerIsReadyForLoad:(EXReactAppManager *)appManager
297{
298  UIView *reactView = appManager.rootView;
299  reactView.frame = CGRectMake(0, 0, self.view.frame.size.width, self.view.frame.size.height);
300  reactView.autoresizingMask = UIViewAutoresizingFlexibleWidth | UIViewAutoresizingFlexibleHeight;
301
302
303  [_contentView removeFromSuperview];
304  _contentView = reactView;
305  [self.view addSubview:_contentView];
306  [self.view sendSubviewToBack:_contentView];
307  [reactView becomeFirstResponder];
308
309  // Set root view background color after adding as subview so we can access window
310  [self _setBackgroundColor:reactView];
311}
312
313- (void)reactAppManagerStartedLoadingJavaScript:(EXReactAppManager *)appManager
314{
315  EXAssertMainThread();
316  self.isLoading = YES;
317}
318
319- (void)reactAppManagerFinishedLoadingJavaScript:(EXReactAppManager *)appManager
320{
321  EXAssertMainThread();
322  self.isLoading = NO;
323  if ([EXKernel sharedInstance].browserController) {
324    [[EXKernel sharedInstance].browserController appDidFinishLoadingSuccessfully:_appRecord];
325  }
326}
327
328- (void)reactAppManager:(EXReactAppManager *)appManager failedToLoadJavaScriptWithError:(NSError *)error
329{
330  EXAssertMainThread();
331  [self maybeShowError:error];
332}
333
334- (void)reactAppManagerDidInvalidate:(EXReactAppManager *)appManager
335{
336
337}
338
339- (void)errorViewDidSelectRetry:(EXErrorView *)errorView
340{
341  [self refresh];
342}
343
344#pragma mark - orientation
345
346- (UIInterfaceOrientationMask)supportedInterfaceOrientations
347{
348#if __has_include(<EXScreenOrientation/EXScreenOrientationRegistry.h>)
349  EXScreenOrientationRegistry *screenOrientationRegistry = (EXScreenOrientationRegistry *)[UMModuleRegistryProvider getSingletonModuleForClass:[EXScreenOrientationRegistry class]];
350  if (screenOrientationRegistry && [screenOrientationRegistry requiredOrientationMask] > 0) {
351    return [screenOrientationRegistry requiredOrientationMask];
352  }
353#endif
354
355  // TODO: Remove once sdk 37 is phased out
356  if (_supportedInterfaceOrientations != EX_INTERFACE_ORIENTATION_USE_MANIFEST) {
357    return _supportedInterfaceOrientations;
358  }
359
360  return [self orientationMaskFromManifestOrDefault];
361}
362
363- (UIInterfaceOrientationMask)orientationMaskFromManifestOrDefault {
364  if (_appRecord.appLoader.manifest) {
365    NSString *orientationConfig = _appRecord.appLoader.manifest[@"orientation"];
366    if ([orientationConfig isEqualToString:@"portrait"]) {
367      // lock to portrait
368      return UIInterfaceOrientationMaskPortrait;
369    } else if ([orientationConfig isEqualToString:@"landscape"]) {
370      // lock to landscape
371      return UIInterfaceOrientationMaskLandscape;
372    }
373  }
374  // no config or default value: allow autorotation
375  return UIInterfaceOrientationMaskAllButUpsideDown;
376}
377
378// TODO: Remove once sdk 37 is phased out
379- (void)setSupportedInterfaceOrientations:(UIInterfaceOrientationMask)supportedInterfaceOrientations
380{
381  _supportedInterfaceOrientations = supportedInterfaceOrientations;
382  [self _enforceDesiredDeviceOrientation];
383}
384
385- (void)traitCollectionDidChange:(nullable UITraitCollection *)previousTraitCollection {
386  [super traitCollectionDidChange:previousTraitCollection];
387  if ((self.traitCollection.verticalSizeClass != previousTraitCollection.verticalSizeClass)
388      || (self.traitCollection.horizontalSizeClass != previousTraitCollection.horizontalSizeClass)) {
389
390    #if __has_include(<EXScreenOrientation/EXScreenOrientationRegistry.h>)
391      EXScreenOrientationRegistry *screenOrientationRegistryController = (EXScreenOrientationRegistry *)[UMModuleRegistryProvider getSingletonModuleForClass:[EXScreenOrientationRegistry class]];
392      [screenOrientationRegistryController traitCollectionDidChangeTo:self.traitCollection];
393    #endif
394
395    // TODO: Remove once sdk 37 is phased out
396    [[EXKernel sharedInstance].serviceRegistry.screenOrientationManager handleScreenOrientationChange:self.traitCollection];
397  }
398}
399
400// TODO: Remove once sdk 37 is phased out
401- (void)_enforceDesiredDeviceOrientation
402{
403  RCTAssertMainQueue();
404  UIInterfaceOrientationMask mask = [self supportedInterfaceOrientations];
405  UIDeviceOrientation currentOrientation = [[UIDevice currentDevice] orientation];
406  UIInterfaceOrientation newOrientation = UIInterfaceOrientationUnknown;
407  switch (mask) {
408    case UIInterfaceOrientationMaskPortrait | UIInterfaceOrientationMaskPortraitUpsideDown:
409      if (!UIDeviceOrientationIsPortrait(currentOrientation)) {
410        newOrientation = UIInterfaceOrientationPortrait;
411      }
412      break;
413    case UIInterfaceOrientationMaskPortrait:
414      newOrientation = UIInterfaceOrientationPortrait;
415      break;
416    case UIInterfaceOrientationMaskPortraitUpsideDown:
417      newOrientation = UIInterfaceOrientationPortraitUpsideDown;
418      break;
419    case UIInterfaceOrientationMaskLandscape:
420      if (!UIDeviceOrientationIsLandscape(currentOrientation)) {
421        newOrientation = UIInterfaceOrientationLandscapeLeft;
422      }
423      break;
424    case UIInterfaceOrientationMaskLandscapeLeft:
425      newOrientation = UIInterfaceOrientationLandscapeLeft;
426      break;
427    case UIInterfaceOrientationMaskLandscapeRight:
428      newOrientation = UIInterfaceOrientationLandscapeRight;
429      break;
430    case UIInterfaceOrientationMaskAllButUpsideDown:
431      if (currentOrientation == UIDeviceOrientationFaceDown) {
432        newOrientation = UIInterfaceOrientationPortrait;
433      }
434      break;
435    default:
436      break;
437  }
438  if (newOrientation != UIInterfaceOrientationUnknown) {
439    [[UIDevice currentDevice] setValue:@(newOrientation) forKey:@"orientation"];
440  }
441  [UIViewController attemptRotationToDeviceOrientation];
442}
443
444#pragma mark - user interface style
445
446- (void)_overrideUserInterfaceStyleOf:(UIViewController *)viewController
447{
448  if (@available(iOS 13.0, *)) {
449    NSString *userInterfaceStyle = [self _readUserInterfaceStyleFromManifest:_appRecord.appLoader.manifest];
450    viewController.overrideUserInterfaceStyle = [self _userInterfaceStyleForString:userInterfaceStyle];
451  }
452}
453
454- (NSString * _Nullable)_readUserInterfaceStyleFromManifest:(NSDictionary *)manifest
455{
456  if (manifest[@"ios"] && manifest[@"ios"][@"userInterfaceStyle"]) {
457    return manifest[@"ios"][@"userInterfaceStyle"];
458  }
459  return manifest[@"userInterfaceStyle"];
460}
461
462- (UIUserInterfaceStyle)_userInterfaceStyleForString:(NSString *)userInterfaceStyleString API_AVAILABLE(ios(12.0)) {
463  if ([userInterfaceStyleString isEqualToString:@"dark"]) {
464    return UIUserInterfaceStyleDark;
465  }
466  if ([userInterfaceStyleString isEqualToString:@"automatic"]) {
467    return UIUserInterfaceStyleUnspecified;
468  }
469  return UIUserInterfaceStyleLight;
470}
471
472#pragma mark - root view and window background color
473
474- (void)_setBackgroundColor:(UIView *)view
475{
476    NSString *backgroundColorString = [self _readBackgroundColorFromManifest:_appRecord.appLoader.manifest];
477    UIColor *backgroundColor = [EXUtil colorWithHexString:backgroundColorString];
478
479    if (backgroundColor) {
480      view.backgroundColor = backgroundColor;
481      // NOTE(brentvatne): it may be desirable at some point to split the window backgroundColor out from the
482      // root view, we can do if use case is presented to us.
483      view.window.backgroundColor = backgroundColor;
484    } else {
485      view.backgroundColor = [UIColor whiteColor];
486
487      // NOTE(brentvatne): we used to use white as a default background color for window but this caused
488      // problems when using form sheet presentation style with vcs eg: <Modal /> and native-stack. Most
489      // users expect the background behind these to be black, which is the default if backgroundColor is nil.
490      view.window.backgroundColor = nil;
491
492      // NOTE(brentvatne): we may want to default to respecting the default system background color
493      // on iOS13 and higher, but if we do make this choice then we will have to implement it on Android
494      // as well. This would also be a breaking change. Leaaving this here as a placeholder for the future.
495      // if (@available(iOS 13.0, *)) {
496      //   view.backgroundColor = [UIColor systemBackgroundColor];
497      // } else {
498      //  view.backgroundColor = [UIColor whiteColor];
499      // }
500    }
501}
502
503- (NSString * _Nullable)_readBackgroundColorFromManifest:(NSDictionary *)manifest
504{
505  if (manifest[@"ios"] && manifest[@"ios"][@"backgroundColor"]) {
506    return manifest[@"ios"][@"backgroundColor"];
507  }
508  return manifest[@"backgroundColor"];
509}
510
511
512#pragma mark - Internal
513
514- (void)_showErrorWithType:(EXFatalErrorType)type error:(nullable NSError *)error
515{
516  EXAssertMainThread();
517  _dtmLastFatalErrorShown = [NSDate date];
518  if (_errorView && _contentView == _errorView) {
519    // already showing, just update
520    _errorView.type = type;
521    _errorView.error = error;
522  } {
523    [_contentView removeFromSuperview];
524    if (!_errorView) {
525      _errorView = [[EXErrorView alloc] initWithFrame:CGRectMake(0, 0, self.view.frame.size.width, self.view.frame.size.height)];
526      _errorView.delegate = self;
527      _errorView.appRecord = _appRecord;
528    }
529    _errorView.type = type;
530    _errorView.error = error;
531    _contentView = _errorView;
532    [self.view addSubview:_contentView];
533    [[EXAnalytics sharedInstance] logErrorVisibleEvent];
534  }
535}
536
537- (void)setIsLoading:(BOOL)isLoading
538{
539  if ([_appRecord.appManager enablesDeveloperTools] && _dtmLastFatalErrorShown) {
540    if ([_dtmLastFatalErrorShown timeIntervalSinceNow] >= -kEXDevelopmentErrorCoolDownSeconds) {
541      // we just showed a fatal error very recently, do not begin loading.
542      // this can happen in some cases where react native sends the 'started loading' notif
543      // in spite of a packager error.
544      return;
545    }
546  }
547  _isLoading = isLoading;
548  dispatch_async(dispatch_get_main_queue(), ^{
549    if (isLoading) {
550      self.loadingView.hidden = NO;
551      [self.view bringSubviewToFront:self.loadingView];
552    } else {
553      self.loadingView.hidden = YES;
554    }
555  });
556}
557
558#pragma mark - error recovery
559
560- (BOOL)_willAutoRecoverFromError:(NSError *)error
561{
562  if (![_appRecord.appManager enablesDeveloperTools]) {
563    BOOL shouldRecover = [[EXKernel sharedInstance].serviceRegistry.errorRecoveryManager experienceIdShouldReloadOnError:_appRecord.experienceId];
564    if (shouldRecover) {
565      [self _invalidateRecoveryTimer];
566      _tmrAutoReloadDebounce = [NSTimer scheduledTimerWithTimeInterval:kEXAutoReloadDebounceSeconds
567                                                                target:self
568                                                              selector:@selector(refresh)
569                                                              userInfo:nil
570                                                               repeats:NO];
571    }
572    return shouldRecover;
573  }
574  return NO;
575}
576
577- (void)_invalidateRecoveryTimer
578{
579  if (_tmrAutoReloadDebounce) {
580    [_tmrAutoReloadDebounce invalidate];
581    _tmrAutoReloadDebounce = nil;
582  }
583}
584
585@end
586
587NS_ASSUME_NONNULL_END
588