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