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  if ([EXKernel sharedInstance].browserController) {
223    [[EXKernel sharedInstance].browserController addHistoryItemWithUrl:appLoader.manifestUrl manifest:manifest];
224  }
225  [self _rebuildBridgeWithLoadingViewManifest:manifest];
226}
227
228- (void)appLoader:(EXAppLoader *)appLoader didLoadBundleWithProgress:(EXLoadingProgress *)progress
229{
230  dispatch_async(dispatch_get_main_queue(), ^{
231    [self->_loadingView updateStatusWithProgress:progress];
232  });
233}
234
235- (void)appLoader:(EXAppLoader *)appLoader didFinishLoadingManifest:(NSDictionary *)manifest bundle:(NSData *)data
236{
237  [self _rebuildBridgeWithLoadingViewManifest:manifest];
238  if (self->_appRecord.appManager.status == kEXReactAppManagerStatusBridgeLoading) {
239    [self->_appRecord.appManager appLoaderFinished];
240  }
241}
242
243- (void)appLoader:(EXAppLoader *)appLoader didFailWithError:(NSError *)error
244{
245  if (_appRecord.appManager.status == kEXReactAppManagerStatusBridgeLoading) {
246    [_appRecord.appManager appLoaderFailedWithError:error];
247  }
248  [self maybeShowError:error];
249}
250
251- (void)appLoader:(EXAppLoader *)appLoader didResolveUpdatedBundleWithManifest:(NSDictionary * _Nullable)manifest isFromCache:(BOOL)isFromCache error:(NSError * _Nullable)error
252{
253  [[EXKernel sharedInstance].serviceRegistry.updatesManager notifyApp:_appRecord ofDownloadWithManifest:manifest isNew:!isFromCache error:error];
254}
255
256#pragma mark - EXReactAppManagerDelegate
257
258- (void)reactAppManagerIsReadyForLoad:(EXReactAppManager *)appManager
259{
260  UIView *reactView = appManager.rootView;
261  reactView.frame = CGRectMake(0, 0, self.view.frame.size.width, self.view.frame.size.height);
262  reactView.autoresizingMask = UIViewAutoresizingFlexibleWidth | UIViewAutoresizingFlexibleHeight;
263  reactView.backgroundColor = [UIColor clearColor];
264
265  [_contentView removeFromSuperview];
266  _contentView = reactView;
267  [self.view addSubview:_contentView];
268  [self.view sendSubviewToBack:_contentView];
269
270  [reactView becomeFirstResponder];
271}
272
273- (void)reactAppManagerStartedLoadingJavaScript:(EXReactAppManager *)appManager
274{
275  EXAssertMainThread();
276  self.isLoading = YES;
277}
278
279- (void)reactAppManagerFinishedLoadingJavaScript:(EXReactAppManager *)appManager
280{
281  EXAssertMainThread();
282  self.isLoading = NO;
283  if ([EXKernel sharedInstance].browserController) {
284    [[EXKernel sharedInstance].browserController appDidFinishLoadingSuccessfully:_appRecord];
285  }
286}
287
288- (void)reactAppManager:(EXReactAppManager *)appManager failedToLoadJavaScriptWithError:(NSError *)error
289{
290  EXAssertMainThread();
291  [self maybeShowError:error];
292}
293
294- (void)reactAppManagerDidInvalidate:(EXReactAppManager *)appManager
295{
296
297}
298
299- (void)errorViewDidSelectRetry:(EXErrorView *)errorView
300{
301  [self refresh];
302}
303
304#pragma mark - orientation
305
306- (UIInterfaceOrientationMask)supportedInterfaceOrientations
307{
308  if (_supportedInterfaceOrientations != EX_INTERFACE_ORIENTATION_USE_MANIFEST) {
309    return _supportedInterfaceOrientations;
310  }
311  if (_appRecord.appLoader.manifest) {
312    NSString *orientationConfig = _appRecord.appLoader.manifest[@"orientation"];
313    if ([orientationConfig isEqualToString:@"portrait"]) {
314      // lock to portrait
315      return UIInterfaceOrientationMaskPortrait;
316    } else if ([orientationConfig isEqualToString:@"landscape"]) {
317      // lock to landscape
318      return UIInterfaceOrientationMaskLandscape;
319    }
320  }
321  // no config or default value: allow autorotation
322  return UIInterfaceOrientationMaskAllButUpsideDown;
323}
324
325- (void)setSupportedInterfaceOrientations:(UIInterfaceOrientationMask)supportedInterfaceOrientations
326{
327  _supportedInterfaceOrientations = supportedInterfaceOrientations;
328  [self _enforceDesiredDeviceOrientation];
329}
330
331- (void)traitCollectionDidChange:(nullable UITraitCollection *)previousTraitCollection {
332  [super traitCollectionDidChange:previousTraitCollection];
333  if ((self.traitCollection.verticalSizeClass != previousTraitCollection.verticalSizeClass)
334      || (self.traitCollection.horizontalSizeClass != previousTraitCollection.horizontalSizeClass)) {
335    [[EXKernel sharedInstance].serviceRegistry.screenOrientationManager handleScreenOrientationChange:self.traitCollection];
336  }
337}
338
339- (void)_enforceDesiredDeviceOrientation
340{
341  RCTAssertMainQueue();
342  UIInterfaceOrientationMask mask = [self supportedInterfaceOrientations];
343  UIDeviceOrientation currentOrientation = [[UIDevice currentDevice] orientation];
344  UIInterfaceOrientation newOrientation = UIInterfaceOrientationUnknown;
345  switch (mask) {
346    case UIInterfaceOrientationMaskPortrait:
347      if (!UIDeviceOrientationIsPortrait(currentOrientation)) {
348        newOrientation = UIInterfaceOrientationPortrait;
349      }
350      break;
351    case UIInterfaceOrientationMaskPortraitUpsideDown:
352      newOrientation = UIInterfaceOrientationPortraitUpsideDown;
353      break;
354    case UIInterfaceOrientationMaskLandscape:
355      if (!UIDeviceOrientationIsLandscape(currentOrientation)) {
356        newOrientation = UIInterfaceOrientationLandscapeLeft;
357      }
358      break;
359    case UIInterfaceOrientationMaskLandscapeLeft:
360      newOrientation = UIInterfaceOrientationLandscapeLeft;
361      break;
362    case UIInterfaceOrientationMaskLandscapeRight:
363      newOrientation = UIInterfaceOrientationLandscapeRight;
364      break;
365    case UIInterfaceOrientationMaskAllButUpsideDown:
366      if (currentOrientation == UIDeviceOrientationFaceDown) {
367        newOrientation = UIInterfaceOrientationPortrait;
368      }
369      break;
370    default:
371      break;
372  }
373  if (newOrientation != UIInterfaceOrientationUnknown) {
374    [[UIDevice currentDevice] setValue:@(newOrientation) forKey:@"orientation"];
375  }
376  [UIViewController attemptRotationToDeviceOrientation];
377}
378
379#pragma mark - Internal
380
381- (void)_showErrorWithType:(EXFatalErrorType)type error:(nullable NSError *)error
382{
383  EXAssertMainThread();
384  _dtmLastFatalErrorShown = [NSDate date];
385  if (_errorView && _contentView == _errorView) {
386    // already showing, just update
387    _errorView.type = type;
388    _errorView.error = error;
389  } {
390    [_contentView removeFromSuperview];
391    if (!_errorView) {
392      _errorView = [[EXErrorView alloc] initWithFrame:CGRectMake(0, 0, self.view.frame.size.width, self.view.frame.size.height)];
393      _errorView.delegate = self;
394      _errorView.appRecord = _appRecord;
395    }
396    _errorView.type = type;
397    _errorView.error = error;
398    _contentView = _errorView;
399    [self.view addSubview:_contentView];
400    [[EXAnalytics sharedInstance] logErrorVisibleEvent];
401  }
402}
403
404- (void)setIsLoading:(BOOL)isLoading
405{
406  if ([_appRecord.appManager enablesDeveloperTools] && _dtmLastFatalErrorShown) {
407    if ([_dtmLastFatalErrorShown timeIntervalSinceNow] >= -kEXDevelopmentErrorCoolDownSeconds) {
408      // we just showed a fatal error very recently, do not begin loading.
409      // this can happen in some cases where react native sends the 'started loading' notif
410      // in spite of a packager error.
411      return;
412    }
413  }
414  _isLoading = isLoading;
415  dispatch_async(dispatch_get_main_queue(), ^{
416    if (isLoading) {
417      self.loadingView.hidden = NO;
418      [self.view bringSubviewToFront:self.loadingView];
419    } else {
420      self.loadingView.hidden = YES;
421    }
422  });
423}
424
425#pragma mark - error recovery
426
427- (BOOL)_willAutoRecoverFromError:(NSError *)error
428{
429  if (![_appRecord.appManager enablesDeveloperTools]) {
430    BOOL shouldRecover = [[EXKernel sharedInstance].serviceRegistry.errorRecoveryManager experienceIdShouldReloadOnError:_appRecord.experienceId];
431    if (shouldRecover) {
432      [self _invalidateRecoveryTimer];
433      _tmrAutoReloadDebounce = [NSTimer scheduledTimerWithTimeInterval:kEXAutoReloadDebounceSeconds
434                                                                target:self
435                                                              selector:@selector(refresh)
436                                                              userInfo:nil
437                                                               repeats:NO];
438    }
439    return shouldRecover;
440  }
441  return NO;
442}
443
444- (void)_invalidateRecoveryTimer
445{
446  if (_tmrAutoReloadDebounce) {
447    [_tmrAutoReloadDebounce invalidate];
448    _tmrAutoReloadDebounce = nil;
449  }
450}
451
452@end
453
454NS_ASSUME_NONNULL_END
455