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