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