Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions cspell.json
Original file line number Diff line number Diff line change
Expand Up @@ -677,6 +677,7 @@
"expensifynewkansas",
"expensifyreactnative",
"exportedto",
"extensionless",
"fabs",
"falso",
"favicons",
Expand Down Expand Up @@ -949,6 +950,7 @@
"QBWC",
"qrcode",
"quasis",
"quicktime",
"quotepath",
"rach",
"reactnative",
Expand Down
1 change: 1 addition & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,7 @@
"mapbox-gl": "^3.24.0",
"metro": "0.84.5",
"metro-transform-plugins": "0.84.5",
"mime-db": "1.52.0",
"onfido-sdk-ui": "14.53.1",
"pako": "^2.1.0",
"process": "^0.11.10",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,9 @@ type AttachmentViewVideoProps = Pick<AttachmentViewProps, 'duration' | 'isHovere
/** Video file source URL */
source: string;

/** Original file name of the video, used to name the file saved by the player's Download action */
fileName?: string;

shouldUseSharedVideoElement?: boolean;

/** The reportID related to the attachment */
Expand All @@ -20,13 +23,14 @@ type AttachmentViewVideoProps = Pick<AttachmentViewProps, 'duration' | 'isHovere
onTap?: (shouldShowArrows?: boolean) => void;
};

function AttachmentViewVideo({source, isHovered = false, shouldUseSharedVideoElement = false, duration = 0, reportID, onTap}: AttachmentViewVideoProps) {
function AttachmentViewVideo({source, fileName, isHovered = false, shouldUseSharedVideoElement = false, duration = 0, reportID, onTap}: AttachmentViewVideoProps) {
const {shouldUseNarrowLayout} = useResponsiveLayout();
const styles = useThemeStyles();

return (
<VideoPlayer
url={source}
fileName={fileName}
shouldUseSharedVideoElement={shouldUseSharedVideoElement && !shouldUseNarrowLayout}
isVideoHovered={isHovered}
videoDuration={duration}
Expand Down
1 change: 1 addition & 0 deletions src/components/Attachments/AttachmentView/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -411,6 +411,7 @@ function AttachmentView({
return (
<AttachmentViewVideo
source={source}
fileName={file?.name}
shouldUseSharedVideoElement={!CONST.ATTACHMENT_LOCAL_URL_PREFIX.some((prefix) => source.startsWith(prefix))}
isHovered={isHovered}
duration={duration}
Expand Down
5 changes: 2 additions & 3 deletions src/components/Composer/implementation/index.native.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import useTheme from '@hooks/useTheme';
import useThemeStyles from '@hooks/useThemeStyles';

import {containsOnlyEmojis} from '@libs/EmojiUtils';
import {splitExtensionFromFileName} from '@libs/fileDownload/FileUtils';
import {getExtensionFromMimeType, splitExtensionFromFileName} from '@libs/fileDownload/FileUtils';
import getLandscapeTextInputRefProxy from '@libs/getLandscapeTextInputRefProxy';
import Parser from '@libs/Parser';

Expand All @@ -22,7 +22,6 @@ import type {FileObject} from '@src/types/utils/Attachment';
import type {MarkdownStyle, MarkdownTextInput} from '@expensify/react-native-live-markdown';
import type {NativeSyntheticEvent, TextInputChangeEvent} from 'react-native';

import mimeDb from 'mime-db';
import React, {useCallback, useEffect, useMemo, useRef} from 'react';
import {StyleSheet} from 'react-native';

Expand Down Expand Up @@ -121,7 +120,7 @@ function Composer({
const fileURI = clipboardContent?.data;
const baseFileName = fileURI?.split('/').pop() ?? 'file';
const {fileName: stem, fileExtension: originalFileExtension} = splitExtensionFromFileName(baseFileName);
const fileExtension = originalFileExtension || (mimeDb[mimeType]?.extensions?.[0] ?? 'bin');
const fileExtension = originalFileExtension || (getExtensionFromMimeType(mimeType) ?? 'bin');
const fileName = `${stem}.${fileExtension}`;
let file: FileObject = {uri: fileURI, name: fileName, type: mimeType, size: 0};
getFileSize(file.uri ?? '')
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,9 @@ function VideoRenderer({tnode, key}: VideoRendererProps) {
const htmlAttribs = tnode.attributes;
const attrHref = htmlAttribs[CONST.ATTACHMENT_SOURCE_ATTRIBUTE] || htmlAttribs.src || htmlAttribs.href || '';
const sourceURL = tryResolveUrlFromApiRoot(attrHref);
const fileName = getFileName(`${sourceURL}`);
// Prefer the stored file name, like ImageRenderer does: deriving it from the URL yields the raw storage
// key, which has no extension and so downloads as a file the OS treats as a generic document.
const fileName = htmlAttribs[CONST.ATTACHMENT_ORIGINAL_FILENAME_ATTRIBUTE] || getFileName(`${sourceURL}`);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Read the video node text when data-name is absent

For received video attachments whose HTML is the normal <video …>clip.mp4</video> form without data-name, this falls back to the storage URL rather than the element text. The shared attachment parser explicitly extracts video filenames from that text (getAttachmentDetails), so attachments backed by extensionless storage keys—the case this change targets—will still be passed to fileDownload with no extension. Extract the video node's text child before falling back to getFileName(sourceURL).

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Valid point but this follows the same pattern that already exists at extractAttachments.ts:54, and the current scope is only to fix view/downloads going forward per this confirmation. The wrong-download-name issue also reproduces on latest main, so we should track it in a separate issue.

const thumbnailUrl = tryResolveUrlFromApiRoot(htmlAttribs[CONST.ATTACHMENT_THUMBNAIL_URL_ATTRIBUTE]);
const width = Number(htmlAttribs[CONST.ATTACHMENT_THUMBNAIL_WIDTH_ATTRIBUTE]);
const height = Number(htmlAttribs[CONST.ATTACHMENT_THUMBNAIL_HEIGHT_ATTRIBUTE]);
Expand Down
2 changes: 2 additions & 0 deletions src/components/VideoPlayer/BaseVideoPlayer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ type BaseVideoPlayerProps = VideoPlayerProps & {reportID: string};
function BaseVideoPlayer(props: BaseVideoPlayerProps) {
const {
url,
fileName,
onSourceLoaded,
isLooping = false,
style,
Expand Down Expand Up @@ -651,6 +652,7 @@ function BaseVideoPlayer(props: BaseVideoPlayerProps) {
duration={duration || 0}
position={currentTime || 0}
url={url}
fileName={fileName}
videoPlayerRef={videoPlayerRef}
videoViewRef={videoViewRef}
isPlaying={isPlaying}
Expand Down
10 changes: 8 additions & 2 deletions src/components/VideoPlayer/VideoPlayerControls/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,10 @@ type VideoPlayerControlsProps = {
position: number;

url: string;

/** Original file name of the video, used to name the file saved by the popover menu's Download action. */
fileName?: string;

videoPlayerRef: RefObject<VideoPlayer | null>;
videoViewRef: RefObject<VideoView | null>;
isPlaying: boolean;
Expand All @@ -56,7 +60,7 @@ type VideoPlayerControlsProps = {
};

/** Three-dots overflow trigger; records the active player + source before opening. */
function MoreMenuTrigger({videoPlayerRef, url, small}: {videoPlayerRef: RefObject<VideoPlayer | null>; url: string; small: boolean}) {
function MoreMenuTrigger({videoPlayerRef, url, fileName, small}: {videoPlayerRef: RefObject<VideoPlayer | null>; url: string; fileName: string | undefined; small: boolean}) {
const {updateVideoPopoverMenuPlayerRef, updateSource} = useVideoPopoverMenuActions();
const icons = useMemoizedLazyExpensifyIcons(['ThreeDots']);
const {translate} = useLocalize();
Expand All @@ -67,7 +71,7 @@ function MoreMenuTrigger({videoPlayerRef, url, small}: {videoPlayerRef: RefObjec
return;
}
updateVideoPopoverMenuPlayerRef(videoPlayerRef.current);
updateSource(url);
updateSource(url, fileName);
};

return (
Expand All @@ -87,6 +91,7 @@ function VideoPlayerControls({
duration,
position,
url,
fileName,
videoPlayerRef,
videoViewRef,
isPlaying,
Expand Down Expand Up @@ -170,6 +175,7 @@ function VideoPlayerControls({
<MoreMenuTrigger
videoPlayerRef={videoPlayerRef}
url={url}
fileName={fileName}
small={small}
/>
</View>
Expand Down
6 changes: 6 additions & 0 deletions src/components/VideoPlayer/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,12 @@ type VideoPlayerProps = {
*/
url: string;

/**
* Original file name of the video, used to name the file saved by the player's Download action.
* Without it the download falls back to the raw storage key, which has no extension.
*/
fileName?: string;

/**
* Callback fired when the sourceLoad event is triggered.
* @param event Data containing information about the video source that finished loading.
Expand Down
12 changes: 10 additions & 2 deletions src/components/VideoPlayerContexts/VideoPopoverMenuContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ function VideoPopoverMenuContextProvider({children}: ChildrenProps) {
const {translate} = useLocalize();
const {currentVideoPlayerRef, originalParent} = usePlaybackStateContext();
const [source, setSource] = useState('');
const [sourceFileName, setSourceFileName] = useState<string | undefined>(undefined);
const [currentPlaybackSpeed, setCurrentPlaybackSpeed] = useState<PlaybackSpeed>(CONST.VIDEO_PLAYER.PLAYBACK_SPEEDS[3]);
const isLocalFile = !!source && CONST.ATTACHMENT_LOCAL_URL_PREFIX.some((prefix) => source.startsWith(prefix));
const videoPopoverMenuPlayerRef = useRef<VideoPlayer>(null);
Expand Down Expand Up @@ -55,18 +56,25 @@ function VideoPopoverMenuContextProvider({children}: ChildrenProps) {
videoPopoverMenuPlayerRef.current = videoPlayer;
};

const updateSource = (newSource: string, newFileName?: string) => {
setSource(newSource);
setSourceFileName(newFileName);
};

const downloadAttachment = () => {
if (typeof source === 'number' || !source) {
return;
}
fileDownload(translate, addEncryptedAuthTokenToURL(source, encryptedAuthToken));
// Without the original file name the download falls back to the last segment of the URL, which is the
// raw storage key and carries no extension, so the saved file opens as a generic document.
fileDownload(translate, addEncryptedAuthTokenToURL(source, encryptedAuthToken), sourceFileName);
};

const stateValue = {currentPlaybackSpeed, isLocalFile};
const actionsValue = {
updateVideoPopoverMenuPlayerRef,
updatePlaybackSpeed,
updateSource: setSource,
updateSource,
downloadAttachment,
};

Expand Down
3 changes: 2 additions & 1 deletion src/components/VideoPlayerContexts/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,8 +54,9 @@ type VideoPopoverMenuActionsContextType = {
/**
* Updates the video source URL inside VideoPopoverMenuContext.
* @param source The new source URL.
* @param fileName The original file name of the source, used to name the downloaded file.
*/
updateSource: (source: string) => void;
updateSource: (source: string, fileName?: string) => void;

/** Triggers the download flow for the current source. */
downloadAttachment: () => void;
Expand Down
1 change: 1 addition & 0 deletions src/components/VideoPlayerPreview/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,7 @@ function VideoPlayerPreview({videoUrl, thumbnailUrl, reportID, fileName, videoDi
<View style={styles.flex1}>
<VideoPlayer
url={videoUrl}
fileName={fileName}
onSourceLoaded={onSourceLoaded}
videoDuration={videoDuration}
shouldUseSmallVideoControls
Expand Down
46 changes: 46 additions & 0 deletions src/libs/fileDownload/FileUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import type {ReactNativeBlobUtilReadStream} from 'react-native-blob-util';
import type {TupleToUnion, ValueOf} from 'type-fest';

import {Str} from 'expensify-common';
import mimeDb from 'mime-db';
import {Alert, Linking, Platform} from 'react-native';
import ReactNativeBlobUtil from 'react-native-blob-util';
import ImageSize from 'react-native-image-size';
Expand Down Expand Up @@ -176,6 +177,48 @@ function getFileNameWithFallback(fileName: string | null | undefined, uri: strin
return fileNameFromURI;
}

/**
* Extensions to use instead of mime-db's first registered one. mime-db lists `video/quicktime` as
* `qt` first, but `Str.isVideo` only recognizes `mov`, so a `.qt` file would render as a generic file.
*/
const MIME_TYPE_EXTENSION_OVERRIDES = new Map<string, string>([['video/quicktime', 'mov']]);

/**
* Returns the most common file extension registered for a MIME type, e.g. `video/mp4` resolves to `mp4`.
* Returns undefined for an empty or unrecognized MIME type so callers can pick their own fallback.
*/
function getExtensionFromMimeType(mimeType: string | undefined): string | undefined {
if (!mimeType) {
return undefined;
}
return MIME_TYPE_EXTENSION_OVERRIDES.get(mimeType) ?? mimeDb[mimeType]?.extensions?.at(0);
}

/**
* Whether a MIME type says nothing about the file's contents. Web reports `''` for a file it can't
* identify, and the Android document picker reports `application/octet-stream` for an extensionless file.
*/
function isUnknownMimeType(mimeType: string | undefined): boolean {
return !mimeType || mimeType === 'application/octet-stream';
}

/**
* Adds an extension to a file name that has none, recovering it from the file's MIME type.
* Some pickers hand us a name with no extension at all (an Android `content://` URI resolves to a bare
* numeric segment, and the document picker can return `name: null`), and such a file downloads as a
* bare blob the OS treats as a generic document even though the bytes are valid.
* The name is returned unchanged when it already has an extension or the MIME type is unknown or can't be
* resolved, because a wrong or meaningless extension (such as `.bin`) is worse than no extension.
*/
function appendExtensionFromMimeType(fileName: string, mimeType: string | undefined): string {
if (!fileName || isUnknownMimeType(mimeType) || splitExtensionFromFileName(fileName).fileExtension) {
return fileName;
}

const fileExtension = getExtensionFromMimeType(mimeType);
return fileExtension ? `${fileName}.${fileExtension}` : fileName;
}

/**
* Returns the MIME type for a given file extension.
* Falls back to 'application/octet-stream' for unrecognized extensions.
Expand Down Expand Up @@ -965,6 +1008,9 @@ export {
showCameraPermissionsAlert,
splitExtensionFromFileName,
getMimeType,
getExtensionFromMimeType,
isUnknownMimeType,
appendExtensionFromMimeType,
getFileName,
getFileNameWithFallback,
getFileType,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
// cspell:ignore ftyp isom mmp4

/** Number of leading bytes read from a file, enough to cover every signature below. */
const FILE_HEADER_LENGTH = 32;

/**
* ISO base media brands known to be MP4 videos. Any other brand returns no type, because image formats
* such as HEIC and AVIF share the `ftyp` box with MP4, and a wrong extension is worse than none. HEIC in
* particular must keep its extensionless name so the HEIC conversion flow isn't bypassed.
*/
const MP4_FTYP_BRANDS = new Set(['isom', 'iso2', 'iso3', 'iso4', 'iso5', 'iso6', 'mp41', 'mp42', 'avc1', 'dash', 'M4V ', 'mmp4']);

const AUDIO_FTYP_BRANDS = new Set(['M4A ', 'M4B ']);

function hasBytes(bytes: Uint8Array, signature: number[], offset = 0): boolean {
return signature.every((byte, index) => bytes[offset + index] === byte);
}

function readASCII(bytes: Uint8Array, offset: number, length: number): string {
return String.fromCharCode(...bytes.subarray(offset, offset + length));
}

function getMimeTypeFromFtypBrand(brand: string): string | undefined {
if (MP4_FTYP_BRANDS.has(brand)) {
return 'video/mp4';
}
if (AUDIO_FTYP_BRANDS.has(brand)) {
return 'audio/mp4';
}
if (brand === 'qt ') {
return 'video/quicktime';
}
if (brand.startsWith('3gp')) {
return 'video/3gpp';
}
return undefined;
}

/**
* Identifies a file's MIME type from its leading bytes. Used when a file has neither an extension nor a
* meaningful MIME type, such as an extensionless file picked through the Android document picker.
* Returns undefined when no known signature matches.
*/
function getMimeTypeFromHeaderBytes(bytes: Uint8Array): string | undefined {
if (readASCII(bytes, 4, 4) === 'ftyp') {
return getMimeTypeFromFtypBrand(readASCII(bytes, 8, 4));
}
if (hasBytes(bytes, [0x1a, 0x45, 0xdf, 0xa3])) {
return 'video/webm';
}
if (hasBytes(bytes, [0xff, 0xd8, 0xff])) {
return 'image/jpeg';
}
if (hasBytes(bytes, [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])) {
return 'image/png';
}
if (readASCII(bytes, 0, 4) === 'GIF8') {
return 'image/gif';
}
if (readASCII(bytes, 0, 4) === '%PDF') {
return 'application/pdf';
}
if (readASCII(bytes, 0, 4) === 'RIFF' && readASCII(bytes, 8, 4) === 'WEBP') {
return 'image/webp';
}
return undefined;
}

export default getMimeTypeFromHeaderBytes;
export {FILE_HEADER_LENGTH};
29 changes: 29 additions & 0 deletions src/libs/getMimeTypeFromFileHeader/index.android.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import fileURIToPath from '@libs/fileURIToPath';
import Log from '@libs/Log';

import type {FileObject} from '@src/types/utils/Attachment';

import {Buffer} from 'buffer';
import RNFS from 'react-native-fs';

import getMimeTypeFromHeaderBytes, {FILE_HEADER_LENGTH} from './getMimeTypeFromHeaderBytes';

/**
* Reads only the first bytes of a picked file from its URI and identifies its MIME type from them.
* RNFS reads just the requested byte range, so this stays cheap for large videos.
* Resolves undefined when the file can't be read or no known signature matches.
*/
async function getMimeTypeFromFileHeader(file: FileObject): Promise<string | undefined> {
if (typeof file.uri !== 'string') {
return undefined;
}
try {
const header = await RNFS.read(fileURIToPath(file.uri), FILE_HEADER_LENGTH, 0, 'base64');
return getMimeTypeFromHeaderBytes(Buffer.from(header, 'base64'));
} catch (error) {
Log.warn('[getMimeTypeFromFileHeader] Could not read file header', {error: error instanceof Error ? error.message : String(error)});
return undefined;
}
}

export default getMimeTypeFromFileHeader;
Loading
Loading