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    base = [NSString stringWithFormat:@"%@-%@", (_abiVersion) ?: [EXVersions sharedInstance].temporarySdkVersion, _resourceName];
218  } else {
219    base = _resourceName;
220  }
221
222  if (useLegacy) {
223    return base;
224  } else {
225    return [NSString stringWithFormat:@"%@-%lu", base, (unsigned long)[_remoteUrl hash]];
226  }
227}
228
229- (NSString *)resourceCachePath
230{
231  NSString *versionedResourceFilename = [NSString stringWithFormat:@"%@.%@", [self _resourceCacheFilenameUsingLegacy:NO], _resourceType];
232  return [_cachePath stringByAppendingPathComponent:versionedResourceFilename];
233}
234
235- (NSString *)resourceBundlePath
236{
237  return [[NSBundle mainBundle] pathForResource:_resourceName ofType:_resourceType];
238}
239
240- (NSString *)_resourceLocalPathPreferringCache
241{
242  if ([self isUsingEmbeddedResource]) {
243    return [self resourceBundlePath];
244  }
245  return [self resourceCachePath];
246}
247
248- (BOOL)isUsingEmbeddedResource
249{
250  // by default, only use the embedded resource if no cached copy exists
251  // but this behavior can be overridden by subclasses
252  NSString *localPath = [self resourceCachePath];
253  return ![[NSFileManager defaultManager] fileExistsAtPath:localPath isDirectory:nil];
254}
255
256- (BOOL)removeCache
257{
258  NSString *localPath = [self resourceCachePath];
259  if ([[NSFileManager defaultManager] fileExistsAtPath:localPath isDirectory:nil]) {
260    NSError *error;
261    [[NSFileManager defaultManager] removeItemAtPath:localPath error:&error];
262    return (error == nil);
263  }
264  return NO;
265}
266
267- (NSError *)_validateResponseData:(NSData *)data response:(NSURLResponse *)response
268{
269  // always valid
270  return nil;
271}
272
273- (NSError *)_validateErrorData:(NSError *)error response:(NSURLResponse *)response
274{
275  NSError *networkError = [NSError errorWithDomain:EXNetworkErrorDomain code:error.code userInfo:error.userInfo];
276  return networkError;
277}
278
279- (NSString *)_defaultCachePath
280{
281  return [[self class] cachePathWithName:@"EXCachedResource"];
282}
283
284+ (NSString *)cachePathWithName:(NSString *)cacheName
285{
286  NSString *cachesDirectory = [EXEnvironment sharedEnvironment].isDetached
287    ? NSSearchPathForDirectoriesInDomains(NSApplicationSupportDirectory, NSUserDomainMask, YES).firstObject
288    : NSSearchPathForDirectoriesInDomains(NSCachesDirectory, NSUserDomainMask, YES).firstObject;
289
290  NSString *sourceDirectory = [cachesDirectory stringByAppendingPathComponent:cacheName];
291  NSString *sourceDirectoryVersioned = [EXEnvironment sharedEnvironment].isDetached
292    ? [sourceDirectory stringByAppendingPathComponent:[EXVersions sharedInstance].temporarySdkVersion]
293    : sourceDirectory;
294
295  BOOL cacheDirectoryExists = [[NSFileManager defaultManager] fileExistsAtPath:sourceDirectoryVersioned isDirectory:nil];
296  if (!cacheDirectoryExists) {
297    NSError *error;
298    BOOL created = [[NSFileManager defaultManager] createDirectoryAtPath:sourceDirectoryVersioned
299                                             withIntermediateDirectories:YES
300                                                              attributes:nil
301                                                                   error:&error];
302    if (created) {
303      cacheDirectoryExists = YES;
304    } else {
305      DDLogError(@"Could not create source cache directory: %@", error.localizedDescription);
306    }
307  }
308
309  if (cacheDirectoryExists && [EXEnvironment sharedEnvironment].isDetached) {
310    NSURL *cacheDirectoryUrl = [NSURL fileURLWithPath:sourceDirectoryVersioned];
311    NSError *error;
312    if (![cacheDirectoryUrl setResourceValue:@(YES) forKey:NSURLIsExcludedFromBackupKey error:&error]) {
313      DDLogError(@"Could not exclude source cache directory from backup: %@", error.localizedDescription);
314    }
315  }
316
317  if ([EXEnvironment sharedEnvironment].isDetached) {
318    static dispatch_once_t onceToken;
319    dispatch_once(&onceToken, ^{
320      _reapingQueue = dispatch_queue_create("expo.cached-resource.reaping", DISPATCH_QUEUE_SERIAL);
321    });
322
323    dispatch_async(_reapingQueue, ^{
324      NSError *error;
325      NSArray<NSString *>* subfolders = [[NSFileManager defaultManager] contentsOfDirectoryAtPath:sourceDirectory error:&error];
326      if (error) {
327        DDLogError(@"Could not read old SDK version cache directories: %@", error.localizedDescription);
328      } else {
329        for (NSString *subfolder in subfolders) {
330          if (![subfolder isEqualToString:[EXVersions sharedInstance].temporarySdkVersion]) {
331            NSString *path = [sourceDirectory stringByAppendingPathComponent:subfolder];
332            NSError *error;
333            if (![[NSFileManager defaultManager] removeItemAtPath:path error:&error]) {
334              DDLogError(@"Failed to reap old SDK version cache directories: %@", error.localizedDescription);
335            }
336          }
337        }
338      }
339    });
340  }
341
342  return (cacheDirectoryExists) ? sourceDirectoryVersioned : nil;
343}
344
345@end
346
347NS_ASSUME_NONNULL_END
348