diff --git a/lib/utils/services-utils.js b/lib/utils/services-utils.js index 7dcb88743..57b858d3b 100644 --- a/lib/utils/services-utils.js +++ b/lib/utils/services-utils.js @@ -1,28 +1,38 @@ // @flow import base64 from 'base-64'; import type { AuthMetadata } from '../shared/identity-client-context.js'; const usingCommServicesAccessToken = false; function handleHTTPResponseError(response: Response): void { if (!response.ok) { const { status, statusText } = response; throw new Error(`Server responded with HTTP ${status}: ${statusText}`); } } function createHTTPAuthorizationHeader(authMetadata: AuthMetadata): string { // explicit destructure to make it future-proof const { userID, deviceID, accessToken } = authMetadata; const payload = JSON.stringify({ userID, deviceID, accessToken }); const base64EncodedPayload = base64.encode(payload); return `Bearer ${base64EncodedPayload}`; } +function createDefaultHTTPRequestHeaders(authMetadata: AuthMetadata): { + [string]: string, +} { + const authorization = createHTTPAuthorizationHeader(authMetadata); + return { + Authorization: authorization, + }; +} + export { handleHTTPResponseError, usingCommServicesAccessToken, createHTTPAuthorizationHeader, + createDefaultHTTPRequestHeaders, }; diff --git a/native/media/encrypted-image.react.js b/native/media/encrypted-image.react.js index 75418cc0c..3d230db61 100644 --- a/native/media/encrypted-image.react.js +++ b/native/media/encrypted-image.react.js @@ -1,121 +1,123 @@ // @flow import invariant from 'invariant'; import * as React from 'react'; import { MediaCacheContext } from 'lib/components/media-cache-provider.react.js'; import { connectionSelector } from 'lib/selectors/keyserver-selectors.js'; import { ashoatKeyserverID } from 'lib/utils/validation-utils.js'; -import { decryptBase64, fetchAndDecryptMedia } from './encryption-utils.js'; +import { decryptBase64, useFetchAndDecryptMedia } from './encryption-utils.js'; import LoadableImage from './loadable-image.react.js'; import { useSelector } from '../redux/redux-utils.js'; import type { ImageSource } from '../types/react-native.js'; import type { ImageStyle } from '../types/styles.js'; type BaseProps = { +blobURI: string, +encryptionKey: string, +onLoad?: (uri: string) => void, +spinnerColor: string, +style: ImageStyle, +invisibleLoad: boolean, +thumbHash?: ?string, }; type Props = { ...BaseProps, }; function EncryptedImage(props: Props): React.Node { const { blobURI, encryptionKey, onLoad: onLoadProp, thumbHash: encryptedThumbHash, } = props; + const fetchAndDecryptMedia = useFetchAndDecryptMedia(); + const mediaCache = React.useContext(MediaCacheContext); const [source, setSource] = React.useState(null); const connection = useSelector(connectionSelector(ashoatKeyserverID)); invariant(connection, 'keyserver missing from keyserverStore'); const connectionStatus = connection.status; const prevConnectionStatusRef = React.useRef(connectionStatus); const [attempt, setAttempt] = React.useState(0); const [errorOccured, setErrorOccured] = React.useState(false); if (prevConnectionStatusRef.current !== connectionStatus) { if (!source && connectionStatus === 'connected') { setAttempt(attempt + 1); } prevConnectionStatusRef.current = connectionStatus; } const placeholder = React.useMemo(() => { if (!encryptedThumbHash) { return null; } try { const decryptedThumbHash = decryptBase64( encryptedThumbHash, encryptionKey, ); return { thumbhash: decryptedThumbHash }; } catch (e) { return null; } }, [encryptedThumbHash, encryptionKey]); React.useEffect(() => { let isMounted = true; setSource(null); const loadDecrypted = async () => { const cached = await mediaCache?.get(blobURI); if (cached && isMounted) { setSource({ uri: cached }); return; } const { result } = await fetchAndDecryptMedia(blobURI, encryptionKey, { destination: 'data_uri', }); if (isMounted) { if (result.success) { void mediaCache?.set(blobURI, result.uri); setSource({ uri: result.uri }); } else { setErrorOccured(true); } } }; void loadDecrypted(); return () => { isMounted = false; }; - }, [attempt, blobURI, encryptionKey, mediaCache]); + }, [attempt, blobURI, encryptionKey, mediaCache, fetchAndDecryptMedia]); const onLoad = React.useCallback(() => { onLoadProp && onLoadProp(blobURI); }, [blobURI, onLoadProp]); const { style, spinnerColor, invisibleLoad } = props; return ( ); } export default EncryptedImage; diff --git a/native/media/encryption-utils.js b/native/media/encryption-utils.js index e5ee2bf0d..ae369204e 100644 --- a/native/media/encryption-utils.js +++ b/native/media/encryption-utils.js @@ -1,410 +1,454 @@ // @flow import invariant from 'invariant'; +import * as React from 'react'; import { uintArrayToHexString, hexToUintArray } from 'lib/media/data-utils.js'; import { replaceExtension, fileInfoFromData, filenameFromPathOrURI, readableFilename, pathFromURI, } from 'lib/media/file-utils.js'; +import type { AuthMetadata } from 'lib/shared/identity-client-context.js'; +import { IdentityClientContext } from 'lib/shared/identity-client-context.js'; import type { MediaMissionFailure, MediaMissionStep, DecryptFileMediaMissionStep, EncryptFileMediaMissionStep, } from 'lib/types/media-types.js'; +import { isBlobServiceURI } from 'lib/utils/blob-service.js'; import { getMessageForException } from 'lib/utils/errors.js'; import { pad, unpad, calculatePaddedLength } from 'lib/utils/pkcs7-padding.js'; +import { createDefaultHTTPRequestHeaders } from 'lib/utils/services-utils.js'; import { temporaryDirectoryPath } from './file-utils.js'; import { getFetchableURI } from './identifier-utils.js'; import type { MediaResult } from './media-utils.js'; import { commUtilsModule } from '../native-modules.js'; import * as AES from '../utils/aes-crypto-module.js'; import { arrayBufferFromBlob } from '../utils/blob-utils-module.js'; const PADDING_THRESHOLD = 5000000; // we don't pad files larger than this type EncryptedFileResult = { +success: true, +uri: string, +sha256Hash: string, +encryptionKey: string, }; /** * Encrypts a single file and returns the encrypted file URI * and the encryption key. The encryption key is returned as a hex string. * The encrypted file is written to the same directory as the original file, * with the same name, but with the extension ".dat". * * @param uri uri to the file to encrypt * @returns encryption result along with mission steps */ async function encryptFile(uri: string): Promise<{ steps: $ReadOnlyArray, result: MediaMissionFailure | EncryptedFileResult, }> { let success = true, exceptionMessage; const steps: EncryptFileMediaMissionStep[] = []; // prepare destination path for temporary encrypted file const originalFilename = filenameFromPathOrURI(uri); invariant(originalFilename, 'encryptFile: Invalid URI - filename is null'); const targetFilename = replaceExtension(originalFilename, 'dat'); const destinationPath = `${temporaryDirectoryPath}${targetFilename}`; const destinationURI = `file://${destinationPath}`; // Step 1. Read the file const startOpenFile = Date.now(); let data; try { const path = pathFromURI(uri); // for local paths (file:// URI) we can use native module which is faster if (path) { const buffer = await commUtilsModule.readBufferFromFile(path); data = new Uint8Array(buffer); } else { const response = await fetch(getFetchableURI(uri)); const blob = await response.blob(); const buffer = arrayBufferFromBlob(blob); data = new Uint8Array(buffer); } } catch (e) { success = false; exceptionMessage = getMessageForException(e); } steps.push({ step: 'read_plaintext_file', file: uri, time: Date.now() - startOpenFile, success, exceptionMessage, }); if (!success || !data) { return { steps, result: { success: false, reason: 'fetch_failed' }, }; } // Step 2. Encrypt the file const startEncrypt = Date.now(); const paddedLength = calculatePaddedLength(data.byteLength); const shouldPad = paddedLength <= PADDING_THRESHOLD; let key, encryptedData, sha256Hash; try { const plaintextData = shouldPad ? pad(data) : data; key = AES.generateKey(); encryptedData = AES.encrypt(key, plaintextData); sha256Hash = commUtilsModule.sha256(encryptedData.buffer); } catch (e) { success = false; exceptionMessage = getMessageForException(e); } steps.push({ step: 'encrypt_data', dataSize: encryptedData?.byteLength ?? -1, isPadded: shouldPad, time: Date.now() - startEncrypt, sha256: sha256Hash, success, exceptionMessage, }); if (encryptedData && !sha256Hash) { return { steps, result: { success: false, reason: 'digest_failed' } }; } if (!success || !encryptedData || !key || !sha256Hash) { return { steps, result: { success: false, reason: 'encryption_failed' }, }; } // Step 3. Write the encrypted file const startWriteFile = Date.now(); try { await commUtilsModule.writeBufferToFile( destinationPath, encryptedData.buffer, ); } catch (e) { success = false; exceptionMessage = getMessageForException(e); } steps.push({ step: 'write_encrypted_file', file: destinationPath, time: Date.now() - startWriteFile, success, exceptionMessage, }); if (!success) { return { steps, result: { success: false, reason: 'write_file_failed' }, }; } return { steps, result: { success: true, uri: destinationURI, encryptionKey: uintArrayToHexString(key), sha256Hash, }, }; } /** * Encrypts a single photo or video. Replaces the uploadURI with the encrypted * file URI. Attaches `encryptionKey` to the result. Changes the mediaType to * `encrypted_photo` or `encrypted_video`. * * @param preprocessedMedia - Result of `processMedia()` call * @returns a `preprocessedMedia` param, but with encryption applied */ async function encryptMedia(preprocessedMedia: MediaResult): Promise<{ result: MediaResult | MediaMissionFailure, steps: $ReadOnlyArray, }> { invariant(preprocessedMedia.success, 'encryptMedia called on failure result'); invariant( preprocessedMedia.mediaType === 'photo' || preprocessedMedia.mediaType === 'video', 'encryptMedia should only be called on unencrypted photos and videos', ); const { uploadURI } = preprocessedMedia; const steps: Array = []; // Encrypt the media file const { steps: encryptionSteps, result: encryptionResult } = await encryptFile(uploadURI); steps.push(...encryptionSteps); if (!encryptionResult.success) { return { steps, result: encryptionResult }; } if (preprocessedMedia.mediaType === 'photo') { const thumbHashResult = preprocessedMedia.thumbHash ? encryptBase64( preprocessedMedia.thumbHash, hexToUintArray(encryptionResult.encryptionKey), ) : null; return { steps, result: { ...preprocessedMedia, mediaType: 'encrypted_photo', uploadURI: encryptionResult.uri, blobHash: encryptionResult.sha256Hash, thumbHash: thumbHashResult?.base64, encryptionKey: encryptionResult.encryptionKey, shouldDisposePath: pathFromURI(encryptionResult.uri), }, }; } // For videos, we also need to encrypt the thumbnail const { steps: thumbnailEncryptionSteps, result: thumbnailEncryptionResult } = await encryptFile(preprocessedMedia.uploadThumbnailURI); steps.push(...thumbnailEncryptionSteps); if (!thumbnailEncryptionResult.success) { return { steps, result: thumbnailEncryptionResult }; } const thumbHashResult = preprocessedMedia.thumbHash ? encryptBase64( preprocessedMedia.thumbHash, hexToUintArray(thumbnailEncryptionResult.encryptionKey), ) : null; return { steps, result: { ...preprocessedMedia, mediaType: 'encrypted_video', uploadURI: encryptionResult.uri, blobHash: encryptionResult.sha256Hash, thumbHash: thumbHashResult?.base64, encryptionKey: encryptionResult.encryptionKey, uploadThumbnailURI: thumbnailEncryptionResult.uri, thumbnailBlobHash: thumbnailEncryptionResult.sha256Hash, thumbnailEncryptionKey: thumbnailEncryptionResult.encryptionKey, shouldDisposePath: pathFromURI(encryptionResult.uri), }, }; } +type FetchAndDecryptMediaOptions = { + +destination: 'file' | 'data_uri', + +destinationDirectory?: string, +}; + +type FetchAndDecryptMediaOutput = { + steps: $ReadOnlyArray, + result: MediaMissionFailure | { success: true, uri: string }, +}; + async function fetchAndDecryptMedia( blobURI: string, encryptionKey: string, - options: { - +destination: 'file' | 'data_uri', - +destinationDirectory?: string, - }, -): Promise<{ - steps: $ReadOnlyArray, - result: MediaMissionFailure | { success: true, uri: string }, -}> { + authMetadata: AuthMetadata, + options: FetchAndDecryptMediaOptions, +): Promise { let success = true, exceptionMessage; const steps: DecryptFileMediaMissionStep[] = []; // Step 1. Fetch the file and convert it to a Uint8Array + let headers; + if (isBlobServiceURI(blobURI)) { + headers = createDefaultHTTPRequestHeaders(authMetadata); + } + const fetchStartTime = Date.now(); let data; try { - const response = await fetch(getFetchableURI(blobURI)); + const response = await fetch(getFetchableURI(blobURI), { headers }); if (!response.ok) { throw new Error(`HTTP error ${response.status}: ${response.statusText}`); } const blob = await response.blob(); const buffer = arrayBufferFromBlob(blob); data = new Uint8Array(buffer); } catch (e) { success = false; exceptionMessage = getMessageForException(e); } steps.push({ step: 'fetch_file', file: blobURI, time: Date.now() - fetchStartTime, success, exceptionMessage, }); if (!success || !data) { return { steps, result: { success: false, reason: 'fetch_file_failed', exceptionMessage }, }; } // Step 2. Decrypt the data const decryptionStartTime = Date.now(); let plaintextData, decryptedData, isPadded; try { const key = hexToUintArray(encryptionKey); plaintextData = AES.decrypt(key, data); isPadded = plaintextData.byteLength <= PADDING_THRESHOLD; decryptedData = isPadded ? unpad(plaintextData) : plaintextData; } catch (e) { success = false; exceptionMessage = getMessageForException(e); } steps.push({ step: 'decrypt_data', dataSize: decryptedData?.byteLength ?? -1, isPadded: !!isPadded, time: Date.now() - decryptionStartTime, success, exceptionMessage, }); if (!success || !decryptedData) { return { steps, result: { success: false, reason: 'decrypt_data_failed', exceptionMessage, }, }; } // Step 3. Write the file to disk or create a data URI let uri; const writeStartTime = Date.now(); // we need extension for react-native-video to work const { mime } = fileInfoFromData(decryptedData); if (!mime) { return { steps, result: { success: false, reason: 'mime_check_failed', mime, }, }; } if (options.destination === 'file') { // blobURI is a URL, we use the last part of the path as the filename const uriSuffix = blobURI.substring(blobURI.lastIndexOf('/') + 1); const filename = readableFilename(uriSuffix, mime) || uriSuffix; const directory = options.destinationDirectory ?? temporaryDirectoryPath; const targetPath = `${directory}${Date.now()}-${filename}`; try { await commUtilsModule.writeBufferToFile(targetPath, decryptedData.buffer); } catch (e) { success = false; exceptionMessage = getMessageForException(e); } uri = `file://${targetPath}`; steps.push({ step: 'write_file', file: uri, mimeType: mime, time: Date.now() - writeStartTime, success, exceptionMessage, }); if (!success) { return { steps, result: { success: false, reason: 'write_file_failed', exceptionMessage, }, }; } } else { const base64 = commUtilsModule.base64EncodeBuffer(decryptedData.buffer); uri = `data:${mime};base64,${base64}`; steps.push({ step: 'create_data_uri', mimeType: mime, time: Date.now() - writeStartTime, success, exceptionMessage, }); } return { steps, result: { success: true, uri }, }; } +function useFetchAndDecryptMedia(): ( + blobURI: string, + encryptionKey: string, + options: FetchAndDecryptMediaOptions, +) => Promise { + const identityContext = React.useContext(IdentityClientContext); + invariant(identityContext, 'Identity context should be set'); + const { getAuthMetadata } = identityContext; + + return React.useCallback( + async (blobURI, encryptionKey, options) => { + const authMetadata = await getAuthMetadata(); + return fetchAndDecryptMedia( + blobURI, + encryptionKey, + authMetadata, + options, + ); + }, + [getAuthMetadata], + ); +} + function encryptBase64( base64: string, keyBytes?: Uint8Array, ): { +base64: string, +keyHex: string } { const rawData = commUtilsModule.base64DecodeBuffer(base64); const aesKey = keyBytes ?? AES.generateKey(); const encrypted = AES.encrypt(aesKey, new Uint8Array(rawData)); return { base64: commUtilsModule.base64EncodeBuffer(encrypted.buffer), keyHex: uintArrayToHexString(aesKey), }; } function decryptBase64(encrypted: string, keyHex: string): string { const encryptedData = commUtilsModule.base64DecodeBuffer(encrypted); const decryptedData = AES.decrypt( hexToUintArray(keyHex), new Uint8Array(encryptedData), ); return commUtilsModule.base64EncodeBuffer(decryptedData.buffer); } -export { encryptMedia, fetchAndDecryptMedia, encryptBase64, decryptBase64 }; +export { + encryptMedia, + fetchAndDecryptMedia, + useFetchAndDecryptMedia, + encryptBase64, + decryptBase64, +}; diff --git a/native/media/save-media.js b/native/media/save-media.js index 9141ef14c..98b4d9683 100644 --- a/native/media/save-media.js +++ b/native/media/save-media.js @@ -1,545 +1,548 @@ // @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 { isBlobServiceURI } from 'lib/utils/blob-service.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 { fetchAndDecryptMedia } 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 { commCoreModule } from '../native-modules.js'; import { displayActionResultModal } from '../navigation/action-result-modal.js'; import { requestAndroidPermission } from '../utils/android-permissions.js'; export type IntentionalSaveMedia = ( mediaInfo: MediaInfo, ids: { uploadID: string, messageServerID: ?string, messageLocalID: ?string, }, ) => Promise; function useIntentionalSaveMedia(): IntentionalSaveMedia { const dispatch = useDispatch(); const mediaReportsEnabled = useIsReportEnabled('mediaReports'); return React.useCallback( async ( 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, 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, 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, encryptionKey, permissions, sendResult); } else if (Platform.OS === 'ios') { 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') || isBlobServiceURI(uri)) { promises.push( (async () => { const { result: tempSaveResult, steps: tempSaveSteps } = 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') || isBlobServiceURI(uri)) { const { result: tempSaveResult, steps: tempSaveSteps } = 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 authMetadata = await commCoreModule.getCommServicesAuthMetadata(); + const { steps: decryptionSteps, result: decryptionResult } = - await fetchAndDecryptMedia(inputURI, encryptionKey, { + await fetchAndDecryptMedia(inputURI, encryptionKey, authMetadata, { 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 }; diff --git a/native/media/video-playback-modal.react.js b/native/media/video-playback-modal.react.js index e29ed39c9..6cb4df262 100644 --- a/native/media/video-playback-modal.react.js +++ b/native/media/video-playback-modal.react.js @@ -1,833 +1,834 @@ // @flow import Icon from '@expo/vector-icons/MaterialCommunityIcons.js'; import invariant from 'invariant'; import * as React from 'react'; import { useState } from 'react'; import { View, Text, TouchableOpacity } from 'react-native'; import filesystem from 'react-native-fs'; import { TapGestureHandler, type TapGestureEvent, } from 'react-native-gesture-handler'; import * as Progress from 'react-native-progress'; import Animated from 'react-native-reanimated'; import { SafeAreaView } from 'react-native-safe-area-context'; import Video from 'react-native-video'; import { MediaCacheContext } from 'lib/components/media-cache-provider.react.js'; import { useIsAppBackgroundedOrInactive } from 'lib/shared/lifecycle-utils.js'; import type { MediaInfo } from 'lib/types/media-types.js'; -import { fetchAndDecryptMedia } from './encryption-utils.js'; +import { useFetchAndDecryptMedia } from './encryption-utils.js'; import { formatDuration } from './video-utils.js'; import ConnectedStatusBar from '../connected-status-bar.react.js'; import type { AppNavigationProp } from '../navigation/app-navigator.react.js'; import { OverlayContext } from '../navigation/overlay-context.js'; import type { NavigationRoute } from '../navigation/route-names.js'; import { useSelector } from '../redux/redux-utils.js'; import { derivedDimensionsInfoSelector } from '../selectors/dimensions-selectors.js'; import { useStyles } from '../themes/colors.js'; import type { ChatMultimediaMessageInfoItem } from '../types/chat-types.js'; import type { VerticalBounds, LayoutCoordinates, } from '../types/layout-types.js'; import type { NativeMethods } from '../types/react-native.js'; import { gestureJustEnded, animateTowards } from '../utils/animation-utils.js'; type TouchableOpacityInstance = React.AbstractComponent< React.ElementConfig, NativeMethods, >; type VideoRef = { +seek: number => mixed, ... }; const { Extrapolate, and, or, block, cond, eq, ceil, call, set, add, sub, multiply, divide, not, max, min, lessThan, greaterThan, abs, interpolateNode, useValue, event, } = Animated; export type VideoPlaybackModalParams = { +presentedFrom: string, +mediaInfo: MediaInfo, +initialCoordinates: LayoutCoordinates, +verticalBounds: VerticalBounds, +item: ChatMultimediaMessageInfoItem, }; type ReactNativeVideoOnProgressData = { +currentTime: number, +playableDuration: number, +seekableDuration: number, }; type Props = { +navigation: AppNavigationProp<'VideoPlaybackModal'>, +route: NavigationRoute<'VideoPlaybackModal'>, }; function VideoPlaybackModal(props: Props): React.Node { const { mediaInfo } = props.route.params; const { uri: videoUri, holder: blobURI, encryptionKey } = mediaInfo; const [videoSource, setVideoSource] = React.useState( videoUri ? { uri: videoUri } : undefined, ); const mediaCache = React.useContext(MediaCacheContext); + const fetchAndDecryptMedia = useFetchAndDecryptMedia(); React.useEffect(() => { // skip for unencrypted videos if (!blobURI || !encryptionKey) { return undefined; } let isMounted = true; let uriToDispose; setVideoSource(undefined); const loadDecrypted = async () => { const cached = await mediaCache?.get(blobURI); if (cached && isMounted) { setVideoSource({ uri: cached }); return; } const { result } = await fetchAndDecryptMedia(blobURI, encryptionKey, { destination: 'file', }); if (result.success) { const { uri } = result; const cacheSetPromise = mediaCache?.set(blobURI, uri); if (isMounted) { uriToDispose = uri; setVideoSource({ uri }); } else { // dispose of the temporary file immediately when unmounted // but wait for the cache to be set await cacheSetPromise; filesystem.unlink(uri); } } }; void loadDecrypted(); return () => { isMounted = false; if (uriToDispose) { // remove the temporary file created by decryptMedia filesystem.unlink(uriToDispose); } }; - }, [blobURI, encryptionKey, mediaCache]); + }, [blobURI, encryptionKey, mediaCache, fetchAndDecryptMedia]); const closeButtonX = useValue(-1); const closeButtonY = useValue(-1); const closeButtonWidth = useValue(-1); const closeButtonHeight = useValue(-1); const closeButtonRef = React.useRef>(); const closeButton = closeButtonRef.current; const onCloseButtonLayoutCalledRef = React.useRef(false); const onCloseButtonLayout = React.useCallback(() => { onCloseButtonLayoutCalledRef.current = true; }, []); const onCloseButtonLayoutCalled = onCloseButtonLayoutCalledRef.current; React.useEffect(() => { if (!closeButton || !onCloseButtonLayoutCalled) { return; } closeButton.measure((x, y, width, height, pageX, pageY) => { closeButtonX.setValue(pageX); closeButtonY.setValue(pageY); closeButtonWidth.setValue(width); closeButtonHeight.setValue(height); }); }, [ closeButton, onCloseButtonLayoutCalled, closeButtonX, closeButtonY, closeButtonWidth, closeButtonHeight, ]); const footerX = useValue(-1); const footerY = useValue(-1); const footerWidth = useValue(-1); const footerHeight = useValue(-1); const footerRef = React.useRef>(); const footer = footerRef.current; const onFooterLayoutCalledRef = React.useRef(false); const onFooterLayout = React.useCallback(() => { onFooterLayoutCalledRef.current = true; }, []); const onFooterLayoutCalled = onFooterLayoutCalledRef.current; React.useEffect(() => { if (!footer || !onFooterLayoutCalled) { return; } footer.measure((x, y, width, height, pageX, pageY) => { footerX.setValue(pageX); footerY.setValue(pageY); footerWidth.setValue(width); footerHeight.setValue(height); }); }, [ footer, onFooterLayoutCalled, footerX, footerY, footerWidth, footerHeight, ]); const controlsShowing = useValue(1); const outsideButtons = React.useCallback( (x: Animated.Value, y: Animated.Value) => and( or( eq(controlsShowing, 0), lessThan(x, closeButtonX), greaterThan(x, add(closeButtonX, closeButtonWidth)), lessThan(y, closeButtonY), greaterThan(y, add(closeButtonY, closeButtonHeight)), ), or( eq(controlsShowing, 0), lessThan(x, footerX), greaterThan(x, add(footerX, footerWidth)), lessThan(y, footerY), greaterThan(y, add(footerY, footerHeight)), ), ), [ controlsShowing, closeButtonX, closeButtonY, closeButtonWidth, closeButtonHeight, footerX, footerY, footerWidth, footerHeight, ], ); /* ===== START FADE CONTROL ANIMATION ===== */ const singleTapState = useValue(-1); const singleTapX = useValue(0); const singleTapY = useValue(0); const singleTapEvent = React.useMemo( () => event([ { nativeEvent: { state: singleTapState, x: singleTapX, y: singleTapY, }, }, ]), [singleTapState, singleTapX, singleTapY], ); const lastTapX = useValue(-1); const lastTapY = useValue(-1); const activeControlsOpacity = React.useMemo( () => animateTowards( block([ cond( and( gestureJustEnded(singleTapState), outsideButtons(lastTapX, lastTapY), ), set(controlsShowing, not(controlsShowing)), ), set(lastTapX, singleTapX), set(lastTapY, singleTapY), controlsShowing, ]), 150, ), [ singleTapState, controlsShowing, outsideButtons, lastTapX, lastTapY, singleTapX, singleTapY, ], ); const [controlsEnabled, setControlsEnabled] = React.useState(true); const enableControls = React.useCallback(() => setControlsEnabled(true), []); const disableControls = React.useCallback( () => setControlsEnabled(false), [], ); const previousOpacityCeiling = useValue(-1); const opacityCeiling = React.useMemo( () => ceil(activeControlsOpacity), [activeControlsOpacity], ); const opacityJustChanged = React.useMemo( () => cond(eq(previousOpacityCeiling, opacityCeiling), 0, [ set(previousOpacityCeiling, opacityCeiling), 1, ]), [previousOpacityCeiling, opacityCeiling], ); const toggleControls = React.useMemo( () => [ cond( and(eq(opacityJustChanged, 1), eq(opacityCeiling, 0)), call([], disableControls), ), cond( and(eq(opacityJustChanged, 1), eq(opacityCeiling, 1)), call([], enableControls), ), ], [opacityJustChanged, opacityCeiling, disableControls, enableControls], ); /* ===== END FADE CONTROL ANIMATION ===== */ const mediaDimensions = mediaInfo.dimensions; const screenDimensions = useSelector(derivedDimensionsInfoSelector); const frame = React.useMemo( () => ({ width: screenDimensions.width, height: screenDimensions.safeAreaHeight, }), [screenDimensions], ); const mediaDisplayDimensions = React.useMemo(() => { let { height: maxHeight, width: maxWidth } = frame; if (maxHeight > maxWidth) { maxHeight -= 100; } else { maxWidth -= 100; } if ( mediaDimensions.height < maxHeight && mediaDimensions.width < maxWidth ) { return mediaDimensions; } const heightRatio = maxHeight / mediaDimensions.height; const widthRatio = maxWidth / mediaDimensions.width; if (heightRatio < widthRatio) { return { height: maxHeight, width: mediaDimensions.width * heightRatio, }; } else { return { width: maxWidth, height: mediaDimensions.height * widthRatio, }; } }, [frame, mediaDimensions]); const centerX = useValue(frame.width / 2); const centerY = useValue(frame.height / 2 + screenDimensions.topInset); const frameWidth = useValue(frame.width); const frameHeight = useValue(frame.height); const imageWidth = useValue(mediaDisplayDimensions.width); const imageHeight = useValue(mediaDisplayDimensions.height); React.useEffect(() => { const { width: frameW, height: frameH } = frame; const { topInset } = screenDimensions; frameWidth.setValue(frameW); frameHeight.setValue(frameH); centerX.setValue(frameW / 2); centerY.setValue(frameH / 2 + topInset); const { width, height } = mediaDisplayDimensions; imageWidth.setValue(width); imageHeight.setValue(height); }, [ screenDimensions, frame, mediaDisplayDimensions, frameWidth, frameHeight, centerX, centerY, imageWidth, imageHeight, ]); const left = React.useMemo( () => sub(centerX, divide(imageWidth, 2)), [centerX, imageWidth], ); const top = React.useMemo( () => sub(centerY, divide(imageHeight, 2)), [centerY, imageHeight], ); const { initialCoordinates } = props.route.params; const initialScale = React.useMemo( () => divide(initialCoordinates.width, imageWidth), [initialCoordinates, imageWidth], ); const initialTranslateX = React.useMemo( () => sub( initialCoordinates.x + initialCoordinates.width / 2, add(left, divide(imageWidth, 2)), ), [initialCoordinates, left, imageWidth], ); const initialTranslateY = React.useMemo( () => sub( initialCoordinates.y + initialCoordinates.height / 2, add(top, divide(imageHeight, 2)), ), [initialCoordinates, top, imageHeight], ); // The all-important outputs const curScale = useValue(1); const curX = useValue(0); const curY = useValue(0); const curBackdropOpacity = useValue(1); const progressiveOpacity = React.useMemo( () => max( min( sub(1, abs(divide(curX, frameWidth))), sub(1, abs(divide(curY, frameHeight))), ), 0, ), [curX, curY, frameWidth, frameHeight], ); const updates = React.useMemo( () => [toggleControls, set(curBackdropOpacity, progressiveOpacity)], [curBackdropOpacity, progressiveOpacity, toggleControls], ); const updatedScale = React.useMemo( () => [updates, curScale], [updates, curScale], ); const updatedCurX = React.useMemo(() => [updates, curX], [updates, curX]); const updatedCurY = React.useMemo(() => [updates, curY], [updates, curY]); const updatedBackdropOpacity = React.useMemo( () => [updates, curBackdropOpacity], [updates, curBackdropOpacity], ); const updatedActiveControlsOpacity = React.useMemo( () => block([updates, activeControlsOpacity]), [updates, activeControlsOpacity], ); const overlayContext = React.useContext(OverlayContext); invariant(overlayContext, 'VideoPlaybackModal should have OverlayContext'); const navigationProgress = overlayContext.position; const reverseNavigationProgress = React.useMemo( () => sub(1, navigationProgress), [navigationProgress], ); const dismissalButtonOpacity = interpolateNode(updatedBackdropOpacity, { inputRange: [0.95, 1], outputRange: [0, 1], extrapolate: Extrapolate.CLAMP, }); const controlsOpacity = multiply( navigationProgress, dismissalButtonOpacity, updatedActiveControlsOpacity, ); const scale = React.useMemo( () => add( multiply(reverseNavigationProgress, initialScale), multiply(navigationProgress, updatedScale), ), [reverseNavigationProgress, initialScale, navigationProgress, updatedScale], ); const x = React.useMemo( () => add( multiply(reverseNavigationProgress, initialTranslateX), multiply(navigationProgress, updatedCurX), ), [ reverseNavigationProgress, initialTranslateX, navigationProgress, updatedCurX, ], ); const y = React.useMemo( () => add( multiply(reverseNavigationProgress, initialTranslateY), multiply(navigationProgress, updatedCurY), ), [ reverseNavigationProgress, initialTranslateY, navigationProgress, updatedCurY, ], ); const backdropOpacity = React.useMemo( () => multiply(navigationProgress, updatedBackdropOpacity), [navigationProgress, updatedBackdropOpacity], ); const imageContainerOpacity = React.useMemo( () => interpolateNode(navigationProgress, { inputRange: [0, 0.1], outputRange: [0, 1], extrapolate: Extrapolate.CLAMP, }), [navigationProgress], ); const { verticalBounds } = props.route.params; const videoContainerStyle = React.useMemo(() => { const { height, width } = mediaDisplayDimensions; const { height: frameH, width: frameW } = frame; return { height, width, marginTop: (frameH - height) / 2 + screenDimensions.topInset - verticalBounds.y, marginLeft: (frameW - width) / 2, opacity: imageContainerOpacity, transform: [{ translateX: x }, { translateY: y }, { scale: scale }], }; }, [ mediaDisplayDimensions, frame, screenDimensions.topInset, verticalBounds.y, imageContainerOpacity, x, y, scale, ]); const styles = useStyles(unboundStyles); const [paused, setPaused] = useState(false); const [percentElapsed, setPercentElapsed] = useState(0); const [spinnerVisible, setSpinnerVisible] = useState(true); const [timeElapsed, setTimeElapsed] = useState('0:00'); const [totalDuration, setTotalDuration] = useState('0:00'); const videoRef = React.useRef(); const backgroundedOrInactive = useIsAppBackgroundedOrInactive(); React.useEffect(() => { if (backgroundedOrInactive) { setPaused(true); controlsShowing.setValue(1); } }, [backgroundedOrInactive, controlsShowing]); const { navigation } = props; const togglePlayback = React.useCallback(() => { setPaused(!paused); }, [paused]); const resetVideo = React.useCallback(() => { invariant(videoRef.current, 'videoRef.current should be set in resetVideo'); videoRef.current.seek(0); }, []); const progressCallback = React.useCallback( (res: ReactNativeVideoOnProgressData) => { setTimeElapsed(formatDuration(res.currentTime)); setTotalDuration(formatDuration(res.seekableDuration)); setPercentElapsed( Math.ceil((res.currentTime / res.seekableDuration) * 100), ); }, [], ); const readyForDisplayCallback = React.useCallback(() => { setSpinnerVisible(false); }, []); const statusBar = overlayContext.isDismissing ? null : (