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