diff --git a/native/data/sqlite-data-handler.js b/native/data/sqlite-data-handler.js index 4b5109344..f1bd20a27 100644 --- a/native/data/sqlite-data-handler.js +++ b/native/data/sqlite-data-handler.js @@ -1,196 +1,204 @@ // @flow import * as React from 'react'; import { Alert } from 'react-native'; import { useDispatch } from 'react-redux'; import { setClientDBStoreActionType } from 'lib/actions/client-db-store-actions.js'; +import { MediaCacheContext } from 'lib/components/media-cache-provider.react.js'; import { isLoggedIn } from 'lib/selectors/user-selectors.js'; import { logInActionSources, type LogInActionSource, } from 'lib/types/account-types.js'; import { fetchNewCookieFromNativeCredentials } from 'lib/utils/action-utils.js'; import { getMessageForException } from 'lib/utils/errors.js'; import { convertClientDBThreadInfosToRawThreadInfos } from 'lib/utils/thread-ops-utils.js'; +import { filesystemMediaCache } from '../media/media-cache.js'; import { commCoreModule } from '../native-modules.js'; import { setStoreLoadedActionType } from '../redux/action-types.js'; import { useSelector } from '../redux/redux-utils.js'; import { StaffContext } from '../staff/staff-context.js'; import { isTaskCancelledError } from '../utils/error-handling.js'; import { useStaffCanSee } from '../utils/staff-utils.js'; function SQLiteDataHandler(): React.Node { const storeLoaded = useSelector(state => state.storeLoaded); const dispatch = useDispatch(); const rehydrateConcluded = useSelector( state => !!(state._persist && state._persist.rehydrated), ); const cookie = useSelector(state => state.cookie); const urlPrefix = useSelector(state => state.urlPrefix); const staffCanSee = useStaffCanSee(); const { staffUserHasBeenLoggedIn } = React.useContext(StaffContext); const loggedIn = useSelector(isLoggedIn); const currentLoggedInUserID = useSelector(state => state.currentUserInfo?.anonymous ? undefined : state.currentUserInfo?.id, ); + const mediaCacheContext = React.useContext(MediaCacheContext); const callFetchNewCookieFromNativeCredentials = React.useCallback( async (source: LogInActionSource) => { try { await fetchNewCookieFromNativeCredentials( dispatch, cookie, urlPrefix, source, ); dispatch({ type: setStoreLoadedActionType }); } catch (fetchCookieException) { if (staffCanSee) { Alert.alert( `Error fetching new cookie from native credentials: ${ getMessageForException(fetchCookieException) ?? '{no exception message}' }. Please kill the app.`, ); } else { commCoreModule.terminate(); } } }, [cookie, dispatch, staffCanSee, urlPrefix], ); const callClearSensitiveData = React.useCallback( async (triggeredBy: string) => { if (staffCanSee || staffUserHasBeenLoggedIn) { Alert.alert('Starting SQLite database deletion process'); } await commCoreModule.clearSensitiveData(); + await filesystemMediaCache.clearCache(); if (staffCanSee || staffUserHasBeenLoggedIn) { Alert.alert( 'SQLite database successfully deleted', `SQLite database deletion was triggered by ${triggeredBy}`, ); } }, [staffCanSee, staffUserHasBeenLoggedIn], ); const handleSensitiveData = React.useCallback(async () => { try { const databaseCurrentUserInfoID = await commCoreModule.getCurrentUserID(); if ( databaseCurrentUserInfoID && databaseCurrentUserInfoID !== currentLoggedInUserID ) { await callClearSensitiveData('change in logged-in user credentials'); } if (currentLoggedInUserID) { await commCoreModule.setCurrentUserID(currentLoggedInUserID); } const databaseDeviceID = await commCoreModule.getDeviceID(); if (!databaseDeviceID) { await commCoreModule.setDeviceID('MOBILE'); } } catch (e) { if (isTaskCancelledError(e)) { return; } if (__DEV__) { throw e; } else { console.log(e); commCoreModule.terminate(); } } }, [callClearSensitiveData, currentLoggedInUserID]); React.useEffect(() => { if (!rehydrateConcluded) { return; } const databaseNeedsDeletion = commCoreModule.checkIfDatabaseNeedsDeletion(); if (databaseNeedsDeletion) { (async () => { try { await callClearSensitiveData('detecting corrupted database'); } catch (e) { if (__DEV__) { throw e; } else { console.log(e); commCoreModule.terminate(); } } await callFetchNewCookieFromNativeCredentials( logInActionSources.corruptedDatabaseDeletion, ); })(); return; } const sensitiveDataHandled = handleSensitiveData(); if (storeLoaded) { return; } if (!loggedIn) { dispatch({ type: setStoreLoadedActionType }); return; } (async () => { - await sensitiveDataHandled; + await Promise.all([ + sensitiveDataHandled, + mediaCacheContext?.evictCache(), + ]); try { const { threads, messages, drafts } = await commCoreModule.getClientDBStore(); const threadInfosFromDB = convertClientDBThreadInfosToRawThreadInfos(threads); dispatch({ type: setClientDBStoreActionType, payload: { drafts, messages, threadStore: { threadInfos: threadInfosFromDB }, currentUserID: currentLoggedInUserID, }, }); } catch (setStoreException) { if (isTaskCancelledError(setStoreException)) { dispatch({ type: setStoreLoadedActionType }); return; } if (staffCanSee) { Alert.alert( `Error setting threadStore or messageStore: ${ getMessageForException(setStoreException) ?? '{no exception message}' }`, ); } await callFetchNewCookieFromNativeCredentials( logInActionSources.sqliteLoadFailure, ); } })(); }, [ currentLoggedInUserID, handleSensitiveData, loggedIn, cookie, dispatch, rehydrateConcluded, staffCanSee, storeLoaded, urlPrefix, staffUserHasBeenLoggedIn, callFetchNewCookieFromNativeCredentials, callClearSensitiveData, + mediaCacheContext, ]); return null; } export { SQLiteDataHandler }; diff --git a/native/media/encrypted-image.react.js b/native/media/encrypted-image.react.js index cb1d9b042..e71e51402 100644 --- a/native/media/encrypted-image.react.js +++ b/native/media/encrypted-image.react.js @@ -1,77 +1,87 @@ // @flow import * as React from 'react'; +import { MediaCacheContext } from 'lib/components/media-cache-provider.react.js'; + import { decryptMedia } from './encryption-utils.js'; import LoadableImage from './loadable-image.react.js'; import { useSelector } from '../redux/redux-utils.js'; import type { ImageStyle } from '../types/styles.js'; type BaseProps = { +holder: string, +encryptionKey: string, +onLoad: (uri: string) => void, +spinnerColor: string, +style: ImageStyle, +invisibleLoad: boolean, }; type Props = { ...BaseProps, }; function EncryptedImage(props: Props): React.Node { const { holder, encryptionKey, onLoad: onLoadProp } = props; + const mediaCache = React.useContext(MediaCacheContext); const [source, setSource] = React.useState(null); const connectionStatus = useSelector(state => state.connection.status); const prevConnectionStatusRef = React.useRef(connectionStatus); const [attempt, setAttempt] = React.useState(0); if (prevConnectionStatusRef.current !== connectionStatus) { if (!source && connectionStatus === 'connected') { setAttempt(attempt + 1); } prevConnectionStatusRef.current = connectionStatus; } React.useEffect(() => { let isMounted = true; setSource(null); const loadDecrypted = async () => { + const cached = await mediaCache?.get(holder); + if (cached && isMounted) { + setSource({ uri: cached }); + return; + } + const { result } = await decryptMedia(holder, encryptionKey, { destination: 'data_uri', }); // TODO: decide what to do if decryption fails if (result.success && isMounted) { + mediaCache?.set(holder, result.uri); setSource({ uri: result.uri }); } }; loadDecrypted(); return () => { isMounted = false; }; - }, [attempt, holder, encryptionKey]); + }, [attempt, holder, encryptionKey, mediaCache]); const onLoad = React.useCallback(() => { onLoadProp && onLoadProp(holder); }, [holder, onLoadProp]); const { style, spinnerColor, invisibleLoad } = props; return ( ); } export default EncryptedImage; diff --git a/native/media/video-playback-modal.react.js b/native/media/video-playback-modal.react.js index c93e67d0d..b465a86cc 100644 --- a/native/media/video-playback-modal.react.js +++ b/native/media/video-playback-modal.react.js @@ -1,799 +1,817 @@ // @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 } 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 { decryptMedia } 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, >; /* eslint-disable import/no-named-as-default-member */ 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 Props = { +navigation: AppNavigationProp<'VideoPlaybackModal'>, +route: NavigationRoute<'VideoPlaybackModal'>, }; function VideoPlaybackModal(props: Props): React.Node { const { mediaInfo } = props.route.params; - const { uri, holder, encryptionKey } = mediaInfo; + const { uri: videoUri, holder, encryptionKey } = mediaInfo; const [videoSource, setVideoSource] = React.useState( - uri ? { uri } : undefined, + videoUri ? { uri: videoUri } : undefined, ); + const mediaCache = React.useContext(MediaCacheContext); + React.useEffect(() => { // skip for unencrypted videos if (!holder || !encryptionKey) { return; } let isMounted = true; let uriToDispose; setVideoSource(undefined); const loadDecrypted = async () => { + const cached = await mediaCache?.get(holder); + if (cached && isMounted) { + setVideoSource({ uri: cached }); + return; + } + const { result } = await decryptMedia(holder, encryptionKey, { destination: 'file', }); - if (result.success && isMounted) { - uriToDispose = result.uri; - setVideoSource({ uri: result.uri }); + if (result.success) { + const { uri } = result; + const cacheSetPromise = mediaCache?.set(holder, 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); + } } }; loadDecrypted(); return () => { isMounted = false; if (uriToDispose) { // remove the temporary file created by decryptMedia filesystem.unlink(uriToDispose); } }; - }, [holder, encryptionKey]); + }, [holder, encryptionKey, mediaCache]); 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, y) => 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 => { 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 : (