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