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{
steps: $ReadOnlyArray,
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 : (
);
const backdropStyle = React.useMemo(
() => ({ opacity: backdropOpacity }),
[backdropOpacity],
);
const contentContainerStyle = React.useMemo(() => {
const fullScreenHeight = screenDimensions.height;
const bottom = fullScreenHeight - verticalBounds.y - verticalBounds.height;
// margin will clip, but padding won't
const verticalStyle = overlayContext.isDismissing
? { marginTop: verticalBounds.y, marginBottom: bottom }
: { paddingTop: verticalBounds.y, paddingBottom: bottom };
return [styles.contentContainer, verticalStyle];
}, [
screenDimensions.height,
verticalBounds.y,
verticalBounds.height,
overlayContext.isDismissing,
styles.contentContainer,
]);
let controls;
if (videoSource) {
controls = (
{timeElapsed} / {totalDuration}
);
}
let spinner;
if (spinnerVisible) {
spinner = (
);
}
let videoPlayer;
if (videoSource) {
videoPlayer = (
);
}
return (
{statusBar}
{spinner}
{videoPlayer}
{controls}
);
}
const unboundStyles = {
expand: {
flex: 1,
},
backgroundVideo: {
position: 'absolute',
top: 0,
left: 0,
bottom: 0,
right: 0,
},
footer: {
position: 'absolute',
left: 0,
right: 0,
bottom: 0,
backgroundColor: 'rgba(52,52,52,0.6)',
height: 76,
display: 'flex',
flexDirection: 'row',
justifyContent: 'center',
alignItems: 'center',
paddingHorizontal: 8,
},
header: {
position: 'absolute',
left: 0,
right: 0,
top: 0,
},
playPauseButton: {
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
},
closeButton: {
paddingTop: 10,
paddingRight: 20,
justifyContent: 'flex-end',
alignItems: 'center',
flexDirection: 'row',
height: 100,
},
progressBar: {
flex: 1,
position: 'relative',
alignItems: 'center',
justifyContent: 'center',
color: 'white',
paddingRight: 10,
display: 'flex',
flexDirection: 'row',
},
progressCircle: {
position: 'absolute',
alignItems: 'center',
justifyContent: 'center',
top: 0,
bottom: 0,
left: 0,
right: 0,
},
iconButton: {
marginHorizontal: 10,
color: 'white',
},
durationText: {
color: 'white',
fontSize: 11,
width: 70,
},
backdrop: {
backgroundColor: 'black',
bottom: 0,
left: 0,
position: 'absolute',
right: 0,
top: 0,
},
controls: {
position: 'absolute',
bottom: 0,
left: 0,
right: 0,
top: 0,
},
contentContainer: {
flex: 1,
overflow: 'hidden',
},
fill: {
flex: 1,
},
};
export default VideoPlaybackModal;