1// Copyright 2020-present 650 Industries. All rights reserved.
2
3#import "EXAppFetcher.h"
4#import "EXAppLoaderExpoUpdates.h"
5#import "EXClientReleaseType.h"
6#import "EXEnvironment.h"
7#import "EXErrorRecoveryManager.h"
8#import "EXFileDownloader.h"
9#import "EXKernel.h"
10#import "EXKernelLinkingManager.h"
11#import "EXManifestResource.h"
12#import "EXSession.h"
13#import "EXUpdatesDatabaseManager.h"
14#import "EXVersions.h"
15
16#import <EXUpdates/EXUpdatesAppLauncherNoDatabase.h>
17#import <EXUpdates/EXUpdatesAppLoaderTask.h>
18#import <EXUpdates/EXUpdatesConfig.h>
19#import <EXUpdates/EXUpdatesDatabase.h>
20#import <EXUpdates/EXUpdatesFileDownloader.h>
21#import <EXUpdates/EXUpdatesLauncherSelectionPolicyFilterAware.h>
22#import <EXUpdates/EXUpdatesLoaderSelectionPolicyFilterAware.h>
23#import <EXUpdates/EXUpdatesReaper.h>
24#import <EXUpdates/EXUpdatesReaperSelectionPolicyDevelopmentClient.h>
25#import <EXUpdates/EXUpdatesSelectionPolicy.h>
26#import <EXUpdates/EXUpdatesUtils.h>
27#import <EXManifests/EXManifestsManifestFactory.h>
28#import <React/RCTUtils.h>
29#import <sys/utsname.h>
30
31NS_ASSUME_NONNULL_BEGIN
32
33@interface EXAppLoaderExpoUpdates ()
34
35@property (nonatomic, strong, nullable) NSURL *manifestUrl;
36@property (nonatomic, strong, nullable) NSURL *httpManifestUrl;
37
38@property (nonatomic, strong, nullable) EXManifestsManifest *confirmedManifest;
39@property (nonatomic, strong, nullable) EXManifestsManifest *optimisticManifest;
40@property (nonatomic, strong, nullable) NSData *bundle;
41@property (nonatomic, assign) EXAppLoaderRemoteUpdateStatus remoteUpdateStatus;
42@property (nonatomic, assign) BOOL shouldShowRemoteUpdateStatus;
43@property (nonatomic, assign) BOOL isUpToDate;
44
45/**
46 * Stateful variable to let us prevent multiple simultaneous fetches from the development server.
47 * This can happen when reloading a bundle with remote debugging enabled;
48 * RN requests the bundle multiple times for some reason.
49 */
50@property (nonatomic, assign) BOOL isLoadingDevelopmentJavaScriptResource;
51
52@property (nonatomic, strong, nullable) NSError *error;
53
54@property (nonatomic, assign) BOOL shouldUseCacheOnly;
55
56@property (nonatomic, strong) dispatch_queue_t appLoaderQueue;
57
58@property (nonatomic, nullable) EXUpdatesConfig *config;
59@property (nonatomic, nullable) EXUpdatesSelectionPolicy *selectionPolicy;
60@property (nonatomic, nullable) id<EXUpdatesAppLauncher> appLauncher;
61@property (nonatomic, assign) BOOL isEmergencyLaunch;
62
63@end
64
65@implementation EXAppLoaderExpoUpdates
66
67@synthesize manifestUrl = _manifestUrl;
68@synthesize bundle = _bundle;
69@synthesize remoteUpdateStatus = _remoteUpdateStatus;
70@synthesize shouldShowRemoteUpdateStatus = _shouldShowRemoteUpdateStatus;
71@synthesize config = _config;
72@synthesize selectionPolicy = _selectionPolicy;
73@synthesize appLauncher = _appLauncher;
74@synthesize isEmergencyLaunch = _isEmergencyLaunch;
75@synthesize isUpToDate = _isUpToDate;
76
77- (instancetype)initWithManifestUrl:(NSURL *)url
78{
79  if (self = [super init]) {
80    _manifestUrl = url;
81    _httpManifestUrl = [EXAppLoaderExpoUpdates _httpUrlFromManifestUrl:_manifestUrl];
82    _appLoaderQueue = dispatch_queue_create("host.exp.exponent.LoaderQueue", DISPATCH_QUEUE_SERIAL);
83  }
84  return self;
85}
86
87#pragma mark - getters and lifecycle
88
89- (void)_reset
90{
91  _confirmedManifest = nil;
92  _optimisticManifest = nil;
93  _bundle = nil;
94  _config = nil;
95  _selectionPolicy = nil;
96  _appLauncher = nil;
97  _error = nil;
98  _shouldUseCacheOnly = NO;
99  _isEmergencyLaunch = NO;
100  _remoteUpdateStatus = kEXAppLoaderRemoteUpdateStatusChecking;
101  _shouldShowRemoteUpdateStatus = YES;
102  _isUpToDate = NO;
103  _isLoadingDevelopmentJavaScriptResource = NO;
104}
105
106- (EXAppLoaderStatus)status
107{
108  if (_error) {
109    return kEXAppLoaderStatusError;
110  } else if (_bundle) {
111    return kEXAppLoaderStatusHasManifestAndBundle;
112  } else if (_optimisticManifest) {
113    return kEXAppLoaderStatusHasManifest;
114  }
115  return kEXAppLoaderStatusNew;
116}
117
118- (nullable EXManifestsManifest *)manifest
119{
120  if (_confirmedManifest) {
121    return _confirmedManifest;
122  }
123  if (_optimisticManifest) {
124    return _optimisticManifest;
125  }
126  return nil;
127}
128
129- (nullable NSData *)bundle
130{
131  if (_bundle) {
132    return _bundle;
133  }
134  return nil;
135}
136
137- (void)forceBundleReload
138{
139  if (self.status == kEXAppLoaderStatusNew) {
140    @throw [NSException exceptionWithName:NSInternalInconsistencyException
141                                   reason:@"Tried to load a bundle from an AppLoader with no manifest."
142                                 userInfo:@{}];
143  }
144  NSAssert([self supportsBundleReload], @"Tried to force a bundle reload on a non-development bundle");
145  if (self.isLoadingDevelopmentJavaScriptResource) {
146    // prevent multiple simultaneous fetches from the development server.
147    // this can happen when reloading a bundle with remote debugging enabled;
148    // RN requests the bundle multiple times for some reason.
149    // TODO: fix inside of RN
150    return;
151  }
152  [self _loadDevelopmentJavaScriptResource];
153}
154
155- (BOOL)supportsBundleReload
156{
157  if (_optimisticManifest) {
158    return _optimisticManifest.isUsingDeveloperTool;
159  }
160  return NO;
161}
162
163#pragma mark - public
164
165- (void)request
166{
167  [self _reset];
168  if (_manifestUrl) {
169    [self _beginRequest];
170  }
171}
172
173- (void)requestFromCache
174{
175  [self _reset];
176  _shouldUseCacheOnly = YES;
177  if (_manifestUrl) {
178    [self _beginRequest];
179  }
180}
181
182#pragma mark - EXUpdatesAppLoaderTaskDelegate
183
184- (BOOL)appLoaderTask:(EXUpdatesAppLoaderTask *)appLoaderTask didLoadCachedUpdate:(EXUpdatesUpdate *)update
185{
186  [self _setShouldShowRemoteUpdateStatus:update.manifest];
187  // if cached manifest was dev mode, or a previous run of this app failed due to a loading error, we want to make sure to check for remote updates
188  if (update.manifest.isUsingDeveloperTool || [[EXKernel sharedInstance].serviceRegistry.errorRecoveryManager scopeKeyIsRecoveringFromError:update.manifest.scopeKey]) {
189    return NO;
190  }
191  return YES;
192}
193
194- (void)appLoaderTask:(EXUpdatesAppLoaderTask *)appLoaderTask didStartLoadingUpdate:(EXUpdatesUpdate *)update
195{
196  // expo-cli does not always respect our SDK version headers and respond with a compatible update or an error
197  // so we need to check the compatibility here
198  EXManifestResource *manifestResource = [[EXManifestResource alloc] initWithManifestUrl:_httpManifestUrl originalUrl:_manifestUrl];
199  NSError *manifestCompatibilityError = [manifestResource verifyManifestSdkVersion:update.manifest];
200  if (manifestCompatibilityError) {
201    _error = manifestCompatibilityError;
202    if (self.delegate) {
203      [self.delegate appLoader:self didFailWithError:_error];
204      return;
205    }
206  }
207
208  _remoteUpdateStatus = kEXAppLoaderRemoteUpdateStatusDownloading;
209  [self _setShouldShowRemoteUpdateStatus:update.manifest];
210  [self _setOptimisticManifest:[self _processManifest:update.manifest]];
211}
212
213- (void)appLoaderTask:(EXUpdatesAppLoaderTask *)appLoaderTask didFinishWithLauncher:(id<EXUpdatesAppLauncher>)launcher isUpToDate:(BOOL)isUpToDate
214{
215  if (_error) {
216    return;
217  }
218
219  if (!_optimisticManifest) {
220    [self _setOptimisticManifest:[self _processManifest:launcher.launchedUpdate.manifest]];
221  }
222  _isUpToDate = isUpToDate;
223  if (launcher.launchedUpdate.manifest.isUsingDeveloperTool) {
224    // in dev mode, we need to set an optimistic manifest but nothing else
225    return;
226  }
227  _confirmedManifest = [self _processManifest:launcher.launchedUpdate.manifest];
228  _bundle = [NSData dataWithContentsOfURL:launcher.launchAssetUrl];
229  _appLauncher = launcher;
230  if (self.delegate) {
231    [self.delegate appLoader:self didFinishLoadingManifest:_confirmedManifest bundle:_bundle];
232  }
233}
234
235- (void)appLoaderTask:(EXUpdatesAppLoaderTask *)appLoaderTask didFinishWithError:(NSError *)error
236{
237  if ([EXEnvironment sharedEnvironment].isDetached) {
238    _isEmergencyLaunch = YES;
239    [self _launchWithNoDatabaseAndError:error];
240  } else if (!_error) {
241    _error = error;
242
243    // if the error payload conforms to the error protocol, we can parse it and display
244    // a slightly nicer error message to the user
245    id errorJson = [NSJSONSerialization JSONObjectWithData:[error.localizedDescription dataUsingEncoding:NSUTF8StringEncoding] options:kNilOptions error:nil];
246    if (errorJson && [errorJson isKindOfClass:[NSDictionary class]]) {
247      EXManifestResource *manifestResource = [[EXManifestResource alloc] initWithManifestUrl:_httpManifestUrl originalUrl:_manifestUrl];
248      _error = [manifestResource formatError:[NSError errorWithDomain:EXNetworkErrorDomain code:error.code userInfo:errorJson]];
249    }
250
251    if (self.delegate) {
252      [self.delegate appLoader:self didFailWithError:_error];
253    }
254  }
255}
256
257- (void)appLoaderTask:(EXUpdatesAppLoaderTask *)appLoaderTask didFinishBackgroundUpdateWithStatus:(EXUpdatesBackgroundUpdateStatus)status update:(nullable EXUpdatesUpdate *)update error:(nullable NSError *)error
258{
259  if (self.delegate) {
260    [self.delegate appLoader:self didResolveUpdatedBundleWithManifest:update.manifest isFromCache:(status == EXUpdatesBackgroundUpdateStatusNoUpdateAvailable) error:error];
261  }
262}
263
264#pragma mark - internal
265
266+ (NSURL *)_httpUrlFromManifestUrl:(NSURL *)url
267{
268  NSURLComponents *components = [NSURLComponents componentsWithURL:url resolvingAgainstBaseURL:YES];
269  // if scheme is exps or https, use https. Else default to http
270  if (components.scheme && ([components.scheme isEqualToString:@"exps"] || [components.scheme isEqualToString:@"https"])){
271    components.scheme = @"https";
272  } else {
273    components.scheme = @"http";
274  }
275  NSMutableString *path = [((components.path) ? components.path : @"") mutableCopy];
276  path = [[EXKernelLinkingManager stringByRemovingDeepLink:path] mutableCopy];
277  components.path = path;
278  return [components URL];
279}
280
281- (BOOL)_initializeDatabase
282{
283  EXUpdatesDatabaseManager *updatesDatabaseManager = [EXKernel sharedInstance].serviceRegistry.updatesDatabaseManager;
284  BOOL success = updatesDatabaseManager.isDatabaseOpen;
285  if (!updatesDatabaseManager.isDatabaseOpen) {
286    success = [updatesDatabaseManager openDatabase];
287  }
288
289  if (!success) {
290    _error = updatesDatabaseManager.error;
291    if (self.delegate) {
292      [self.delegate appLoader:self didFailWithError:_error];
293    }
294    return NO;
295  } else {
296    return YES;
297  }
298}
299
300- (void)_beginRequest
301{
302  if (![self _initializeDatabase]) {
303    return;
304  }
305  [self _startLoaderTask];
306}
307
308- (void)_startLoaderTask
309{
310  BOOL shouldCheckOnLaunch;
311  NSNumber *launchWaitMs;
312  if (_shouldUseCacheOnly) {
313    shouldCheckOnLaunch = NO;
314    launchWaitMs = @(0);
315  } else {
316    if ([EXEnvironment sharedEnvironment].isDetached) {
317      shouldCheckOnLaunch = [EXEnvironment sharedEnvironment].updatesCheckAutomatically;
318      launchWaitMs = [EXEnvironment sharedEnvironment].updatesFallbackToCacheTimeout;
319    } else {
320      shouldCheckOnLaunch = YES;
321      launchWaitMs = @(60000);
322    }
323  }
324
325  NSURL *httpManifestUrl = [[self class] _httpUrlFromManifestUrl:_manifestUrl];
326
327  NSString *releaseChannel = [EXEnvironment sharedEnvironment].releaseChannel;
328  if (![EXEnvironment sharedEnvironment].isDetached) {
329    // in Expo Go, the release channel can change at runtime depending on the URL we load
330    NSURLComponents *manifestUrlComponents = [NSURLComponents componentsWithURL:httpManifestUrl resolvingAgainstBaseURL:YES];
331    releaseChannel = [EXKernelLinkingManager releaseChannelWithUrlComponents:manifestUrlComponents];
332  }
333
334  _config = [EXUpdatesConfig configWithDictionary:@{
335    @"EXUpdatesURL": httpManifestUrl.absoluteString,
336    @"EXUpdatesSDKVersion": [self _sdkVersions],
337    @"EXUpdatesScopeKey": httpManifestUrl.absoluteString,
338    @"EXUpdatesReleaseChannel": releaseChannel,
339    @"EXUpdatesHasEmbeddedUpdate": @([EXEnvironment sharedEnvironment].isDetached),
340    @"EXUpdatesEnabled": @([EXEnvironment sharedEnvironment].areRemoteUpdatesEnabled),
341    @"EXUpdatesLaunchWaitMs": launchWaitMs,
342    @"EXUpdatesCheckOnLaunch": shouldCheckOnLaunch ? @"ALWAYS" : @"NEVER",
343    @"EXUpdatesExpectsSignedManifest": @YES,
344    @"EXUpdatesRequestHeaders": [self _requestHeaders]
345  }];
346
347  if (![EXEnvironment sharedEnvironment].areRemoteUpdatesEnabled) {
348    [self _launchWithNoDatabaseAndError:nil];
349    return;
350  }
351
352  EXUpdatesDatabaseManager *updatesDatabaseManager = [EXKernel sharedInstance].serviceRegistry.updatesDatabaseManager;
353
354  NSMutableArray *sdkVersions = [[EXVersions sharedInstance].versions[@"sdkVersions"] ?: @[[EXVersions sharedInstance].temporarySdkVersion] mutableCopy];
355  [sdkVersions addObject:@"UNVERSIONED"];
356
357  NSMutableArray *sdkVersionRuntimeVersions = [[NSMutableArray alloc] initWithCapacity:sdkVersions.count];
358  for (NSString *sdkVersion in sdkVersions) {
359    [sdkVersionRuntimeVersions addObject:[NSString stringWithFormat:@"exposdk:%@", sdkVersion]];
360  }
361  [sdkVersionRuntimeVersions addObject:@"exposdk:UNVERSIONED"];
362  [sdkVersions addObjectsFromArray:sdkVersionRuntimeVersions];
363
364
365  _selectionPolicy = [[EXUpdatesSelectionPolicy alloc]
366                      initWithLauncherSelectionPolicy:[[EXUpdatesLauncherSelectionPolicyFilterAware alloc] initWithRuntimeVersions:sdkVersions]
367                      loaderSelectionPolicy:[EXUpdatesLoaderSelectionPolicyFilterAware new]
368                      reaperSelectionPolicy:[EXUpdatesReaperSelectionPolicyDevelopmentClient new]];
369
370  EXUpdatesAppLoaderTask *loaderTask = [[EXUpdatesAppLoaderTask alloc] initWithConfig:_config
371                                                                             database:updatesDatabaseManager.database
372                                                                            directory:updatesDatabaseManager.updatesDirectory
373                                                                      selectionPolicy:_selectionPolicy
374                                                                        delegateQueue:_appLoaderQueue];
375  loaderTask.delegate = self;
376  [loaderTask start];
377}
378
379- (void)_launchWithNoDatabaseAndError:(nullable NSError *)error
380{
381  EXUpdatesAppLauncherNoDatabase *appLauncher = [[EXUpdatesAppLauncherNoDatabase alloc] init];
382  [appLauncher launchUpdateWithConfig:_config fatalError:error];
383
384  _confirmedManifest = [self _processManifest:appLauncher.launchedUpdate.manifest];
385  _optimisticManifest = _confirmedManifest;
386  _bundle = [NSData dataWithContentsOfURL:appLauncher.launchAssetUrl];
387  _appLauncher = appLauncher;
388  if (self.delegate) {
389    [self.delegate appLoader:self didLoadOptimisticManifest:_confirmedManifest];
390    [self.delegate appLoader:self didFinishLoadingManifest:_confirmedManifest bundle:_bundle];
391  }
392}
393
394- (void)_runReaper
395{
396  if (_appLauncher.launchedUpdate) {
397    EXUpdatesDatabaseManager *updatesDatabaseManager = [EXKernel sharedInstance].serviceRegistry.updatesDatabaseManager;
398    [EXUpdatesReaper reapUnusedUpdatesWithConfig:_config
399                                        database:updatesDatabaseManager.database
400                                       directory:updatesDatabaseManager.updatesDirectory
401                                 selectionPolicy:_selectionPolicy
402                                  launchedUpdate:_appLauncher.launchedUpdate];
403  }
404}
405
406- (void)_setOptimisticManifest:(EXManifestsManifest *)manifest
407{
408  _optimisticManifest = manifest;
409  if (self.delegate) {
410    [self.delegate appLoader:self didLoadOptimisticManifest:_optimisticManifest];
411  }
412}
413
414- (void)_setShouldShowRemoteUpdateStatus:(EXManifestsManifest *)manifest
415{
416  // we don't want to show the cached experience alert when Updates.reloadAsync() is called
417  if (_shouldUseCacheOnly) {
418    _shouldShowRemoteUpdateStatus = NO;
419    return;
420  }
421
422  if (manifest) {
423    if (manifest.isDevelopmentSilentLaunch) {
424      _shouldShowRemoteUpdateStatus = NO;
425      return;
426    }
427
428    // we want to avoid showing the status for older snack SDK versions, too
429    // we make our best guess based on the manifest fields
430    // TODO: remove this after SDK 38 is phased out
431    NSString *sdkVersion = manifest.sdkVersion;
432    NSString *bundleUrl = manifest.bundleUrl;
433    if (![@"UNVERSIONED" isEqual:sdkVersion] &&
434        sdkVersion.integerValue < 39 &&
435        [@"snack" isEqual:manifest.slug] &&
436        [bundleUrl hasPrefix:@"https://d1wp6m56sqw74a.cloudfront.net/%40exponent%2Fsnack"]) {
437      _shouldShowRemoteUpdateStatus = NO;
438      return;
439    }
440  }
441  _shouldShowRemoteUpdateStatus = YES;
442}
443
444- (void)_loadDevelopmentJavaScriptResource
445{
446  _isLoadingDevelopmentJavaScriptResource = YES;
447  EXAppFetcher *appFetcher = [[EXAppFetcher alloc] initWithAppLoader:self];
448  [appFetcher fetchJSBundleWithManifest:self.optimisticManifest cacheBehavior:EXCachedResourceNoCache timeoutInterval:kEXJSBundleTimeout progress:^(EXLoadingProgress *progress) {
449    if (self.delegate) {
450      [self.delegate appLoader:self didLoadBundleWithProgress:progress];
451    }
452  } success:^(NSData *bundle) {
453    self.isUpToDate = YES;
454    self.bundle = bundle;
455    self.isLoadingDevelopmentJavaScriptResource = NO;
456    if (self.delegate) {
457      [self.delegate appLoader:self didFinishLoadingManifest:self.optimisticManifest bundle:self.bundle];
458    }
459  } error:^(NSError *error) {
460    self.error = error;
461    self.isLoadingDevelopmentJavaScriptResource = NO;
462    if (self.delegate) {
463      [self.delegate appLoader:self didFailWithError:error];
464    }
465  }];
466}
467
468# pragma mark - manifest processing
469
470- (EXManifestsManifest *)_processManifest:(EXManifestsManifest *)manifest
471{
472  NSMutableDictionary *mutableManifest = [manifest.rawManifestJSON mutableCopy];
473  if (!mutableManifest[@"isVerified"] && ![EXKernelLinkingManager isExpoHostedUrl:_httpManifestUrl] && !EXEnvironment.sharedEnvironment.isDetached){
474    // the manifest id determines the namespace/experience id an app is sandboxed with
475    // if manifest is hosted by third parties, we sandbox it with the hostname to avoid clobbering exp.host namespaces
476    // for https urls, sandboxed id is of form quinlanj.github.io/myProj-myApp
477    // for http urls, sandboxed id is of form UNVERIFIED-quinlanj.github.io/myProj-myApp
478    NSString *securityPrefix = [_httpManifestUrl.scheme isEqualToString:@"https"] ? @"" : @"UNVERIFIED-";
479    NSString *slugSuffix = manifest.slug ? [@"-" stringByAppendingString:manifest.slug]: @"";
480    mutableManifest[@"id"] = [NSString stringWithFormat:@"%@%@%@%@", securityPrefix, _httpManifestUrl.host, _httpManifestUrl.path ?: @"", slugSuffix];
481    mutableManifest[@"isVerified"] = @(YES);
482  }
483  if (!mutableManifest[@"isVerified"]) {
484    mutableManifest[@"isVerified"] = @(NO);
485  }
486
487  if (![mutableManifest[@"isVerified"] boolValue] && (EXEnvironment.sharedEnvironment.isManifestVerificationBypassed || [EXAppLoaderExpoUpdates _isAnonymousExperience:manifest])) {
488    mutableManifest[@"isVerified"] = @(YES);
489  }
490
491  return [EXManifestsManifestFactory manifestForManifestJSON:[mutableManifest copy]];
492}
493
494+ (BOOL)_isAnonymousExperience:(EXManifestsManifest *)manifest
495{
496  return manifest.legacyId != nil && [manifest.legacyId hasPrefix:@"@anonymous/"];
497}
498
499#pragma mark - headers
500
501- (NSDictionary *)_requestHeaders
502{
503  NSDictionary *requestHeaders = @{
504      @"Exponent-SDK-Version": [self _sdkVersions],
505      @"Exponent-Accept-Signature": @"true",
506      @"Exponent-Platform": @"ios",
507      @"Exponent-Version": [[NSBundle mainBundle] objectForInfoDictionaryKey:@"CFBundleShortVersionString"],
508      @"Expo-Client-Environment": [self _clientEnvironment],
509      @"Expo-Updates-Environment": [self _clientEnvironment],
510      @"User-Agent": [self _userAgentString],
511      @"Expo-Client-Release-Type": [EXClientReleaseType clientReleaseType]
512  };
513
514  NSString *sessionSecret = [[EXSession sharedInstance] sessionSecret];
515  if (sessionSecret) {
516    NSMutableDictionary *requestHeadersMutable = [requestHeaders mutableCopy];
517    requestHeadersMutable[@"Expo-Session"] = sessionSecret;
518    requestHeaders = requestHeadersMutable;
519  }
520
521  return requestHeaders;
522}
523
524- (NSString *)_userAgentString
525{
526  struct utsname systemInfo;
527  uname(&systemInfo);
528  NSString *deviceModel = [NSString stringWithCString:systemInfo.machine encoding:NSUTF8StringEncoding];
529  return [NSString stringWithFormat:@"Exponent/%@ (%@; %@ %@; Scale/%.2f; %@)",
530          [[NSBundle mainBundle] objectForInfoDictionaryKey:@"CFBundleShortVersionString"],
531          deviceModel,
532          [UIDevice currentDevice].systemName,
533          [UIDevice currentDevice].systemVersion,
534          [UIScreen mainScreen].scale,
535          [NSLocale autoupdatingCurrentLocale].localeIdentifier];
536}
537
538- (NSString *)_clientEnvironment
539{
540  if ([EXEnvironment sharedEnvironment].isDetached) {
541    return @"STANDALONE";
542  } else {
543    return @"EXPO_DEVICE";
544#if TARGET_IPHONE_SIMULATOR
545    return @"EXPO_SIMULATOR";
546#endif
547  }
548}
549
550- (NSString *)_sdkVersions
551{
552  NSArray *versionsAvailable = [EXVersions sharedInstance].versions[@"sdkVersions"];
553  if (versionsAvailable) {
554    return [versionsAvailable componentsJoinedByString:@","];
555  } else {
556    return [EXVersions sharedInstance].temporarySdkVersion;
557  }
558}
559
560@end
561
562NS_ASSUME_NONNULL_END
563