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