diff --git a/lib/components/media-cache-provider.react.js b/lib/components/media-cache-provider.react.js index 4f055a7e0..290315d40 100644 --- a/lib/components/media-cache-provider.react.js +++ b/lib/components/media-cache-provider.react.js @@ -1,113 +1,114 @@ // @flow import * as React from 'react'; /** * This represents a persistent cache layer (e.g filesystem) * underneath the memory Holder->URI map. */ export type MediaCachePersistence = { // returns true if the URI is a cached media URI. This check should be fast - +hasURI: (uri: string) => Promise, - // returns URI if holder cached or null if not - +getCachedFile: (holder: string) => Promise, - // returns URI of saved file - +saveFile: (holder: string, uri: string) => Promise, + +hasURI: (mediaURI: string) => Promise, + // returns URI if blob URI is cached or null if not + +getCachedFile: (blobURI: string) => Promise, + // returns URI of saved file. Blob URI is the cache key, media URI is the + // media content URI (either file or data-uri) + +saveFile: (blobURI: string, mediaURI: string) => Promise, // clears cache (deletes all files) +clearCache: () => Promise, // returns size of cache in bytes +getCacheSize: () => Promise, // cleans up old files until cache size is less than cacheSizeLimit (bytes) // returns true if some files were deleted and memory cache should be // invalidated +cleanupOldFiles: (cacheSizeLimit: number) => Promise, }; const DEFAULT_CACHE_SIZE_LIMIT = 100 * 1024 * 1024; // 100 MiB in bytes type MediaCacheContextType = { /** - * Gets the URI for a given holder, or `null` if it's not cached. + * Gets the media URI for a given blob URI, or `null` if it's not cached. */ - +get: (holder: string) => Promise, + +get: (blobURI: string) => Promise, /** - * Saves the URI for a given holder. Accepts both file and data URIs. + * Saves the media URI for a given blob URI. Accepts both file and data URIs. */ - +set: (holder: string, uri: string) => Promise, + +set: (blobURI: string, mediaURI: string) => Promise, /** * Clears the in-memory cache and cleans up old files from the platform cache. * This should be called when no media components are mounted. */ +evictCache: () => Promise, }; function createMediaCacheContext( persistence: MediaCachePersistence, options: { +cacheSizeLimit?: number }, ): MediaCacheContextType { // holder -> URI const uriCache = new Map(); - async function get(holder: string): Promise { - const cachedURI = uriCache.get(holder); - if (cachedURI) { + async function get(blobURI: string): Promise { + const cachedMediaURI = uriCache.get(blobURI); + if (cachedMediaURI) { // even though we have the URI in memory, we still need to check if it's // still valid (e.g. file was deleted from the platform cache) - const uriExists = await persistence.hasURI(cachedURI); + const uriExists = await persistence.hasURI(cachedMediaURI); if (uriExists) { - return cachedURI; + return cachedMediaURI; } else { - uriCache.delete(holder); + uriCache.delete(blobURI); } } // if the in-memory cache doesn't have it, check the platform cache - const cachedFile = await persistence.getCachedFile(holder); + const cachedFile = await persistence.getCachedFile(blobURI); if (cachedFile) { - uriCache.set(holder, cachedFile); + uriCache.set(blobURI, cachedFile); } return cachedFile; } - async function set(holder: string, uri: string): Promise { - const cachedURI = await persistence.saveFile(holder, uri); - uriCache.set(holder, cachedURI); + async function set(blobURI: string, mediaURI: string): Promise { + const cachedURI = await persistence.saveFile(blobURI, mediaURI); + uriCache.set(blobURI, cachedURI); } async function evictCache() { uriCache.clear(); try { await persistence.cleanupOldFiles( options.cacheSizeLimit ?? DEFAULT_CACHE_SIZE_LIMIT, ); } catch (e) { console.log('Failed to evict media cache', e); } } return { get, set, evictCache }; } const MediaCacheContext: React.Context = React.createContext(null); type Props = { +children: React.Node, +persistence: MediaCachePersistence, +cacheSizeLimit?: number, }; function MediaCacheProvider(props: Props): React.Node { const { children, persistence, cacheSizeLimit } = props; const cacheContext = React.useMemo( () => createMediaCacheContext(persistence, { cacheSizeLimit }), [persistence, cacheSizeLimit], ); return ( {children} ); } export { MediaCacheContext, MediaCacheProvider }; diff --git a/native/media/media-cache.js b/native/media/media-cache.js index 72e79064a..646556f2a 100644 --- a/native/media/media-cache.js +++ b/native/media/media-cache.js @@ -1,129 +1,129 @@ // @flow import invariant from 'invariant'; import fs from 'react-native-fs'; import type { MediaCachePersistence } from 'lib/components/media-cache-provider.react.js'; import { extensionFromFilename, filenameFromPathOrURI, filenameWithoutExtension, pathFromURI, readableFilename, replaceExtension, } from 'lib/media/file-utils.js'; import { temporaryDirectoryPath } from './file-utils.js'; const cacheDirectory = `${temporaryDirectoryPath}media-cache`; -function basenameFromHolder(holder: string) { - // if holder is a file URI or path, use the last segment of the path - const holderBase = holder.split('/').pop(); - return filenameWithoutExtension(holderBase); +function basenameFromBlobURI(blobURI: string) { + // if blobURI is a file URI or path, use the last segment of the path + const filename = blobURI.split('/').pop(); + return filenameWithoutExtension(filename); } async function ensureCacheDirectory() { await fs.mkdir(cacheDirectory, { // iOS only, apple rejects apps having offline cache without this attribute NSURLIsExcludedFromBackupKey: true, }); } async function listCachedFiles() { await ensureCacheDirectory(); return await fs.readdir(cacheDirectory); } async function getCacheSize() { const files = await listCachedFiles(); return files.reduce((total, file) => total + file.size, 0); } -async function hasURI(uri: string): Promise { - const path = pathFromURI(uri); +async function hasURI(mediaURI: string): Promise { + const path = pathFromURI(mediaURI); if (!path) { return false; } return await fs.exists(path); } -async function getCachedFile(holder: string) { +async function getCachedFile(blobURI: string) { const cachedFiles = await listCachedFiles(); - const baseHolder = basenameFromHolder(holder); + const basename = basenameFromBlobURI(blobURI); const cachedFile = cachedFiles.find(file => - filenameWithoutExtension(file).startsWith(baseHolder), + filenameWithoutExtension(file).startsWith(basename), ); if (cachedFile) { return `file://${cacheDirectory}/${cachedFile}`; } return null; } async function clearCache() { const cacheDirExists = await fs.exists(cacheDirectory); if (cacheDirExists) { await fs.unlink(cacheDirectory); } // recreate empty directory await ensureCacheDirectory(); } const dataURLRegex = /^data:([^;]+);base64,([a-zA-Z0-9+/]+={0,2})$/; -async function saveFile(holder: string, uri: string): Promise { +async function saveFile(blobURI: string, mediaURI: string): Promise { await ensureCacheDirectory(); let filePath; - const baseHolder = basenameFromHolder(holder); - const isDataURI = uri.startsWith('data:'); + const basename = basenameFromBlobURI(blobURI); + const isDataURI = mediaURI.startsWith('data:'); if (isDataURI) { - const [, mime, data] = uri.match(dataURLRegex) ?? []; + const [, mime, data] = mediaURI.match(dataURLRegex) ?? []; invariant(mime, 'malformed data-URI: missing MIME type'); invariant(data, 'malformed data-URI: invalid data'); - const filename = readableFilename(baseHolder, mime) ?? baseHolder; + const filename = readableFilename(basename, mime) ?? basename; filePath = `${cacheDirectory}/${filename}`; await fs.writeFile(filePath, data, 'base64'); } else { - const uriFilename = filenameFromPathOrURI(uri); + const uriFilename = filenameFromPathOrURI(mediaURI); invariant(uriFilename, 'malformed URI: missing filename'); const extension = extensionFromFilename(uriFilename); const filename = extension - ? replaceExtension(baseHolder, extension) - : baseHolder; + ? replaceExtension(basename, extension) + : basename; filePath = `${cacheDirectory}/${filename}`; - await fs.copyFile(uri, filePath); + await fs.copyFile(mediaURI, filePath); } return `file://${filePath}`; } async function cleanupOldFiles(cacheSizeLimit: number): Promise { await ensureCacheDirectory(); const files = await fs.readDir(cacheDirectory); let cacheSize = files.reduce((total, file) => total + file.size, 0); const filesSorted = [...files].sort((a, b) => a.mtime - b.mtime); const filenamesToRemove = []; while (cacheSize > cacheSizeLimit) { const file = filesSorted.shift(); if (!file) { break; } filenamesToRemove.push(file.name); cacheSize -= file.size; } await Promise.all( filenamesToRemove.map(file => fs.unlink(`${cacheDirectory}/${file}`)), ); return filenamesToRemove.length > 0; } const filesystemMediaCache: MediaCachePersistence = { hasURI, getCachedFile, saveFile, getCacheSize, cleanupOldFiles, clearCache, }; export { filesystemMediaCache };