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#import "EXUtil.h" 19 20#import <React/RCTUtils.h> 21 22#define EX_INTERFACE_ORIENTATION_USE_MANIFEST 0 23 24// when we encounter an error and auto-refresh, we may actually see a series of errors. 25// we only want to trigger refresh once, so we debounce refresh on a timer. 26const CGFloat kEXAutoReloadDebounceSeconds = 0.1; 27 28// in development only, some errors can happen before we even start loading 29// (e.g. certain packager errors, such as an invalid bundle url) 30// and we want to make sure not to cover the error with a loading view or other chrome. 31const CGFloat kEXDevelopmentErrorCoolDownSeconds = 0.1; 32 33NS_ASSUME_NONNULL_BEGIN 34 35@interface EXAppViewController () 36 <EXReactAppManagerUIDelegate, EXAppLoaderDelegate, EXErrorViewDelegate> 37 38@property (nonatomic, assign) BOOL isLoading; 39@property (nonatomic, assign) BOOL isBridgeAlreadyLoading; 40@property (nonatomic, weak) EXKernelAppRecord *appRecord; 41@property (nonatomic, strong) EXAppLoadingView *loadingView; 42@property (nonatomic, strong) EXErrorView *errorView; 43@property (nonatomic, assign) UIInterfaceOrientationMask supportedInterfaceOrientations; // override super 44@property (nonatomic, strong) NSTimer *tmrAutoReloadDebounce; 45@property (nonatomic, strong) NSDate *dtmLastFatalErrorShown; 46@property (nonatomic, strong) NSMutableArray<UIViewController *> *backgroundedControllers; 47 48@end 49 50@implementation EXAppViewController 51 52@synthesize supportedInterfaceOrientations = _supportedInterfaceOrientations; 53 54#pragma mark - Lifecycle 55 56- (instancetype)initWithAppRecord:(EXKernelAppRecord *)record 57{ 58 if (self = [super init]) { 59 _appRecord = record; 60 _supportedInterfaceOrientations = EX_INTERFACE_ORIENTATION_USE_MANIFEST; 61 } 62 return self; 63} 64 65- (void)dealloc 66{ 67 [self _invalidateRecoveryTimer]; 68 [[NSNotificationCenter defaultCenter] removeObserver:self]; 69} 70 71- (void)viewDidLoad 72{ 73 [super viewDidLoad]; 74 75 // TODO(brentvatne): probably this should not just be UIColor whiteColor? 76 self.view.backgroundColor = [UIColor whiteColor]; 77 78 _loadingView = [[EXAppLoadingView alloc] initWithAppRecord:_appRecord]; 79 [self.view addSubview:_loadingView]; 80 _appRecord.appManager.delegate = self; 81 self.isLoading = YES; 82} 83 84- (void)viewDidAppear:(BOOL)animated 85{ 86 [super viewDidAppear:animated]; 87 if (_appRecord && _appRecord.status == kEXKernelAppRecordStatusNew) { 88 _appRecord.appLoader.delegate = self; 89 _appRecord.appLoader.dataSource = _appRecord.appManager; 90 [self refresh]; 91 } 92} 93 94- (BOOL)shouldAutorotate 95{ 96 return YES; 97} 98 99- (void)viewWillLayoutSubviews 100{ 101 [super viewWillLayoutSubviews]; 102 if (_loadingView) { 103 _loadingView.frame = CGRectMake(0, 0, self.view.frame.size.width, self.view.frame.size.height); 104 } 105 if (_contentView) { 106 _contentView.frame = CGRectMake(0, 0, self.view.frame.size.width, self.view.frame.size.height); 107 } 108} 109 110#pragma mark - Public 111 112- (void)maybeShowError:(NSError *)error 113{ 114 self.isLoading = NO; 115 if ([self _willAutoRecoverFromError:error]) { 116 return; 117 } 118 if (error && ![error isKindOfClass:[NSError class]]) { 119#if DEBUG 120 NSAssert(NO, @"AppViewController error handler was called on an object that isn't an NSError"); 121#endif 122 return; 123 } 124 125 // we don't ever want to show any Expo UI in a production standalone app, so hard crash 126 if ([EXEnvironment sharedEnvironment].isDetached && ![_appRecord.appManager enablesDeveloperTools]) { 127 NSException *e = [NSException exceptionWithName:@"ExpoFatalError" 128 reason:[NSString stringWithFormat:@"Expo encountered a fatal error: %@", [error localizedDescription]] 129 userInfo:@{NSUnderlyingErrorKey: error}]; 130 @throw e; 131 } 132 133 NSString *domain = (error && error.domain) ? error.domain : @""; 134 BOOL isNetworkError = ([domain isEqualToString:(NSString *)kCFErrorDomainCFNetwork] || [domain isEqualToString:EXNetworkErrorDomain]); 135 136 if (isNetworkError) { 137 // show a human-readable reachability error 138 dispatch_async(dispatch_get_main_queue(), ^{ 139 [self _showErrorWithType:kEXFatalErrorTypeLoading error:error]; 140 }); 141 } else if ([domain isEqualToString:@"JSServer"] && [_appRecord.appManager enablesDeveloperTools]) { 142 // RCTRedBox already handled this 143 } else if ([domain rangeOfString:RCTErrorDomain].length > 0 && [_appRecord.appManager enablesDeveloperTools]) { 144 // RCTRedBox already handled this 145 } else { 146 dispatch_async(dispatch_get_main_queue(), ^{ 147 [self _showErrorWithType:kEXFatalErrorTypeException error:error]; 148 }); 149 } 150} 151 152- (void)_rebuildBridge 153{ 154 [self _invalidateRecoveryTimer]; 155 [[EXKernel sharedInstance] logAnalyticsEvent:@"LOAD_EXPERIENCE" forAppRecord:_appRecord]; 156 [_appRecord.appManager rebuildBridge]; 157} 158 159- (void)refresh 160{ 161 self.isLoading = YES; 162 self.isBridgeAlreadyLoading = NO; 163 [self _invalidateRecoveryTimer]; 164 [_appRecord.appLoader request]; 165} 166 167- (void)reloadFromCache 168{ 169 self.isLoading = YES; 170 self.isBridgeAlreadyLoading = NO; 171 [self _invalidateRecoveryTimer]; 172 [_appRecord.appLoader requestFromCache]; 173} 174 175- (void)appStateDidBecomeActive 176{ 177 dispatch_async(dispatch_get_main_queue(), ^{ 178 [self _enforceDesiredDeviceOrientation]; 179 }); 180 [_appRecord.appManager appStateDidBecomeActive]; 181} 182 183- (void)appStateDidBecomeInactive 184{ 185 [_appRecord.appManager appStateDidBecomeInactive]; 186} 187 188- (void)_rebuildBridgeWithLoadingViewManifest:(NSDictionary *)manifest 189{ 190 if (!self.isBridgeAlreadyLoading) { 191 self.isBridgeAlreadyLoading = YES; 192 dispatch_async(dispatch_get_main_queue(), ^{ 193 self->_loadingView.manifest = manifest; 194 [self _overrideUserInterfaceStyle]; 195 [self _enforceDesiredDeviceOrientation]; 196 [self _rebuildBridge]; 197 }); 198 } 199} 200 201- (void)foregroundControllers 202{ 203 if (_backgroundedControllers != nil) { 204 __block UIViewController *parentController = self; 205 206 [_backgroundedControllers enumerateObjectsUsingBlock:^(UIViewController * _Nonnull viewController, NSUInteger idx, BOOL * _Nonnull stop) { 207 [parentController presentViewController:viewController animated:NO completion:nil]; 208 parentController = viewController; 209 }]; 210 211 _backgroundedControllers = nil; 212 } 213} 214 215- (void)backgroundControllers 216{ 217 UIViewController *childController = [self presentedViewController]; 218 219 if (childController != nil) { 220 if (_backgroundedControllers == nil) { 221 _backgroundedControllers = [NSMutableArray new]; 222 } 223 224 while (childController != nil) { 225 [_backgroundedControllers addObject:childController]; 226 childController = childController.presentedViewController; 227 } 228 } 229} 230 231#pragma mark - EXAppLoaderDelegate 232 233- (void)appLoader:(EXAppLoader *)appLoader didLoadOptimisticManifest:(NSDictionary *)manifest 234{ 235 if ([EXKernel sharedInstance].browserController) { 236 [[EXKernel sharedInstance].browserController addHistoryItemWithUrl:appLoader.manifestUrl manifest:manifest]; 237 } 238 [self _rebuildBridgeWithLoadingViewManifest:manifest]; 239} 240 241- (void)appLoader:(EXAppLoader *)appLoader didLoadBundleWithProgress:(EXLoadingProgress *)progress 242{ 243 dispatch_async(dispatch_get_main_queue(), ^{ 244 [self->_loadingView updateStatusWithProgress:progress]; 245 }); 246} 247 248- (void)appLoader:(EXAppLoader *)appLoader didFinishLoadingManifest:(NSDictionary *)manifest bundle:(NSData *)data 249{ 250 [self _rebuildBridgeWithLoadingViewManifest:manifest]; 251 if (self->_appRecord.appManager.status == kEXReactAppManagerStatusBridgeLoading) { 252 [self->_appRecord.appManager appLoaderFinished]; 253 } 254} 255 256- (void)appLoader:(EXAppLoader *)appLoader didFailWithError:(NSError *)error 257{ 258 if (_appRecord.appManager.status == kEXReactAppManagerStatusBridgeLoading) { 259 [_appRecord.appManager appLoaderFailedWithError:error]; 260 } 261 [self maybeShowError:error]; 262} 263 264- (void)appLoader:(EXAppLoader *)appLoader didResolveUpdatedBundleWithManifest:(NSDictionary * _Nullable)manifest isFromCache:(BOOL)isFromCache error:(NSError * _Nullable)error 265{ 266 [[EXKernel sharedInstance].serviceRegistry.updatesManager notifyApp:_appRecord ofDownloadWithManifest:manifest isNew:!isFromCache error:error]; 267} 268 269#pragma mark - EXReactAppManagerDelegate 270 271- (void)reactAppManagerIsReadyForLoad:(EXReactAppManager *)appManager 272{ 273 UIView *reactView = appManager.rootView; 274 reactView.frame = CGRectMake(0, 0, self.view.frame.size.width, self.view.frame.size.height); 275 reactView.autoresizingMask = UIViewAutoresizingFlexibleWidth | UIViewAutoresizingFlexibleHeight; 276 277 [self _setRootViewBackgroundColor:reactView]; 278 279 [_contentView removeFromSuperview]; 280 _contentView = reactView; 281 [self.view addSubview:_contentView]; 282 [self.view sendSubviewToBack:_contentView]; 283 284 [reactView becomeFirstResponder]; 285} 286 287- (void)reactAppManagerStartedLoadingJavaScript:(EXReactAppManager *)appManager 288{ 289 EXAssertMainThread(); 290 self.isLoading = YES; 291} 292 293- (void)reactAppManagerFinishedLoadingJavaScript:(EXReactAppManager *)appManager 294{ 295 EXAssertMainThread(); 296 self.isLoading = NO; 297 if ([EXKernel sharedInstance].browserController) { 298 [[EXKernel sharedInstance].browserController appDidFinishLoadingSuccessfully:_appRecord]; 299 } 300} 301 302- (void)reactAppManager:(EXReactAppManager *)appManager failedToLoadJavaScriptWithError:(NSError *)error 303{ 304 EXAssertMainThread(); 305 [self maybeShowError:error]; 306} 307 308- (void)reactAppManagerDidInvalidate:(EXReactAppManager *)appManager 309{ 310 311} 312 313- (void)errorViewDidSelectRetry:(EXErrorView *)errorView 314{ 315 [self refresh]; 316} 317 318#pragma mark - orientation 319 320- (UIInterfaceOrientationMask)supportedInterfaceOrientations 321{ 322 if (_supportedInterfaceOrientations != EX_INTERFACE_ORIENTATION_USE_MANIFEST) { 323 return _supportedInterfaceOrientations; 324 } 325 if (_appRecord.appLoader.manifest) { 326 NSString *orientationConfig = _appRecord.appLoader.manifest[@"orientation"]; 327 if ([orientationConfig isEqualToString:@"portrait"]) { 328 // lock to portrait 329 return UIInterfaceOrientationMaskPortrait; 330 } else if ([orientationConfig isEqualToString:@"landscape"]) { 331 // lock to landscape 332 return UIInterfaceOrientationMaskLandscape; 333 } 334 } 335 // no config or default value: allow autorotation 336 return UIInterfaceOrientationMaskAllButUpsideDown; 337} 338 339- (void)setSupportedInterfaceOrientations:(UIInterfaceOrientationMask)supportedInterfaceOrientations 340{ 341 _supportedInterfaceOrientations = supportedInterfaceOrientations; 342 [self _enforceDesiredDeviceOrientation]; 343} 344 345- (void)traitCollectionDidChange:(nullable UITraitCollection *)previousTraitCollection { 346 [super traitCollectionDidChange:previousTraitCollection]; 347 if ((self.traitCollection.verticalSizeClass != previousTraitCollection.verticalSizeClass) 348 || (self.traitCollection.horizontalSizeClass != previousTraitCollection.horizontalSizeClass)) { 349 [[EXKernel sharedInstance].serviceRegistry.screenOrientationManager handleScreenOrientationChange:self.traitCollection]; 350 } 351} 352 353- (void)_enforceDesiredDeviceOrientation 354{ 355 RCTAssertMainQueue(); 356 UIInterfaceOrientationMask mask = [self supportedInterfaceOrientations]; 357 UIDeviceOrientation currentOrientation = [[UIDevice currentDevice] orientation]; 358 UIInterfaceOrientation newOrientation = UIInterfaceOrientationUnknown; 359 switch (mask) { 360 case UIInterfaceOrientationMaskPortrait | UIInterfaceOrientationMaskPortraitUpsideDown: 361 if (!UIDeviceOrientationIsPortrait(currentOrientation)) { 362 newOrientation = UIInterfaceOrientationPortrait; 363 } 364 break; 365 case UIInterfaceOrientationMaskPortrait: 366 newOrientation = UIInterfaceOrientationPortrait; 367 break; 368 case UIInterfaceOrientationMaskPortraitUpsideDown: 369 newOrientation = UIInterfaceOrientationPortraitUpsideDown; 370 break; 371 case UIInterfaceOrientationMaskLandscape: 372 if (!UIDeviceOrientationIsLandscape(currentOrientation)) { 373 newOrientation = UIInterfaceOrientationLandscapeLeft; 374 } 375 break; 376 case UIInterfaceOrientationMaskLandscapeLeft: 377 newOrientation = UIInterfaceOrientationLandscapeLeft; 378 break; 379 case UIInterfaceOrientationMaskLandscapeRight: 380 newOrientation = UIInterfaceOrientationLandscapeRight; 381 break; 382 case UIInterfaceOrientationMaskAllButUpsideDown: 383 if (currentOrientation == UIDeviceOrientationFaceDown) { 384 newOrientation = UIInterfaceOrientationPortrait; 385 } 386 break; 387 default: 388 break; 389 } 390 if (newOrientation != UIInterfaceOrientationUnknown) { 391 [[UIDevice currentDevice] setValue:@(newOrientation) forKey:@"orientation"]; 392 } 393 [UIViewController attemptRotationToDeviceOrientation]; 394} 395 396#pragma mark - user interface style 397 398- (void)_overrideUserInterfaceStyle 399{ 400 if (@available(iOS 13.0, *)) { 401 NSString *userInterfaceStyle = [self _readUserInterfaceStyleFromManifest:_appRecord.appLoader.manifest]; 402 self.overrideUserInterfaceStyle = [self _userInterfaceStyleForString:userInterfaceStyle]; 403 } 404} 405 406- (NSString * _Nullable)_readUserInterfaceStyleFromManifest:(NSDictionary *)manifest 407{ 408 if (manifest[@"ios"] && manifest[@"ios"][@"userInterfaceStyle"]) { 409 return manifest[@"ios"][@"userInterfaceStyle"]; 410 } 411 return manifest[@"userInterfaceStyle"]; 412} 413 414- (UIUserInterfaceStyle)_userInterfaceStyleForString:(NSString *)userInterfaceStyleString API_AVAILABLE(ios(12.0)) { 415 if ([userInterfaceStyleString isEqualToString:@"dark"]) { 416 return UIUserInterfaceStyleDark; 417 } 418 if ([userInterfaceStyleString isEqualToString:@"automatic"]) { 419 return UIUserInterfaceStyleUnspecified; 420 } 421 return UIUserInterfaceStyleLight; 422} 423 424#pragma mark - root view background color 425 426- (void)_setRootViewBackgroundColor:(UIView *)view 427{ 428 NSString *backgroundColorString = [self _readBackgroundColorFromManifest:_appRecord.appLoader.manifest]; 429 UIColor *backgroundColor = [EXUtil colorWithHexString:backgroundColorString]; 430 431 if (backgroundColor) { 432 view.backgroundColor = backgroundColor; 433 } else { 434 view.backgroundColor = [UIColor whiteColor]; 435 436 // NOTE(brentvatne): we may want to default to respecting the default system background color 437 // on iOS13 and higher, but if we do make this choice then we will have to implement it on Android 438 // as well. This would also be a breaking change. Leaaving this here as a placeholder for the future. 439 // if (@available(iOS 13.0, *)) { 440 // view.backgroundColor = [UIColor systemBackgroundColor]; 441 // } else { 442 // view.backgroundColor = [UIColor whiteColor]; 443 // } 444 } 445} 446 447- (NSString * _Nullable)_readBackgroundColorFromManifest:(NSDictionary *)manifest 448{ 449 if (manifest[@"ios"] && manifest[@"ios"][@"backgroundColor"]) { 450 return manifest[@"ios"][@"backgroundColor"]; 451 } 452 return manifest[@"backgroundColor"]; 453} 454 455 456#pragma mark - Internal 457 458- (void)_showErrorWithType:(EXFatalErrorType)type error:(nullable NSError *)error 459{ 460 EXAssertMainThread(); 461 _dtmLastFatalErrorShown = [NSDate date]; 462 if (_errorView && _contentView == _errorView) { 463 // already showing, just update 464 _errorView.type = type; 465 _errorView.error = error; 466 } { 467 [_contentView removeFromSuperview]; 468 if (!_errorView) { 469 _errorView = [[EXErrorView alloc] initWithFrame:CGRectMake(0, 0, self.view.frame.size.width, self.view.frame.size.height)]; 470 _errorView.delegate = self; 471 _errorView.appRecord = _appRecord; 472 } 473 _errorView.type = type; 474 _errorView.error = error; 475 _contentView = _errorView; 476 [self.view addSubview:_contentView]; 477 [[EXAnalytics sharedInstance] logErrorVisibleEvent]; 478 } 479} 480 481- (void)setIsLoading:(BOOL)isLoading 482{ 483 if ([_appRecord.appManager enablesDeveloperTools] && _dtmLastFatalErrorShown) { 484 if ([_dtmLastFatalErrorShown timeIntervalSinceNow] >= -kEXDevelopmentErrorCoolDownSeconds) { 485 // we just showed a fatal error very recently, do not begin loading. 486 // this can happen in some cases where react native sends the 'started loading' notif 487 // in spite of a packager error. 488 return; 489 } 490 } 491 _isLoading = isLoading; 492 dispatch_async(dispatch_get_main_queue(), ^{ 493 if (isLoading) { 494 self.loadingView.hidden = NO; 495 [self.view bringSubviewToFront:self.loadingView]; 496 } else { 497 self.loadingView.hidden = YES; 498 } 499 }); 500} 501 502#pragma mark - error recovery 503 504- (BOOL)_willAutoRecoverFromError:(NSError *)error 505{ 506 if (![_appRecord.appManager enablesDeveloperTools]) { 507 BOOL shouldRecover = [[EXKernel sharedInstance].serviceRegistry.errorRecoveryManager experienceIdShouldReloadOnError:_appRecord.experienceId]; 508 if (shouldRecover) { 509 [self _invalidateRecoveryTimer]; 510 _tmrAutoReloadDebounce = [NSTimer scheduledTimerWithTimeInterval:kEXAutoReloadDebounceSeconds 511 target:self 512 selector:@selector(refresh) 513 userInfo:nil 514 repeats:NO]; 515 } 516 return shouldRecover; 517 } 518 return NO; 519} 520 521- (void)_invalidateRecoveryTimer 522{ 523 if (_tmrAutoReloadDebounce) { 524 [_tmrAutoReloadDebounce invalidate]; 525 _tmrAutoReloadDebounce = nil; 526 } 527} 528 529@end 530 531NS_ASSUME_NONNULL_END 532