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