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