1// Copyright 2015-present 650 Industries. All rights reserved.
2
3@import UIKit;
4
5#import "EXAnalytics.h"
6#import "EXAppLoadingView.h"
7#import "EXErrorRecoveryManager.h"
8#import "EXFileDownloader.h"
9#import "EXAppViewController.h"
10#import "EXReactAppManager.h"
11#import "EXErrorView.h"
12#import "EXKernel.h"
13#import "EXKernelAppLoader.h"
14#import "EXKernelUtil.h"
15#import "EXScreenOrientationManager.h"
16#import "EXShellManager.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, EXKernelAppLoaderDelegate, 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
46@end
47
48@implementation EXAppViewController
49
50@synthesize supportedInterfaceOrientations = _supportedInterfaceOrientations;
51
52#pragma mark - Lifecycle
53
54- (instancetype)initWithAppRecord:(EXKernelAppRecord *)record
55{
56  if (self = [super init]) {
57    _appRecord = record;
58    _supportedInterfaceOrientations = EX_INTERFACE_ORIENTATION_USE_MANIFEST;
59  }
60  return self;
61}
62
63- (void)dealloc
64{
65  [self _invalidateRecoveryTimer];
66  [[NSNotificationCenter defaultCenter] removeObserver:self];
67}
68
69- (void)viewDidLoad
70{
71  [super viewDidLoad];
72  self.view.backgroundColor = [UIColor whiteColor];
73
74  _loadingView = [[EXAppLoadingView alloc] initWithAppRecord:_appRecord];
75  [self.view addSubview:_loadingView];
76  _appRecord.appManager.delegate = self;
77  self.isLoading = YES;
78}
79
80- (void)viewDidAppear:(BOOL)animated
81{
82  [super viewDidAppear:animated];
83  if (_appRecord && _appRecord.status == kEXKernelAppRecordStatusNew) {
84    _appRecord.appLoader.delegate = self;
85    _appRecord.appLoader.dataSource = _appRecord.appManager;
86    [self refresh];
87  }
88}
89
90- (BOOL)shouldAutorotate
91{
92  return YES;
93}
94
95- (void)viewWillLayoutSubviews
96{
97  [super viewWillLayoutSubviews];
98  if (_loadingView) {
99    _loadingView.frame = CGRectMake(0, 0, self.view.frame.size.width, self.view.frame.size.height);
100  }
101  if (_contentView) {
102    _contentView.frame = CGRectMake(0, 0, self.view.frame.size.width, self.view.frame.size.height);
103  }
104}
105
106#pragma mark - Public
107
108- (void)maybeShowError:(NSError *)error
109{
110  self.isLoading = NO;
111  if ([self _willAutoRecoverFromError:error]) {
112    return;
113  }
114  if (error && ![error isKindOfClass:[NSError class]]) {
115#if DEBUG
116    NSAssert(NO, @"AppViewController error handler was called on an object that isn't an NSError");
117#endif
118    return;
119  }
120  NSString *domain = (error && error.domain) ? error.domain : @"";
121  BOOL isNetworkError = ([domain isEqualToString:(NSString *)kCFErrorDomainCFNetwork] || [domain isEqualToString:EXNetworkErrorDomain]);
122
123  if (isNetworkError) {
124    // show a human-readable reachability error
125    dispatch_async(dispatch_get_main_queue(), ^{
126      [self _showErrorWithType:kEXFatalErrorTypeLoading error:error];
127    });
128  } else if ([domain isEqualToString:@"JSServer"] && [_appRecord.appManager enablesDeveloperTools]) {
129    // RCTRedBox already handled this
130  } else if ([domain rangeOfString:RCTErrorDomain].length > 0 && [_appRecord.appManager enablesDeveloperTools]) {
131    // RCTRedBox already handled this
132  } else {
133    dispatch_async(dispatch_get_main_queue(), ^{
134      [self _showErrorWithType:kEXFatalErrorTypeException error:error];
135    });
136  }
137}
138
139- (void)_rebuildBridge
140{
141  [self _invalidateRecoveryTimer];
142  [[EXKernel sharedInstance] logAnalyticsEvent:@"LOAD_EXPERIENCE" forAppRecord:_appRecord];
143  [_appRecord.appManager rebuildBridge];
144}
145
146- (void)refresh
147{
148  self.isLoading = YES;
149  self.isBridgeAlreadyLoading = NO;
150  [self _invalidateRecoveryTimer];
151  [_appRecord.appLoader request];
152}
153
154- (void)reloadFromCache
155{
156  self.isLoading = YES;
157  self.isBridgeAlreadyLoading = NO;
158  [self _invalidateRecoveryTimer];
159  [_appRecord.appLoader requestFromCache];
160}
161
162- (void)appStateDidBecomeActive
163{
164  dispatch_async(dispatch_get_main_queue(), ^{
165    [self _enforceDesiredDeviceOrientation];
166  });
167  [_appRecord.appManager appStateDidBecomeActive];
168}
169
170- (void)appStateDidBecomeInactive
171{
172  [_appRecord.appManager appStateDidBecomeInactive];
173}
174
175- (void)_rebuildBridgeWithLoadingViewManifest:(NSDictionary *)manifest
176{
177  if (!self.isBridgeAlreadyLoading) {
178    self.isBridgeAlreadyLoading = YES;
179    dispatch_async(dispatch_get_main_queue(), ^{
180      _loadingView.manifest = manifest;
181      [self _enforceDesiredDeviceOrientation];
182      [self _rebuildBridge];
183    });
184  }
185}
186
187#pragma mark - EXKernelAppLoaderDelegate
188
189- (void)appLoader:(EXKernelAppLoader *)appLoader didLoadOptimisticManifest:(NSDictionary *)manifest
190{
191  [self _whenManifestIsValidToOpen:manifest performBlock:^{
192    if ([EXKernel sharedInstance].browserController) {
193      [[EXKernel sharedInstance].browserController addHistoryItemWithUrl:appLoader.manifestUrl manifest:manifest];
194    }
195    [self _rebuildBridgeWithLoadingViewManifest:manifest];
196  }];
197}
198
199- (void)appLoader:(EXKernelAppLoader *)appLoader didLoadBundleWithProgress:(EXLoadingProgress *)progress
200{
201  dispatch_async(dispatch_get_main_queue(), ^{
202    [_loadingView updateStatusWithProgress:progress];
203  });
204}
205
206- (void)appLoader:(EXKernelAppLoader *)appLoader didFinishLoadingManifest:(NSDictionary *)manifest bundle:(NSData *)data
207{
208  [self _whenManifestIsValidToOpen:manifest performBlock:^{
209    [self _rebuildBridgeWithLoadingViewManifest:manifest];
210    if (_appRecord.appManager.status == kEXReactAppManagerStatusBridgeLoading) {
211      [_appRecord.appManager appLoaderFinished];
212    }
213  }];
214}
215
216- (void)appLoader:(EXKernelAppLoader *)appLoader didFailWithError:(NSError *)error
217{
218  if (_appRecord.appManager.status == kEXReactAppManagerStatusBridgeLoading) {
219    [_appRecord.appManager appLoaderFailedWithError:error];
220  }
221  [self maybeShowError:error];
222}
223
224- (void)appLoader:(EXKernelAppLoader *)appLoader didResolveUpdatedBundleWithManifest:(NSDictionary * _Nullable)manifest isFromCache:(BOOL)isFromCache error:(NSError * _Nullable)error
225{
226  [[EXKernel sharedInstance].serviceRegistry.updatesManager notifyApp:_appRecord ofDownloadWithManifest:manifest isNew:!isFromCache error:error];
227}
228
229#pragma mark - EXReactAppManagerDelegate
230
231- (void)reactAppManagerIsReadyForLoad:(EXReactAppManager *)appManager
232{
233  UIView *reactView = appManager.rootView;
234  reactView.frame = CGRectMake(0, 0, self.view.frame.size.width, self.view.frame.size.height);
235  reactView.autoresizingMask = UIViewAutoresizingFlexibleWidth | UIViewAutoresizingFlexibleHeight;
236  reactView.backgroundColor = [UIColor clearColor];
237
238  [_contentView removeFromSuperview];
239  _contentView = reactView;
240  [self.view addSubview:_contentView];
241  [self.view sendSubviewToBack:_contentView];
242
243  [reactView becomeFirstResponder];
244}
245
246- (void)reactAppManagerStartedLoadingJavaScript:(EXReactAppManager *)appManager
247{
248  EXAssertMainThread();
249  self.isLoading = YES;
250}
251
252- (void)reactAppManagerFinishedLoadingJavaScript:(EXReactAppManager *)appManager
253{
254  EXAssertMainThread();
255  self.isLoading = NO;
256  if ([EXKernel sharedInstance].browserController) {
257    [[EXKernel sharedInstance].browserController appDidFinishLoadingSuccessfully:_appRecord];
258  }
259}
260
261- (void)reactAppManager:(EXReactAppManager *)appManager failedToLoadJavaScriptWithError:(NSError *)error
262{
263  EXAssertMainThread();
264  [self maybeShowError:error];
265}
266
267- (void)reactAppManagerDidInvalidate:(EXReactAppManager *)appManager
268{
269
270}
271
272- (void)errorViewDidSelectRetry:(EXErrorView *)errorView
273{
274  [self refresh];
275}
276
277#pragma mark - orientation
278
279- (UIInterfaceOrientationMask)supportedInterfaceOrientations
280{
281  if (_supportedInterfaceOrientations != EX_INTERFACE_ORIENTATION_USE_MANIFEST) {
282    return _supportedInterfaceOrientations;
283  }
284  if (_appRecord.appLoader.manifest) {
285    NSString *orientationConfig = _appRecord.appLoader.manifest[@"orientation"];
286    if ([orientationConfig isEqualToString:@"portrait"]) {
287      // lock to portrait
288      return UIInterfaceOrientationMaskPortrait;
289    } else if ([orientationConfig isEqualToString:@"landscape"]) {
290      // lock to landscape
291      return UIInterfaceOrientationMaskLandscape;
292    }
293  }
294  // no config or default value: allow autorotation
295  return UIInterfaceOrientationMaskAllButUpsideDown;
296}
297
298- (void)setSupportedInterfaceOrientations:(UIInterfaceOrientationMask)supportedInterfaceOrientations
299{
300  _supportedInterfaceOrientations = supportedInterfaceOrientations;
301  [self _enforceDesiredDeviceOrientation];
302}
303
304- (void)_enforceDesiredDeviceOrientation
305{
306  RCTAssertMainQueue();
307  UIInterfaceOrientationMask mask = [self supportedInterfaceOrientations];
308  UIDeviceOrientation currentOrientation = [[UIDevice currentDevice] orientation];
309  UIInterfaceOrientation newOrientation = UIInterfaceOrientationUnknown;
310  switch (mask) {
311    case UIInterfaceOrientationMaskPortrait:
312      if (!UIDeviceOrientationIsPortrait(currentOrientation)) {
313        newOrientation = UIInterfaceOrientationPortrait;
314      }
315      break;
316    case UIInterfaceOrientationMaskPortraitUpsideDown:
317      newOrientation = UIInterfaceOrientationPortraitUpsideDown;
318      break;
319    case UIInterfaceOrientationMaskLandscape:
320      if (!UIDeviceOrientationIsLandscape(currentOrientation)) {
321        newOrientation = UIInterfaceOrientationLandscapeLeft;
322      }
323      break;
324    case UIInterfaceOrientationMaskLandscapeLeft:
325      newOrientation = UIInterfaceOrientationLandscapeLeft;
326      break;
327    case UIInterfaceOrientationMaskLandscapeRight:
328      newOrientation = UIInterfaceOrientationLandscapeRight;
329      break;
330    case UIInterfaceOrientationMaskAllButUpsideDown:
331      if (currentOrientation == UIDeviceOrientationFaceDown) {
332        newOrientation = UIInterfaceOrientationPortrait;
333      }
334      break;
335    default:
336      break;
337  }
338  if (newOrientation != UIInterfaceOrientationUnknown) {
339    [[UIDevice currentDevice] setValue:@(newOrientation) forKey:@"orientation"];
340  }
341  [UIViewController attemptRotationToDeviceOrientation];
342}
343
344#pragma mark - Internal
345
346- (void)_showErrorWithType:(EXFatalErrorType)type error:(nullable NSError *)error
347{
348  EXAssertMainThread();
349  _dtmLastFatalErrorShown = [NSDate date];
350  if (_errorView && _contentView == _errorView) {
351    // already showing, just update
352    _errorView.type = type;
353    _errorView.error = error;
354  } {
355    [_contentView removeFromSuperview];
356    if (!_errorView) {
357      _errorView = [[EXErrorView alloc] initWithFrame:CGRectMake(0, 0, self.view.frame.size.width, self.view.frame.size.height)];
358      _errorView.delegate = self;
359      _errorView.appRecord = _appRecord;
360    }
361    _errorView.type = type;
362    _errorView.error = error;
363    _contentView = _errorView;
364    [self.view addSubview:_contentView];
365    [[EXAnalytics sharedInstance] logErrorVisibleEvent];
366  }
367}
368
369- (void)setIsLoading:(BOOL)isLoading
370{
371  if ([_appRecord.appManager enablesDeveloperTools] && _dtmLastFatalErrorShown) {
372    if ([_dtmLastFatalErrorShown timeIntervalSinceNow] >= -kEXDevelopmentErrorCoolDownSeconds) {
373      // we just showed a fatal error very recently, do not begin loading.
374      // this can happen in some cases where react native sends the 'started loading' notif
375      // in spite of a packager error.
376      return;
377    }
378  }
379  _isLoading = isLoading;
380  dispatch_async(dispatch_get_main_queue(), ^{
381    if (isLoading) {
382      self.loadingView.hidden = NO;
383      [self.view bringSubviewToFront:self.loadingView];
384    } else {
385      self.loadingView.hidden = YES;
386    }
387  });
388}
389
390#pragma mark - error recovery
391
392- (BOOL)_willAutoRecoverFromError:(NSError *)error
393{
394  if (error.code == kEXErrorCodeAppForbidden) {
395    return NO;
396  }
397  if (![_appRecord.appManager enablesDeveloperTools]) {
398    BOOL shouldRecover = [[EXKernel sharedInstance].serviceRegistry.errorRecoveryManager experienceIdShouldReloadOnError:_appRecord.experienceId];
399    if (shouldRecover) {
400      [self _invalidateRecoveryTimer];
401      _tmrAutoReloadDebounce = [NSTimer scheduledTimerWithTimeInterval:kEXAutoReloadDebounceSeconds
402                                                                target:self
403                                                              selector:@selector(refresh)
404                                                              userInfo:nil
405                                                               repeats:NO];
406    }
407    return shouldRecover;
408  }
409  return NO;
410}
411
412- (void)_invalidateRecoveryTimer
413{
414  if (_tmrAutoReloadDebounce) {
415    [_tmrAutoReloadDebounce invalidate];
416    _tmrAutoReloadDebounce = nil;
417  }
418}
419
420- (void)_whenManifestIsValidToOpen:(NSDictionary *)manifest performBlock:(void (^)(void))block
421{
422  if (self.appRecord.appManager.requiresValidManifests && [EXKernel sharedInstance].browserController) {
423    [[EXKernel sharedInstance].browserController getIsValidHomeManifestToOpen:manifest completion:^(BOOL isValid) {
424      if (isValid) {
425        block();
426      } else {
427        [self appLoader:_appRecord.appLoader didFailWithError:[NSError errorWithDomain:EXNetworkErrorDomain
428                                                                                  code:kEXErrorCodeAppForbidden
429                                                                              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." }]];
430      }
431    }];
432  } else {
433    // no browser present, everything is valid
434    block();
435  }
436}
437
438@end
439
440NS_ASSUME_NONNULL_END
441