diff --git a/native/media/image-modal.react.js b/native/media/image-modal.react.js index 748c8bb6c..664aa8633 100644 --- a/native/media/image-modal.react.js +++ b/native/media/image-modal.react.js @@ -1,115 +1,109 @@ // @flow import Clipboard from '@react-native-clipboard/clipboard'; -import invariant from 'invariant'; import * as React from 'react'; import type { MediaInfo, Dimensions } from 'lib/types/media-types.js'; import Multimedia from './multimedia.react.js'; import { useIntentionalSaveMedia } from './save-media.js'; import FullScreenViewModal from '../components/full-screen-view-modal.react.js'; import { displayActionResultModal } from '../navigation/action-result-modal.js'; import type { AppNavigationProp } from '../navigation/app-navigator.react.js'; import type { NavigationRoute } from '../navigation/route-names.js'; import { useSelector } from '../redux/redux-utils.js'; import { derivedDimensionsInfoSelector } from '../selectors/dimensions-selectors.js'; import type { ChatMultimediaMessageInfoItem } from '../types/chat-types.js'; import { type VerticalBounds, type LayoutCoordinates, } from '../types/layout-types.js'; export type ImageModalParams = { +presentedFrom: string, +mediaInfo: MediaInfo, +initialCoordinates: LayoutCoordinates, +verticalBounds: VerticalBounds, +item: ChatMultimediaMessageInfoItem, }; type Props = { +navigation: AppNavigationProp<'ImageModal'>, +route: NavigationRoute<'ImageModal'>, }; function ImageModal(props: Props): React.Node { const { navigation, route } = props; const { mediaInfo, item } = route.params; const dimensionsInfo = useSelector(derivedDimensionsInfoSelector); const intentionalSaveMedia = useIntentionalSaveMedia(); const onPressSave = React.useCallback(() => { - invariant( - mediaInfo.type === 'photo' || mediaInfo.type === 'video', - 'saving media of type ' + mediaInfo.type + ' is not supported', - ); - - const { id: uploadID, uri } = mediaInfo; + const uploadID = mediaInfo.id; const { id: messageServerID, localID: messageLocalID } = item.messageInfo; const ids = { uploadID, messageServerID, messageLocalID }; - return intentionalSaveMedia(uri, ids); + return intentionalSaveMedia(mediaInfo, ids); }, [intentionalSaveMedia, item.messageInfo, mediaInfo]); const onPressCopy = React.useCallback(() => { const { uri } = mediaInfo; Clipboard.setImageFromURL(uri, success => { displayActionResultModal(success ? 'copied!' : 'failed to copy :('); }); }, [mediaInfo]); const imageDimensions: Dimensions = React.useMemo(() => { const frame = { width: dimensionsInfo.width, height: dimensionsInfo.safeAreaHeight, }; // Make space for the close button let { height: maxHeight, width: maxWidth } = frame; if (maxHeight > maxWidth) { maxHeight -= 100; } else { maxWidth -= 100; } const { dimensions } = mediaInfo; if (dimensions.height < maxHeight && dimensions.width < maxWidth) { return dimensions; } const heightRatio = maxHeight / dimensions.height; const widthRatio = maxWidth / dimensions.width; if (heightRatio < widthRatio) { return { height: maxHeight, width: dimensions.width * heightRatio, }; } else { return { width: maxWidth, height: dimensions.height * widthRatio, }; } }, [dimensionsInfo.safeAreaHeight, dimensionsInfo.width, mediaInfo]); const imageModal = React.useMemo( () => ( ), [imageDimensions, mediaInfo, navigation, onPressCopy, onPressSave, route], ); return imageModal; } export default ImageModal; diff --git a/native/media/save-media.js b/native/media/save-media.js index 64e39b11b..3e1a06fdf 100644 --- a/native/media/save-media.js +++ b/native/media/save-media.js @@ -1,476 +1,544 @@ // @flow import * as MediaLibrary from 'expo-media-library'; import invariant from 'invariant'; import * as React from 'react'; import { Platform, PermissionsAndroid } from 'react-native'; import filesystem from 'react-native-fs'; import { queueReportsActionType } from 'lib/actions/report-actions.js'; import { readableFilename, pathFromURI } from 'lib/media/file-utils.js'; import { isLocalUploadID } from 'lib/media/media-utils.js'; import type { MediaMissionStep, MediaMissionResult, MediaMissionFailure, + MediaInfo, } from 'lib/types/media-types.js'; import { reportTypes, type ClientMediaMissionReportCreationRequest, } from 'lib/types/report-types.js'; import { getConfig } from 'lib/utils/config.js'; import { getMessageForException } from 'lib/utils/errors.js'; import { useDispatch } from 'lib/utils/redux-utils.js'; import { generateReportID, useIsReportEnabled, } from 'lib/utils/report-utils.js'; import { fetchBlob } from './blob-utils.js'; +import { decryptMedia } from './encryption-utils.js'; import { fetchAssetInfo, fetchFileInfo, disposeTempFile, mkdir, androidScanFile, fetchFileHash, copyFile, temporaryDirectoryPath, type FetchFileInfoResult, } from './file-utils.js'; import { getMediaLibraryIdentifier } from './identifier-utils.js'; import { displayActionResultModal } from '../navigation/action-result-modal.js'; import { requestAndroidPermission } from '../utils/android-permissions.js'; export type IntentionalSaveMedia = ( - uri: string, + mediaInfo: MediaInfo, ids: { uploadID: string, messageServerID: ?string, messageLocalID: ?string, }, ) => Promise; function useIntentionalSaveMedia(): IntentionalSaveMedia { const dispatch = useDispatch(); const mediaReportsEnabled = useIsReportEnabled('mediaReports'); return React.useCallback( async ( - uri: string, + mediaInfo: MediaInfo, ids: { uploadID: string, messageServerID: ?string, messageLocalID: ?string, }, ) => { const start = Date.now(); + const { uri: mediaURI, blobURI, holder, encryptionKey } = mediaInfo; + const uri = mediaURI ?? blobURI ?? holder; + invariant(uri, 'mediaInfo should have a uri or a blobURI'); + const steps: Array = [ { step: 'save_media', uri, time: start }, ]; - const { resultPromise, reportPromise } = saveMedia(uri, 'request'); + const { resultPromise, reportPromise } = saveMedia( + uri, + encryptionKey, + 'request', + ); const result = await resultPromise; const userTime = Date.now() - start; let message; if (result.success) { message = 'saved!'; } else if (result.reason === 'save_unsupported') { const os: string = Platform.select({ ios: 'iOS', android: 'Android', default: Platform.OS, }); message = `saving media is unsupported on ${os}`; } else if (result.reason === 'missing_permission') { message = 'don’t have permission :('; } else if ( result.reason === 'resolve_failed' || result.reason === 'data_uri_failed' ) { message = 'failed to resolve :('; } else if (result.reason === 'fetch_failed') { message = 'failed to download :('; } else { message = 'failed to save :('; } displayActionResultModal(message); if (!mediaReportsEnabled) { return; } const reportSteps = await reportPromise; steps.push(...reportSteps); const totalTime = Date.now() - start; const mediaMission = { steps, result, userTime, totalTime }; const { uploadID, messageServerID, messageLocalID } = ids; const uploadIDIsLocal = isLocalUploadID(uploadID); const report: ClientMediaMissionReportCreationRequest = { type: reportTypes.MEDIA_MISSION, time: Date.now(), platformDetails: getConfig().platformDetails, mediaMission, uploadServerID: uploadIDIsLocal ? undefined : uploadID, uploadLocalID: uploadIDIsLocal ? uploadID : undefined, messageServerID, messageLocalID, id: generateReportID(), }; dispatch({ type: queueReportsActionType, payload: { reports: [report] }, }); }, [dispatch, mediaReportsEnabled], ); } type Permissions = 'check' | 'request'; function saveMedia( uri: string, + encryptionKey?: ?string, permissions?: Permissions = 'check', ): { resultPromise: Promise, reportPromise: Promise<$ReadOnlyArray>, } { let resolveResult; const sendResult = (result: MediaMissionResult) => { if (resolveResult) { resolveResult(result); } }; - const reportPromise = innerSaveMedia(uri, permissions, sendResult); + const reportPromise = innerSaveMedia( + uri, + encryptionKey, + permissions, + sendResult, + ); const resultPromise = new Promise(resolve => { resolveResult = resolve; }); return { reportPromise, resultPromise }; } async function innerSaveMedia( uri: string, + encryptionKey?: ?string, permissions: Permissions, sendResult: (result: MediaMissionResult) => void, ): Promise<$ReadOnlyArray> { if (Platform.OS === 'android') { - return await saveMediaAndroid(uri, permissions, sendResult); + return await saveMediaAndroid(uri, encryptionKey, permissions, sendResult); } else if (Platform.OS === 'ios') { - return await saveMediaIOS(uri, sendResult); + return await saveMediaIOS(uri, encryptionKey, sendResult); } else { sendResult({ success: false, reason: 'save_unsupported' }); return []; } } const androidSavePermission = PermissionsAndroid.PERMISSIONS.WRITE_EXTERNAL_STORAGE; // On Android, we save the media to our own Comm folder in the // Pictures directory, and then trigger the media scanner to pick it up async function saveMediaAndroid( inputURI: string, + encryptionKey?: ?string, permissions: Permissions, sendResult: (result: MediaMissionResult) => void, ): Promise<$ReadOnlyArray> { const steps: Array = []; let hasPermission = false, permissionCheckExceptionMessage; const permissionCheckStart = Date.now(); try { hasPermission = await requestAndroidPermission( androidSavePermission, 'throw', ); } catch (e) { permissionCheckExceptionMessage = getMessageForException(e); } steps.push({ step: 'permissions_check', success: hasPermission, exceptionMessage: permissionCheckExceptionMessage, time: Date.now() - permissionCheckStart, platform: Platform.OS, permissions: [androidSavePermission], }); if (!hasPermission) { sendResult({ success: false, reason: 'missing_permission' }); return steps; } const promises = []; let success = true; const saveFolder = `${filesystem.PicturesDirectoryPath}/Comm/`; promises.push( (async () => { const makeDirectoryStep = await mkdir(saveFolder); if (!makeDirectoryStep.success) { success = false; sendResult({ success, reason: 'make_directory_failed' }); } steps.push(makeDirectoryStep); })(), ); let uri = inputURI; let tempFile, mime; if (uri.startsWith('http')) { promises.push( (async () => { const { result: tempSaveResult, steps: tempSaveSteps } = - await saveRemoteMediaToDisk(uri, temporaryDirectoryPath); + await saveRemoteMediaToDisk( + uri, + encryptionKey, + temporaryDirectoryPath, + ); steps.push(...tempSaveSteps); if (!tempSaveResult.success) { success = false; sendResult(tempSaveResult); } else { tempFile = tempSaveResult.path; uri = `file://${tempFile}`; mime = tempSaveResult.mime; } })(), ); } await Promise.all(promises); if (!success) { return steps; } const { result: copyResult, steps: copySteps } = await copyToSortedDirectory( uri, saveFolder, mime, ); steps.push(...copySteps); if (!copyResult.success) { sendResult(copyResult); return steps; } sendResult({ success: true }); const postResultPromises = []; postResultPromises.push( (async () => { const scanFileStep = await androidScanFile(copyResult.path); steps.push(scanFileStep); })(), ); if (tempFile) { postResultPromises.push( (async (file: string) => { const disposeStep = await disposeTempFile(file); steps.push(disposeStep); })(tempFile), ); } await Promise.all(postResultPromises); return steps; } // On iOS, we save the media to the camera roll async function saveMediaIOS( inputURI: string, + encryptionKey?: ?string, sendResult: (result: MediaMissionResult) => void, ): Promise<$ReadOnlyArray> { const steps: Array = []; let uri = inputURI; let tempFile; if (uri.startsWith('http')) { const { result: tempSaveResult, steps: tempSaveSteps } = - await saveRemoteMediaToDisk(uri, temporaryDirectoryPath); + await saveRemoteMediaToDisk(uri, encryptionKey, temporaryDirectoryPath); steps.push(...tempSaveSteps); if (!tempSaveResult.success) { sendResult(tempSaveResult); return steps; } tempFile = tempSaveResult.path; uri = `file://${tempFile}`; } else if (!uri.startsWith('file://')) { const mediaNativeID = getMediaLibraryIdentifier(uri); if (mediaNativeID) { const { result: fetchAssetInfoResult, steps: fetchAssetInfoSteps } = await fetchAssetInfo(mediaNativeID); steps.push(...fetchAssetInfoSteps); const { localURI } = fetchAssetInfoResult; if (localURI) { uri = localURI; } } } if (!uri.startsWith('file://')) { sendResult({ success: false, reason: 'resolve_failed', uri }); return steps; } let success = false, exceptionMessage; const start = Date.now(); try { await MediaLibrary.saveToLibraryAsync(uri); success = true; } catch (e) { exceptionMessage = getMessageForException(e); } steps.push({ step: 'ios_save_to_library', success, exceptionMessage, time: Date.now() - start, uri, }); if (success) { sendResult({ success: true }); } else { sendResult({ success: false, reason: 'save_to_library_failed', uri }); } if (tempFile) { const disposeStep = await disposeTempFile(tempFile); steps.push(disposeStep); } return steps; } type IntermediateSaveResult = { result: { success: true, path: string, mime: string } | MediaMissionFailure, steps: $ReadOnlyArray, }; async function saveRemoteMediaToDisk( inputURI: string, + encryptionKey?: ?string, directory: string, // should end with a / ): Promise { const steps: Array = []; + if (encryptionKey) { + const { steps: decryptionSteps, result: decryptionResult } = + await decryptMedia(inputURI, encryptionKey, { + destination: 'file', + destinationDirectory: directory, + }); + steps.push(...decryptionSteps); + if (!decryptionResult.success) { + return { result: decryptionResult, steps }; + } + const { uri } = decryptionResult; + const path = pathFromURI(uri); + if (!path) { + return { + result: { success: false, reason: 'resolve_failed', uri }, + steps, + }; + } + + const { steps: fetchFileInfoSteps, result: fetchFileInfoResult } = + await fetchFileInfo(uri, undefined, { + mime: true, + }); + steps.push(...fetchFileInfoSteps); + if (!fetchFileInfoResult.success) { + return { result: fetchFileInfoResult, steps }; + } + const { mime } = fetchFileInfoResult; + if (!mime) { + return { + steps, + result: { + success: false, + reason: 'media_type_fetch_failed', + detectedMIME: mime, + }, + }; + } + + return { + result: { success: true, path, mime }, + steps, + }; + } const { result: fetchBlobResult, steps: fetchBlobSteps } = await fetchBlob(inputURI); steps.push(...fetchBlobSteps); if (!fetchBlobResult.success) { return { result: fetchBlobResult, steps }; } const { mime, base64 } = fetchBlobResult; const tempName = readableFilename('', mime); if (!tempName) { return { result: { success: false, reason: 'mime_check_failed', mime }, steps, }; } const tempPath = `${directory}tempsave.${tempName}`; const start = Date.now(); let success = false, exceptionMessage; try { await filesystem.writeFile(tempPath, base64, 'base64'); success = true; } catch (e) { exceptionMessage = getMessageForException(e); } steps.push({ step: 'write_file', success, exceptionMessage, time: Date.now() - start, path: tempPath, length: base64.length, }); if (!success) { return { result: { success: false, reason: 'write_file_failed' }, steps }; } return { result: { success: true, path: tempPath, mime }, steps }; } async function copyToSortedDirectory( localURI: string, directory: string, // should end with a / inputMIME: ?string, ): Promise { const steps: Array = []; const path = pathFromURI(localURI); if (!path) { return { result: { success: false, reason: 'resolve_failed', uri: localURI }, steps, }; } let mime = inputMIME; const hashStepPromise = fetchFileHash(path); const fileInfoPromise: Promise, result: MediaMissionFailure | FetchFileInfoResult, }> = (async () => { if (mime) { return undefined; } return await fetchFileInfo(localURI, undefined, { mime: true, }); })(); const [hashStep, fileInfoResult] = await Promise.all([ hashStepPromise, fileInfoPromise, ]); steps.push(hashStep); if (!hashStep.success) { return { result: { success: false, reason: 'fetch_file_hash_failed' }, steps, }; } const { hash } = hashStep; invariant(hash, 'hash should be truthy if hashStep.success is truthy'); if (fileInfoResult) { steps.push(...fileInfoResult.steps); if (fileInfoResult.result.success && fileInfoResult.result.mime) { ({ mime } = fileInfoResult.result); } } if (!mime) { return { result: { success: false, reason: 'mime_check_failed', mime }, steps, }; } const name = readableFilename(hash, mime); if (!name) { return { result: { success: false, reason: 'mime_check_failed', mime }, steps, }; } const newPath = `${directory}${name}`; const copyStep = await copyFile(path, newPath); steps.push(copyStep); if (!copyStep.success) { return { result: { success: false, reason: 'copy_file_failed' }, steps, }; } return { result: { success: true, path: newPath, mime }, steps, }; } export { useIntentionalSaveMedia, saveMedia };