@@ -12,6 +12,23 @@ import pathLib from 'path'
12
12
import RNFS from 'react-native-fs'
13
13
import sha1 from 'crypto-js/sha1'
14
14
import URL from 'url-parse'
15
+ import { forkJoin , from , Observable , of , throwError } from 'rxjs'
16
+ import {
17
+ switchMap ,
18
+ catchError ,
19
+ mapTo ,
20
+ publishReplay ,
21
+ refCount ,
22
+ mergeMap ,
23
+ map ,
24
+ delayWhen ,
25
+ } from 'rxjs/operators'
26
+ import uuid from 'react-native-uuid'
27
+
28
+ export interface CacheFileInfo {
29
+ path : string | null
30
+ fileName : string
31
+ }
15
32
16
33
/**
17
34
* Resolves if 'unlink' resolves or if the file doesn't exist.
@@ -53,6 +70,9 @@ export class FileSystem {
53
70
[ component : string ] : boolean
54
71
}
55
72
} = { }
73
+ static cacheObservables : {
74
+ [ key : string ] : Observable < CacheFileInfo >
75
+ } = { }
56
76
baseFilePath : string
57
77
cachePruneTriggerLimit : number
58
78
@@ -79,6 +99,10 @@ export class FileSystem {
79
99
Object . keys ( FileSystem . cacheLock [ fileName ] ) . length === 0
80
100
) {
81
101
delete FileSystem . cacheLock [ fileName ]
102
+
103
+ if ( FileSystem . cacheObservables [ fileName ] ) {
104
+ delete FileSystem . cacheObservables [ fileName ]
105
+ }
82
106
}
83
107
}
84
108
@@ -154,7 +178,7 @@ export class FileSystem {
154
178
* @throws error on invalid (non jpg, png, gif, bmp) url file type. NOTE file extension or content-type header does not guarantee file mime type. We are trusting that it is set correctly on the server side.
155
179
* @returns fileName {string} - A SHA1 filename that is unique to the resource located at passed in URL and includes an appropriate extension.
156
180
*/
157
- async getFileNameFromUrl ( url : string ) {
181
+ getFileNameFromUrl ( url : string ) {
158
182
const urlParts = new URL ( url )
159
183
const urlExt = urlParts . pathname . split ( '.' ) . pop ( )
160
184
@@ -173,30 +197,19 @@ export class FileSystem {
173
197
* @param permanent {Boolean} - True persists the file locally indefinitely, false caches the file temporarily (until file is removed during cache pruning).
174
198
* @returns {Promise<string|null> } promise that resolves to the local file path of downloaded url file.
175
199
*/
176
- async getLocalFilePathFromUrl ( url : string , permanent : boolean ) {
177
- let filePath = null
178
-
179
- const fileName = await this . getFileNameFromUrl ( url )
180
-
181
- const permanentFileExists = this . exists ( 'permanent/' + fileName )
182
- const cacheFileExists = this . exists ( 'cache/' + fileName )
200
+ async getLocalFilePathFromUrl ( url : string , permanent = false ) {
201
+ const fileName = this . getFileNameFromUrl ( url )
202
+ const requestId = uuid . v4 ( )
183
203
184
- const exists = await Promise . all ( [ permanentFileExists , cacheFileExists ] )
204
+ try {
205
+ FileSystem . lockCacheFile ( fileName , requestId )
185
206
186
- if ( exists [ 0 ] ) {
187
- filePath = this . baseFilePath + 'permanent/' + fileName
188
- } else if ( exists [ 1 ] ) {
189
- filePath = this . baseFilePath + 'cache/' + fileName
190
- } else {
191
- const result = await this . fetchFile ( url , permanent , null , true ) // Clobber must be true to allow concurrent CacheableImage components with same source url (ie: bullet point images).
192
- filePath = result . path
193
- }
207
+ const { path } = await this . observable ( url , requestId , permanent , fileName ) . toPromise ( )
194
208
195
- if ( filePath ) {
196
- return Platform . OS === 'android' ? 'file://' + filePath : filePath
209
+ return path
210
+ } finally {
211
+ FileSystem . unlockCacheFile ( fileName , requestId )
197
212
}
198
-
199
- return null
200
213
}
201
214
202
215
/**
@@ -213,7 +226,7 @@ export class FileSystem {
213
226
* @returns {Promise } promise that resolves to an object that contains cached file info.
214
227
*/
215
228
async cacheLocalFile ( local : string , url : string , permanent = false , move = false ) {
216
- const fileName = await this . getFileNameFromUrl ( url )
229
+ const fileName = this . getFileNameFromUrl ( url )
217
230
const path = this . baseFilePath + ( permanent ? 'permanent/' : 'cache/' ) + fileName
218
231
this . _validatePath ( path , true )
219
232
@@ -254,52 +267,66 @@ export class FileSystem {
254
267
* @param url {String} - url of file to download.
255
268
* @param permanent {Boolean} - True persists the file locally indefinitely, false caches the file temporarily (until file is removed during cache pruning).
256
269
* @param fileName {String} - defaults to a sha1 hash of the url param with extension of same filetype.
257
- * @param clobber {String } - whether or not to overwrite a file that already exists at path. defaults to false.
258
- * @returns {Promise } promise that resolves to an object that contains the local path of the downloaded file and the filename.
270
+ * @param clobber {Boolean } - whether or not to overwrite a file that already exists at path. defaults to false.
271
+ * @returns {Observable<CacheFileInfo> } observable that resolves to an object that contains the local path of the downloaded file and the filename.
259
272
*/
260
- async fetchFile ( url : string , permanent = false , fileName : string | null , clobber = false ) {
261
- fileName = fileName || ( await this . getFileNameFromUrl ( url ) )
273
+ fetchFile (
274
+ url : string ,
275
+ permanent = false ,
276
+ fileName : string | null = null ,
277
+ clobber = false ,
278
+ ) : Observable < CacheFileInfo > {
279
+ fileName = fileName || this . getFileNameFromUrl ( url )
262
280
const path = this . baseFilePath + ( permanent ? 'permanent/' : 'cache/' ) + fileName
263
281
this . _validatePath ( path , true )
264
282
265
- // Clobber logic
266
- const fileExistsAtPath = await this . exists ( ( permanent ? 'permanent/' : 'cache/' ) + fileName )
267
- if ( ! clobber && fileExistsAtPath ) {
268
- throw new Error ( 'A file already exists at ' + path + ' and clobber is set to false.' )
269
- }
270
-
271
- // Logic here prunes cache directory on "cache" writes to ensure cache doesn't get too large.
272
- if ( ! permanent ) {
273
- await this . pruneCache ( )
274
- }
275
-
276
- // Hit network and download file to local disk.
277
- try {
278
- const cacheDirExists = await this . exists ( permanent ? 'permanent' : 'cache' )
279
- if ( ! cacheDirExists ) {
280
- await RNFS . mkdir ( `${ this . baseFilePath } ${ permanent ? 'permanent' : 'cache' } ` )
281
- }
282
-
283
- const { promise } = RNFS . downloadFile ( {
284
- fromUrl : url ,
285
- toFile : path ,
286
- } )
287
- const response = await promise
288
- if ( response . statusCode !== 200 ) {
289
- throw response
290
- }
291
- } catch ( error ) {
292
- await RNFSUnlinkIfExists ( path )
293
- return {
294
- path : null ,
295
- fileName : pathLib . basename ( path ) ,
296
- }
297
- }
298
-
299
- return {
300
- path,
301
- fileName : pathLib . basename ( path ) ,
302
- }
283
+ return from ( this . exists ( ( permanent ? 'permanent/' : 'cache/' ) + fileName ) ) . pipe (
284
+ // Clobber logic
285
+ delayWhen ( ( fileExistsAtPath ) =>
286
+ from (
287
+ ! clobber && fileExistsAtPath
288
+ ? throwError ( 'A file already exists at ' + path + ' and clobber is set to false.' )
289
+ : Promise . resolve ( ) ,
290
+ ) ,
291
+ ) ,
292
+ // Logic here prunes cache directory on "cache" writes to ensure cache doesn't get too large.
293
+ delayWhen ( ( ) => from ( ! permanent ? this . pruneCache ( ) : Promise . resolve ( ) ) ) ,
294
+ delayWhen ( ( ) =>
295
+ from (
296
+ this . exists ( permanent ? 'permanent' : 'cache' ) . then ( ( cacheDirExists ) =>
297
+ ! cacheDirExists
298
+ ? RNFS . mkdir ( `${ this . baseFilePath } ${ permanent ? 'permanent' : 'cache' } ` )
299
+ : Promise . resolve ( ) ,
300
+ ) ,
301
+ ) ,
302
+ ) ,
303
+ // Hit network and download file to local disk.
304
+ mergeMap ( ( ) =>
305
+ from (
306
+ RNFS . downloadFile ( {
307
+ fromUrl : url ,
308
+ toFile : path ,
309
+ } ) . promise ,
310
+ ) ,
311
+ ) ,
312
+ map ( ( downloadResult ) => {
313
+ if ( downloadResult . statusCode !== 200 ) {
314
+ throw new Error ( 'Request failed ' + downloadResult . statusCode )
315
+ }
316
+ return {
317
+ path : Platform . OS === 'android' ? 'file://' + path : path ,
318
+ fileName : pathLib . basename ( path ) ,
319
+ }
320
+ } ) ,
321
+ catchError ( ( ) => {
322
+ return from ( RNFSUnlinkIfExists ( path ) ) . pipe (
323
+ mapTo ( {
324
+ path : null ,
325
+ fileName : pathLib . basename ( path ) ,
326
+ } ) ,
327
+ )
328
+ } ) ,
329
+ )
303
330
}
304
331
305
332
/**
@@ -365,6 +392,66 @@ export class FileSystem {
365
392
return false
366
393
}
367
394
}
395
+
396
+ observable (
397
+ url : string ,
398
+ componentId : string ,
399
+ permanent = false ,
400
+ fileName : string | null = null ,
401
+ ) : Observable < CacheFileInfo > {
402
+ if ( ! url ) {
403
+ return of ( {
404
+ path : null ,
405
+ fileName : '' ,
406
+ } )
407
+ }
408
+
409
+ fileName = fileName || this . getFileNameFromUrl ( url )
410
+
411
+ if ( ! FileSystem . cacheLock [ fileName ] || ! FileSystem . cacheLock [ fileName ] [ componentId ] ) {
412
+ throw new Error ( 'A lock must be aquired before requesting an observable' )
413
+ }
414
+
415
+ if ( ! FileSystem . cacheObservables [ fileName ] ) {
416
+ const permanentFileExists = this . exists ( 'permanent/' + fileName )
417
+ const cacheFileExists = this . exists ( 'cache/' + fileName )
418
+
419
+ return ( FileSystem . cacheObservables [ fileName ] = forkJoin ( [
420
+ permanentFileExists ,
421
+ cacheFileExists ,
422
+ ] ) . pipe (
423
+ switchMap ( ( [ existsPermanent , existsCache ] ) => {
424
+ // Check caches
425
+ if ( existsPermanent ) {
426
+ return of ( {
427
+ path :
428
+ ( Platform . OS === 'android' ? 'file://' : '' ) +
429
+ this . baseFilePath +
430
+ 'permanent/' +
431
+ fileName ,
432
+ fileName,
433
+ } as CacheFileInfo )
434
+ } else if ( existsCache ) {
435
+ return of ( {
436
+ path :
437
+ ( Platform . OS === 'android' ? 'file://' : '' ) +
438
+ this . baseFilePath +
439
+ 'cache/' +
440
+ fileName ,
441
+ fileName,
442
+ } as CacheFileInfo )
443
+ }
444
+
445
+ // Download
446
+ return this . fetchFile ( url , permanent , fileName , true )
447
+ } ) ,
448
+ publishReplay ( 1 ) ,
449
+ refCount ( ) ,
450
+ ) )
451
+ }
452
+
453
+ return FileSystem . cacheObservables [ fileName ]
454
+ }
368
455
}
369
456
370
457
/**
0 commit comments