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  // TODO(brentvatne): probably this should not just be UIColor whiteColor?
82  self.view.backgroundColor = [UIColor whiteColor];
83
84  _loadingView = [[EXAppLoadingView alloc] initWithAppRecord:_appRecord];
85  [self.view addSubview:_loadingView];
86  _appRecord.appManager.delegate = self;
87  self.isLoading = YES;
88}
89
90- (void)viewDidAppear:(BOOL)animated
91{
92  [super viewDidAppear:animated];
93  if (_appRecord && _appRecord.status == kEXKernelAppRecordStatusNew) {
94    _appRecord.appLoader.delegate = self;
95    _appRecord.appLoader.dataSource = _appRecord.appManager;
96    [self refresh];
97  }
98}
99
100- (BOOL)shouldAutorotate
101{
102  return YES;
103}
104
105- (void)viewWillLayoutSubviews
106{
107  [super viewWillLayoutSubviews];
108  if (_loadingView) {
109    _loadingView.frame = CGRectMake(0, 0, self.view.frame.size.width, self.view.frame.size.height);
110  }
111  if (_contentView) {
112    _contentView.frame = CGRectMake(0, 0, self.view.frame.size.width, self.view.frame.size.height);
113  }
114}
115
116#pragma mark - Public
117
118- (void)maybeShowError:(NSError *)error
119{
120  self.isLoading = NO;
121  if ([self _willAutoRecoverFromError:error]) {
122    return;
123  }
124  if (error && ![error isKindOfClass:[NSError class]]) {
125#if DEBUG
126    NSAssert(NO, @"AppViewController error handler was called on an object that isn't an NSError");
127#endif
128    return;
129  }
130
131  // we don't ever want to show any Expo UI in a production standalone app, so hard crash
132  if ([EXEnvironment sharedEnvironment].isDetached && ![_appRecord.appManager enablesDeveloperTools]) {
133    NSException *e = [NSException exceptionWithName:@"ExpoFatalError"
134                                             reason:[NSString stringWithFormat:@"Expo encountered a fatal error: %@", [error localizedDescription]]
135                                           userInfo:@{NSUnderlyingErrorKey: error}];
136    @throw e;
137  }
138
139  NSString *domain = (error && error.domain) ? error.domain : @"";
140  BOOL isNetworkError = ([domain isEqualToString:(NSString *)kCFErrorDomainCFNetwork] || [domain isEqualToString:EXNetworkErrorDomain]);
141
142  if (isNetworkError) {
143    // show a human-readable reachability error
144    dispatch_async(dispatch_get_main_queue(), ^{
145      [self _showErrorWithType:kEXFatalErrorTypeLoading error:error];
146    });
147  } else if ([domain isEqualToString:@"JSServer"] && [_appRecord.appManager enablesDeveloperTools]) {
148    // RCTRedBox already handled this
149  } else if ([domain rangeOfString:RCTErrorDomain].length > 0 && [_appRecord.appManager enablesDeveloperTools]) {
150    // RCTRedBox already handled this
151  } else {
152    dispatch_async(dispatch_get_main_queue(), ^{
153      [self _showErrorWithType:kEXFatalErrorTypeException error:error];
154    });
155  }
156}
157
158- (void)_rebuildBridge
159{
160  [self _invalidateRecoveryTimer];
161  [[EXKernel sharedInstance] logAnalyticsEvent:@"LOAD_EXPERIENCE" forAppRecord:_appRecord];
162  [_appRecord.appManager rebuildBridge];
163}
164
165- (void)refresh
166{
167  self.isLoading = YES;
168  self.isBridgeAlreadyLoading = NO;
169  [self _invalidateRecoveryTimer];
170  [_appRecord.appLoader request];
171}
172
173- (void)reloadFromCache
174{
175  self.isLoading = YES;
176  self.isBridgeAlreadyLoading = NO;
177  [self _invalidateRecoveryTimer];
178  [_appRecord.appLoader requestFromCache];
179}
180
181- (void)appStateDidBecomeActive
182{
183  dispatch_async(dispatch_get_main_queue(), ^{
184    [self _enforceDesiredDeviceOrientation];
185  });
186  [_appRecord.appManager appStateDidBecomeActive];
187}
188
189- (void)appStateDidBecomeInactive
190{
191  [_appRecord.appManager appStateDidBecomeInactive];
192}
193
194- (void)_rebuildBridgeWithLoadingViewManifest:(NSDictionary *)manifest
195{
196  if (!self.isBridgeAlreadyLoading) {
197    self.isBridgeAlreadyLoading = YES;
198    dispatch_async(dispatch_get_main_queue(), ^{
199      self->_loadingView.manifest = manifest;
200      [self _overrideUserInterfaceStyle];
201      [self _enforceDesiredDeviceOrientation];
202      [self _rebuildBridge];
203    });
204  }
205}
206
207- (void)foregroundControllers
208{
209  if (_backgroundedControllers != nil) {
210    __block UIViewController *parentController = self;
211
212    [_backgroundedControllers enumerateObjectsUsingBlock:^(UIViewController * _Nonnull viewController, NSUInteger idx, BOOL * _Nonnull stop) {
213      [parentController presentViewController:viewController animated:NO completion:nil];
214      parentController = viewController;
215    }];
216
217    _backgroundedControllers = nil;
218  }
219}
220
221- (void)backgroundControllers
222{
223  UIViewController *childController = [self presentedViewController];
224
225  if (childController != nil) {
226    if (_backgroundedControllers == nil) {
227      _backgroundedControllers = [NSMutableArray new];
228    }
229
230    while (childController != nil) {
231      [_backgroundedControllers addObject:childController];
232      childController = childController.presentedViewController;
233    }
234  }
235}
236
237#pragma mark - EXAppLoaderDelegate
238
239- (void)appLoader:(EXAppLoader *)appLoader didLoadOptimisticManifest:(NSDictionary *)manifest
240{
241  if ([EXKernel sharedInstance].browserController) {
242    [[EXKernel sharedInstance].browserController addHistoryItemWithUrl:appLoader.manifestUrl manifest:manifest];
243  }
244  [self _rebuildBridgeWithLoadingViewManifest:manifest];
245}
246
247- (void)appLoader:(EXAppLoader *)appLoader didLoadBundleWithProgress:(EXLoadingProgress *)progress
248{
249  dispatch_async(dispatch_get_main_queue(), ^{
250    [self->_loadingView updateStatusWithProgress:progress];
251  });
252}
253
254- (void)appLoader:(EXAppLoader *)appLoader didFinishLoadingManifest:(NSDictionary *)manifest bundle:(NSData *)data
255{
256  [self _rebuildBridgeWithLoadingViewManifest:manifest];
257  if (self->_appRecord.appManager.status == kEXReactAppManagerStatusBridgeLoading) {
258    [self->_appRecord.appManager appLoaderFinished];
259  }
260}
261
262- (void)appLoader:(EXAppLoader *)appLoader didFailWithError:(NSError *)error
263{
264  if (_appRecord.appManager.status == kEXReactAppManagerStatusBridgeLoading) {
265    [_appRecord.appManager appLoaderFailedWithError:error];
266  }
267  [self maybeShowError:error];
268}
269
270- (void)appLoader:(EXAppLoader *)appLoader didResolveUpdatedBundleWithManifest:(NSDictionary * _Nullable)manifest isFromCache:(BOOL)isFromCache error:(NSError * _Nullable)error
271{
272  [[EXKernel sharedInstance].serviceRegistry.updatesManager notifyApp:_appRecord ofDownloadWithManifest:manifest isNew:!isFromCache error:error];
273}
274
275#pragma mark - EXReactAppManagerDelegate
276
277- (void)reactAppManagerIsReadyForLoad:(EXReactAppManager *)appManager
278{
279  UIView *reactView = appManager.rootView;
280  reactView.frame = CGRectMake(0, 0, self.view.frame.size.width, self.view.frame.size.height);
281  reactView.autoresizingMask = UIViewAutoresizingFlexibleWidth | UIViewAutoresizingFlexibleHeight;
282
283  [self _setRootViewBackgroundColor:reactView];
284
285  [_contentView removeFromSuperview];
286  _contentView = reactView;
287  [self.view addSubview:_contentView];
288  [self.view sendSubviewToBack:_contentView];
289
290  [reactView becomeFirstResponder];
291}
292
293- (void)reactAppManagerStartedLoadingJavaScript:(EXReactAppManager *)appManager
294{
295  EXAssertMainThread();
296  self.isLoading = YES;
297}
298
299- (void)reactAppManagerFinishedLoadingJavaScript:(EXReactAppManager *)appManager
300{
301  EXAssertMainThread();
302  self.isLoading = NO;
303  if ([EXKernel sharedInstance].browserController) {
304    [[EXKernel sharedInstance].browserController appDidFinishLoadingSuccessfully:_appRecord];
305  }
306}
307
308- (void)reactAppManager:(EXReactAppManager *)appManager failedToLoadJavaScriptWithError:(NSError *)error
309{
310  EXAssertMainThread();
311  [self maybeShowError:error];
312}
313
314- (void)reactAppManagerDidInvalidate:(EXReactAppManager *)appManager
315{
316
317}
318
319- (void)errorViewDidSelectRetry:(EXErrorView *)errorView
320{
321  [self refresh];
322}
323
324#pragma mark - orientation
325
326- (UIInterfaceOrientationMask)supportedInterfaceOrientations
327{
328#if __has_include(<EXScreenOrientation/EXScreenOrientationRegistry.h>)
329  EXScreenOrientationRegistry *screenOrientationRegistry = (EXScreenOrientationRegistry *)[UMModuleRegistryProvider getSingletonModuleForClass:[EXScreenOrientationRegistry class]];
330  if (screenOrientationRegistry && [screenOrientationRegistry requiredOrientationMask] > 0) {
331    return [screenOrientationRegistry requiredOrientationMask];
332  }
333#endif
334
335  // TODO: Remove once sdk 37 is phased out
336  if (_supportedInterfaceOrientations != EX_INTERFACE_ORIENTATION_USE_MANIFEST) {
337    return _supportedInterfaceOrientations;
338  }
339
340  return [self orientationMaskFromManifestOrDefault];
341}
342
343- (UIInterfaceOrientationMask)orientationMaskFromManifestOrDefault {
344  if (_appRecord.appLoader.manifest) {
345    NSString *orientationConfig = _appRecord.appLoader.manifest[@"orientation"];
346    if ([orientationConfig isEqualToString:@"portrait"]) {
347      // lock to portrait
348      return UIInterfaceOrientationMaskPortrait;
349    } else if ([orientationConfig isEqualToString:@"landscape"]) {
350      // lock to landscape
351      return UIInterfaceOrientationMaskLandscape;
352    }
353  }
354  // no config or default value: allow autorotation
355  return UIInterfaceOrientationMaskAllButUpsideDown;
356}
357
358// TODO: Remove once sdk 37 is phased out
359- (void)setSupportedInterfaceOrientations:(UIInterfaceOrientationMask)supportedInterfaceOrientations
360{
361  _supportedInterfaceOrientations = supportedInterfaceOrientations;
362  [self _enforceDesiredDeviceOrientation];
363}
364
365- (void)traitCollectionDidChange:(nullable UITraitCollection *)previousTraitCollection {
366  [super traitCollectionDidChange:previousTraitCollection];
367  if ((self.traitCollection.verticalSizeClass != previousTraitCollection.verticalSizeClass)
368      || (self.traitCollection.horizontalSizeClass != previousTraitCollection.horizontalSizeClass)) {
369
370    #if __has_include(<EXScreenOrientation/EXScreenOrientationRegistry.h>)
371      EXScreenOrientationRegistry *screenOrientationRegistryController = (EXScreenOrientationRegistry *)[UMModuleRegistryProvider getSingletonModuleForClass:[EXScreenOrientationRegistry class]];
372      [screenOrientationRegistryController traitCollectionDidChangeTo:self.traitCollection];
373    #endif
374
375    // TODO: Remove once sdk 37 is phased out
376    [[EXKernel sharedInstance].serviceRegistry.screenOrientationManager handleScreenOrientationChange:self.traitCollection];
377  }
378}
379
380// TODO: Remove once sdk 37 is phased out
381- (void)_enforceDesiredDeviceOrientation
382{
383  RCTAssertMainQueue();
384  UIInterfaceOrientationMask mask = [self supportedInterfaceOrientations];
385  UIDeviceOrientation currentOrientation = [[UIDevice currentDevice] orientation];
386  UIInterfaceOrientation newOrientation = UIInterfaceOrientationUnknown;
387  switch (mask) {
388    case UIInterfaceOrientationMaskPortrait | UIInterfaceOrientationMaskPortraitUpsideDown:
389      if (!UIDeviceOrientationIsPortrait(currentOrientation)) {
390        newOrientation = UIInterfaceOrientationPortrait;
391      }
392      break;
393    case UIInterfaceOrientationMaskPortrait:
394      newOrientation = UIInterfaceOrientationPortrait;
395      break;
396    case UIInterfaceOrientationMaskPortraitUpsideDown:
397      newOrientation = UIInterfaceOrientationPortraitUpsideDown;
398      break;
399    case UIInterfaceOrientationMaskLandscape:
400      if (!UIDeviceOrientationIsLandscape(currentOrientation)) {
401        newOrientation = UIInterfaceOrientationLandscapeLeft;
402      }
403      break;
404    case UIInterfaceOrientationMaskLandscapeLeft:
405      newOrientation = UIInterfaceOrientationLandscapeLeft;
406      break;
407    case UIInterfaceOrientationMaskLandscapeRight:
408      newOrientation = UIInterfaceOrientationLandscapeRight;
409      break;
410    case UIInterfaceOrientationMaskAllButUpsideDown:
411      if (currentOrientation == UIDeviceOrientationFaceDown) {
412        newOrientation = UIInterfaceOrientationPortrait;
413      }
414      break;
415    default:
416      break;
417  }
418  if (newOrientation != UIInterfaceOrientationUnknown) {
419    [[UIDevice currentDevice] setValue:@(newOrientation) forKey:@"orientation"];
420  }
421  [UIViewController attemptRotationToDeviceOrientation];
422}
423
424#pragma mark - user interface style
425
426- (void)_overrideUserInterfaceStyle
427{
428  if (@available(iOS 13.0, *)) {
429    NSString *userInterfaceStyle = [self _readUserInterfaceStyleFromManifest:_appRecord.appLoader.manifest];
430    self.overrideUserInterfaceStyle = [self _userInterfaceStyleForString:userInterfaceStyle];
431  }
432}
433
434- (NSString * _Nullable)_readUserInterfaceStyleFromManifest:(NSDictionary *)manifest
435{
436  if (manifest[@"ios"] && manifest[@"ios"][@"userInterfaceStyle"]) {
437    return manifest[@"ios"][@"userInterfaceStyle"];
438  }
439  return manifest[@"userInterfaceStyle"];
440}
441
442- (UIUserInterfaceStyle)_userInterfaceStyleForString:(NSString *)userInterfaceStyleString API_AVAILABLE(ios(12.0)) {
443  if ([userInterfaceStyleString isEqualToString:@"dark"]) {
444    return UIUserInterfaceStyleDark;
445  }
446  if ([userInterfaceStyleString isEqualToString:@"automatic"]) {
447    return UIUserInterfaceStyleUnspecified;
448  }
449  return UIUserInterfaceStyleLight;
450}
451
452#pragma mark - root view background color
453
454- (void)_setRootViewBackgroundColor:(UIView *)view
455{
456    NSString *backgroundColorString = [self _readBackgroundColorFromManifest:_appRecord.appLoader.manifest];
457    UIColor *backgroundColor = [EXUtil colorWithHexString:backgroundColorString];
458
459    if (backgroundColor) {
460      view.backgroundColor = backgroundColor;
461    } else {
462      view.backgroundColor = [UIColor whiteColor];
463
464      // NOTE(brentvatne): we may want to default to respecting the default system background color
465      // on iOS13 and higher, but if we do make this choice then we will have to implement it on Android
466      // as well. This would also be a breaking change. Leaaving this here as a placeholder for the future.
467      // if (@available(iOS 13.0, *)) {
468      //   view.backgroundColor = [UIColor systemBackgroundColor];
469      // } else {
470      //  view.backgroundColor = [UIColor whiteColor];
471      // }
472    }
473}
474
475- (NSString * _Nullable)_readBackgroundColorFromManifest:(NSDictionary *)manifest
476{
477  if (manifest[@"ios"] && manifest[@"ios"][@"backgroundColor"]) {
478    return manifest[@"ios"][@"backgroundColor"];
479  }
480  return manifest[@"backgroundColor"];
481}
482
483
484#pragma mark - Internal
485
486- (void)_showErrorWithType:(EXFatalErrorType)type error:(nullable NSError *)error
487{
488  EXAssertMainThread();
489  _dtmLastFatalErrorShown = [NSDate date];
490  if (_errorView && _contentView == _errorView) {
491    // already showing, just update
492    _errorView.type = type;
493    _errorView.error = error;
494  } {
495    [_contentView removeFromSuperview];
496    if (!_errorView) {
497      _errorView = [[EXErrorView alloc] initWithFrame:CGRectMake(0, 0, self.view.frame.size.width, self.view.frame.size.height)];
498      _errorView.delegate = self;
499      _errorView.appRecord = _appRecord;
500    }
501    _errorView.type = type;
502    _errorView.error = error;
503    _contentView = _errorView;
504    [self.view addSubview:_contentView];
505    [[EXAnalytics sharedInstance] logErrorVisibleEvent];
506  }
507}
508
509- (void)setIsLoading:(BOOL)isLoading
510{
511  if ([_appRecord.appManager enablesDeveloperTools] && _dtmLastFatalErrorShown) {
512    if ([_dtmLastFatalErrorShown timeIntervalSinceNow] >= -kEXDevelopmentErrorCoolDownSeconds) {
513      // we just showed a fatal error very recently, do not begin loading.
514      // this can happen in some cases where react native sends the 'started loading' notif
515      // in spite of a packager error.
516      return;
517    }
518  }
519  _isLoading = isLoading;
520  dispatch_async(dispatch_get_main_queue(), ^{
521    if (isLoading) {
522      self.loadingView.hidden = NO;
523      [self.view bringSubviewToFront:self.loadingView];
524    } else {
525      self.loadingView.hidden = YES;
526    }
527  });
528}
529
530#pragma mark - error recovery
531
532- (BOOL)_willAutoRecoverFromError:(NSError *)error
533{
534  if (![_appRecord.appManager enablesDeveloperTools]) {
535    BOOL shouldRecover = [[EXKernel sharedInstance].serviceRegistry.errorRecoveryManager experienceIdShouldReloadOnError:_appRecord.experienceId];
536    if (shouldRecover) {
537      [self _invalidateRecoveryTimer];
538      _tmrAutoReloadDebounce = [NSTimer scheduledTimerWithTimeInterval:kEXAutoReloadDebounceSeconds
539                                                                target:self
540                                                              selector:@selector(refresh)
541                                                              userInfo:nil
542                                                               repeats:NO];
543    }
544    return shouldRecover;
545  }
546  return NO;
547}
548
549- (void)_invalidateRecoveryTimer
550{
551  if (_tmrAutoReloadDebounce) {
552    [_tmrAutoReloadDebounce invalidate];
553    _tmrAutoReloadDebounce = nil;
554  }
555}
556
557@end
558
559NS_ASSUME_NONNULL_END
560