1// Copyright 2015-present 650 Industries. All rights reserved.
2
3#import "EXCachedResource.h"
4#import "EXEnvironment.h"
5#import "EXFileDownloader.h"
6#import "EXKernelUtil.h"
7#import "EXUtil.h"
8#import "EXVersions.h"
9
10#import <React/RCTUtils.h>
11
12NS_ASSUME_NONNULL_BEGIN
13
14@implementation EXLoadingProgress
15
16@end
17
18@interface EXCachedResource ()
19
20@property (nonatomic, strong) NSString *resourceName;
21@property (nonatomic, strong) NSString *resourceType;
22@property (nonatomic, strong) NSString *cachePath;
23
24@end
25
26@implementation EXCachedResource
27
28static dispatch_queue_t _reapingQueue;
29
30- (instancetype)initWithResourceName:(NSString *)resourceName resourceType:(NSString *)resourceType remoteUrl:(nonnull NSURL *)url cachePath:(NSString * _Nullable)cachePath
31{
32  if (self = [super init]) {
33    _shouldVersionCache = YES;
34    _resourceName = [EXUtil escapedResourceName:resourceName];
35    _resourceType = resourceType;
36    _remoteUrl = url;
37    _cachePath = (cachePath) ? cachePath : [self _defaultCachePath];
38  }
39  return self;
40}
41
42- (void)loadResourceWithBehavior:(EXCachedResourceBehavior)behavior
43                   progressBlock:(__nullable EXCachedResourceProgressBlock)progressBlock
44                    successBlock:(EXCachedResourceSuccessBlock)success
45                      errorBlock:(EXCachedResourceErrorBlock)error
46{
47  switch (behavior) {
48    case EXCachedResourceNoCache: {
49      NSLog(@"EXCachedResource: Not using cache for %@", _resourceName);
50      [self _loadRemoteResourceWithSuccess:success error:error ignoringCache:YES];
51      break;
52    }
53    case EXCachedResourceWriteToCache: {
54      [self _loadRemoteAndWriteToCacheWithSuccess:success error:error];
55      break;
56    }
57    case EXCachedResourceUseCacheImmediately: {
58      [self _loadCacheImmediatelyAndDownload:YES withSuccess:success error:error];
59      break;
60    }
61    case EXCachedResourceFallBackToNetwork: {
62      [self _loadCacheAndDownloadConditionallyWithSuccess:success error:error];
63      break;
64    }
65    case EXCachedResourceFallBackToCache: default: {
66      [self _loadRemoteAndFallBackToCacheWithSuccess:success error:error];
67      break;
68    }
69    case EXCachedResourceOnlyCache: {
70      [self _loadCacheImmediatelyAndDownload:NO withSuccess:success error:error];
71      break;
72    }
73  }
74}
75
76/**
77 *  If @ignoreCache is true, make sure NSURLSession busts any existing cache.
78 */
79- (void)_loadRemoteResourceWithSuccess:(EXCachedResourceSuccessBlock)successBlock
80                                 error:(EXCachedResourceErrorBlock)errorBlock
81                         ignoringCache:(BOOL)ignoreCache
82{
83  EXFileDownloader *downloader = [[EXFileDownloader alloc] init];
84  if (_requestTimeoutInterval) {
85    downloader.timeoutInterval = _requestTimeoutInterval;
86  }
87  if (_abiVersion) {
88    downloader.abiVersion = _abiVersion;
89  }
90  if (_releaseChannel){
91    downloader.releaseChannel = _releaseChannel;
92  }
93  if (_urlCache || ignoreCache) {
94    NSURLSessionConfiguration *customConfiguration = [NSURLSessionConfiguration defaultSessionConfiguration];
95    if (_urlCache) {
96      customConfiguration.URLCache = _urlCache;
97    }
98    if (ignoreCache) {
99      customConfiguration.requestCachePolicy = NSURLRequestReloadIgnoringLocalCacheData;
100    }
101    downloader.urlSessionConfiguration = customConfiguration;
102  }
103
104  [downloader downloadFileFromURL:_remoteUrl successBlock:^(NSData *data, NSURLResponse *response) {
105    NSError *err = [self _validateResponseData:data response:response];
106    if (err) {
107      errorBlock(err);
108    } else {
109      successBlock(data);
110    }
111  } errorBlock:^(NSError *error, NSURLResponse *response) {
112    NSError *err = [self _validateErrorData:error response:response];
113    errorBlock(err);
114  }];
115}
116
117- (void)_loadCacheImmediatelyAndDownload:(BOOL)shouldAttemptDownload
118                             withSuccess:(EXCachedResourceSuccessBlock)successBlock
119                                   error:(EXCachedResourceErrorBlock)errorBlock
120{
121  BOOL hasLocalBundle = NO;
122  NSString *resourceCachePath = [self resourceCachePath];
123  NSString *resourceLocalPath = [self _resourceLocalPathPreferringCache];
124
125  // check cache
126  if (resourceLocalPath) {
127    NSData *data = [NSData dataWithContentsOfFile:resourceLocalPath];
128    if (data && data.length) {
129      hasLocalBundle = YES;
130      NSLog(@"EXCachedResource: Using cached resource at %@...", resourceLocalPath);
131      successBlock(data);
132    }
133  }
134
135  if (shouldAttemptDownload) {
136    EXCachedResourceSuccessBlock onSuccess = ^(NSData *data) {
137      if (!hasLocalBundle) {
138        // no local bundle found, so call back with the newly downloaded resource
139        successBlock(data);
140      }
141
142      // write to cache for next time
143      NSLog(@"EXCachedResource: Caching resource to %@...", resourceCachePath);
144      [data writeToFile:resourceCachePath atomically:YES];
145    };
146    EXCachedResourceErrorBlock onError = ^(NSError *error) {
147      if (!hasLocalBundle) {
148        // no local bundle found, and download failed, so call back with the bad news
149        errorBlock(error);
150      }
151    };
152
153    [self _loadRemoteResourceWithSuccess:onSuccess error:onError ignoringCache:NO];
154  } else {
155    // download not allowed, and we found no cached data, so fail
156    if (!hasLocalBundle) {
157      errorBlock(RCTErrorWithMessage([NSString stringWithFormat:@"No cache exists for this resource: %@.%@", _resourceName, _resourceType]));
158    }
159  }
160}
161
162- (void)_loadRemoteAndWriteToCacheWithSuccess:(EXCachedResourceSuccessBlock)successBlock
163                                           error:(EXCachedResourceErrorBlock)errorBlock
164{
165  NSString *resourceCachePath = [self resourceCachePath];
166
167  [self _loadRemoteResourceWithSuccess:^(NSData * _Nonnull data) {
168    // write to cache for next time
169    NSLog(@"EXCachedResource: Caching resource to %@...", resourceCachePath);
170    [data writeToFile:resourceCachePath atomically:YES];
171    successBlock(data);
172  } error:errorBlock ignoringCache:YES];
173}
174
175- (void)_loadRemoteAndFallBackToCacheWithSuccess:(EXCachedResourceSuccessBlock)successBlock
176                                           error:(EXCachedResourceErrorBlock)errorBlock
177{
178  NSString *resourceCachePath = [self resourceCachePath];
179  NSString *resourceLocalPath = [self _resourceLocalPathPreferringCache];
180
181  [self _loadRemoteResourceWithSuccess:^(NSData * _Nonnull data) {
182    // write to cache for next time
183    NSLog(@"EXCachedResource: Caching resource to %@...", resourceCachePath);
184    [data writeToFile:resourceCachePath atomically:YES];
185    successBlock(data);
186  } error:^(NSError * _Nonnull error) {
187    // failed, check cache instead
188    BOOL hasLocalBundle = NO;
189    if (resourceLocalPath) {
190      NSData *data = [NSData dataWithContentsOfFile:resourceLocalPath];
191      if (data && data.length) {
192        hasLocalBundle = YES;
193        NSLog(@"EXCachedResource: Using cached resource at %@...", resourceLocalPath);
194        successBlock(data);
195      }
196    }
197    if (!hasLocalBundle) {
198      errorBlock(error);
199    }
200  } ignoringCache:NO];
201}
202
203- (void)_loadCacheAndDownloadConditionallyWithSuccess:(EXCachedResourceSuccessBlock)successBlock
204                                                error:(EXCachedResourceErrorBlock)errorBlock
205{
206  [self _loadCacheImmediatelyAndDownload:NO withSuccess:successBlock error:^(NSError * _Nonnull error) {
207    [self _loadRemoteAndWriteToCacheWithSuccess:successBlock error:errorBlock];
208  }];
209}
210
211- (NSString *)_resourceCacheFilenameUsingLegacy:(BOOL)useLegacy
212{
213  NSString *base;
214
215  // this is versioned because it can persist between updates of native code
216  if (_shouldVersionCache) {
217    NSString *prefix = _abiVersion && _abiVersion.length > 0 ? _abiVersion : [EXVersions sharedInstance].temporarySdkVersion;
218    base = [NSString stringWithFormat:@"%@-%@", prefix ?: @"", _resourceName];
219  } else {
220    base = _resourceName;
221  }
222
223  if (useLegacy) {
224    return base;
225  } else {
226    return [NSString stringWithFormat:@"%@-%lu", base, (unsigned long)[_remoteUrl hash]];
227  }
228}
229
230- (NSString *)resourceCachePath
231{
232  NSString *versionedResourceFilename = [NSString stringWithFormat:@"%@.%@", [self _resourceCacheFilenameUsingLegacy:NO], _resourceType];
233  return [_cachePath stringByAppendingPathComponent:versionedResourceFilename];
234}
235
236- (NSString *)resourceBundlePath
237{
238  return [[NSBundle mainBundle] pathForResource:_resourceName ofType:_resourceType];
239}
240
241- (NSString *)_resourceLocalPathPreferringCache
242{
243  if ([self isUsingEmbeddedResource]) {
244    return [self resourceBundlePath];
245  }
246  return [self resourceCachePath];
247}
248
249- (BOOL)isUsingEmbeddedResource
250{
251  // by default, only use the embedded resource if no cached copy exists
252  // but this behavior can be overridden by subclasses
253  NSString *localPath = [self resourceCachePath];
254  return ![[NSFileManager defaultManager] fileExistsAtPath:localPath isDirectory:nil];
255}
256
257- (BOOL)removeCache
258{
259  NSString *localPath = [self resourceCachePath];
260  if ([[NSFileManager defaultManager] fileExistsAtPath:localPath isDirectory:nil]) {
261    NSError *error;
262    [[NSFileManager defaultManager] removeItemAtPath:localPath error:&error];
263    return (error == nil);
264  }
265  return NO;
266}
267
268- (NSError *)_validateResponseData:(NSData *)data response:(NSURLResponse *)response
269{
270  // always valid
271  return nil;
272}
273
274- (NSError *)_validateErrorData:(NSError *)error response:(NSURLResponse *)response
275{
276  NSError *networkError = [NSError errorWithDomain:EXNetworkErrorDomain code:error.code userInfo:error.userInfo];
277  return networkError;
278}
279
280- (NSString *)_defaultCachePath
281{
282  return [[self class] cachePathWithName:@"EXCachedResource"];
283}
284
285+ (NSString *)cachePathWithName:(NSString *)cacheName
286{
287  NSString *cachesDirectory = [EXEnvironment sharedEnvironment].isDetached
288    ? NSSearchPathForDirectoriesInDomains(NSApplicationSupportDirectory, NSUserDomainMask, YES).firstObject
289    : NSSearchPathForDirectoriesInDomains(NSCachesDirectory, NSUserDomainMask, YES).firstObject;
290
291  NSString *sourceDirectory = [cachesDirectory stringByAppendingPathComponent:cacheName];
292  NSString *sourceDirectoryVersioned = [EXEnvironment sharedEnvironment].isDetached
293    ? [sourceDirectory stringByAppendingPathComponent:[EXVersions sharedInstance].temporarySdkVersion]
294    : sourceDirectory;
295
296  BOOL cacheDirectoryExists = [[NSFileManager defaultManager] fileExistsAtPath:sourceDirectoryVersioned isDirectory:nil];
297  if (!cacheDirectoryExists) {
298    NSError *error;
299    BOOL created = [[NSFileManager defaultManager] createDirectoryAtPath:sourceDirectoryVersioned
300                                             withIntermediateDirectories:YES
301                                                              attributes:nil
302                                                                   error:&error];
303    if (created) {
304      cacheDirectoryExists = YES;
305    } else {
306      DDLogError(@"Could not create source cache directory: %@", error.localizedDescription);
307    }
308  }
309
310  if (cacheDirectoryExists && [EXEnvironment sharedEnvironment].isDetached) {
311    NSURL *cacheDirectoryUrl = [NSURL fileURLWithPath:sourceDirectoryVersioned];
312    NSError *error;
313    if (![cacheDirectoryUrl setResourceValue:@(YES) forKey:NSURLIsExcludedFromBackupKey error:&error]) {
314      DDLogError(@"Could not exclude source cache directory from backup: %@", error.localizedDescription);
315    }
316  }
317
318  if ([EXEnvironment sharedEnvironment].isDetached) {
319    static dispatch_once_t onceToken;
320    dispatch_once(&onceToken, ^{
321      _reapingQueue = dispatch_queue_create("expo.cached-resource.reaping", DISPATCH_QUEUE_SERIAL);
322    });
323
324    dispatch_async(_reapingQueue, ^{
325      NSError *error;
326      NSArray<NSString *>* subfolders = [[NSFileManager defaultManager] contentsOfDirectoryAtPath:sourceDirectory error:&error];
327      if (error) {
328        DDLogError(@"Could not read old SDK version cache directories: %@", error.localizedDescription);
329      } else {
330        for (NSString *subfolder in subfolders) {
331          if (![subfolder isEqualToString:[EXVersions sharedInstance].temporarySdkVersion]) {
332            NSString *path = [sourceDirectory stringByAppendingPathComponent:subfolder];
333            NSError *error;
334            if (![[NSFileManager defaultManager] removeItemAtPath:path error:&error]) {
335              DDLogError(@"Failed to reap old SDK version cache directories: %@", error.localizedDescription);
336            }
337          }
338        }
339      }
340    });
341  }
342
343  return (cacheDirectoryExists) ? sourceDirectoryVersioned : nil;
344}
345
346@end
347
348NS_ASSUME_NONNULL_END
349