diff --git a/native/media/file-utils.js b/native/media/file-utils.js index b7165e20e..d22658caa 100644 --- a/native/media/file-utils.js +++ b/native/media/file-utils.js @@ -1,446 +1,451 @@ // @flow import base64 from 'base-64'; import * as ExpoFileSystem from 'expo-file-system'; import * as MediaLibrary from 'expo-media-library'; import invariant from 'invariant'; import { Platform } from 'react-native'; import filesystem from 'react-native-fs'; import { mediaConfig, pathFromURI, fileInfoFromData, bytesNeededForFileTypeCheck, } from 'lib/media/file-utils.js'; import type { MediaMissionStep, MediaMissionFailure, MediaType, ReadFileHeaderMediaMissionStep, DisposeTemporaryFileMediaMissionStep, MakeDirectoryMediaMissionStep, AndroidScanFileMediaMissionStep, FetchFileHashMediaMissionStep, CopyFileMediaMissionStep, } from 'lib/types/media-types.js'; import { getMessageForException } from 'lib/utils/errors.js'; import { stringToIntArray } from './blob-utils.js'; import { ffmpeg } from './ffmpeg.js'; const defaultInputs = Object.freeze({}); const defaultFields = Object.freeze({}); export type FetchFileInfoResult = { +success: true, +uri: string, +orientation: ?number, +fileSize: number, +mime: ?string, +mediaType: ?MediaType, }; type OptionalInputs = Partial<{ +mediaNativeID: ?string }>; type OptionalFields = Partial<{ +orientation: boolean, +mediaType: boolean, +mime: boolean, }>; async function fetchFileInfo( inputURI: string, optionalInputs?: OptionalInputs = defaultInputs, optionalFields?: OptionalFields = defaultFields, ): Promise<{ steps: $ReadOnlyArray, result: MediaMissionFailure | FetchFileInfoResult, }> { const { mediaNativeID } = optionalInputs; const steps: Array = []; let assetInfoPromise, assetURI; const inputPath = pathFromURI(inputURI); if (mediaNativeID && (!inputPath || optionalFields.orientation)) { assetInfoPromise = (async () => { const { steps: assetInfoSteps, result: assetInfoResult } = await fetchAssetInfo(mediaNativeID); steps.push(...assetInfoSteps); assetURI = assetInfoResult.localURI; return assetInfoResult; })(); } const getLocalURIPromise: Promise = (async () => { if (inputPath) { return { localURI: inputURI, path: inputPath }; } if (!assetInfoPromise) { return null; } const { localURI } = await assetInfoPromise; if (!localURI) { return null; } const path = pathFromURI(localURI); if (!path) { return null; } return { localURI, path }; })(); const getOrientationPromise: Promise = (async () => { if (!optionalFields.orientation || !assetInfoPromise) { return null; } const { orientation } = await assetInfoPromise; return orientation; })(); const getFileSizePromise: Promise = (async () => { const localURIResult = await getLocalURIPromise; if (!localURIResult) { return null; } const { localURI } = localURIResult; const { steps: fileSizeSteps, result: fileSize } = await fetchFileSize(localURI); steps.push(...fileSizeSteps); return fileSize; })(); const getTypesPromise: Promise<{ +mime: ?string, +mediaType: ?MediaType, }> = (async () => { if (!optionalFields.mime && !optionalFields.mediaType) { return { mime: null, mediaType: null }; } const [localURIResult, fileSize] = await Promise.all([ getLocalURIPromise, getFileSizePromise, ]); if (!localURIResult || !fileSize) { return { mime: null, mediaType: null }; } const { localURI, path } = localURIResult; const readFileStep = await readFileHeader(localURI, fileSize); steps.push(readFileStep); const { mime, mediaType: baseMediaType } = readFileStep; if (!optionalFields.mediaType || !mime || !baseMediaType) { return { mime, mediaType: null }; } const { steps: getMediaTypeSteps, result: mediaType } = await getMediaTypeInfo(path, mime, baseMediaType); steps.push(...getMediaTypeSteps); return { mime, mediaType }; })(); const [localURIResult, orientation, fileSize, types] = await Promise.all([ getLocalURIPromise, getOrientationPromise, getFileSizePromise, getTypesPromise, ]); if (!localURIResult) { return { steps, result: { success: false, reason: 'no_file_path' } }; } const uri = localURIResult.localURI; if (!fileSize) { return { steps, result: { success: false, reason: 'file_stat_failed', uri }, }; } let finalURI = uri; // prefer asset URI, with one exception: // if the target URI is a file in our app local cache dir, we shouldn't // replace it because it was already preprocessed by either our media // processing logic or cropped by expo-image-picker const isFileInCacheDir = uri.includes(temporaryDirectoryPath) || uri.includes(ExpoFileSystem.cacheDirectory); if (assetURI && assetURI !== uri && !isFileInCacheDir) { finalURI = assetURI; console.log( 'fetchAssetInfo returned localURI ' + `${assetURI} when we already had ${uri}`, ); } return { steps, result: { success: true, uri: finalURI, orientation, fileSize, mime: types.mime, mediaType: types.mediaType, }, }; } async function fetchAssetInfo(mediaNativeID: string): Promise<{ steps: $ReadOnlyArray, result: { localURI: ?string, orientation: ?number }, }> { let localURI, orientation, success = false, exceptionMessage; const start = Date.now(); try { const assetInfo = await MediaLibrary.getAssetInfoAsync(mediaNativeID); success = true; - localURI = assetInfo.localUri; + // on iOS18 getAssetInfoAsync returns uri with a weird #YnBsaXN... suffix + // this looks like iOS bug, so to mitigate it for now we simply remove + // the suffix + // https://github.com/expo/expo/issues/31857 + // https://forums.developer.apple.com/forums/thread/760499 + localURI = assetInfo.localUri.replace(/#.*$/, ''); if (Platform.OS === 'ios') { orientation = assetInfo.orientation; } else { orientation = assetInfo.exif && assetInfo.exif.Orientation; } } catch (e) { exceptionMessage = getMessageForException(e); } return { steps: [ { step: 'asset_info_fetch', success, exceptionMessage, time: Date.now() - start, localURI, orientation, }, ], result: { localURI, orientation, }, }; } async function fetchFileSize(uri: string): Promise<{ steps: $ReadOnlyArray, result: ?number, }> { let fileSize, success = false, exceptionMessage; const statStart = Date.now(); try { const result = await filesystem.stat(uri); success = true; fileSize = result.size; } catch (e) { exceptionMessage = getMessageForException(e); } return { steps: [ { step: 'stat_file', success, exceptionMessage, time: Date.now() - statStart, uri, fileSize, }, ], result: fileSize, }; } async function readFileHeader( localURI: string, fileSize: number, ): Promise { const fetchBytes = Math.min(fileSize, bytesNeededForFileTypeCheck); const start = Date.now(); let fileData, success = false, exceptionMessage; try { fileData = await filesystem.read(localURI, fetchBytes, 0, 'base64'); success = true; } catch (e) { exceptionMessage = getMessageForException(e); } let mime, mediaType; if (fileData) { const utf8 = base64.decode(fileData); const intArray = stringToIntArray(utf8); ({ mime, mediaType } = fileInfoFromData(intArray)); } return { step: 'read_file_header', success, exceptionMessage, time: Date.now() - start, uri: localURI, mime, mediaType, }; } async function getMediaTypeInfo( path: string, mime: string, baseMediaType: MediaType, ): Promise<{ steps: $ReadOnlyArray, result: ?MediaType, }> { if (!mediaConfig[mime] || mediaConfig[mime].mediaType !== 'photo_or_video') { return { steps: [], result: baseMediaType }; } let hasMultipleFrames, success = false, exceptionMessage; const start = Date.now(); try { hasMultipleFrames = await ffmpeg.hasMultipleFrames(path); success = true; } catch (e) { exceptionMessage = getMessageForException(e); } const steps = [ { step: 'frame_count', success, exceptionMessage, time: Date.now() - start, path, mime, hasMultipleFrames, }, ]; const result = hasMultipleFrames ? 'video' : 'photo'; return { steps, result }; } async function disposeTempFile( path: string, ): Promise { let success = false, exceptionMessage; const start = Date.now(); try { await filesystem.unlink(path); success = true; } catch (e) { exceptionMessage = getMessageForException(e); } return { step: 'dispose_temporary_file', success, exceptionMessage, time: Date.now() - start, path, }; } async function mkdir(path: string): Promise { let success = false, exceptionMessage; const start = Date.now(); try { await filesystem.mkdir(path); success = true; } catch (e) { exceptionMessage = getMessageForException(e); } return { step: 'make_directory', success, exceptionMessage, time: Date.now() - start, path, }; } async function androidScanFile( path: string, ): Promise { invariant(Platform.OS === 'android', 'androidScanFile only works on Android'); let success = false, exceptionMessage; const start = Date.now(); try { await filesystem.scanFile(path); success = true; } catch (e) { exceptionMessage = getMessageForException(e); } return { step: 'android_scan_file', success, exceptionMessage, time: Date.now() - start, path, }; } async function fetchFileHash( path: string, ): Promise { let hash, exceptionMessage; const start = Date.now(); try { hash = await filesystem.hash(path, 'md5'); } catch (e) { exceptionMessage = getMessageForException(e); } return { step: 'fetch_file_hash', success: !!hash, exceptionMessage, time: Date.now() - start, path, hash, }; } async function copyFile( source: string, destination: string, ): Promise { let success = false, exceptionMessage; const start = Date.now(); try { await filesystem.copyFile(source, destination); success = true; } catch (e) { exceptionMessage = getMessageForException(e); } return { step: 'copy_file', success, exceptionMessage, time: Date.now() - start, source, destination, }; } const temporaryDirectoryPath: string = Platform.select({ ios: filesystem.TemporaryDirectoryPath, default: `${filesystem.TemporaryDirectoryPath}/`, }); export { fetchAssetInfo, fetchFileInfo, temporaryDirectoryPath, disposeTempFile, mkdir, androidScanFile, fetchFileHash, copyFile, };