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  NSString *domain = (error && error.domain) ? error.domain : @"";
122  BOOL isNetworkError = ([domain isEqualToString:(NSString *)kCFErrorDomainCFNetwork] || [domain isEqualToString:EXNetworkErrorDomain]);
123
124  if (isNetworkError) {
125    // show a human-readable reachability error
126    dispatch_async(dispatch_get_main_queue(), ^{
127      [self _showErrorWithType:kEXFatalErrorTypeLoading error:error];
128    });
129  } else if ([domain isEqualToString:@"JSServer"] && [_appRecord.appManager enablesDeveloperTools]) {
130    // RCTRedBox already handled this
131  } else if ([domain rangeOfString:RCTErrorDomain].length > 0 && [_appRecord.appManager enablesDeveloperTools]) {
132    // RCTRedBox already handled this
133  } else {
134    dispatch_async(dispatch_get_main_queue(), ^{
135      [self _showErrorWithType:kEXFatalErrorTypeException error:error];
136    });
137  }
138}
139
140- (void)_rebuildBridge
141{
142  [self _invalidateRecoveryTimer];
143  [[EXKernel sharedInstance] logAnalyticsEvent:@"LOAD_EXPERIENCE" forAppRecord:_appRecord];
144  [_appRecord.appManager rebuildBridge];
145}
146
147- (void)refresh
148{
149  self.isLoading = YES;
150  self.isBridgeAlreadyLoading = NO;
151  [self _invalidateRecoveryTimer];
152  [_appRecord.appLoader request];
153}
154
155- (void)reloadFromCache
156{
157  self.isLoading = YES;
158  self.isBridgeAlreadyLoading = NO;
159  [self _invalidateRecoveryTimer];
160  [_appRecord.appLoader requestFromCache];
161}
162
163- (void)appStateDidBecomeActive
164{
165  dispatch_async(dispatch_get_main_queue(), ^{
166    [self _enforceDesiredDeviceOrientation];
167  });
168  [_appRecord.appManager appStateDidBecomeActive];
169}
170
171- (void)appStateDidBecomeInactive
172{
173  [_appRecord.appManager appStateDidBecomeInactive];
174}
175
176- (void)_rebuildBridgeWithLoadingViewManifest:(NSDictionary *)manifest
177{
178  if (!self.isBridgeAlreadyLoading) {
179    self.isBridgeAlreadyLoading = YES;
180    dispatch_async(dispatch_get_main_queue(), ^{
181      self->_loadingView.manifest = manifest;
182      [self _enforceDesiredDeviceOrientation];
183      [self _rebuildBridge];
184    });
185  }
186}
187
188- (void)foregroundControllers
189{
190  if (_backgroundedControllers != nil) {
191    __block UIViewController *parentController = self;
192
193    [_backgroundedControllers enumerateObjectsUsingBlock:^(UIViewController * _Nonnull viewController, NSUInteger idx, BOOL * _Nonnull stop) {
194      [parentController presentViewController:viewController animated:NO completion:nil];
195      parentController = viewController;
196    }];
197
198    _backgroundedControllers = nil;
199  }
200}
201
202- (void)backgroundControllers
203{
204  UIViewController *childController = [self presentedViewController];
205
206  if (childController != nil) {
207    if (_backgroundedControllers == nil) {
208      _backgroundedControllers = [NSMutableArray new];
209    }
210
211    while (childController != nil) {
212      [_backgroundedControllers addObject:childController];
213      childController = childController.presentedViewController;
214    }
215  }
216}
217
218#pragma mark - EXAppLoaderDelegate
219
220- (void)appLoader:(EXAppLoader *)appLoader didLoadOptimisticManifest:(NSDictionary *)manifest
221{
222  [self _whenManifestIsValidToOpen:manifest manifestUrl:appLoader.manifestUrl performBlock:^{
223    if ([EXKernel sharedInstance].browserController) {
224      [[EXKernel sharedInstance].browserController addHistoryItemWithUrl:appLoader.manifestUrl manifest:manifest];
225    }
226    [self _rebuildBridgeWithLoadingViewManifest:manifest];
227  }];
228}
229
230- (void)appLoader:(EXAppLoader *)appLoader didLoadBundleWithProgress:(EXLoadingProgress *)progress
231{
232  dispatch_async(dispatch_get_main_queue(), ^{
233    [self->_loadingView updateStatusWithProgress:progress];
234  });
235}
236
237- (void)appLoader:(EXAppLoader *)appLoader didFinishLoadingManifest:(NSDictionary *)manifest bundle:(NSData *)data
238{
239  [self _whenManifestIsValidToOpen:manifest manifestUrl:appLoader.manifestUrl performBlock:^{
240    [self _rebuildBridgeWithLoadingViewManifest:manifest];
241    if (self->_appRecord.appManager.status == kEXReactAppManagerStatusBridgeLoading) {
242      [self->_appRecord.appManager appLoaderFinished];
243    }
244  }];
245}
246
247- (void)appLoader:(EXAppLoader *)appLoader didFailWithError:(NSError *)error
248{
249  if (_appRecord.appManager.status == kEXReactAppManagerStatusBridgeLoading) {
250    [_appRecord.appManager appLoaderFailedWithError:error];
251  }
252  [self maybeShowError:error];
253}
254
255- (void)appLoader:(EXAppLoader *)appLoader didResolveUpdatedBundleWithManifest:(NSDictionary * _Nullable)manifest isFromCache:(BOOL)isFromCache error:(NSError * _Nullable)error
256{
257  [[EXKernel sharedInstance].serviceRegistry.updatesManager notifyApp:_appRecord ofDownloadWithManifest:manifest isNew:!isFromCache error:error];
258}
259
260#pragma mark - EXReactAppManagerDelegate
261
262- (void)reactAppManagerIsReadyForLoad:(EXReactAppManager *)appManager
263{
264  UIView *reactView = appManager.rootView;
265  reactView.frame = CGRectMake(0, 0, self.view.frame.size.width, self.view.frame.size.height);
266  reactView.autoresizingMask = UIViewAutoresizingFlexibleWidth | UIViewAutoresizingFlexibleHeight;
267  reactView.backgroundColor = [UIColor clearColor];
268
269  [_contentView removeFromSuperview];
270  _contentView = reactView;
271  [self.view addSubview:_contentView];
272  [self.view sendSubviewToBack:_contentView];
273
274  [reactView becomeFirstResponder];
275}
276
277- (void)reactAppManagerStartedLoadingJavaScript:(EXReactAppManager *)appManager
278{
279  EXAssertMainThread();
280  self.isLoading = YES;
281}
282
283- (void)reactAppManagerFinishedLoadingJavaScript:(EXReactAppManager *)appManager
284{
285  EXAssertMainThread();
286  self.isLoading = NO;
287  if ([EXKernel sharedInstance].browserController) {
288    [[EXKernel sharedInstance].browserController appDidFinishLoadingSuccessfully:_appRecord];
289  }
290}
291
292- (void)reactAppManager:(EXReactAppManager *)appManager failedToLoadJavaScriptWithError:(NSError *)error
293{
294  EXAssertMainThread();
295  [self maybeShowError:error];
296}
297
298- (void)reactAppManagerDidInvalidate:(EXReactAppManager *)appManager
299{
300
301}
302
303- (void)errorViewDidSelectRetry:(EXErrorView *)errorView
304{
305  [self refresh];
306}
307
308#pragma mark - orientation
309
310- (UIInterfaceOrientationMask)supportedInterfaceOrientations
311{
312  if (_supportedInterfaceOrientations != EX_INTERFACE_ORIENTATION_USE_MANIFEST) {
313    return _supportedInterfaceOrientations;
314  }
315  if (_appRecord.appLoader.manifest) {
316    NSString *orientationConfig = _appRecord.appLoader.manifest[@"orientation"];
317    if ([orientationConfig isEqualToString:@"portrait"]) {
318      // lock to portrait
319      return UIInterfaceOrientationMaskPortrait;
320    } else if ([orientationConfig isEqualToString:@"landscape"]) {
321      // lock to landscape
322      return UIInterfaceOrientationMaskLandscape;
323    }
324  }
325  // no config or default value: allow autorotation
326  return UIInterfaceOrientationMaskAllButUpsideDown;
327}
328
329- (void)setSupportedInterfaceOrientations:(UIInterfaceOrientationMask)supportedInterfaceOrientations
330{
331  _supportedInterfaceOrientations = supportedInterfaceOrientations;
332  [self _enforceDesiredDeviceOrientation];
333}
334
335- (void)_enforceDesiredDeviceOrientation
336{
337  RCTAssertMainQueue();
338  UIInterfaceOrientationMask mask = [self supportedInterfaceOrientations];
339  UIDeviceOrientation currentOrientation = [[UIDevice currentDevice] orientation];
340  UIInterfaceOrientation newOrientation = UIInterfaceOrientationUnknown;
341  switch (mask) {
342    case UIInterfaceOrientationMaskPortrait:
343      if (!UIDeviceOrientationIsPortrait(currentOrientation)) {
344        newOrientation = UIInterfaceOrientationPortrait;
345      }
346      break;
347    case UIInterfaceOrientationMaskPortraitUpsideDown:
348      newOrientation = UIInterfaceOrientationPortraitUpsideDown;
349      break;
350    case UIInterfaceOrientationMaskLandscape:
351      if (!UIDeviceOrientationIsLandscape(currentOrientation)) {
352        newOrientation = UIInterfaceOrientationLandscapeLeft;
353      }
354      break;
355    case UIInterfaceOrientationMaskLandscapeLeft:
356      newOrientation = UIInterfaceOrientationLandscapeLeft;
357      break;
358    case UIInterfaceOrientationMaskLandscapeRight:
359      newOrientation = UIInterfaceOrientationLandscapeRight;
360      break;
361    case UIInterfaceOrientationMaskAllButUpsideDown:
362      if (currentOrientation == UIDeviceOrientationFaceDown) {
363        newOrientation = UIInterfaceOrientationPortrait;
364      }
365      break;
366    default:
367      break;
368  }
369  if (newOrientation != UIInterfaceOrientationUnknown) {
370    [[UIDevice currentDevice] setValue:@(newOrientation) forKey:@"orientation"];
371  }
372  [UIViewController attemptRotationToDeviceOrientation];
373}
374
375#pragma mark - Internal
376
377- (void)_showErrorWithType:(EXFatalErrorType)type error:(nullable NSError *)error
378{
379  EXAssertMainThread();
380  _dtmLastFatalErrorShown = [NSDate date];
381  if (_errorView && _contentView == _errorView) {
382    // already showing, just update
383    _errorView.type = type;
384    _errorView.error = error;
385  } {
386    [_contentView removeFromSuperview];
387    if (!_errorView) {
388      _errorView = [[EXErrorView alloc] initWithFrame:CGRectMake(0, 0, self.view.frame.size.width, self.view.frame.size.height)];
389      _errorView.delegate = self;
390      _errorView.appRecord = _appRecord;
391    }
392    _errorView.type = type;
393    _errorView.error = error;
394    _contentView = _errorView;
395    [self.view addSubview:_contentView];
396    [[EXAnalytics sharedInstance] logErrorVisibleEvent];
397  }
398}
399
400- (void)setIsLoading:(BOOL)isLoading
401{
402  if ([_appRecord.appManager enablesDeveloperTools] && _dtmLastFatalErrorShown) {
403    if ([_dtmLastFatalErrorShown timeIntervalSinceNow] >= -kEXDevelopmentErrorCoolDownSeconds) {
404      // we just showed a fatal error very recently, do not begin loading.
405      // this can happen in some cases where react native sends the 'started loading' notif
406      // in spite of a packager error.
407      return;
408    }
409  }
410  _isLoading = isLoading;
411  dispatch_async(dispatch_get_main_queue(), ^{
412    if (isLoading) {
413      self.loadingView.hidden = NO;
414      [self.view bringSubviewToFront:self.loadingView];
415    } else {
416      self.loadingView.hidden = YES;
417    }
418  });
419}
420
421#pragma mark - error recovery
422
423- (BOOL)_willAutoRecoverFromError:(NSError *)error
424{
425  if (error.code == kEXErrorCodeAppForbidden) {
426    return NO;
427  }
428  if (![_appRecord.appManager enablesDeveloperTools]) {
429    BOOL shouldRecover = [[EXKernel sharedInstance].serviceRegistry.errorRecoveryManager experienceIdShouldReloadOnError:_appRecord.experienceId];
430    if (shouldRecover) {
431      [self _invalidateRecoveryTimer];
432      _tmrAutoReloadDebounce = [NSTimer scheduledTimerWithTimeInterval:kEXAutoReloadDebounceSeconds
433                                                                target:self
434                                                              selector:@selector(refresh)
435                                                              userInfo:nil
436                                                               repeats:NO];
437    }
438    return shouldRecover;
439  }
440  return NO;
441}
442
443- (void)_invalidateRecoveryTimer
444{
445  if (_tmrAutoReloadDebounce) {
446    [_tmrAutoReloadDebounce invalidate];
447    _tmrAutoReloadDebounce = nil;
448  }
449}
450
451- (void)_whenManifestIsValidToOpen:(NSDictionary *)manifest manifestUrl:(NSURL *) manifestUrl performBlock:(void (^)(void))block
452{
453  if (self.appRecord.appManager.requiresValidManifests && [EXKernel sharedInstance].browserController) {
454    [[EXKernel sharedInstance].browserController getIsValidHomeManifestToOpen:manifest manifestUrl:manifestUrl completion:^(BOOL isValid) {
455      if (isValid) {
456        block();
457      } else {
458        [self appLoader:self->_appRecord.appLoader didFailWithError:[NSError errorWithDomain:EXNetworkErrorDomain
459                                                                                        code:kEXErrorCodeAppForbidden
460                                                                                    userInfo:@{ NSLocalizedDescriptionKey: @"Expo Client can only be used to view your own projects. To view this project, please ensure you are signed in to the same Expo account that created it." }]];
461      }
462    }];
463  } else {
464    // no browser present, everything is valid
465    block();
466  }
467}
468
469@end
470
471NS_ASSUME_NONNULL_END
472