diff --git a/native/account/registration/registration-server-call.js b/native/account/registration/registration-server-call.js index 9cb9e3ce6..35cd43cd3 100644 --- a/native/account/registration/registration-server-call.js +++ b/native/account/registration/registration-server-call.js @@ -1,230 +1,230 @@ // @flow import * as React from 'react'; import { setDataLoadedActionType } from 'lib/actions/client-db-store-actions.js'; import { registerActionTypes, register } from 'lib/actions/user-actions.js'; import type { LogInStartingPayload } from 'lib/types/account-types.js'; import { useServerCall, useDispatchActionPromise, } from 'lib/utils/action-utils.js'; import { useDispatch } from 'lib/utils/redux-utils.js'; import { setURLPrefix } from 'lib/utils/url-utils.js'; import type { RegistrationServerCallInput, UsernameAccountSelection, AvatarData, } from './registration-types.js'; import { useNativeSetUserAvatar, useUploadSelectedMedia, } from '../../avatars/avatar-hooks.js'; import { NavContext } from '../../navigation/navigation-context.js'; import { useSelector } from '../../redux/redux-utils.js'; import { nativeLogInExtraInfoSelector } from '../../selectors/account-selectors.js'; import { AppOutOfDateAlertDetails } from '../../utils/alert-messages.js'; import Alert from '../../utils/alert.js'; import { setNativeCredentials } from '../native-credentials.js'; import { useSIWEServerCall } from '../siwe-hooks.js'; // We can't just do everything in one async callback, since the server calls // would get bound to Redux state from before the registration. The registration // flow has multiple steps where critical Redux state is changed, where // subsequent steps depend on accessing the updated Redux state. // To address this, we break the registration process up into multiple steps. // When each step completes we update the currentStep state, and we have Redux // selectors that trigger useEffects for subsequent steps when relevant data // starts to appear in Redux. type CurrentStep = | { +step: 'inactive' } | { +step: 'waiting_for_registration_call', +avatarData: ?AvatarData, +resolve: () => void, +reject: Error => void, }; const inactiveStep = { step: 'inactive' }; function useRegistrationServerCall(): RegistrationServerCallInput => Promise { const [currentStep, setCurrentStep] = React.useState(inactiveStep); // STEP 1: ACCOUNT REGISTRATION const navContext = React.useContext(NavContext); const logInExtraInfo = useSelector(state => nativeLogInExtraInfoSelector({ redux: state, navContext, }), ); const dispatchActionPromise = useDispatchActionPromise(); const callRegister = useServerCall(register); const registerUsernameAccount = React.useCallback( async ( accountSelection: UsernameAccountSelection, keyserverURL: string, ) => { const extraInfo = await logInExtraInfo(); const registerPromise = (async () => { try { const result = await callRegister( { ...extraInfo, username: accountSelection.username, password: accountSelection.password, }, { urlPrefixOverride: keyserverURL, }, ); await setNativeCredentials({ username: result.currentUserInfo.username, password: accountSelection.password, }); return result; } catch (e) { if (e.message === 'username_reserved') { Alert.alert( 'Username reserved', 'This username is currently reserved. Please contact support@' + 'comm.app if you would like to claim this account.', ); } else if (e.message === 'username_taken') { Alert.alert( 'Username taken', 'An account with that username already exists', ); } else if (e.message === 'client_version_unsupported') { Alert.alert( AppOutOfDateAlertDetails.title, AppOutOfDateAlertDetails.message, ); } else { Alert.alert('Unknown error', 'Uhh... try again?'); } throw e; } })(); dispatchActionPromise( registerActionTypes, registerPromise, undefined, ({ calendarQuery: extraInfo.calendarQuery }: LogInStartingPayload), ); await registerPromise; }, [logInExtraInfo, callRegister, dispatchActionPromise], ); const siweServerCallParams = React.useMemo(() => { const onServerCallFailure = () => { Alert.alert('Unknown error', 'Uhh... try again?'); }; return { onFailure: onServerCallFailure }; }, []); const siweServerCall = useSIWEServerCall(siweServerCallParams); const dispatch = useDispatch(); const returnedFunc = React.useCallback( (input: RegistrationServerCallInput) => - new Promise( + new Promise( // eslint-disable-next-line no-async-promise-executor async (resolve, reject) => { try { if (currentStep.step !== 'inactive') { return; } const { accountSelection, avatarData, keyserverURL } = input; if (accountSelection.accountType === 'username') { await registerUsernameAccount(accountSelection, keyserverURL); } else { await siweServerCall(accountSelection, { urlPrefixOverride: keyserverURL, }); } dispatch({ type: setURLPrefix, payload: keyserverURL, }); setCurrentStep({ step: 'waiting_for_registration_call', avatarData, resolve, reject, }); } catch (e) { reject(e); } }, ), [currentStep, registerUsernameAccount, siweServerCall, dispatch], ); // STEP 2: SETTING AVATAR const uploadSelectedMedia = useUploadSelectedMedia(); const nativeSetUserAvatar = useNativeSetUserAvatar(); const hasCurrentUserInfo = useSelector( state => !!state.currentUserInfo && !state.currentUserInfo.anonymous, ); const avatarBeingSetRef = React.useRef(false); React.useEffect(() => { if ( !hasCurrentUserInfo || currentStep.step !== 'waiting_for_registration_call' || avatarBeingSetRef.current ) { return; } avatarBeingSetRef.current = true; const { avatarData, resolve } = currentStep; (async () => { try { if (!avatarData) { return; } let updateUserAvatarRequest; if (!avatarData.needsUpload) { ({ updateUserAvatarRequest } = avatarData); } else { const { mediaSelection } = avatarData; updateUserAvatarRequest = await uploadSelectedMedia(mediaSelection); if (!updateUserAvatarRequest) { return; } } await nativeSetUserAvatar(updateUserAvatarRequest); } finally { dispatch({ type: setDataLoadedActionType, payload: { dataLoaded: true, }, }); setCurrentStep(inactiveStep); avatarBeingSetRef.current = false; resolve(); } })(); }, [ currentStep, hasCurrentUserInfo, uploadSelectedMedia, nativeSetUserAvatar, dispatch, ]); return returnedFunc; } export { useRegistrationServerCall }; diff --git a/native/backup/use-client-backup.js b/native/backup/use-client-backup.js index de26317cd..bd61a08a7 100644 --- a/native/backup/use-client-backup.js +++ b/native/backup/use-client-backup.js @@ -1,208 +1,208 @@ // @flow import _isEqual from 'lodash/fp/isEqual.js'; import * as React from 'react'; import { uintArrayToHexString } from 'lib/media/data-utils.js'; import { isLoggedIn } from 'lib/selectors/user-selectors.js'; import type { BackupAuth, UserData, UserKeys } from 'lib/types/backup-types.js'; import { getBackupID, getUserData, getUserKeys, uploadBackup } from './api.js'; import { BACKUP_ID_LENGTH } from './constants.js'; import { decryptUserData, decryptUserKeys, encryptBackup, } from './encryption.js'; import { commCoreModule } from '../native-modules.js'; import { useSelector } from '../redux/redux-utils.js'; import { generateKey } from '../utils/aes-crypto-module.js'; import { getContentSigningKey } from '../utils/crypto-utils.js'; // purpose of this result is to improve logging and // testing the initial backup version type RestoreBackupResult = { getBackupID?: boolean, getUserKeys?: boolean, getUserData?: boolean, decryptUserKeys?: boolean, decryptUserData?: boolean, userDataIntegrity?: boolean, error?: Error, }; type ClientBackup = { +uploadBackupProtocol: (userData: UserData) => Promise, +restoreBackupProtocol: ( expectedUserData: UserData, ) => Promise, }; function useClientBackup(): ClientBackup { const accessToken = useSelector(state => state.commServicesAccessToken); const currentUserID = useSelector( state => state.currentUserInfo && state.currentUserInfo.id, ); const loggedIn = useSelector(isLoggedIn); const uploadBackupProtocol = React.useCallback( async (userData: UserData) => { if (!loggedIn || !currentUserID) { throw new Error('Attempt to upload backup for not logged in user.'); } console.info('Start uploading backup...'); const backupDataKey = generateKey(); const [ed25519, backupID] = await Promise.all([ getContentSigningKey(), commCoreModule.generateRandomString(BACKUP_ID_LENGTH), ]); const userKeys: UserKeys = { backupDataKey: uintArrayToHexString(backupDataKey), ed25519, }; const encryptedBackup = await encryptBackup({ backupID, userKeys, userData, }); const backupAuth: BackupAuth = { userID: currentUserID, accessToken: accessToken ? accessToken : '', deviceID: ed25519, }; await uploadBackup(encryptedBackup, backupAuth); console.info('Backup uploaded.'); }, [accessToken, currentUserID, loggedIn], ); const restoreBackupProtocol = React.useCallback( async (expectedUserData: UserData) => { if (!loggedIn || !currentUserID) { throw new Error('Attempt to restore backup for not logged in user.'); } const result: RestoreBackupResult = { getBackupID: undefined, getUserKeys: undefined, getUserData: undefined, decryptUserKeys: undefined, decryptUserData: undefined, userDataIntegrity: undefined, error: undefined, }; - const backupIDPromise = (async () => { + const backupIDPromise: Promise = (async () => { try { // We are using UserID instead of the username. // The reason is tha the initial version of the backup service // cannot get UserID based on username. const backupID = await getBackupID(currentUserID); result.getBackupID = true; return backupID; } catch (e) { result.getBackupID = false; result.error = e; return undefined; } })(); const [ed25519, backupID] = await Promise.all([ getContentSigningKey(), backupIDPromise, ]); if (!backupID) { return result; } const backupAuth: BackupAuth = { userID: currentUserID, accessToken: accessToken ? accessToken : '', deviceID: ed25519, }; - const userKeysPromise = (async () => { + const userKeysPromise: Promise = (async () => { try { const userKeysResponse = await getUserKeys(backupID, backupAuth); result.getUserKeys = true; return userKeysResponse; } catch (e) { result.getUserKeys = false; result.error = e; return undefined; } })(); - const userDataPromise = (async () => { + const userDataPromise: Promise = (async () => { try { const userDataResponse = await getUserData(backupID, backupAuth); result.getUserData = true; return userDataResponse; } catch (e) { result.getUserData = false; result.error = e; return undefined; } })(); const [userKeysResponse, userDataResponse] = await Promise.all([ userKeysPromise, userDataPromise, ]); if (!userKeysResponse) { result.getUserKeys = false; result.error = new Error('UserKeys response is empty'); return result; } let userKeys; try { userKeys = await decryptUserKeys(backupID, userKeysResponse.buffer); result.decryptUserKeys = true; } catch (e) { result.decryptUserKeys = false; result.error = e; } if (!userKeys) { result.decryptUserKeys = false; result.error = new Error('UserKeys is empty'); return result; } if (!userDataResponse) { result.getUserData = false; result.error = new Error('UserData response is empty'); return result; } let userData; try { userData = await decryptUserData( userKeys.backupDataKey, userDataResponse.buffer, ); result.decryptUserData = true; } catch (e) { result.decryptUserData = false; result.error = e; } result.userDataIntegrity = !!_isEqual(userData, expectedUserData); return result; }, [accessToken, currentUserID, loggedIn], ); return { uploadBackupProtocol, restoreBackupProtocol }; } export { useClientBackup }; diff --git a/native/media/file-utils.js b/native/media/file-utils.js index 542c4cddc..76c195c3c 100644 --- a/native/media/file-utils.js +++ b/native/media/file-utils.js @@ -1,442 +1,448 @@ // @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 { Shape } from 'lib/types/core.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 = Shape<{ +mediaNativeID: ?string }>; type OptionalFields = Shape<{ +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 = (async () => { + 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 = (async () => { + const getOrientationPromise: Promise = (async () => { if (!optionalFields.orientation || !assetInfoPromise) { return null; } const { orientation } = await assetInfoPromise; return orientation; })(); - const getFileSizePromise = (async () => { + 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 = (async () => { + 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; 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, }; diff --git a/native/media/media-utils.js b/native/media/media-utils.js index 518ed5aa3..d29759710 100644 --- a/native/media/media-utils.js +++ b/native/media/media-utils.js @@ -1,293 +1,295 @@ // @flow import invariant from 'invariant'; import { Image } from 'react-native'; import { pathFromURI, sanitizeFilename } from 'lib/media/file-utils.js'; import type { Dimensions, MediaMissionStep, MediaMissionFailure, NativeMediaSelection, GenerateThumbhashMediaMissionStep, } from 'lib/types/media-types.js'; import { getMessageForException } from 'lib/utils/errors.js'; import { fetchFileInfo } from './file-utils.js'; import { processImage } from './image-utils.js'; import { saveMedia } from './save-media.js'; import { processVideo } from './video-utils.js'; import { generateThumbHash } from '../utils/thumbhash-module.js'; type MediaProcessConfig = { +hasWiFi: boolean, // Blocks return until we can confirm result has the correct MIME +finalFileHeaderCheck?: boolean, +onTranscodingProgress?: (percent: number) => void, }; type SharedMediaResult = { +success: true, +uploadURI: string, +shouldDisposePath: ?string, +filename: string, +mime: string, +dimensions: Dimensions, +thumbHash: ?string, }; export type MediaResult = | { +mediaType: 'photo', ...SharedMediaResult } | { +mediaType: 'video', ...SharedMediaResult, +uploadThumbnailURI: string, +loop: boolean, } | { +mediaType: 'encrypted_photo', ...SharedMediaResult, +blobHash: string, +encryptionKey: string, } | { +mediaType: 'encrypted_video', ...SharedMediaResult, +blobHash: string, +encryptionKey: string, +thumbnailBlobHash: string, +thumbnailEncryptionKey: string, +uploadThumbnailURI: string, +loop: boolean, }; function processMedia( selection: NativeMediaSelection, config: MediaProcessConfig, ): { resultPromise: Promise, reportPromise: Promise<$ReadOnlyArray>, } { let resolveResult; const sendResult = (result: MediaMissionFailure | MediaResult) => { if (resolveResult) { resolveResult(result); } }; const reportPromise = innerProcessMedia(selection, config, sendResult); - const resultPromise = new Promise(resolve => { - resolveResult = resolve; - }); + const resultPromise = new Promise( + resolve => { + resolveResult = resolve; + }, + ); return { reportPromise, resultPromise }; } async function innerProcessMedia( selection: NativeMediaSelection, config: MediaProcessConfig, sendResult: (result: MediaMissionFailure | MediaResult) => void, ): Promise<$ReadOnlyArray> { let initialURI = null, uploadURI = null, uploadThumbnailURI = null, dimensions = selection.dimensions, mediaType = null, mime = null, loop = false, resultReturned = false, thumbHash = null; const returnResult = (failure?: MediaMissionFailure) => { invariant( !resultReturned, 'returnResult called twice in innerProcessMedia', ); resultReturned = true; if (failure) { sendResult(failure); return; } invariant( uploadURI && mime && mediaType, 'missing required fields in returnResult', ); const shouldDisposePath = initialURI !== uploadURI ? pathFromURI(uploadURI) : null; const filename = sanitizeFilename(selection.filename, mime); if (mediaType === 'video') { invariant(uploadThumbnailURI, 'video should have uploadThumbnailURI'); sendResult({ success: true, uploadURI, uploadThumbnailURI, shouldDisposePath, filename, mime, mediaType, dimensions, loop, thumbHash, }); } else { sendResult({ success: true, uploadURI, shouldDisposePath, filename, mime, mediaType, dimensions, thumbHash, }); } }; const steps: Array = [], completeBeforeFinish = []; const finish = async (failure?: MediaMissionFailure) => { if (!resultReturned) { returnResult(failure); } await Promise.all(completeBeforeFinish); return steps; }; if (selection.captureTime && selection.retries === 0) { const { uri } = selection; invariant( pathFromURI(uri), `captured URI ${uri} should use file:// scheme`, ); completeBeforeFinish.push( (async () => { const { reportPromise } = saveMedia(uri); const saveMediaSteps = await reportPromise; steps.push(...saveMediaSteps); })(), ); } const possiblyPhoto = selection.step.startsWith('photo_'); const mediaNativeID = selection.mediaNativeID ? selection.mediaNativeID : null; const { steps: fileInfoSteps, result: fileInfoResult } = await fetchFileInfo( selection.uri, { mediaNativeID }, { orientation: possiblyPhoto, mime: true, mediaType: true, }, ); steps.push(...fileInfoSteps); if (!fileInfoResult.success) { return await finish(fileInfoResult); } const { orientation, fileSize } = fileInfoResult; ({ uri: initialURI, mime, mediaType } = fileInfoResult); if (!mime || !mediaType) { return await finish({ success: false, reason: 'media_type_fetch_failed', detectedMIME: mime, }); } if (mediaType === 'video') { const { steps: videoSteps, result: videoResult } = await processVideo( { uri: initialURI, mime, filename: selection.filename, fileSize, dimensions, hasWiFi: config.hasWiFi, }, { onTranscodingProgress: config.onTranscodingProgress, }, ); steps.push(...videoSteps); if (!videoResult.success) { return await finish(videoResult); } ({ uri: uploadURI, thumbnailURI: uploadThumbnailURI, mime, dimensions, loop, thumbHash, } = videoResult); } else if (mediaType === 'photo') { const { steps: imageSteps, result: imageResult } = await processImage({ uri: initialURI, dimensions, mime, fileSize, orientation, }); steps.push(...imageSteps); if (!imageResult.success) { return await finish(imageResult); } ({ uri: uploadURI, mime, dimensions, thumbHash } = imageResult); } else { invariant(false, `unknown mediaType ${mediaType}`); } if (uploadURI === initialURI) { return await finish(); } if (!config.finalFileHeaderCheck) { returnResult(); } const { steps: finalFileInfoSteps, result: finalFileInfoResult } = await fetchFileInfo(uploadURI, undefined, { mime: true }); steps.push(...finalFileInfoSteps); if (!finalFileInfoResult.success) { return await finish(finalFileInfoResult); } if (finalFileInfoResult.mime && finalFileInfoResult.mime !== mime) { return await finish({ success: false, reason: 'mime_type_mismatch', reportedMediaType: mediaType, reportedMIME: mime, detectedMIME: finalFileInfoResult.mime, }); } return await finish(); } function getDimensions(uri: string): Promise { return new Promise((resolve, reject) => { Image.getSize( uri, (width: number, height: number) => resolve({ height, width }), reject, ); }); } async function generateThumbhashStep( uri: string, ): Promise { let thumbHash, exceptionMessage; try { thumbHash = await generateThumbHash(uri); } catch (err) { exceptionMessage = getMessageForException(err); } return { step: 'generate_thumbhash', success: !!thumbHash && !exceptionMessage, exceptionMessage, thumbHash, }; } export { processMedia, getDimensions, generateThumbhashStep };