1// Copyright 2015-present 650 Industries. All rights reserved. 2 3@import UIKit; 4 5#import "EXAnalytics.h" 6#import "EXAppLoadingView.h" 7#import "EXErrorRecoveryManager.h" 8#import "EXFileDownloader.h" 9#import "EXAppViewController.h" 10#import "EXReactAppManager.h" 11#import "EXErrorView.h" 12#import "EXKernel.h" 13#import "EXKernelAppLoader.h" 14#import "EXKernelUtil.h" 15#import "EXScreenOrientationManager.h" 16#import "EXShellManager.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, EXKernelAppLoaderDelegate, 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 46@end 47 48@implementation EXAppViewController 49 50@synthesize supportedInterfaceOrientations = _supportedInterfaceOrientations; 51 52#pragma mark - Lifecycle 53 54- (instancetype)initWithAppRecord:(EXKernelAppRecord *)record 55{ 56 if (self = [super init]) { 57 _appRecord = record; 58 _supportedInterfaceOrientations = EX_INTERFACE_ORIENTATION_USE_MANIFEST; 59 } 60 return self; 61} 62 63- (void)dealloc 64{ 65 [self _invalidateRecoveryTimer]; 66 [[NSNotificationCenter defaultCenter] removeObserver:self]; 67} 68 69- (void)viewDidLoad 70{ 71 [super viewDidLoad]; 72 self.view.backgroundColor = [UIColor whiteColor]; 73 74 _loadingView = [[EXAppLoadingView alloc] initWithAppRecord:_appRecord]; 75 [self.view addSubview:_loadingView]; 76 _appRecord.appManager.delegate = self; 77 self.isLoading = YES; 78} 79 80- (void)viewDidAppear:(BOOL)animated 81{ 82 [super viewDidAppear:animated]; 83 if (_appRecord && _appRecord.status == kEXKernelAppRecordStatusNew) { 84 _appRecord.appLoader.delegate = self; 85 _appRecord.appLoader.dataSource = _appRecord.appManager; 86 [self refresh]; 87 } 88} 89 90- (BOOL)shouldAutorotate 91{ 92 return YES; 93} 94 95- (void)viewWillLayoutSubviews 96{ 97 [super viewWillLayoutSubviews]; 98 if (_loadingView) { 99 _loadingView.frame = CGRectMake(0, 0, self.view.frame.size.width, self.view.frame.size.height); 100 } 101 if (_contentView) { 102 _contentView.frame = CGRectMake(0, 0, self.view.frame.size.width, self.view.frame.size.height); 103 } 104} 105 106#pragma mark - Public 107 108- (void)maybeShowError:(NSError *)error 109{ 110 self.isLoading = NO; 111 if ([self _willAutoRecoverFromError:error]) { 112 return; 113 } 114 if (error && ![error isKindOfClass:[NSError class]]) { 115#if DEBUG 116 NSAssert(NO, @"AppViewController error handler was called on an object that isn't an NSError"); 117#endif 118 return; 119 } 120 NSString *domain = (error && error.domain) ? error.domain : @""; 121 BOOL isNetworkError = ([domain isEqualToString:(NSString *)kCFErrorDomainCFNetwork] || [domain isEqualToString:EXNetworkErrorDomain]); 122 123 if (isNetworkError) { 124 // show a human-readable reachability error 125 dispatch_async(dispatch_get_main_queue(), ^{ 126 [self _showErrorWithType:kEXFatalErrorTypeLoading error:error]; 127 }); 128 } else if ([domain isEqualToString:@"JSServer"] && [_appRecord.appManager enablesDeveloperTools]) { 129 // RCTRedBox already handled this 130 } else if ([domain rangeOfString:RCTErrorDomain].length > 0 && [_appRecord.appManager enablesDeveloperTools]) { 131 // RCTRedBox already handled this 132 } else { 133 dispatch_async(dispatch_get_main_queue(), ^{ 134 [self _showErrorWithType:kEXFatalErrorTypeException error:error]; 135 }); 136 } 137} 138 139- (void)_rebuildBridge 140{ 141 [self _invalidateRecoveryTimer]; 142 [[EXKernel sharedInstance] logAnalyticsEvent:@"LOAD_EXPERIENCE" forAppRecord:_appRecord]; 143 [_appRecord.appManager rebuildBridge]; 144} 145 146- (void)refresh 147{ 148 self.isLoading = YES; 149 self.isBridgeAlreadyLoading = NO; 150 [self _invalidateRecoveryTimer]; 151 [_appRecord.appLoader request]; 152} 153 154- (void)reloadFromCache 155{ 156 self.isLoading = YES; 157 self.isBridgeAlreadyLoading = NO; 158 [self _invalidateRecoveryTimer]; 159 [_appRecord.appLoader requestFromCache]; 160} 161 162- (void)appStateDidBecomeActive 163{ 164 dispatch_async(dispatch_get_main_queue(), ^{ 165 [self _enforceDesiredDeviceOrientation]; 166 }); 167 [_appRecord.appManager appStateDidBecomeActive]; 168} 169 170- (void)appStateDidBecomeInactive 171{ 172 [_appRecord.appManager appStateDidBecomeInactive]; 173} 174 175- (void)_rebuildBridgeWithLoadingViewManifest:(NSDictionary *)manifest 176{ 177 if (!self.isBridgeAlreadyLoading) { 178 self.isBridgeAlreadyLoading = YES; 179 dispatch_async(dispatch_get_main_queue(), ^{ 180 _loadingView.manifest = manifest; 181 [self _enforceDesiredDeviceOrientation]; 182 [self _rebuildBridge]; 183 }); 184 } 185} 186 187#pragma mark - EXKernelAppLoaderDelegate 188 189- (void)appLoader:(EXKernelAppLoader *)appLoader didLoadOptimisticManifest:(NSDictionary *)manifest 190{ 191 [self _whenManifestIsValidToOpen:manifest performBlock:^{ 192 if ([EXKernel sharedInstance].browserController) { 193 [[EXKernel sharedInstance].browserController addHistoryItemWithUrl:appLoader.manifestUrl manifest:manifest]; 194 } 195 [self _rebuildBridgeWithLoadingViewManifest:manifest]; 196 }]; 197} 198 199- (void)appLoader:(EXKernelAppLoader *)appLoader didLoadBundleWithProgress:(EXLoadingProgress *)progress 200{ 201 dispatch_async(dispatch_get_main_queue(), ^{ 202 [_loadingView updateStatusWithProgress:progress]; 203 }); 204} 205 206- (void)appLoader:(EXKernelAppLoader *)appLoader didFinishLoadingManifest:(NSDictionary *)manifest bundle:(NSData *)data 207{ 208 [self _whenManifestIsValidToOpen:manifest performBlock:^{ 209 [self _rebuildBridgeWithLoadingViewManifest:manifest]; 210 if (_appRecord.appManager.status == kEXReactAppManagerStatusBridgeLoading) { 211 [_appRecord.appManager appLoaderFinished]; 212 } 213 }]; 214} 215 216- (void)appLoader:(EXKernelAppLoader *)appLoader didFailWithError:(NSError *)error 217{ 218 if (_appRecord.appManager.status == kEXReactAppManagerStatusBridgeLoading) { 219 [_appRecord.appManager appLoaderFailedWithError:error]; 220 } 221 [self maybeShowError:error]; 222} 223 224- (void)appLoader:(EXKernelAppLoader *)appLoader didResolveUpdatedBundleWithManifest:(NSDictionary * _Nullable)manifest isFromCache:(BOOL)isFromCache error:(NSError * _Nullable)error 225{ 226 [[EXKernel sharedInstance].serviceRegistry.updatesManager notifyApp:_appRecord ofDownloadWithManifest:manifest isNew:!isFromCache error:error]; 227} 228 229#pragma mark - EXReactAppManagerDelegate 230 231- (void)reactAppManagerIsReadyForLoad:(EXReactAppManager *)appManager 232{ 233 UIView *reactView = appManager.rootView; 234 reactView.frame = CGRectMake(0, 0, self.view.frame.size.width, self.view.frame.size.height); 235 reactView.autoresizingMask = UIViewAutoresizingFlexibleWidth | UIViewAutoresizingFlexibleHeight; 236 reactView.backgroundColor = [UIColor clearColor]; 237 238 [_contentView removeFromSuperview]; 239 _contentView = reactView; 240 [self.view addSubview:_contentView]; 241 [self.view sendSubviewToBack:_contentView]; 242 243 [reactView becomeFirstResponder]; 244} 245 246- (void)reactAppManagerStartedLoadingJavaScript:(EXReactAppManager *)appManager 247{ 248 EXAssertMainThread(); 249 self.isLoading = YES; 250} 251 252- (void)reactAppManagerFinishedLoadingJavaScript:(EXReactAppManager *)appManager 253{ 254 EXAssertMainThread(); 255 self.isLoading = NO; 256 if ([EXKernel sharedInstance].browserController) { 257 [[EXKernel sharedInstance].browserController appDidFinishLoadingSuccessfully:_appRecord]; 258 } 259} 260 261- (void)reactAppManager:(EXReactAppManager *)appManager failedToLoadJavaScriptWithError:(NSError *)error 262{ 263 EXAssertMainThread(); 264 [self maybeShowError:error]; 265} 266 267- (void)reactAppManagerDidInvalidate:(EXReactAppManager *)appManager 268{ 269 270} 271 272- (void)errorViewDidSelectRetry:(EXErrorView *)errorView 273{ 274 [self refresh]; 275} 276 277#pragma mark - orientation 278 279- (UIInterfaceOrientationMask)supportedInterfaceOrientations 280{ 281 if (_supportedInterfaceOrientations != EX_INTERFACE_ORIENTATION_USE_MANIFEST) { 282 return _supportedInterfaceOrientations; 283 } 284 if (_appRecord.appLoader.manifest) { 285 NSString *orientationConfig = _appRecord.appLoader.manifest[@"orientation"]; 286 if ([orientationConfig isEqualToString:@"portrait"]) { 287 // lock to portrait 288 return UIInterfaceOrientationMaskPortrait; 289 } else if ([orientationConfig isEqualToString:@"landscape"]) { 290 // lock to landscape 291 return UIInterfaceOrientationMaskLandscape; 292 } 293 } 294 // no config or default value: allow autorotation 295 return UIInterfaceOrientationMaskAllButUpsideDown; 296} 297 298- (void)setSupportedInterfaceOrientations:(UIInterfaceOrientationMask)supportedInterfaceOrientations 299{ 300 _supportedInterfaceOrientations = supportedInterfaceOrientations; 301 [self _enforceDesiredDeviceOrientation]; 302} 303 304- (void)_enforceDesiredDeviceOrientation 305{ 306 RCTAssertMainQueue(); 307 UIInterfaceOrientationMask mask = [self supportedInterfaceOrientations]; 308 UIDeviceOrientation currentOrientation = [[UIDevice currentDevice] orientation]; 309 UIInterfaceOrientation newOrientation = UIInterfaceOrientationUnknown; 310 switch (mask) { 311 case UIInterfaceOrientationMaskPortrait: 312 if (!UIDeviceOrientationIsPortrait(currentOrientation)) { 313 newOrientation = UIInterfaceOrientationPortrait; 314 } 315 break; 316 case UIInterfaceOrientationMaskPortraitUpsideDown: 317 newOrientation = UIInterfaceOrientationPortraitUpsideDown; 318 break; 319 case UIInterfaceOrientationMaskLandscape: 320 if (!UIDeviceOrientationIsLandscape(currentOrientation)) { 321 newOrientation = UIInterfaceOrientationLandscapeLeft; 322 } 323 break; 324 case UIInterfaceOrientationMaskLandscapeLeft: 325 newOrientation = UIInterfaceOrientationLandscapeLeft; 326 break; 327 case UIInterfaceOrientationMaskLandscapeRight: 328 newOrientation = UIInterfaceOrientationLandscapeRight; 329 break; 330 case UIInterfaceOrientationMaskAllButUpsideDown: 331 if (currentOrientation == UIDeviceOrientationFaceDown) { 332 newOrientation = UIInterfaceOrientationPortrait; 333 } 334 break; 335 default: 336 break; 337 } 338 if (newOrientation != UIInterfaceOrientationUnknown) { 339 [[UIDevice currentDevice] setValue:@(newOrientation) forKey:@"orientation"]; 340 } 341 [UIViewController attemptRotationToDeviceOrientation]; 342} 343 344#pragma mark - Internal 345 346- (void)_showErrorWithType:(EXFatalErrorType)type error:(nullable NSError *)error 347{ 348 EXAssertMainThread(); 349 _dtmLastFatalErrorShown = [NSDate date]; 350 if (_errorView && _contentView == _errorView) { 351 // already showing, just update 352 _errorView.type = type; 353 _errorView.error = error; 354 } { 355 [_contentView removeFromSuperview]; 356 if (!_errorView) { 357 _errorView = [[EXErrorView alloc] initWithFrame:CGRectMake(0, 0, self.view.frame.size.width, self.view.frame.size.height)]; 358 _errorView.delegate = self; 359 _errorView.appRecord = _appRecord; 360 } 361 _errorView.type = type; 362 _errorView.error = error; 363 _contentView = _errorView; 364 [self.view addSubview:_contentView]; 365 [[EXAnalytics sharedInstance] logErrorVisibleEvent]; 366 } 367} 368 369- (void)setIsLoading:(BOOL)isLoading 370{ 371 if ([_appRecord.appManager enablesDeveloperTools] && _dtmLastFatalErrorShown) { 372 if ([_dtmLastFatalErrorShown timeIntervalSinceNow] >= -kEXDevelopmentErrorCoolDownSeconds) { 373 // we just showed a fatal error very recently, do not begin loading. 374 // this can happen in some cases where react native sends the 'started loading' notif 375 // in spite of a packager error. 376 return; 377 } 378 } 379 _isLoading = isLoading; 380 dispatch_async(dispatch_get_main_queue(), ^{ 381 if (isLoading) { 382 self.loadingView.hidden = NO; 383 [self.view bringSubviewToFront:self.loadingView]; 384 } else { 385 self.loadingView.hidden = YES; 386 } 387 }); 388} 389 390#pragma mark - error recovery 391 392- (BOOL)_willAutoRecoverFromError:(NSError *)error 393{ 394 if (error.code == kEXErrorCodeAppForbidden) { 395 return NO; 396 } 397 if (![_appRecord.appManager enablesDeveloperTools]) { 398 BOOL shouldRecover = [[EXKernel sharedInstance].serviceRegistry.errorRecoveryManager experienceIdShouldReloadOnError:_appRecord.experienceId]; 399 if (shouldRecover) { 400 [self _invalidateRecoveryTimer]; 401 _tmrAutoReloadDebounce = [NSTimer scheduledTimerWithTimeInterval:kEXAutoReloadDebounceSeconds 402 target:self 403 selector:@selector(refresh) 404 userInfo:nil 405 repeats:NO]; 406 } 407 return shouldRecover; 408 } 409 return NO; 410} 411 412- (void)_invalidateRecoveryTimer 413{ 414 if (_tmrAutoReloadDebounce) { 415 [_tmrAutoReloadDebounce invalidate]; 416 _tmrAutoReloadDebounce = nil; 417 } 418} 419 420- (void)_whenManifestIsValidToOpen:(NSDictionary *)manifest performBlock:(void (^)(void))block 421{ 422 if (self.appRecord.appManager.requiresValidManifests && [EXKernel sharedInstance].browserController) { 423 [[EXKernel sharedInstance].browserController getIsValidHomeManifestToOpen:manifest completion:^(BOOL isValid) { 424 if (isValid) { 425 block(); 426 } else { 427 [self appLoader:_appRecord.appLoader didFailWithError:[NSError errorWithDomain:EXNetworkErrorDomain 428 code:kEXErrorCodeAppForbidden 429 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." }]]; 430 } 431 }]; 432 } else { 433 // no browser present, everything is valid 434 block(); 435 } 436} 437 438@end 439 440NS_ASSUME_NONNULL_END 441