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