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