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