diff --git a/lib/media/file-utils.js b/lib/media/file-utils.js index c2bbd83ec..9c5e83c9c 100644 --- a/lib/media/file-utils.js +++ b/lib/media/file-utils.js @@ -1,229 +1,238 @@ // @flow import fileType from 'file-type'; import invariant from 'invariant'; import type { Shape } from '../types/core.js'; import type { MediaType } from '../types/media-types.js'; type ResultMIME = 'image/png' | 'image/jpeg'; type MediaConfig = { +mediaType: 'photo' | 'video' | 'photo_or_video', +extension: string, +serverCanHandle: boolean, +serverTranscodesImage: boolean, +imageConfig?: Shape<{ +convertTo: ResultMIME, }>, +videoConfig?: Shape<{ +loop: boolean, }>, }; const mediaConfig: { [mime: string]: MediaConfig } = Object.freeze({ 'image/png': { mediaType: 'photo', extension: 'png', serverCanHandle: true, serverTranscodesImage: true, }, 'image/jpeg': { mediaType: 'photo', extension: 'jpg', serverCanHandle: true, serverTranscodesImage: true, }, 'image/gif': { // Set mediaType to 'photo_or_video' when working on // video messages to treat animated GIFs as videos. mediaType: 'photo', extension: 'gif', serverCanHandle: true, serverTranscodesImage: true, imageConfig: { convertTo: 'image/png', }, videoConfig: { loop: true, }, }, 'image/heic': { mediaType: 'photo', extension: 'heic', serverCanHandle: false, serverTranscodesImage: false, imageConfig: { convertTo: 'image/jpeg', }, }, 'image/webp': { mediaType: 'photo', extension: 'webp', serverCanHandle: true, serverTranscodesImage: true, imageConfig: { convertTo: 'image/jpeg', }, }, 'image/tiff': { mediaType: 'photo', extension: 'tiff', serverCanHandle: true, serverTranscodesImage: true, imageConfig: { convertTo: 'image/jpeg', }, }, 'image/svg+xml': { mediaType: 'photo', extension: 'svg', serverCanHandle: true, serverTranscodesImage: true, imageConfig: { convertTo: 'image/png', }, }, 'image/bmp': { mediaType: 'photo', extension: 'bmp', serverCanHandle: true, serverTranscodesImage: true, imageConfig: { convertTo: 'image/png', }, }, 'video/mp4': { mediaType: 'video', extension: 'mp4', serverCanHandle: true, serverTranscodesImage: false, }, 'video/quicktime': { mediaType: 'video', extension: 'mp4', serverCanHandle: true, serverTranscodesImage: false, }, }); const serverTranscodableTypes: Set<$Keys> = new Set(); const serverCanHandleTypes: Set<$Keys> = new Set(); for (const mime in mediaConfig) { if (mediaConfig[mime].serverTranscodesImage) { serverTranscodableTypes.add(mime); } if (mediaConfig[mime].serverCanHandle) { serverCanHandleTypes.add(mime); } } function getTargetMIME(inputMIME: string): ResultMIME { const config = mediaConfig[inputMIME]; if (!config) { return 'image/jpeg'; } const targetMIME = config.imageConfig && config.imageConfig.convertTo; if (targetMIME) { return targetMIME; } invariant( inputMIME === 'image/png' || inputMIME === 'image/jpeg', 'all images must be converted to jpeg or png', ); return inputMIME; } const bytesNeededForFileTypeCheck = 64; export type FileDataInfo = { mime: ?string, mediaType: ?MediaType, }; function fileInfoFromData( data: Uint8Array | Buffer | ArrayBuffer, ): FileDataInfo { const fileTypeResult = fileType(data); if (!fileTypeResult) { return { mime: null, mediaType: null }; } const { mime } = fileTypeResult; const rawMediaType = mediaConfig[mime] && mediaConfig[mime].mediaType; const mediaType = rawMediaType === 'photo_or_video' ? 'photo' : rawMediaType; return { mime, mediaType }; } function replaceExtension(filename: string, ext: string): string { const lastIndex = filename.lastIndexOf('.'); let name = lastIndex >= 0 ? filename.substring(0, lastIndex) : filename; if (!name) { name = Math.random().toString(36).slice(-5); } const maxReadableLength = 255 - ext.length - 1; return `${name.substring(0, maxReadableLength)}.${ext}`; } function readableFilename(filename: string, mime: string): ?string { const ext = mediaConfig[mime] && mediaConfig[mime].extension; if (!ext) { return null; } return replaceExtension(filename, ext); } const basenameRegex = /[^a-z0-9._-]/gi; function sanitizeFilename(filename: ?string, mime: string): string { if (!filename) { // Generate a random filename and deduce the extension from the mime type. const randomName = Math.random().toString(36).slice(-5); filename = readableFilename(randomName, mime) || randomName; } return filename.replace(basenameRegex, '_'); } const extRegex = /\.([0-9a-z]+)$/i; function extensionFromFilename(filename: string): ?string { const matches = filename.match(extRegex); if (!matches) { return null; } const match = matches[1]; if (!match) { return null; } return match.toLowerCase(); } +function filenameWithoutExtension(filename: string): string { + const extension = extensionFromFilename(filename); + if (!extension) { + return filename; + } + return filename.replace(new RegExp(`\\.${extension}$`), ''); +} + const pathRegex = /^file:\/\/(.*)$/; function pathFromURI(uri: string): ?string { const matches = uri.match(pathRegex); if (!matches) { return null; } return matches[1] ? matches[1] : null; } const filenameRegex = /[^/]+$/; function filenameFromPathOrURI(pathOrURI: string): ?string { const matches = pathOrURI.match(filenameRegex); if (!matches) { return null; } return matches[0] ? matches[0] : null; } export { mediaConfig, serverTranscodableTypes, serverCanHandleTypes, getTargetMIME, bytesNeededForFileTypeCheck, fileInfoFromData, replaceExtension, readableFilename, sanitizeFilename, extensionFromFilename, pathFromURI, filenameFromPathOrURI, + filenameWithoutExtension, }; diff --git a/lib/media/file-utils.test.js b/lib/media/file-utils.test.js new file mode 100644 index 000000000..3b8017a8a --- /dev/null +++ b/lib/media/file-utils.test.js @@ -0,0 +1,17 @@ +// @flow + +import { filenameWithoutExtension } from './file-utils.js'; + +describe('filenameWithoutExtension', () => { + it('removes extension from filename', () => { + expect(filenameWithoutExtension('foo.jpg')).toBe('foo'); + }); + + it('removes only last extension part from filename', () => { + expect(filenameWithoutExtension('foo.bar.jpg')).toBe('foo.bar'); + }); + + it('returns filename if it has no extension', () => { + expect(filenameWithoutExtension('foo')).toBe('foo'); + }); +}); diff --git a/native/media/media-cache.js b/native/media/media-cache.js new file mode 100644 index 000000000..601c22819 --- /dev/null +++ b/native/media/media-cache.js @@ -0,0 +1,120 @@ +// @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); +} + +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 { + return await fs.exists(pathFromURI(uri)); +} + +async function getCachedFile(holder: string) { + const cachedFiles = await listCachedFiles(); + const baseHolder = basenameFromHolder(holder); + const cachedFile = cachedFiles.find(file => + filenameWithoutExtension(file).startsWith(baseHolder), + ); + if (cachedFile) { + return `file://${cacheDirectory}/${cachedFile}`; + } + return null; +} + +async function clearCache() { + await fs.unlink(cacheDirectory); +} + +const dataURLRegex = /^data:([^;]+);base64,([a-zA-Z0-9+/]+={0,2})$/; +async function saveFile(holder: string, uri: string): Promise { + await ensureCacheDirectory(); + let filePath; + const baseHolder = basenameFromHolder(holder); + const isDataURI = uri.startsWith('data:'); + if (isDataURI) { + const [, mime, data] = uri.match(dataURLRegex) ?? []; + invariant(mime, 'malformed data-URI: missing MIME type'); + invariant(data, 'malformed data-URI: invalid data'); + const filename = readableFilename(baseHolder, mime) ?? baseHolder; + filePath = `${cacheDirectory}/${filename}`; + + await fs.writeFile(filePath, data, 'base64'); + } else { + const uriFilename = filenameFromPathOrURI(uri); + invariant(uriFilename, 'malformed URI: missing filename'); + const extension = extensionFromFilename(uriFilename); + const filename = extension + ? replaceExtension(baseHolder, extension) + : baseHolder; + filePath = `${cacheDirectory}/${filename}`; + await fs.copyFile(uri, 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 };