// Copyright 2015-present 650 Industries. All rights reserved. @import UIKit; #import "EXAnalytics.h" #import "EXAppLoader.h" #import "EXAppLoadingView.h" #import "EXAppViewController.h" #import "EXEnvironment.h" #import "EXErrorRecoveryManager.h" #import "EXErrorView.h" #import "EXFileDownloader.h" #import "EXKernel.h" #import "EXKernelUtil.h" #import "EXReactAppManager.h" #import "EXScreenOrientationManager.h" #import "EXVersions.h" #import "EXUpdatesManager.h" #import "EXUtil.h" #import #if __has_include() #import #endif #import #define EX_INTERFACE_ORIENTATION_USE_MANIFEST 0 // when we encounter an error and auto-refresh, we may actually see a series of errors. // we only want to trigger refresh once, so we debounce refresh on a timer. const CGFloat kEXAutoReloadDebounceSeconds = 0.1; // in development only, some errors can happen before we even start loading // (e.g. certain packager errors, such as an invalid bundle url) // and we want to make sure not to cover the error with a loading view or other chrome. const CGFloat kEXDevelopmentErrorCoolDownSeconds = 0.1; NS_ASSUME_NONNULL_BEGIN @interface EXAppViewController () @property (nonatomic, assign) BOOL isLoading; @property (nonatomic, assign) BOOL isBridgeAlreadyLoading; @property (nonatomic, weak) EXKernelAppRecord *appRecord; @property (nonatomic, strong) EXAppLoadingView *loadingView; @property (nonatomic, strong) EXErrorView *errorView; @property (nonatomic, assign) UIInterfaceOrientationMask supportedInterfaceOrientations; // override super @property (nonatomic, strong) NSTimer *tmrAutoReloadDebounce; @property (nonatomic, strong) NSDate *dtmLastFatalErrorShown; @property (nonatomic, strong) NSMutableArray *backgroundedControllers; @end @implementation EXAppViewController @synthesize supportedInterfaceOrientations = _supportedInterfaceOrientations; #pragma mark - Lifecycle - (instancetype)initWithAppRecord:(EXKernelAppRecord *)record { if (self = [super init]) { _appRecord = record; _supportedInterfaceOrientations = EX_INTERFACE_ORIENTATION_USE_MANIFEST; } return self; } - (void)dealloc { [self _invalidateRecoveryTimer]; [[NSNotificationCenter defaultCenter] removeObserver:self]; } - (void)viewDidLoad { [super viewDidLoad]; self.view.backgroundColor = [UIColor whiteColor]; _loadingView = [[EXAppLoadingView alloc] initWithAppRecord:_appRecord]; [self.view addSubview:_loadingView]; _appRecord.appManager.delegate = self; self.isLoading = YES; } - (void)viewDidAppear:(BOOL)animated { [super viewDidAppear:animated]; if (_appRecord && _appRecord.status == kEXKernelAppRecordStatusNew) { _appRecord.appLoader.delegate = self; _appRecord.appLoader.dataSource = _appRecord.appManager; [self refresh]; } } - (BOOL)shouldAutorotate { return YES; } - (void)viewWillLayoutSubviews { [super viewWillLayoutSubviews]; if (_loadingView) { _loadingView.frame = CGRectMake(0, 0, self.view.frame.size.width, self.view.frame.size.height); } if (_contentView) { _contentView.frame = CGRectMake(0, 0, self.view.frame.size.width, self.view.frame.size.height); } } /** * Force presented view controllers to use the same user interface style. */ - (void)presentViewController:(UIViewController *)viewControllerToPresent animated: (BOOL)flag completion:(void (^ __nullable)(void))completion { [super presentViewController:viewControllerToPresent animated:flag completion:completion]; [self _overrideUserInterfaceStyleOf:viewControllerToPresent]; } /** * Force child view controllers to use the same user interface style. */ - (void)addChildViewController:(UIViewController *)childController { [super addChildViewController:childController]; [self _overrideUserInterfaceStyleOf:childController]; } #pragma mark - Public - (void)maybeShowError:(NSError *)error { self.isLoading = NO; if ([self _willAutoRecoverFromError:error]) { return; } if (error && ![error isKindOfClass:[NSError class]]) { #if DEBUG NSAssert(NO, @"AppViewController error handler was called on an object that isn't an NSError"); #endif return; } // we don't ever want to show any Expo UI in a production standalone app, so hard crash if ([EXEnvironment sharedEnvironment].isDetached && ![_appRecord.appManager enablesDeveloperTools]) { NSException *e = [NSException exceptionWithName:@"ExpoFatalError" reason:[NSString stringWithFormat:@"Expo encountered a fatal error: %@", [error localizedDescription]] userInfo:@{NSUnderlyingErrorKey: error}]; @throw e; } NSString *domain = (error && error.domain) ? error.domain : @""; BOOL isNetworkError = ([domain isEqualToString:(NSString *)kCFErrorDomainCFNetwork] || [domain isEqualToString:EXNetworkErrorDomain]); if (isNetworkError) { // show a human-readable reachability error dispatch_async(dispatch_get_main_queue(), ^{ [self _showErrorWithType:kEXFatalErrorTypeLoading error:error]; }); } else if ([domain isEqualToString:@"JSServer"] && [_appRecord.appManager enablesDeveloperTools]) { // RCTRedBox already handled this } else if ([domain rangeOfString:RCTErrorDomain].length > 0 && [_appRecord.appManager enablesDeveloperTools]) { // RCTRedBox already handled this } else { dispatch_async(dispatch_get_main_queue(), ^{ [self _showErrorWithType:kEXFatalErrorTypeException error:error]; }); } } - (void)_rebuildBridge { [self _invalidateRecoveryTimer]; [[EXKernel sharedInstance] logAnalyticsEvent:@"LOAD_EXPERIENCE" forAppRecord:_appRecord]; [_appRecord.appManager rebuildBridge]; } - (void)refresh { self.isLoading = YES; self.isBridgeAlreadyLoading = NO; [self _invalidateRecoveryTimer]; [_appRecord.appLoader request]; } - (void)reloadFromCache { self.isLoading = YES; self.isBridgeAlreadyLoading = NO; [self _invalidateRecoveryTimer]; [_appRecord.appLoader requestFromCache]; } - (void)appStateDidBecomeActive { dispatch_async(dispatch_get_main_queue(), ^{ [self _enforceDesiredDeviceOrientation]; // Reset the root view background color and window color if we switch between Expo home and project [self _setBackgroundColor:self.view]; }); [_appRecord.appManager appStateDidBecomeActive]; } - (void)appStateDidBecomeInactive { [_appRecord.appManager appStateDidBecomeInactive]; } - (void)_rebuildBridgeWithLoadingViewManifest:(NSDictionary *)manifest { if (!self.isBridgeAlreadyLoading) { self.isBridgeAlreadyLoading = YES; dispatch_async(dispatch_get_main_queue(), ^{ self->_loadingView.manifest = manifest; [self _overrideUserInterfaceStyleOf:self]; [self _enforceDesiredDeviceOrientation]; [self _rebuildBridge]; }); } } - (void)foregroundControllers { if (_backgroundedControllers != nil) { __block UIViewController *parentController = self; [_backgroundedControllers enumerateObjectsUsingBlock:^(UIViewController * _Nonnull viewController, NSUInteger idx, BOOL * _Nonnull stop) { [parentController presentViewController:viewController animated:NO completion:nil]; parentController = viewController; }]; _backgroundedControllers = nil; } } - (void)backgroundControllers { UIViewController *childController = [self presentedViewController]; if (childController != nil) { if (_backgroundedControllers == nil) { _backgroundedControllers = [NSMutableArray new]; } while (childController != nil) { [_backgroundedControllers addObject:childController]; childController = childController.presentedViewController; } } } #pragma mark - EXAppLoaderDelegate - (void)appLoader:(EXAppLoader *)appLoader didLoadOptimisticManifest:(NSDictionary *)manifest { if ([EXKernel sharedInstance].browserController) { [[EXKernel sharedInstance].browserController addHistoryItemWithUrl:appLoader.manifestUrl manifest:manifest]; } [self _rebuildBridgeWithLoadingViewManifest:manifest]; } - (void)appLoader:(EXAppLoader *)appLoader didLoadBundleWithProgress:(EXLoadingProgress *)progress { dispatch_async(dispatch_get_main_queue(), ^{ [self->_loadingView updateStatusWithProgress:progress]; }); } - (void)appLoader:(EXAppLoader *)appLoader didFinishLoadingManifest:(NSDictionary *)manifest bundle:(NSData *)data { [self _rebuildBridgeWithLoadingViewManifest:manifest]; if (self->_appRecord.appManager.status == kEXReactAppManagerStatusBridgeLoading) { [self->_appRecord.appManager appLoaderFinished]; } } - (void)appLoader:(EXAppLoader *)appLoader didFailWithError:(NSError *)error { if (_appRecord.appManager.status == kEXReactAppManagerStatusBridgeLoading) { [_appRecord.appManager appLoaderFailedWithError:error]; } [self maybeShowError:error]; } - (void)appLoader:(EXAppLoader *)appLoader didResolveUpdatedBundleWithManifest:(NSDictionary * _Nullable)manifest isFromCache:(BOOL)isFromCache error:(NSError * _Nullable)error { [[EXKernel sharedInstance].serviceRegistry.updatesManager notifyApp:_appRecord ofDownloadWithManifest:manifest isNew:!isFromCache error:error]; } #pragma mark - EXReactAppManagerDelegate - (void)reactAppManagerIsReadyForLoad:(EXReactAppManager *)appManager { UIView *reactView = appManager.rootView; reactView.frame = CGRectMake(0, 0, self.view.frame.size.width, self.view.frame.size.height); reactView.autoresizingMask = UIViewAutoresizingFlexibleWidth | UIViewAutoresizingFlexibleHeight; [_contentView removeFromSuperview]; _contentView = reactView; [self.view addSubview:_contentView]; [self.view sendSubviewToBack:_contentView]; [reactView becomeFirstResponder]; // Set root view background color after adding as subview so we can access window [self _setBackgroundColor:reactView]; } - (void)reactAppManagerStartedLoadingJavaScript:(EXReactAppManager *)appManager { EXAssertMainThread(); self.isLoading = YES; } - (void)reactAppManagerFinishedLoadingJavaScript:(EXReactAppManager *)appManager { EXAssertMainThread(); self.isLoading = NO; if ([EXKernel sharedInstance].browserController) { [[EXKernel sharedInstance].browserController appDidFinishLoadingSuccessfully:_appRecord]; } } - (void)reactAppManager:(EXReactAppManager *)appManager failedToLoadJavaScriptWithError:(NSError *)error { EXAssertMainThread(); [self maybeShowError:error]; } - (void)reactAppManagerDidInvalidate:(EXReactAppManager *)appManager { } - (void)errorViewDidSelectRetry:(EXErrorView *)errorView { [self refresh]; } #pragma mark - orientation - (UIInterfaceOrientationMask)supportedInterfaceOrientations { #if __has_include() EXScreenOrientationRegistry *screenOrientationRegistry = (EXScreenOrientationRegistry *)[UMModuleRegistryProvider getSingletonModuleForClass:[EXScreenOrientationRegistry class]]; if (screenOrientationRegistry && [screenOrientationRegistry requiredOrientationMask] > 0) { return [screenOrientationRegistry requiredOrientationMask]; } #endif // TODO: Remove once sdk 37 is phased out if (_supportedInterfaceOrientations != EX_INTERFACE_ORIENTATION_USE_MANIFEST) { return _supportedInterfaceOrientations; } return [self orientationMaskFromManifestOrDefault]; } - (UIInterfaceOrientationMask)orientationMaskFromManifestOrDefault { if (_appRecord.appLoader.manifest) { NSString *orientationConfig = _appRecord.appLoader.manifest[@"orientation"]; if ([orientationConfig isEqualToString:@"portrait"]) { // lock to portrait return UIInterfaceOrientationMaskPortrait; } else if ([orientationConfig isEqualToString:@"landscape"]) { // lock to landscape return UIInterfaceOrientationMaskLandscape; } } // no config or default value: allow autorotation return UIInterfaceOrientationMaskAllButUpsideDown; } // TODO: Remove once sdk 37 is phased out - (void)setSupportedInterfaceOrientations:(UIInterfaceOrientationMask)supportedInterfaceOrientations { _supportedInterfaceOrientations = supportedInterfaceOrientations; [self _enforceDesiredDeviceOrientation]; } - (void)traitCollectionDidChange:(nullable UITraitCollection *)previousTraitCollection { [super traitCollectionDidChange:previousTraitCollection]; if ((self.traitCollection.verticalSizeClass != previousTraitCollection.verticalSizeClass) || (self.traitCollection.horizontalSizeClass != previousTraitCollection.horizontalSizeClass)) { #if __has_include() EXScreenOrientationRegistry *screenOrientationRegistryController = (EXScreenOrientationRegistry *)[UMModuleRegistryProvider getSingletonModuleForClass:[EXScreenOrientationRegistry class]]; [screenOrientationRegistryController traitCollectionDidChangeTo:self.traitCollection]; #endif // TODO: Remove once sdk 37 is phased out [[EXKernel sharedInstance].serviceRegistry.screenOrientationManager handleScreenOrientationChange:self.traitCollection]; } } // TODO: Remove once sdk 37 is phased out - (void)_enforceDesiredDeviceOrientation { RCTAssertMainQueue(); UIInterfaceOrientationMask mask = [self supportedInterfaceOrientations]; UIDeviceOrientation currentOrientation = [[UIDevice currentDevice] orientation]; UIInterfaceOrientation newOrientation = UIInterfaceOrientationUnknown; switch (mask) { case UIInterfaceOrientationMaskPortrait | UIInterfaceOrientationMaskPortraitUpsideDown: if (!UIDeviceOrientationIsPortrait(currentOrientation)) { newOrientation = UIInterfaceOrientationPortrait; } break; case UIInterfaceOrientationMaskPortrait: newOrientation = UIInterfaceOrientationPortrait; break; case UIInterfaceOrientationMaskPortraitUpsideDown: newOrientation = UIInterfaceOrientationPortraitUpsideDown; break; case UIInterfaceOrientationMaskLandscape: if (!UIDeviceOrientationIsLandscape(currentOrientation)) { newOrientation = UIInterfaceOrientationLandscapeLeft; } break; case UIInterfaceOrientationMaskLandscapeLeft: newOrientation = UIInterfaceOrientationLandscapeLeft; break; case UIInterfaceOrientationMaskLandscapeRight: newOrientation = UIInterfaceOrientationLandscapeRight; break; case UIInterfaceOrientationMaskAllButUpsideDown: if (currentOrientation == UIDeviceOrientationFaceDown) { newOrientation = UIInterfaceOrientationPortrait; } break; default: break; } if (newOrientation != UIInterfaceOrientationUnknown) { [[UIDevice currentDevice] setValue:@(newOrientation) forKey:@"orientation"]; } [UIViewController attemptRotationToDeviceOrientation]; } #pragma mark - user interface style - (void)_overrideUserInterfaceStyleOf:(UIViewController *)viewController { if (@available(iOS 13.0, *)) { NSString *userInterfaceStyle = [self _readUserInterfaceStyleFromManifest:_appRecord.appLoader.manifest]; viewController.overrideUserInterfaceStyle = [self _userInterfaceStyleForString:userInterfaceStyle]; } } - (NSString * _Nullable)_readUserInterfaceStyleFromManifest:(NSDictionary *)manifest { if (manifest[@"ios"] && manifest[@"ios"][@"userInterfaceStyle"]) { return manifest[@"ios"][@"userInterfaceStyle"]; } return manifest[@"userInterfaceStyle"]; } - (UIUserInterfaceStyle)_userInterfaceStyleForString:(NSString *)userInterfaceStyleString API_AVAILABLE(ios(12.0)) { if ([userInterfaceStyleString isEqualToString:@"dark"]) { return UIUserInterfaceStyleDark; } if ([userInterfaceStyleString isEqualToString:@"automatic"]) { return UIUserInterfaceStyleUnspecified; } return UIUserInterfaceStyleLight; } #pragma mark - root view and window background color - (void)_setBackgroundColor:(UIView *)view { NSString *backgroundColorString = [self _readBackgroundColorFromManifest:_appRecord.appLoader.manifest]; UIColor *backgroundColor = [EXUtil colorWithHexString:backgroundColorString]; if (backgroundColor) { view.backgroundColor = backgroundColor; // NOTE(brentvatne): it may be desirable at some point to split the window backgroundColor out from the // root view, we can do if use case is presented to us. view.window.backgroundColor = backgroundColor; } else { view.backgroundColor = [UIColor whiteColor]; // NOTE(brentvatne): we used to use white as a default background color for window but this caused // problems when using form sheet presentation style with vcs eg: and native-stack. Most // users expect the background behind these to be black, which is the default if backgroundColor is nil. view.window.backgroundColor = nil; // NOTE(brentvatne): we may want to default to respecting the default system background color // on iOS13 and higher, but if we do make this choice then we will have to implement it on Android // as well. This would also be a breaking change. Leaaving this here as a placeholder for the future. // if (@available(iOS 13.0, *)) { // view.backgroundColor = [UIColor systemBackgroundColor]; // } else { // view.backgroundColor = [UIColor whiteColor]; // } } } - (NSString * _Nullable)_readBackgroundColorFromManifest:(NSDictionary *)manifest { if (manifest[@"ios"] && manifest[@"ios"][@"backgroundColor"]) { return manifest[@"ios"][@"backgroundColor"]; } return manifest[@"backgroundColor"]; } #pragma mark - Internal - (void)_showErrorWithType:(EXFatalErrorType)type error:(nullable NSError *)error { EXAssertMainThread(); _dtmLastFatalErrorShown = [NSDate date]; if (_errorView && _contentView == _errorView) { // already showing, just update _errorView.type = type; _errorView.error = error; } { [_contentView removeFromSuperview]; if (!_errorView) { _errorView = [[EXErrorView alloc] initWithFrame:CGRectMake(0, 0, self.view.frame.size.width, self.view.frame.size.height)]; _errorView.delegate = self; _errorView.appRecord = _appRecord; } _errorView.type = type; _errorView.error = error; _contentView = _errorView; [self.view addSubview:_contentView]; [[EXAnalytics sharedInstance] logErrorVisibleEvent]; } } - (void)setIsLoading:(BOOL)isLoading { if ([_appRecord.appManager enablesDeveloperTools] && _dtmLastFatalErrorShown) { if ([_dtmLastFatalErrorShown timeIntervalSinceNow] >= -kEXDevelopmentErrorCoolDownSeconds) { // we just showed a fatal error very recently, do not begin loading. // this can happen in some cases where react native sends the 'started loading' notif // in spite of a packager error. return; } } _isLoading = isLoading; dispatch_async(dispatch_get_main_queue(), ^{ if (isLoading) { self.loadingView.hidden = NO; [self.view bringSubviewToFront:self.loadingView]; } else { self.loadingView.hidden = YES; } }); } #pragma mark - error recovery - (BOOL)_willAutoRecoverFromError:(NSError *)error { if (![_appRecord.appManager enablesDeveloperTools]) { BOOL shouldRecover = [[EXKernel sharedInstance].serviceRegistry.errorRecoveryManager experienceIdShouldReloadOnError:_appRecord.experienceId]; if (shouldRecover) { [self _invalidateRecoveryTimer]; _tmrAutoReloadDebounce = [NSTimer scheduledTimerWithTimeInterval:kEXAutoReloadDebounceSeconds target:self selector:@selector(refresh) userInfo:nil repeats:NO]; } return shouldRecover; } return NO; } - (void)_invalidateRecoveryTimer { if (_tmrAutoReloadDebounce) { [_tmrAutoReloadDebounce invalidate]; _tmrAutoReloadDebounce = nil; } } @end NS_ASSUME_NONNULL_END