Secure Files and preview PDF inline (#8844)

This commit is contained in:
Elias Nahum 2025-06-19 15:30:56 +08:00 committed by GitHub
parent b365cb34d6
commit b91e42615f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
137 changed files with 9520 additions and 582 deletions

View file

@ -3137,3 +3137,31 @@ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
---
## Pdfium
This product contains 'Pdfium' by The PDFium Authors.
PDF rendering library used for displaying PDF documents.
* HOMEPAGE:
* https://pdfium.googlesource.com/pdfium/
* LICENSE: Apache-2.0
Copyright 2014 The PDFium Authors
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
---

View file

@ -214,11 +214,11 @@ dependencies {
androidTestImplementation 'com.wix:detox:20.26.2'
// For animated GIF support
implementation 'com.facebook.fresco:animated-gif:3.6.0'
implementation 'com.facebook.fresco:animated-gif:3.6.0'
// For WebP support, including animated WebP
implementation 'com.facebook.fresco:animated-webp:3.6.0'
implementation 'com.facebook.fresco:webpsupport:3.6.0'
// For WebP support, including animated WebP
implementation 'com.facebook.fresco:animated-webp:3.6.0'
implementation 'com.facebook.fresco:webpsupport:3.6.0'
implementation project(':reactnativenotifications')
implementation project(':watermelondb-jsi')

View file

@ -33,9 +33,12 @@ buildscript {
allprojects {
repositories {
mavenLocal()
google()
maven {
url "$rootDir/../detox/node_modules/detox/Detox-android"
}
maven { url 'https://jitpack.io' }
}
}

View file

@ -1,7 +1,7 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {Button as ElementButton} from '@rneui/base';
import {Button as ElementButton, type ButtonProps} from '@rneui/base';
import React, {useMemo, type ReactNode} from 'react';
import {type StyleProp, StyleSheet, Text, type TextStyle, View, type ViewStyle, type Insets} from 'react-native';
@ -9,7 +9,7 @@ import CompassIcon from '@components/compass_icon';
import Loading from '@components/loading';
import {buttonBackgroundStyle, buttonTextStyle} from '@utils/buttonStyles';
type Props = {
type Props = Omit<ButtonProps, 'size'> & {
theme: Theme;
backgroundStyle?: StyleProp<ViewStyle>;
buttonContainerStyle?: StyleProp<ViewStyle>;

View file

@ -18,6 +18,7 @@ import type FileModel from '@typings/database/models/servers/file';
type Props = {
bookmark: ChannelBookmarkModel;
canDownloadFiles: boolean;
enableSecureFilePreview: boolean;
file: FileModel;
onLongPress: () => void;
}
@ -33,10 +34,10 @@ const styles = StyleSheet.create({
},
});
const BookmarkDocument = ({bookmark, canDownloadFiles, file, onLongPress}: Props) => {
const BookmarkDocument = ({bookmark, canDownloadFiles, enableSecureFilePreview, file, onLongPress}: Props) => {
const document = useRef<DocumentRef>(null);
const theme = useTheme();
const {progress, toggleDownloadAndPreview} = useDownloadFileAndPreview();
const {progress, toggleDownloadAndPreview} = useDownloadFileAndPreview(enableSecureFilePreview);
const handlePress = useCallback(async () => {
if (document.current) {
@ -47,6 +48,7 @@ const BookmarkDocument = ({bookmark, canDownloadFiles, file, onLongPress}: Props
return (
<Document
canDownloadFiles={canDownloadFiles}
enableSecureFilePreview={enableSecureFilePreview}
file={file.toFileInfo(bookmark.ownerId)}
downloadAndPreviewFile={toggleDownloadAndPreview}
ref={document}

View file

@ -33,6 +33,7 @@ type Props = {
canDeleteBookmarks: boolean;
canDownloadFiles: boolean;
canEditBookmarks: boolean;
enableSecureFilePreview: boolean;
file?: FileModel;
setAction: React.Dispatch<React.SetStateAction<GalleryAction>>;
}
@ -54,6 +55,7 @@ const ChannelBookmarkOptions = ({
canDeleteBookmarks,
canDownloadFiles,
canEditBookmarks,
enableSecureFilePreview,
file,
setAction,
}: Props) => {
@ -86,7 +88,7 @@ const ChannelBookmarkOptions = ({
return item;
}
return null;
}, [bookmark, file]);
}, [bookmark.ownerId, file, isImageFile, isVideoFile]);
const handleDelete = useCallback(async () => {
const {error} = await deleteChannelBookmark(serverUrl, bookmark.channelId, bookmark.id);
@ -141,7 +143,7 @@ const ChannelBookmarkOptions = ({
style: 'cancel',
}],
);
}, [bookmark, handleDelete]);
}, [bookmark.displayName, handleDelete, intl]);
const onEdit = useCallback(async () => {
await dismissBottomSheet();
@ -181,6 +183,7 @@ const ChannelBookmarkOptions = ({
children: (
<DownloadWithAction
action={'sharing'}
enableSecureFilePreview={enableSecureFilePreview}
galleryView={false}
item={galleryItem!}
setAction={setAction}
@ -203,7 +206,7 @@ const ChannelBookmarkOptions = ({
// do nothing
});
}
}, [bookmark, file, serverUrl, setAction]);
}, [bookmark.displayName, bookmark.id, bookmark.linkUrl, bookmark.type, enableSecureFilePreview, file, galleryItem, setAction]);
return (
<>
@ -226,7 +229,7 @@ const ChannelBookmarkOptions = ({
type='default'
/>
}
{canCopyPublicLink &&
{!enableSecureFilePreview && canCopyPublicLink &&
<OptionItem
action={onCopy}
label={intl.formatMessage({id: 'channel_bookmark.copy_option', defaultMessage: 'Copy Link'})}
@ -234,7 +237,7 @@ const ChannelBookmarkOptions = ({
type='default'
/>
}
{canShare &&
{!enableSecureFilePreview && canShare &&
<OptionItem
action={onShare}
label={intl.formatMessage({id: 'channel_bookmark.share_option', defaultMessage: 'Share'})}

View file

@ -4,8 +4,8 @@
import {useManagedConfig} from '@mattermost/react-native-emm';
import {Button} from '@rneui/base';
import React, {useCallback, useEffect, useMemo, useState} from 'react';
import {useIntl, type IntlShape} from 'react-intl';
import {Alert, StyleSheet} from 'react-native';
import {useIntl} from 'react-intl';
import {StyleSheet} from 'react-native';
import Animated from 'react-native-reanimated';
import {ITEM_HEIGHT} from '@components/option_item';
@ -14,10 +14,9 @@ import {useTheme} from '@context/theme';
import {useGalleryItem} from '@hooks/gallery';
import {TITLE_HEIGHT} from '@screens/bottom_sheet';
import {bottomSheet, dismissOverlay} from '@screens/navigation';
import {handleDeepLink, matchDeepLink} from '@utils/deep_link';
import {isDocument} from '@utils/file';
import {bottomSheetSnapPoint} from '@utils/helpers';
import {normalizeProtocol, tryOpenURL} from '@utils/url';
import {openLink} from '@utils/url/links';
import BookmarkDetails from './bookmark_details';
import BookmarkDocument from './bookmark_document';
@ -32,6 +31,7 @@ type Props = {
canDeleteBookmarks: boolean;
canDownloadFiles: boolean;
canEditBookmarks: boolean;
enableSecureFilePreview: boolean;
file?: FileModel;
galleryIdentifier: string;
index?: number;
@ -53,39 +53,8 @@ const styles = StyleSheet.create({
},
});
const openLink = async (href: string, serverUrl: string, siteURL: string, intl: IntlShape) => {
const url = normalizeProtocol(href);
if (!url) {
return;
}
const match = matchDeepLink(url, serverUrl, siteURL);
const onLinkError = () => {
Alert.alert(
intl.formatMessage({
id: 'mobile.link.error.title',
defaultMessage: 'Error',
}),
intl.formatMessage({
id: 'mobile.link.error.text',
defaultMessage: 'Unable to open the link.',
}),
);
};
if (match) {
const {error} = await handleDeepLink(match.url, intl);
if (error) {
tryOpenURL(match.url, onLinkError);
}
} else {
tryOpenURL(url, onLinkError);
}
};
const ChannelBookmark = ({
bookmark, canDeleteBookmarks, canDownloadFiles, canEditBookmarks,
bookmark, canDeleteBookmarks, canDownloadFiles, canEditBookmarks, enableSecureFilePreview,
file, galleryIdentifier, index, onPress, publicLinkEnabled, siteURL,
}: Props) => {
const theme = useTheme();
@ -94,7 +63,7 @@ const ChannelBookmark = ({
const intl = useIntl();
const [action, setAction] = useState<GalleryAction>('none');
const isDocumentFile = useMemo(() => isDocument(file), [file]);
const canCopyPublicLink = Boolean((bookmark.type === 'link' || (file?.id && publicLinkEnabled)) && managedConfig.copyAndPasteProtection !== 'true');
const canCopyPublicLink = !enableSecureFilePreview && Boolean((bookmark.type === 'link' || (file?.id && publicLinkEnabled)) && managedConfig.copyAndPasteProtection !== 'true');
const handlePress = useCallback(() => {
if (bookmark.linkUrl) {
@ -106,7 +75,7 @@ const ChannelBookmark = ({
}, [bookmark, index, intl, onPress, serverUrl, siteURL]);
const handleLongPress = useCallback(() => {
const canShare = canDownloadFiles || bookmark.type === 'link';
const canShare = !enableSecureFilePreview && (canDownloadFiles || bookmark.type === 'link');
const count = [canCopyPublicLink, canDeleteBookmarks, canShare, canEditBookmarks].
filter((e) => e).length;
@ -115,8 +84,9 @@ const ChannelBookmark = ({
bookmark={bookmark}
canCopyPublicLink={canCopyPublicLink}
canDeleteBookmarks={canDeleteBookmarks}
canDownloadFiles={canDownloadFiles}
canDownloadFiles={!enableSecureFilePreview && canDownloadFiles}
canEditBookmarks={canEditBookmarks}
enableSecureFilePreview={enableSecureFilePreview}
file={file}
setAction={setAction}
/>
@ -129,7 +99,7 @@ const ChannelBookmark = ({
theme,
closeButtonId: 'close-channel-bookmark-actions',
});
}, [bookmark, canCopyPublicLink, canDeleteBookmarks, canDownloadFiles, canEditBookmarks, file, theme]);
}, [bookmark, canCopyPublicLink, canDeleteBookmarks, canDownloadFiles, canEditBookmarks, enableSecureFilePreview, file, theme]);
const {onGestureEvent, ref} = useGalleryItem(galleryIdentifier, index || 0, handlePress);
@ -144,6 +114,7 @@ const ChannelBookmark = ({
<BookmarkDocument
bookmark={bookmark}
canDownloadFiles={canDownloadFiles}
enableSecureFilePreview={enableSecureFilePreview}
file={file!}
onLongPress={handleLongPress}
/>

View file

@ -9,9 +9,9 @@ import Animated from 'react-native-reanimated';
import {GalleryInit} from '@context/gallery';
import {useTheme} from '@context/theme';
import {useChannelBookmarkFiles} from '@hooks/files';
import {usePreventDoubleTap} from '@hooks/utils';
import ChannelBookmarkModel from '@typings/database/models/servers/channel_bookmark';
import {fileToGalleryItem, openGalleryAtIndex} from '@utils/gallery';
import {preventDoubleTap} from '@utils/tap';
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
import AddBookmark from './add_bookmark';
@ -26,6 +26,7 @@ type Props = {
canUploadFiles: boolean;
channelId: string;
currentUserId: string;
enableSecureFilePreview: boolean;
publicLinkEnabled: boolean;
showInInfo: boolean;
separator?: boolean;
@ -68,24 +69,24 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({
const ChannelBookmarks = ({
bookmarks, canAddBookmarks, canDeleteBookmarks, canDownloadFiles, canEditBookmarks, canUploadFiles,
channelId, currentUserId, publicLinkEnabled, showInInfo, separator = true,
channelId, currentUserId, enableSecureFilePreview, publicLinkEnabled, showInInfo, separator = true,
}: Props) => {
const galleryIdentifier = `${channelId}-bookmarks`;
const theme = useTheme();
const styles = getStyleSheet(theme);
const files = useChannelBookmarkFiles(bookmarks, publicLinkEnabled);
const files = useChannelBookmarkFiles(bookmarks);
const [allowEndFade, setAllowEndFade] = useState(true);
const attachmentIndex = useCallback((fileId: string) => {
return files.findIndex((file) => file.id === fileId) || 0;
}, [files]);
const handlePreviewPress = useCallback(preventDoubleTap((idx: number) => {
const handlePreviewPress = usePreventDoubleTap(useCallback((idx: number) => {
if (files.length) {
const items = files.map((f) => fileToGalleryItem(f, f.user_id));
openGalleryAtIndex(galleryIdentifier, idx, items);
}
}), [files]);
}, [files, galleryIdentifier]));
const renderItem = useCallback(({item}: ListRenderItemInfo<ChannelBookmarkModel>) => {
return (
@ -94,6 +95,7 @@ const ChannelBookmarks = ({
canDeleteBookmarks={canDeleteBookmarks}
canDownloadFiles={canDownloadFiles}
canEditBookmarks={canEditBookmarks}
enableSecureFilePreview={enableSecureFilePreview}
galleryIdentifier={galleryIdentifier}
index={item.fileId ? attachmentIndex(item.fileId) : undefined}
onPress={handlePreviewPress}
@ -101,7 +103,7 @@ const ChannelBookmarks = ({
/>
);
}, [
canDeleteBookmarks, canDownloadFiles, canEditBookmarks,
canDeleteBookmarks, canDownloadFiles, canEditBookmarks, enableSecureFilePreview,
galleryIdentifier, attachmentIndex, handlePreviewPress,
publicLinkEnabled,
]);

View file

@ -4,7 +4,8 @@
import {withDatabase, withObservables} from '@nozbe/watermelondb/react';
import {observeBookmarks, observeCanDeleteBookmarks, observeCanEditBookmarks} from '@queries/servers/channel_bookmark';
import {observeCanDownloadFiles, observeCanUploadFiles, observeConfigBooleanValue, observeCurrentUserId} from '@queries/servers/system';
import {observeCanDownloadFiles, observeCanUploadFiles, observeEnableSecureFilePreview} from '@queries/servers/security';
import {observeConfigBooleanValue, observeCurrentUserId} from '@queries/servers/system';
import ChannelBookmarks from './channel_bookmarks';
@ -19,6 +20,7 @@ const enhanced = withObservables([], ({channelId, database}: Props) => {
bookmarks: observeBookmarks(database, channelId),
canDeleteBookmarks: observeCanDeleteBookmarks(database, channelId),
canDownloadFiles: observeCanDownloadFiles(database),
enableSecureFilePreview: observeEnableSecureFilePreview(database),
canEditBookmarks: observeCanEditBookmarks(database, channelId),
canUploadFiles: observeCanUploadFiles(database),
currentUserId: observeCurrentUserId(database),

View file

@ -5,6 +5,7 @@ import {forwardRef, useImperativeHandle, type ReactNode, useCallback} from 'reac
import {useIntl} from 'react-intl';
import {alertDownloadDocumentDisabled} from '@utils/document';
import {isPdf} from '@utils/file';
export type DocumentRef = {
handlePreviewPress: () => void;
@ -12,22 +13,23 @@ export type DocumentRef = {
type DocumentProps = {
canDownloadFiles: boolean;
enableSecureFilePreview: boolean;
file: FileInfo;
children: ReactNode;
downloadAndPreviewFile: (file: FileInfo) => void;
}
const Document = forwardRef<DocumentRef, DocumentProps>(({canDownloadFiles, children, downloadAndPreviewFile, file}: DocumentProps, ref) => {
const Document = forwardRef<DocumentRef, DocumentProps>(({canDownloadFiles, children, downloadAndPreviewFile, enableSecureFilePreview, file}: DocumentProps, ref) => {
const intl = useIntl();
const handlePreviewPress = useCallback(async () => {
if (!canDownloadFiles) {
if (!canDownloadFiles || (enableSecureFilePreview && !isPdf(file))) {
alertDownloadDocumentDisabled(intl);
return;
}
downloadAndPreviewFile(file);
}, [canDownloadFiles, downloadAndPreviewFile, intl, file]);
}, [canDownloadFiles, enableSecureFilePreview, downloadAndPreviewFile, file, intl]);
useImperativeHandle(ref, () => ({
handlePreviewPress,

View file

@ -4,13 +4,15 @@
import {withDatabase, withObservables} from '@nozbe/watermelondb/react';
import Files from '@components/files/files';
import {observeCanDownloadFiles, observeConfigBooleanValue} from '@queries/servers/system';
import {observeCanDownloadFiles, observeEnableSecureFilePreview} from '@queries/servers/security';
import {observeConfigBooleanValue} from '@queries/servers/system';
import type {WithDatabaseArgs} from '@typings/database/database';
const enhance = withObservables([], ({database}: WithDatabaseArgs) => {
return {
canDownloadFiles: observeCanDownloadFiles(database),
enableSecureFilePreview: observeEnableSecureFilePreview(database),
publicLinkEnabled: observeConfigBooleanValue(database, 'EnablePublicLink'),
};
});

View file

@ -15,7 +15,7 @@ import Video, {type OnLoadData, type OnProgressData, type OnVideoErrorData, type
import {useTheme} from '@context/theme';
import {useDownloadFileAndPreview} from '@hooks/files';
import useThrottled from '@hooks/throttled';
import {alertDownloadDocumentDisabled} from '@utils/document';
import {alertDownloadDocumentDisabled, alertOnlyPDFSupported} from '@utils/document';
import {logDebug} from '@utils/log';
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
import {typography} from '@utils/typography';
@ -27,6 +27,7 @@ import ProgressBar from '../progress_bar';
type Props = {
file: FileInfo;
canDownloadFiles: boolean;
enableSecureFilePreview: boolean;
}
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({
@ -65,7 +66,7 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({
},
}));
const AudioFile = ({file, canDownloadFiles}: Props) => {
const AudioFile = ({file, canDownloadFiles, enableSecureFilePreview}: Props) => {
const intl = useIntl();
const theme = useTheme();
const style = getStyleSheet(theme);
@ -93,20 +94,25 @@ const AudioFile = ({file, canDownloadFiles}: Props) => {
const source = useMemo(() => ({uri: file.uri}), [file.uri]);
const {toggleDownloadAndPreview} = useDownloadFileAndPreview();
const {toggleDownloadAndPreview} = useDownloadFileAndPreview(enableSecureFilePreview);
const onPlayPress = () => {
setHasPaused(!hasPaused);
};
const onDownloadPress = useCallback(async () => {
if (enableSecureFilePreview) {
alertOnlyPDFSupported(intl);
return;
}
if (!canDownloadFiles) {
alertDownloadDocumentDisabled(intl);
return;
}
toggleDownloadAndPreview(file);
}, [canDownloadFiles, intl, file, toggleDownloadAndPreview]);
}, [enableSecureFilePreview, canDownloadFiles, toggleDownloadAndPreview, file, intl]);
const loadTimeInMinutes = useCallback((timeInSeconds: number) => {
const minutes = Math.floor(timeInSeconds / 60);
@ -209,6 +215,7 @@ const AudioFile = ({file, canDownloadFiles}: Props) => {
<Text style={style.timerText}>{timeInMinutes}</Text>
{!enableSecureFilePreview &&
<TouchableOpacity
onPress={onDownloadPress}
>
@ -218,6 +225,7 @@ const AudioFile = ({file, canDownloadFiles}: Props) => {
style={style.downloadIcon}
/>
</TouchableOpacity>
}
</View>
</TouchableWithoutFeedback>
);

View file

@ -19,6 +19,7 @@ type DocumentFileProps = {
backgroundColor?: string;
disabled?: boolean;
canDownloadFiles: boolean;
enableSecureFilePreview: boolean;
file: FileInfo;
}
@ -32,10 +33,10 @@ const styles = StyleSheet.create({
},
});
const DocumentFile = forwardRef<DocumentRef, DocumentFileProps>(({backgroundColor, canDownloadFiles, disabled = false, file}: DocumentFileProps, ref) => {
const DocumentFile = forwardRef<DocumentRef, DocumentFileProps>(({backgroundColor, canDownloadFiles, disabled = false, enableSecureFilePreview, file}: DocumentFileProps, ref) => {
const theme = useTheme();
const document = useRef<DocumentRef>(null);
const {downloading, progress, toggleDownloadAndPreview} = useDownloadFileAndPreview();
const {downloading, progress, toggleDownloadAndPreview} = useDownloadFileAndPreview(enableSecureFilePreview);
const handlePreviewPress = async () => {
document.current?.handlePreviewPress();
@ -70,6 +71,7 @@ const DocumentFile = forwardRef<DocumentRef, DocumentFileProps>(({backgroundColo
return (
<Document
canDownloadFiles={canDownloadFiles}
enableSecureFilePreview={enableSecureFilePreview}
file={file}
downloadAndPreviewFile={toggleDownloadAndPreview}
ref={document}

View file

@ -24,6 +24,7 @@ import type {DocumentRef} from '@components/document';
type FileProps = {
canDownloadFiles: boolean;
enableSecureFilePreview: boolean;
file: FileInfo;
galleryIdentifier: string;
index: number;
@ -74,6 +75,7 @@ const File = ({
asCard = false,
canDownloadFiles,
channelName,
enableSecureFilePreview,
file,
galleryIdentifier,
inViewPort = false,
@ -205,6 +207,7 @@ const File = ({
ref={document}
canDownloadFiles={canDownloadFiles}
disabled={isPressDisabled}
enableSecureFilePreview={enableSecureFilePreview}
file={file}
/>
</View>
@ -238,6 +241,7 @@ const File = ({
<AudioFile
file={file}
canDownloadFiles={canDownloadFiles}
enableSecureFilePreview={enableSecureFilePreview}
/>
</Animated.View>
);

View file

@ -60,6 +60,7 @@ jest.mocked(File).mockImplementation((props) => (
<Text testID={`${props.file.id}-nonVisibleImagesCount`}>{String(props.nonVisibleImagesCount)}</Text>
<Text testID={`${props.file.id}-wrapperWidth`}>{props.wrapperWidth}</Text>
<Text testID={`${props.file.id}-inViewPort`}>{String(props.inViewPort)}</Text>
<Text testID={`${props.file.id}-enableSecureFilePreview`}>{String(props.enableSecureFilePreview)}</Text>
<TouchableOpacity
testID={`${props.file.id}-onPress`}
onPress={() => props.onPress(props.index)}
@ -74,6 +75,7 @@ jest.mocked(File).mockImplementation((props) => (
function getBaseProps(): ComponentProps<typeof Files> {
return {
canDownloadFiles: true,
enableSecureFilePreview: false,
failed: false,
filesInfo: [],
isReplyPost: false,
@ -170,16 +172,40 @@ describe('Files', () => {
const baseProps = getBaseProps();
baseProps.filesInfo = filesInfo;
baseProps.canDownloadFiles = false;
baseProps.enableSecureFilePreview = false;
const {getByTestId, rerender} = render(<Files {...baseProps}/>);
expect(getByTestId('1-canDownloadFiles')).toHaveTextContent('false');
expect(getByTestId('1-enableSecureFilePreview')).toHaveTextContent('false');
expect(getByTestId('2-canDownloadFiles')).toHaveTextContent('false');
expect(getByTestId('2-enableSecureFilePreview')).toHaveTextContent('false');
baseProps.canDownloadFiles = true;
baseProps.enableSecureFilePreview = true;
rerender(<Files {...baseProps}/>);
expect(getByTestId('1-canDownloadFiles')).toHaveTextContent('true');
expect(getByTestId('1-enableSecureFilePreview')).toHaveTextContent('true');
expect(getByTestId('2-canDownloadFiles')).toHaveTextContent('true');
expect(getByTestId('2-enableSecureFilePreview')).toHaveTextContent('true');
baseProps.canDownloadFiles = true;
baseProps.enableSecureFilePreview = true;
rerender(<Files {...baseProps}/>);
expect(getByTestId('1-canDownloadFiles')).toHaveTextContent('true');
expect(getByTestId('1-enableSecureFilePreview')).toHaveTextContent('true');
expect(getByTestId('2-canDownloadFiles')).toHaveTextContent('true');
expect(getByTestId('2-enableSecureFilePreview')).toHaveTextContent('true');
baseProps.canDownloadFiles = false;
baseProps.enableSecureFilePreview = false;
rerender(<Files {...baseProps}/>);
expect(getByTestId('1-canDownloadFiles')).toHaveTextContent('false');
expect(getByTestId('1-enableSecureFilePreview')).toHaveTextContent('false');
expect(getByTestId('2-canDownloadFiles')).toHaveTextContent('false');
expect(getByTestId('2-enableSecureFilePreview')).toHaveTextContent('false');
});
it('should set layoutWidth if provided', () => {

View file

@ -18,6 +18,7 @@ import File from './file';
type FilesProps = {
canDownloadFiles: boolean;
enableSecureFilePreview: boolean;
failed?: boolean;
filesInfo: FileInfo[];
layoutWidth?: number;
@ -50,6 +51,7 @@ const styles = StyleSheet.create({
const Files = ({
canDownloadFiles,
enableSecureFilePreview,
failed,
filesInfo,
isReplyPost,
@ -106,6 +108,7 @@ const Files = ({
galleryIdentifier={galleryIdentifier}
key={file.id}
canDownloadFiles={canDownloadFiles}
enableSecureFilePreview={enableSecureFilePreview}
file={file}
index={attachmentIndex(file.id!)}
onPress={handlePreviewPress}

View file

@ -6,7 +6,8 @@ import {of as of$, from as from$} from 'rxjs';
import {switchMap} from 'rxjs/operators';
import {queryFilesForPost} from '@queries/servers/file';
import {observeCanDownloadFiles, observeConfigBooleanValue} from '@queries/servers/system';
import {observeCanDownloadFiles, observeEnableSecureFilePreview} from '@queries/servers/security';
import {observeConfigBooleanValue} from '@queries/servers/system';
import {fileExists} from '@utils/file';
import Files from './files';
@ -44,6 +45,7 @@ const enhance = withObservables(['post'], ({database, post}: EnhanceProps) => {
return {
canDownloadFiles: observeCanDownloadFiles(database),
enableSecureFilePreview: observeEnableSecureFilePreview(database),
postId: of$(post.id),
postProps: of$(post.props),
publicLinkEnabled,

View file

@ -3,7 +3,8 @@
import {withDatabase, withObservables} from '@nozbe/watermelondb/react';
import {observeConfigBooleanValue, observeCanDownloadFiles} from '@queries/servers/system';
import {observeCanDownloadFiles, observeEnableSecureFilePreview} from '@queries/servers/security';
import {observeConfigBooleanValue} from '@queries/servers/system';
import OptionMenus from './option_menus';
@ -13,6 +14,7 @@ const enhance = withObservables([], ({database}: WithDatabaseArgs) => {
return {
canDownloadFiles: observeCanDownloadFiles(database),
enablePublicLink: observeConfigBooleanValue(database, 'EnablePublicLink'),
enableSecureFilePreview: observeEnableSecureFilePreview(database),
};
});

View file

@ -14,12 +14,14 @@ import type {GalleryAction} from '@typings/screens/gallery';
type Props = {
canDownloadFiles?: boolean;
enablePublicLink?: boolean;
enableSecureFilePreview: boolean;
fileInfo: FileInfo;
setAction: (action: GalleryAction) => void;
}
const OptionMenus = ({
canDownloadFiles,
enablePublicLink,
enableSecureFilePreview,
fileInfo,
setAction,
}: Props) => {
@ -53,7 +55,7 @@ const OptionMenus = ({
return (
<>
{canDownloadFiles &&
{(!enableSecureFilePreview && canDownloadFiles) &&
<OptionItem
key={'download'}
action={handleDownload}
@ -69,7 +71,7 @@ const OptionMenus = ({
icon={'globe'}
type='default'
/>
{enablePublicLink &&
{(!enableSecureFilePreview && enablePublicLink) &&
<OptionItem
key={'copylink'}
action={handleCopyLink}

View file

@ -24,6 +24,7 @@ const Toasts = ({
return (
<DownloadWithAction
action={action}
enableSecureFilePreview={false}
galleryView={false}
item={galleryItem}
setAction={setAction}

View file

@ -24,6 +24,7 @@ const styles = StyleSheet.create({
type Props = {
canDownloadFiles: boolean;
channelName?: string;
enableSecureFilePreview: boolean;
fileInfo: FileInfo;
index: number;
numOptions: number;
@ -38,6 +39,7 @@ const galleryIdentifier = 'search-files-location';
const FileResult = ({
canDownloadFiles,
channelName,
enableSecureFilePreview,
fileInfo,
index,
numOptions,
@ -81,6 +83,7 @@ const FileResult = ({
<File
asCard={true}
canDownloadFiles={canDownloadFiles}
enableSecureFilePreview={enableSecureFilePreview}
channelName={channelName}
file={fileInfo}
galleryIdentifier={galleryIdentifier}

View file

@ -3,7 +3,6 @@
import React, {useCallback, useMemo, useState} from 'react';
import {FlatList, type ListRenderItemInfo, type StyleProp, type ViewStyle, View} from 'react-native';
import {useSafeAreaInsets} from 'react-native-safe-area-context';
import NoResults from '@components/files_search/no_results';
import FormattedText from '@components/formatted_text';
@ -11,6 +10,7 @@ import NoResultsWithTerm from '@components/no_results_with_term';
import {useTheme} from '@context/theme';
import {useIsTablet} from '@hooks/device';
import {useImageAttachments} from '@hooks/files';
import {usePreventDoubleTap} from '@hooks/utils';
import {
getChannelNamesWithID,
getFileInfosIndexes,
@ -20,7 +20,6 @@ import {
} from '@utils/files';
import {openGalleryAtIndex} from '@utils/gallery';
import {TabTypes} from '@utils/search';
import {preventDoubleTap} from '@utils/tap';
import {makeStyleSheetFromTheme} from '@utils/theme';
import {typography} from '@utils/typography';
@ -41,6 +40,7 @@ const getStyles = makeStyleSheetFromTheme((theme: Theme) => ({
type Props = {
canDownloadFiles: boolean;
enableSecureFilePreview: boolean;
fileChannels: ChannelModel[];
fileInfos: FileInfo[];
paddingTop: StyleProp<ViewStyle>;
@ -57,6 +57,7 @@ const Separator = () => <View style={separatorStyle}/>;
const FileResults = ({
canDownloadFiles,
enableSecureFilePreview,
fileChannels,
fileInfos,
paddingTop,
@ -67,14 +68,13 @@ const FileResults = ({
}: Props) => {
const theme = useTheme();
const styles = getStyles(theme);
const insets = useSafeAreaInsets();
const isTablet = useIsTablet();
const [action, setAction] = useState<GalleryAction>('none');
const [lastViewedFileInfo, setLastViewedFileInfo] = useState<FileInfo | undefined>(undefined);
const containerStyle = useMemo(() => ([paddingTop, {flexGrow: 1}]), [paddingTop]);
const numOptions = getNumberFileMenuOptions(canDownloadFiles, publicLinkEnabled);
const numOptions = getNumberFileMenuOptions(canDownloadFiles, enableSecureFilePreview, publicLinkEnabled);
const {images: imageAttachments, nonImages: nonImageAttachments} = useImageAttachments(fileInfos);
const filesForGallery = useMemo(() => imageAttachments.concat(nonImageAttachments), [imageAttachments, nonImageAttachments]);
@ -84,14 +84,14 @@ const FileResults = ({
const fileInfosIndexes = useMemo(() => getFileInfosIndexes(orderedFileInfos), [orderedFileInfos]);
const orderedGalleryItems = useMemo(() => getOrderedGalleryItems(orderedFileInfos), [orderedFileInfos]);
const onPreviewPress = useCallback(preventDoubleTap((idx: number) => {
const onPreviewPress = usePreventDoubleTap(useCallback((idx: number) => {
openGalleryAtIndex(galleryIdentifier, idx, orderedGalleryItems);
}), [orderedGalleryItems]);
}, [orderedGalleryItems]));
const updateFileForGallery = (idx: number, file: FileInfo) => {
const updateFileForGallery = useCallback((idx: number, file: FileInfo) => {
'worklet';
orderedFileInfos[idx] = file;
};
}, [orderedFileInfos]);
const onOptionsPress = useCallback((fInfo: FileInfo) => {
setLastViewedFileInfo(fInfo);
@ -104,7 +104,7 @@ const FileResults = ({
theme,
});
}
}, [insets, isTablet, numOptions, theme]);
}, [isTablet, numOptions, theme]);
const renderItem = useCallback(({item}: ListRenderItemInfo<FileInfo>) => {
let channelName: string | undefined;
@ -115,6 +115,7 @@ const FileResults = ({
return (
<FileResult
canDownloadFiles={canDownloadFiles}
enableSecureFilePreview={enableSecureFilePreview}
channelName={channelName}
fileInfo={item}
index={fileInfosIndexes[item.id!] || 0}
@ -127,13 +128,15 @@ const FileResults = ({
/>
);
}, [
(orderedFileInfos.length === 1) && orderedFileInfos[0].mime_type,
canDownloadFiles,
enableSecureFilePreview,
channelNames,
fileInfosIndexes,
onPreviewPress,
onOptionsPress,
numOptions,
isChannelFiles,
updateFileForGallery,
]);
const noResults = useMemo(() => {
@ -148,7 +151,7 @@ const FileResults = ({
type={TabTypes.FILES}
/>
);
}, [searchValue]);
}, [isChannelFiles, isFilterEnabled, searchValue]);
return (
<>
@ -180,11 +183,13 @@ const FileResults = ({
showsVerticalScrollIndicator={true}
testID='search_results.post_list.flat_list'
/>
{!enableSecureFilePreview &&
<Toasts
action={action}
fileInfo={lastViewedFileInfo}
setAction={setAction}
/>
}
</>
);
};

View file

@ -5,7 +5,7 @@ import {useManagedConfig} from '@mattermost/react-native-emm';
import Clipboard from '@react-native-clipboard/clipboard';
import React, {useCallback, useMemo, useRef, useState} from 'react';
import {useIntl} from 'react-intl';
import {Alert, Platform, type StyleProp, Text, type TextStyle, TouchableWithoutFeedback, View} from 'react-native';
import {Platform, type StyleProp, Text, type TextStyle, TouchableWithoutFeedback, View} from 'react-native';
import Animated from 'react-native-reanimated';
import {SvgUri} from 'react-native-svg';
import parseUrl from 'url-parse';
@ -30,6 +30,7 @@ import {getMarkdownImageSize, removeImageProxyForKey} from '@utils/markdown';
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
import {secureGetFromRecord} from '@utils/types';
import {normalizeProtocol, safeDecodeURIComponent, tryOpenURL} from '@utils/url';
import {onOpenLinkError} from '@utils/url/links';
import type {GalleryItemType} from '@typings/screens/gallery';
@ -130,22 +131,9 @@ const MarkdownImage = ({
if (linkDestination) {
const url = normalizeProtocol(linkDestination);
const onError = () => {
Alert.alert(
intl.formatMessage({
id: 'mobile.link.error.title',
defaultMessage: 'Error',
}),
intl.formatMessage({
id: 'mobile.link.error.text',
defaultMessage: 'Unable to open the link.',
}),
);
};
tryOpenURL(url, onError);
tryOpenURL(url, () => onOpenLinkError(intl));
}
}, [linkDestination]);
}, [intl, linkDestination]);
const handleLinkLongPress = useCallback(() => {
if (managedConfig?.copyAndPasteProtection !== 'true') {

View file

@ -5,17 +5,16 @@ import {useManagedConfig} from '@mattermost/react-native-emm';
import Clipboard from '@react-native-clipboard/clipboard';
import React, {Children, type ReactElement, useCallback} from 'react';
import {useIntl, defineMessages} from 'react-intl';
import {Alert, StyleSheet, Text, View} from 'react-native';
import {StyleSheet, Text, View} from 'react-native';
import urlParse from 'url-parse';
import SlideUpPanelItem, {ITEM_HEIGHT} from '@components/slide_up_panel_item';
import {useServerUrl} from '@context/server';
import {useTheme} from '@context/theme';
import {usePreventDoubleTap} from '@hooks/utils';
import {bottomSheet, dismissBottomSheet} from '@screens/navigation';
import {handleDeepLink, matchDeepLink} from '@utils/deep_link';
import {bottomSheetSnapPoint, isEmail} from '@utils/helpers';
import {preventDoubleTap} from '@utils/tap';
import {normalizeProtocol, tryOpenURL} from '@utils/url';
import {openLink} from '@utils/url/links';
type MarkdownLinkProps = {
children: ReactElement;
@ -61,39 +60,9 @@ const MarkdownLink = ({children, experimentalNormalizeMarkdownLinks, href, siteU
const serverUrl = useServerUrl();
const theme = useTheme();
const {formatMessage} = intl;
const handlePress = useCallback(preventDoubleTap(async () => {
const url = normalizeProtocol(href);
if (!url) {
return;
}
const onError = () => {
Alert.alert(
formatMessage({
id: 'mobile.link.error.title',
defaultMessage: 'Error',
}),
formatMessage({
id: 'mobile.link.error.text',
defaultMessage: 'Unable to open the link.',
}),
);
};
const match = matchDeepLink(url, serverUrl, siteURL);
if (match) {
const {error} = await handleDeepLink(match.url, intl);
if (error) {
tryOpenURL(match.url, onError);
}
} else {
tryOpenURL(url, onError);
}
}), [href, intl.locale, serverUrl, siteURL]);
const handlePress = usePreventDoubleTap(useCallback(() => {
openLink(href, serverUrl, siteURL, intl);
}, [href, intl, serverUrl, siteURL]));
const parseChildren = useCallback(() => {
return Children.map(children, (child: ReactElement) => {
@ -102,7 +71,7 @@ const MarkdownLink = ({children, experimentalNormalizeMarkdownLinks, href, siteU
}
const {props, ...otherChildProps} = child;
// eslint-disable-next-line react/prop-types
const {literal, ...otherProps} = props;
const nextProps = {

View file

@ -5,7 +5,8 @@ import {withDatabase, withObservables} from '@nozbe/watermelondb/react';
import React from 'react';
import {DEFAULT_SERVER_MAX_FILE_SIZE} from '@constants/post_draft';
import {observeCanUploadFiles, observeConfigIntValue, observeMaxFileCount} from '@queries/servers/system';
import {observeCanUploadFiles} from '@queries/servers/security';
import {observeConfigIntValue, observeMaxFileCount} from '@queries/servers/system';
import DraftHandler from './draft_handler';

View file

@ -5,7 +5,8 @@ import {withDatabase, withObservables} from '@nozbe/watermelondb/react';
import React from 'react';
import {observeIsPostPriorityEnabled} from '@queries/servers/post';
import {observeCanUploadFiles, observeMaxFileCount} from '@queries/servers/system';
import {observeCanUploadFiles} from '@queries/servers/security';
import {observeMaxFileCount} from '@queries/servers/system';
import QuickActions from './quick_actions';

View file

@ -2,12 +2,13 @@
// See LICENSE.txt for license information.
import {Image} from 'expo-image';
import React from 'react';
import React, {useCallback} from 'react';
import {useIntl} from 'react-intl';
import {Alert, Text, View} from 'react-native';
import {Text, View} from 'react-native';
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
import {tryOpenURL} from '@utils/url';
import {onOpenLinkError} from '@utils/url/links';
type Props = {
icon?: string;
@ -39,24 +40,15 @@ const AttachmentAuthor = ({icon, link, name, theme}: Props) => {
const intl = useIntl();
const style = getStyleSheet(theme);
const openLink = () => {
const openLink = useCallback(() => {
if (link) {
const onError = () => {
Alert.alert(
intl.formatMessage({
id: 'mobile.link.error.title',
defaultMessage: 'Error',
}),
intl.formatMessage({
id: 'mobile.link.error.text',
defaultMessage: 'Unable to open the link.',
}),
);
onOpenLinkError(intl);
};
tryOpenURL(link, onError);
}
};
}, [intl, link]);
return (
<View style={style.container}>

View file

@ -1,13 +1,14 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import React, {useCallback} from 'react';
import {useIntl} from 'react-intl';
import {Alert, Text, View} from 'react-native';
import {Text, View} from 'react-native';
import Markdown from '@components/markdown';
import {makeStyleSheetFromTheme} from '@utils/theme';
import {tryOpenURL} from '@utils/url';
import {onOpenLinkError} from '@utils/url/links';
import type {AvailableScreens} from '@typings/screens/navigation';
@ -41,24 +42,15 @@ const AttachmentTitle = ({channelId, link, location, theme, value}: Props) => {
const intl = useIntl();
const style = getStyleSheet(theme);
const openLink = () => {
const openLink = useCallback(() => {
if (link) {
const onError = () => {
Alert.alert(
intl.formatMessage({
id: 'mobile.link.error.title',
defaultMessage: 'Error',
}),
intl.formatMessage({
id: 'mobile.link.error.text',
defaultMessage: 'Unable to open the link.',
}),
);
onOpenLinkError(intl);
};
tryOpenURL(link, onError);
}
};
}, [intl, link]);
let title;
if (link) {

View file

@ -1,12 +1,13 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import React, {useCallback} from 'react';
import {useIntl} from 'react-intl';
import {Alert, Text, TouchableOpacity, View} from 'react-native';
import {Text, TouchableOpacity, View} from 'react-native';
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
import {tryOpenURL} from '@utils/url';
import {onOpenLinkError} from '@utils/url/links';
import OpengraphImage from './opengraph_image';
@ -75,22 +76,13 @@ const Opengraph = ({isReplyPost, layoutWidth, location, metadata, postId, showLi
openGraphData.images.length &&
metadata?.images);
const goToLink = () => {
const goToLink = useCallback(() => {
const onError = () => {
Alert.alert(
intl.formatMessage({
id: 'mobile.link.error.title',
defaultMessage: 'Error',
}),
intl.formatMessage({
id: 'mobile.link.error.text',
defaultMessage: 'Unable to open the link.',
}),
);
onOpenLinkError(intl);
};
tryOpenURL(link, onError);
};
}, [intl, link]);
let siteName;
if (openGraphData.site_name) {

View file

@ -4,12 +4,13 @@
import {ImageBackground} from 'expo-image';
import React, {useCallback} from 'react';
import {useIntl} from 'react-intl';
import {Alert, StyleSheet, TouchableOpacity} from 'react-native';
import {StyleSheet, TouchableOpacity} from 'react-native';
import {useIsTablet} from '@hooks/device';
import {calculateDimensions, getViewPortWidth} from '@utils/images';
import {changeOpacity} from '@utils/theme';
import {getYouTubeVideoId, tryOpenURL} from '@utils/url';
import {onOpenLinkError} from '@utils/url/links';
import YouTubeLogo from './youtube_logo';
@ -62,20 +63,11 @@ const YouTube = ({isReplyPost, layoutWidth, metadata}: YouTubeProps) => {
}
const onError = () => {
Alert.alert(
intl.formatMessage({
id: 'mobile.link.error.title',
defaultMessage: 'Error',
}),
intl.formatMessage({
id: 'mobile.link.error.text',
defaultMessage: 'Unable to open the link.',
}),
);
onOpenLinkError(intl);
};
tryOpenURL(link, onError);
}, [link, intl.locale]);
}, [link, intl]);
let imgUrl;
if (metadata?.images) {

View file

@ -8,6 +8,7 @@ export default keyMirror({
CHANNEL_ARCHIVED: null,
CHANNEL_SWITCH: null,
CLOSE_BOTTOM_SHEET: null,
CLOSE_GALLERY: null,
CONFIG_CHANGED: null,
FREEZE_SCREEN: null,
GALLERY_ACTIONS: null,

View file

@ -1,16 +1,34 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
export default {
SKU_SHORT_NAME: {
E10: 'E10',
E20: 'E20',
Starter: 'starter',
Professional: 'professional',
Enterprise: 'enterprise',
EnterpriseAdvanced: 'advanced',
},
SelfHostedProducts: {
STARTER: 'starter',
},
export const SKU_SHORT_NAME = {
E10: 'E10',
E20: 'E20',
Starter: 'starter',
Professional: 'professional',
Enterprise: 'enterprise',
EnterpriseAdvanced: 'advanced',
};
export const SelfHostedProducts = {
STARTER: 'starter',
};
export const TIER = {
ProfessionalTier: 10,
EnterpriseTier: 20,
EnterpriseAdvancedTier: 30,
};
export const LicenseSkuTier = {
[SKU_SHORT_NAME.Professional]: TIER.ProfessionalTier,
[SKU_SHORT_NAME.Enterprise]: TIER.EnterpriseTier,
[SKU_SHORT_NAME.EnterpriseAdvanced]: TIER.EnterpriseAdvancedTier,
};
export default {
SKU_SHORT_NAME,
SelfHostedProducts,
TIER,
LicenseSkuTier,
};

View file

@ -11,6 +11,8 @@ export const CALL_PARTICIPANTS = 'CallParticipants';
export const CALL_HOST_CONTROLS = 'CallHostControls';
export const CHANNEL = 'Channel';
export const CHANNEL_ADD_MEMBERS = 'ChannelAddMembers';
export const CHANNEL_BANNER = 'ChannelBanner';
export const CHANNEL_BOOKMARK = 'ChannelBookmarkAddOrEdit';
export const CHANNEL_FILES = 'ChannelFiles';
export const CHANNEL_INFO = 'ChannelInfo';
export const CHANNEL_NOTIFICATION_PREFERENCES = 'ChannelNotificationPreferences';
@ -31,7 +33,9 @@ export const EMOJI_PICKER = 'EmojiPicker';
export const FIND_CHANNELS = 'FindChannels';
export const FORGOT_PASSWORD = 'ForgotPassword';
export const GALLERY = 'Gallery';
export const GENERIC_OVERLAY = 'GenericOverlay';
export const GLOBAL_DRAFTS = 'GlobalDrafts';
export const GLOBAL_DRAFTS_AND_SCHEDULED_POSTS = 'GlobalDraftsAndScheduledPosts';
export const GLOBAL_THREADS = 'GlobalThreads';
export const HOME = 'Home';
export const INTEGRATION_SELECTOR = 'IntegrationSelector';
@ -45,6 +49,7 @@ export const MANAGE_CHANNEL_MEMBERS = 'ManageChannelMembers';
export const MENTIONS = 'Mentions';
export const MFA = 'MFA';
export const ONBOARDING = 'Onboarding';
export const PDF_VIEWER = 'PdfViewer';
export const PERMALINK = 'Permalink';
export const PINNED_MESSAGES = 'PinnedMessages';
export const POST_OPTIONS = 'PostOptions';
@ -82,10 +87,6 @@ export const THREAD = 'Thread';
export const THREAD_FOLLOW_BUTTON = 'ThreadFollowButton';
export const THREAD_OPTIONS = 'ThreadOptions';
export const USER_PROFILE = 'UserProfile';
export const CHANNEL_BOOKMARK = 'ChannelBookmarkAddOrEdit';
export const GENERIC_OVERLAY = 'GenericOverlay';
export const GLOBAL_DRAFTS_AND_SCHEDULED_POSTS = 'GlobalDraftsAndScheduledPosts';
export const CHANNEL_BANNER = 'ChannelBanner';
export default {
ABOUT,
@ -98,6 +99,7 @@ export default {
CALL_HOST_CONTROLS,
CHANNEL,
CHANNEL_ADD_MEMBERS,
CHANNEL_BANNER,
CHANNEL_BOOKMARK,
CHANNEL_FILES,
CHANNEL_INFO,
@ -118,7 +120,9 @@ export default {
FIND_CHANNELS,
FORGOT_PASSWORD,
GALLERY,
GENERIC_OVERLAY,
GLOBAL_DRAFTS,
GLOBAL_DRAFTS_AND_SCHEDULED_POSTS,
GLOBAL_THREADS,
HOME,
INTEGRATION_SELECTOR,
@ -132,6 +136,7 @@ export default {
MENTIONS,
MFA,
ONBOARDING,
PDF_VIEWER,
PERMALINK,
PINNED_MESSAGES,
POST_OPTIONS,
@ -141,6 +146,7 @@ export default {
RESCHEDULE_DRAFT,
REVIEW_APP,
SAVED_MESSAGES,
SCHEDULED_POST_OPTIONS,
SEARCH,
SELECT_TEAM,
SERVER,
@ -168,10 +174,6 @@ export default {
THREAD_FOLLOW_BUTTON,
THREAD_OPTIONS,
USER_PROFILE,
GENERIC_OVERLAY,
SCHEDULED_POST_OPTIONS,
GLOBAL_DRAFTS_AND_SCHEDULED_POSTS,
CHANNEL_BANNER,
} as const;
export const MODAL_SCREENS_WITHOUT_BACK = new Set<string>([
@ -186,8 +188,9 @@ export const MODAL_SCREENS_WITHOUT_BACK = new Set<string>([
EDIT_SERVER,
FIND_CHANNELS,
GALLERY,
MANAGE_CHANNEL_MEMBERS,
INVITE,
MANAGE_CHANNEL_MEMBERS,
PDF_VIEWER,
PERMALINK,
RESCHEDULE_DRAFT,
]);

View file

@ -181,7 +181,7 @@ describe('useChannelBookmarkFiles', () => {
(buildFilePreviewUrl as jest.Mock).mockImplementation((url, id) => `${url}/files/${id}/preview`);
(getImageSize as jest.Mock).mockImplementation(() => ({width: 100, height: 100}));
const {result, waitForNextUpdate} = renderHook(() => useChannelBookmarkFiles(bookmarks, true));
const {result, waitForNextUpdate} = renderHook(() => useChannelBookmarkFiles(bookmarks));
await waitForNextUpdate();

View file

@ -12,16 +12,17 @@ import {getLocalFileInfo} from '@actions/local/file';
import {buildFilePreviewUrl, buildFileUrl, downloadFile} from '@actions/remote/file';
import {useServerUrl} from '@context/server';
import {useTheme} from '@context/theme';
import {alertDownloadFailed, alertFailedToOpenDocument} from '@utils/document';
import {alertDownloadFailed, alertFailedToOpenDocument, alertOnlyPDFSupported} from '@utils/document';
import {getFullErrorMessage, isErrorWithMessage} from '@utils/errors';
import {fileExists, getLocalFilePathFromFile, isAudio, isGif, isImage, isVideo} from '@utils/file';
import {fileExists, getLocalFilePathFromFile, isAudio, isGif, isImage, isPdf, isVideo} from '@utils/file';
import {getImageSize} from '@utils/gallery';
import {logDebug} from '@utils/log';
import {previewPdf} from '@utils/navigation';
import type {ClientResponse, ProgressPromise} from '@mattermost/react-native-network-client';
import type ChannelBookmarkModel from '@typings/database/models/servers/channel_bookmark';
const getFileInfo = async (serverUrl: string, bookmarks: ChannelBookmarkModel[], publicLinkEnabled: boolean, cb: (files: FileInfo[]) => void) => {
const getFileInfo = async (serverUrl: string, bookmarks: ChannelBookmarkModel[], cb: (files: FileInfo[]) => void) => {
const fileInfos: FileInfo[] = [];
for await (const b of bookmarks) {
if (b.fileId) {
@ -32,7 +33,7 @@ const getFileInfo = async (serverUrl: string, bookmarks: ChannelBookmarkModel[],
const videoFile = isVideo(fileInfo);
let uri;
if (imageFile || (videoFile && publicLinkEnabled)) {
if (imageFile || videoFile) {
if (fileInfo.localPath) {
uri = fileInfo.localPath;
} else {
@ -87,18 +88,18 @@ export const useImageAttachments = (filesInfo: FileInfo[]) => {
}, [filesInfo, serverUrl]);
};
export const useChannelBookmarkFiles = (bookmarks: ChannelBookmarkModel[], publicLinkEnabled: boolean) => {
export const useChannelBookmarkFiles = (bookmarks: ChannelBookmarkModel[]) => {
const serverUrl = useServerUrl();
const [files, setFiles] = useState<FileInfo[]>([]);
useEffect(() => {
getFileInfo(serverUrl, bookmarks, publicLinkEnabled, setFiles);
}, [serverUrl, bookmarks, publicLinkEnabled]);
getFileInfo(serverUrl, bookmarks, setFiles);
}, [serverUrl, bookmarks]);
return files;
};
export const useDownloadFileAndPreview = () => {
export const useDownloadFileAndPreview = (enableSecureFilePreview: boolean) => {
const serverUrl = useServerUrl();
const intl = useIntl();
const theme = useTheme();
@ -144,6 +145,15 @@ export const useDownloadFileAndPreview = () => {
path = getLocalFilePathFromFile(serverUrl, file);
}
if (enableSecureFilePreview) {
if (isPdf(file)) {
previewPdf(file, path, theme, onDonePreviewingFile);
} else {
alertOnlyPDFSupported(intl);
}
return;
}
setPreview(true);
setStatusBarColor('dark-content');
FileViewer.open(path!.replace('file://', ''), {
@ -163,7 +173,7 @@ export const useDownloadFileAndPreview = () => {
}
});
}
}, [didCancel, preview, serverUrl, intl, onDonePreviewingFile, setStatusBarColor]);
}, [didCancel, preview, enableSecureFilePreview, setStatusBarColor, onDonePreviewingFile, serverUrl, theme, intl]);
const downloadAndPreviewFile = useCallback(async (file: FileInfo) => {
setDidCancel(false);

View file

@ -0,0 +1,438 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {License} from '@constants';
import {SYSTEM_IDENTIFIERS} from '@constants/database';
import DatabaseManager from '@database/manager';
import {observeAllowPdfLinkNavigation, observeCanDownloadFiles, observeCanUploadFiles, observeEnableSecureFilePreview} from './security';
import type ServerDataOperator from '@database/operator/server_data_operator';
import type {Database} from '@nozbe/watermelondb';
describe('observeCanUploadFiles', () => {
const serverUrl = 'baseHandler.test.com';
let database: Database;
let operator: ServerDataOperator;
beforeEach(async () => {
await DatabaseManager.init([serverUrl]);
const serverDatabaseAndOperator = DatabaseManager.getServerDatabaseAndOperator(serverUrl);
database = serverDatabaseAndOperator.database;
operator = serverDatabaseAndOperator.operator;
});
afterEach(async () => {
await DatabaseManager.destroyServerDatabase(serverUrl);
});
it('should return true if no file attachment config value', (done) => {
operator.handleConfigs({configs: [{id: 'EnableMobileFileUpload', value: 'true'}], prepareRecordsOnly: false, configsToDelete: []}).then(() => {
observeCanUploadFiles(database).subscribe((data) => {
if (data === true) {
done();
} else {
done.fail();
}
});
});
}, 1500);
it('should return false if file attachment config value is false', (done) => {
operator.handleConfigs({configs: [{id: 'EnableFileAttachments', value: 'false'}, {id: 'EnableMobileFileUpload', value: 'true'}], prepareRecordsOnly: false, configsToDelete: []}).then(() => {
observeCanUploadFiles(database).subscribe((data) => {
if (data === false) {
done();
} else {
done.fail();
}
});
});
}, 1500);
it('should return true if file attachment config value is true and there is no license', (done) => {
operator.handleConfigs({configs: [{id: 'EnableFileAttachments', value: 'true'}, {id: 'EnableMobileFileUpload', value: 'false'}], prepareRecordsOnly: false, configsToDelete: []}).then(() => {
observeCanUploadFiles(database).subscribe((data) => {
if (data === true) {
done();
} else {
done.fail();
}
});
});
}, 1500);
it('should return true if file attachment config value is true and server is not licensed', (done) => {
operator.handleConfigs({configs: [{id: 'EnableFileAttachments', value: 'true'}, {id: 'EnableMobileFileUpload', value: 'false'}], prepareRecordsOnly: false, configsToDelete: []}).then(() => {
operator.handleSystem({systems: [{id: SYSTEM_IDENTIFIERS.LICENSE, value: {isLicensed: false}}], prepareRecordsOnly: false}).then(() => {
observeCanUploadFiles(database).subscribe((data) => {
if (data === true) {
done();
} else {
done.fail();
}
});
});
});
}, 1500);
it('should return true if file attachment config value is true and server is licensed, but no compliance is set', (done) => {
operator.handleConfigs({configs: [{id: 'EnableFileAttachments', value: 'true'}, {id: 'EnableMobileFileUpload', value: 'false'}], prepareRecordsOnly: false, configsToDelete: []}).then(() => {
operator.handleSystem({systems: [{id: SYSTEM_IDENTIFIERS.LICENSE, value: {IsLicensed: 'true'}}], prepareRecordsOnly: false}).then(() => {
observeCanUploadFiles(database).subscribe((data) => {
if (data === true) {
done();
} else {
done.fail();
}
});
});
});
}, 1500);
it('should return true if file attachment config value is true and server is licensed, but compliance is set to false', (done) => {
operator.handleConfigs({configs: [{id: 'EnableFileAttachments', value: 'true'}, {id: 'EnableMobileFileUpload', value: 'false'}], prepareRecordsOnly: false, configsToDelete: []}).then(() => {
operator.handleSystem({systems: [{id: SYSTEM_IDENTIFIERS.LICENSE, value: {IsLicensed: 'true', Compliance: 'false'}}], prepareRecordsOnly: false}).then(() => {
observeCanUploadFiles(database).subscribe((data) => {
if (data === true) {
done();
} else {
done.fail();
}
});
});
});
}, 1500);
it('should return true if file attachment config value is true and server is licensed and compliance is set to true, but EnableMobileFileUpload is not set', (done) => {
operator.handleConfigs({configs: [{id: 'EnableFileAttachments', value: 'true'}], prepareRecordsOnly: false, configsToDelete: []}).then(() => {
operator.handleSystem({systems: [{id: SYSTEM_IDENTIFIERS.LICENSE, value: {IsLicensed: 'true', Compliance: 'true'}}], prepareRecordsOnly: false}).then(() => {
observeCanUploadFiles(database).subscribe((data) => {
if (data === true) {
done();
} else {
done.fail();
}
});
});
});
}, 1500);
it('should return false if file attachment config value is true and server is licensed and compliance is set to true, but EnableMobileFileUpload is set to false', (done) => {
operator.handleConfigs({configs: [{id: 'EnableFileAttachments', value: 'true'}, {id: 'EnableMobileFileUpload', value: 'false'}], prepareRecordsOnly: false, configsToDelete: []}).then(() => {
operator.handleSystem({systems: [{id: SYSTEM_IDENTIFIERS.LICENSE, value: {IsLicensed: 'true', Compliance: 'true'}}], prepareRecordsOnly: false}).then(() => {
observeCanUploadFiles(database).subscribe((data) => {
if (data === false) {
done();
} else {
done.fail();
}
});
});
});
}, 1500);
it('should return true if file attachment config value is true and server is licensed and compliance is set to true, but EnableMobileFileUpload is set to true', (done) => {
operator.handleConfigs({configs: [{id: 'EnableFileAttachments', value: 'true'}, {id: 'EnableMobileFileUpload', value: 'true'}], prepareRecordsOnly: false, configsToDelete: []}).then(() => {
operator.handleSystem({systems: [{id: SYSTEM_IDENTIFIERS.LICENSE, value: {IsLicensed: 'true', Compliance: 'true'}}], prepareRecordsOnly: false}).then(() => {
observeCanUploadFiles(database).subscribe((data) => {
if (data === true) {
done();
} else {
done.fail();
}
});
});
});
}, 1500);
});
describe('observeCanDownloadFiles', () => {
const serverUrl = 'baseHandler.test.com';
let database: Database;
let operator: ServerDataOperator;
beforeEach(async () => {
await DatabaseManager.init([serverUrl]);
const serverDatabaseAndOperator = DatabaseManager.getServerDatabaseAndOperator(serverUrl);
database = serverDatabaseAndOperator.database;
operator = serverDatabaseAndOperator.operator;
});
afterEach(async () => {
await DatabaseManager.destroyServerDatabase(serverUrl);
});
it('should return true if there is no license', (done) => {
operator.handleConfigs({configs: [{id: 'EnableMobileFileDownload', value: 'false'}], prepareRecordsOnly: false, configsToDelete: []}).then(() => {
observeCanDownloadFiles(database).subscribe((data) => {
if (data === true) {
done();
} else {
done.fail();
}
});
});
}, 1500);
it('should return true if server is not licensed', (done) => {
operator.handleConfigs({configs: [{id: 'EnableMobileFileDownload', value: 'false'}], prepareRecordsOnly: false, configsToDelete: []}).then(() => {
operator.handleSystem({systems: [{id: SYSTEM_IDENTIFIERS.LICENSE, value: {isLicensed: false}}], prepareRecordsOnly: false}).then(() => {
observeCanDownloadFiles(database).subscribe((data) => {
if (data === true) {
done();
} else {
done.fail();
}
});
});
});
}, 1500);
it('should return true if server is licensed, but no compliance is set', (done) => {
operator.handleConfigs({configs: [{id: 'EnableMobileFileDownload', value: 'false'}], prepareRecordsOnly: false, configsToDelete: []}).then(() => {
operator.handleSystem({systems: [{id: SYSTEM_IDENTIFIERS.LICENSE, value: {IsLicensed: 'true'}}], prepareRecordsOnly: false}).then(() => {
observeCanDownloadFiles(database).subscribe((data) => {
if (data === true) {
done();
} else {
done.fail();
}
});
});
});
}, 1500);
it('should return true if server is licensed, but compliance is set to false', (done) => {
operator.handleConfigs({configs: [{id: 'EnableMobileFileDownload', value: 'false'}], prepareRecordsOnly: false, configsToDelete: []}).then(() => {
operator.handleSystem({systems: [{id: SYSTEM_IDENTIFIERS.LICENSE, value: {IsLicensed: 'true', Compliance: 'false'}}], prepareRecordsOnly: false}).then(() => {
observeCanDownloadFiles(database).subscribe((data) => {
if (data === true) {
done();
} else {
done.fail();
}
});
});
});
}, 1500);
it('should return true if is licensed and compliance is set to true, but EnableMobileFileDownload is not set', (done) => {
operator.handleSystem({systems: [{id: SYSTEM_IDENTIFIERS.LICENSE, value: {IsLicensed: 'true', Compliance: 'true'}}], prepareRecordsOnly: false}).then(() => {
observeCanDownloadFiles(database).subscribe((data) => {
if (data === true) {
done();
} else {
done.fail();
}
});
});
}, 1500);
it('should return false if server is licensed and compliance is set to true, but EnableMobileFileDownload is set to false', (done) => {
operator.handleConfigs({configs: [{id: 'EnableMobileFileDownload', value: 'false'}], prepareRecordsOnly: false, configsToDelete: []}).then(() => {
operator.handleSystem({systems: [{id: SYSTEM_IDENTIFIERS.LICENSE, value: {IsLicensed: 'true', Compliance: 'true'}}], prepareRecordsOnly: false}).then(() => {
observeCanDownloadFiles(database).subscribe((data) => {
if (data === false) {
done();
} else {
done.fail();
}
});
});
});
}, 1500);
it('should return true if server is licensed and compliance is set to true, but EnableMobileFileDownload is set to true', (done) => {
operator.handleConfigs({configs: [{id: 'EnableMobileFileDownload', value: 'true'}], prepareRecordsOnly: false, configsToDelete: []}).then(() => {
operator.handleSystem({systems: [{id: SYSTEM_IDENTIFIERS.LICENSE, value: {IsLicensed: 'true', Compliance: 'true'}}], prepareRecordsOnly: false}).then(() => {
observeCanDownloadFiles(database).subscribe((data) => {
if (data === true) {
done();
} else {
done.fail();
}
});
});
});
}, 1500);
});
describe('observeEnableSecureFilePreview', () => {
const serverUrl = 'baseHandler.test.com';
let database: Database;
let operator: ServerDataOperator;
beforeEach(async () => {
await DatabaseManager.init([serverUrl]);
const serverDatabaseAndOperator = DatabaseManager.getServerDatabaseAndOperator(serverUrl);
database = serverDatabaseAndOperator.database;
operator = serverDatabaseAndOperator.operator;
});
afterEach(async () => {
await DatabaseManager.destroyServerDatabase(serverUrl);
});
it('should return false if MobileEnableSecureFilePreview is false and license tier is below EnterpriseAdvanced', (done) => {
operator.handleConfigs({configs: [
{id: 'MobileEnableSecureFilePreview', value: 'false'},
{id: 'BuildEnterpriseReady', value: 'true'},
],
prepareRecordsOnly: false,
configsToDelete: []}).then(() => {
operator.handleSystem({systems: [{id: SYSTEM_IDENTIFIERS.LICENSE, value: {SkuShortName: License.SKU_SHORT_NAME.Professional, IsLicensed: 'true'}}], prepareRecordsOnly: false}).then(() => {
observeEnableSecureFilePreview(database).subscribe((data) => {
if (data === false) {
done();
}
});
});
});
}, 1500);
it('should return true if MobileEnableSecureFilePreview is true and license tier is EnterpriseAdvanced', (done) => {
operator.handleConfigs({configs: [
{id: 'MobileEnableSecureFilePreview', value: 'true'},
{id: 'BuildEnterpriseReady', value: 'true'},
],
prepareRecordsOnly: false,
configsToDelete: []}).then(() => {
operator.handleSystem({systems: [{id: SYSTEM_IDENTIFIERS.LICENSE, value: {SkuShortName: License.SKU_SHORT_NAME.EnterpriseAdvanced, IsLicensed: 'true'}}], prepareRecordsOnly: false}).then(() => {
observeEnableSecureFilePreview(database).subscribe((data) => {
console.log('data', data);
if (data === true) {
done();
}
});
});
});
}, 1500);
it('should return false if MobileEnableSecureFilePreview is false even if license tier is EnterpriseAdvanced', (done) => {
operator.handleConfigs({configs: [
{id: 'MobileEnableSecureFilePreview', value: 'false'},
{id: 'BuildEnterpriseReady', value: 'true'},
],
prepareRecordsOnly: false,
configsToDelete: []}).then(() => {
operator.handleSystem({systems: [{id: SYSTEM_IDENTIFIERS.LICENSE, value: {SkuShortName: License.SKU_SHORT_NAME.EnterpriseAdvanced, IsLicensed: 'true'}}], prepareRecordsOnly: false}).then(() => {
observeEnableSecureFilePreview(database).subscribe((data) => {
if (data === false) {
done();
} else {
done.fail();
}
});
});
});
}, 1500);
it('should return false if MobileEnableSecureFilePreview is true but license tier is below EnterpriseAdvanced', (done) => {
operator.handleConfigs({configs: [
{id: 'MobileEnableSecureFilePreview', value: 'true'},
{id: 'BuildEnterpriseReady', value: 'true'},
],
prepareRecordsOnly: false,
configsToDelete: []}).then(() => {
operator.handleSystem({systems: [{id: SYSTEM_IDENTIFIERS.LICENSE, value: {SkuShortName: License.SKU_SHORT_NAME.Professional, IsLicensed: 'true'}}], prepareRecordsOnly: false}).then(() => {
observeEnableSecureFilePreview(database).subscribe((data) => {
if (data === false) {
done();
} else {
done.fail();
}
});
});
});
}, 1500);
});
describe('observeAllowPdfLinkNavigation', () => {
const serverUrl = 'baseHandler.test.com';
let database: Database;
let operator: ServerDataOperator;
beforeEach(async () => {
await DatabaseManager.init([serverUrl]);
const serverDatabaseAndOperator = DatabaseManager.getServerDatabaseAndOperator(serverUrl);
database = serverDatabaseAndOperator.database;
operator = serverDatabaseAndOperator.operator;
});
afterEach(async () => {
await DatabaseManager.destroyServerDatabase(serverUrl);
});
it('should return false if MobileAllowPdfLinkNavigation is false and license tier is below EnterpriseAdvanced', (done) => {
operator.handleConfigs({configs: [
{id: 'MobileAllowPdfLinkNavigation', value: 'false'},
{id: 'BuildEnterpriseReady', value: 'true'},
],
prepareRecordsOnly: false,
configsToDelete: []}).then(() => {
operator.handleSystem({systems: [{id: SYSTEM_IDENTIFIERS.LICENSE, value: {SkuShortName: License.SKU_SHORT_NAME.Professional, IsLicensed: 'true'}}], prepareRecordsOnly: false}).then(() => {
observeAllowPdfLinkNavigation(database).subscribe((data) => {
if (data === false) {
done();
} else {
done.fail();
}
});
});
});
}, 1500);
it('should return true if MobileAllowPdfLinkNavigation is true and license tier is EnterpriseAdvanced', (done) => {
operator.handleConfigs({configs: [
{id: 'MobileAllowPdfLinkNavigation', value: 'true'},
{id: 'BuildEnterpriseReady', value: 'true'},
],
prepareRecordsOnly: false,
configsToDelete: []}).then(() => {
operator.handleSystem({systems: [{id: SYSTEM_IDENTIFIERS.LICENSE, value: {SkuShortName: License.SKU_SHORT_NAME.EnterpriseAdvanced, IsLicensed: 'true'}}], prepareRecordsOnly: false}).then(() => {
observeAllowPdfLinkNavigation(database).subscribe((data) => {
if (data === true) {
done();
} else {
done.fail();
}
});
});
});
}, 1500);
it('should return false if MobileAllowPdfLinkNavigation is false even if license tier is EnterpriseAdvanced', (done) => {
operator.handleConfigs({configs: [
{id: 'MobileAllowPdfLinkNavigation', value: 'false'},
{id: 'BuildEnterpriseReady', value: 'true'},
],
prepareRecordsOnly: false,
configsToDelete: []}).then(() => {
operator.handleSystem({systems: [{id: SYSTEM_IDENTIFIERS.LICENSE, value: {SkuShortName: License.SKU_SHORT_NAME.EnterpriseAdvanced, IsLicensed: 'true'}}], prepareRecordsOnly: false}).then(() => {
observeAllowPdfLinkNavigation(database).subscribe((data) => {
if (data === false) {
done();
} else {
done.fail();
}
});
});
});
}, 1500);
it('should return false if MobileAllowPdfLinkNavigation is true but license tier is below EnterpriseAdvanced', (done) => {
operator.handleConfigs({configs: [
{id: 'MobileAllowPdfLinkNavigation', value: 'true'},
{id: 'BuildEnterpriseReady', value: 'true'},
],
prepareRecordsOnly: false,
configsToDelete: []}).then(() => {
operator.handleSystem({systems: [{id: SYSTEM_IDENTIFIERS.LICENSE, value: {SkuShortName: License.SKU_SHORT_NAME.Professional, IsLicensed: 'true'}}], prepareRecordsOnly: false}).then(() => {
observeAllowPdfLinkNavigation(database).subscribe((data) => {
if (data === false) {
done();
} else {
done.fail();
}
});
});
});
}, 1500);
});

View file

@ -0,0 +1,55 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {of as of$, combineLatest} from 'rxjs';
import {switchMap, distinctUntilChanged} from 'rxjs/operators';
import {License} from '@constants';
import {observeConfigBooleanValue, observeIsMinimumLicenseTier, observeLicense} from './system';
import type {Database} from '@nozbe/watermelondb';
export const observeCanUploadFiles = (database: Database) => {
const enableFileAttachments = observeConfigBooleanValue(database, 'EnableFileAttachments', true);
const enableMobileFileUpload = observeConfigBooleanValue(database, 'EnableMobileFileUpload', true);
const license = observeLicense(database);
return combineLatest([enableFileAttachments, enableMobileFileUpload, license]).pipe(
switchMap(([efa, emfu, l]) => of$(
efa &&
(l?.IsLicensed !== 'true' || l?.Compliance !== 'true' || emfu),
)),
distinctUntilChanged(),
);
};
export const observeCanDownloadFiles = (database: Database) => {
const enableMobileFileDownload = observeConfigBooleanValue(database, 'EnableMobileFileDownload', true);
const license = observeLicense(database);
return combineLatest([enableMobileFileDownload, license]).pipe(
switchMap(([emfd, l]) => of$((l?.IsLicensed !== 'true' || l?.Compliance !== 'true' || emfd))),
distinctUntilChanged(),
);
};
export const observeEnableSecureFilePreview = (database: Database) => {
const enableSecureFilePreview = observeConfigBooleanValue(database, 'MobileEnableSecureFilePreview', false);
const isAvailable = observeIsMinimumLicenseTier(database, License.SKU_SHORT_NAME.EnterpriseAdvanced);
return combineLatest([enableSecureFilePreview, isAvailable]).pipe(
switchMap(([esfp, is]) => of$(is && esfp)),
distinctUntilChanged(),
);
};
export const observeAllowPdfLinkNavigation = (database: Database) => {
const allowPdfLinkNavigation = observeConfigBooleanValue(database, 'MobileAllowPdfLinkNavigation', false);
const isAvailable = observeIsMinimumLicenseTier(database, License.SKU_SHORT_NAME.EnterpriseAdvanced);
return combineLatest([allowPdfLinkNavigation, isAvailable]).pipe(
switchMap(([esfp, is]) => of$(is && esfp)),
distinctUntilChanged(),
);
};

View file

@ -3,10 +3,11 @@
import {Platform} from 'react-native';
import {License} from '@constants';
import {SYSTEM_IDENTIFIERS} from '@constants/database';
import DatabaseManager from '@database/manager';
import {observeCanDownloadFiles, observeCanUploadFiles, observeReportAProblemMetadata} from './system';
import {observeIsMinimumLicenseTier, observeReportAProblemMetadata} from './system';
import type ServerDataOperator from '@database/operator/server_data_operator';
import type {Database} from '@nozbe/watermelondb';
@ -18,250 +19,6 @@ jest.mock('expo-application', () => {
};
});
describe('observeCanUploadFiles', () => {
const serverUrl = 'baseHandler.test.com';
let database: Database;
let operator: ServerDataOperator;
beforeEach(async () => {
await DatabaseManager.init([serverUrl]);
const serverDatabaseAndOperator = DatabaseManager.getServerDatabaseAndOperator(serverUrl);
database = serverDatabaseAndOperator.database;
operator = serverDatabaseAndOperator.operator;
});
afterEach(async () => {
await DatabaseManager.destroyServerDatabase(serverUrl);
});
it('should return true if no file attachment config value', (done) => {
operator.handleConfigs({configs: [{id: 'EnableMobileFileUpload', value: 'true'}], prepareRecordsOnly: false, configsToDelete: []}).then(() => {
observeCanUploadFiles(database).subscribe((data) => {
if (data === true) {
done();
} else {
done.fail();
}
});
});
}, 1500);
it('should return false if file attachment config value is false', (done) => {
operator.handleConfigs({configs: [{id: 'EnableFileAttachments', value: 'false'}, {id: 'EnableMobileFileUpload', value: 'true'}], prepareRecordsOnly: false, configsToDelete: []}).then(() => {
observeCanUploadFiles(database).subscribe((data) => {
if (data === false) {
done();
} else {
done.fail();
}
});
});
}, 1500);
it('should return true if file attachment config value is true and there is no license', (done) => {
operator.handleConfigs({configs: [{id: 'EnableFileAttachments', value: 'true'}, {id: 'EnableMobileFileUpload', value: 'false'}], prepareRecordsOnly: false, configsToDelete: []}).then(() => {
observeCanUploadFiles(database).subscribe((data) => {
if (data === true) {
done();
} else {
done.fail();
}
});
});
}, 1500);
it('should return true if file attachment config value is true and server is not licensed', (done) => {
operator.handleConfigs({configs: [{id: 'EnableFileAttachments', value: 'true'}, {id: 'EnableMobileFileUpload', value: 'false'}], prepareRecordsOnly: false, configsToDelete: []}).then(() => {
operator.handleSystem({systems: [{id: SYSTEM_IDENTIFIERS.LICENSE, value: {isLicensed: false}}], prepareRecordsOnly: false}).then(() => {
observeCanUploadFiles(database).subscribe((data) => {
if (data === true) {
done();
} else {
done.fail();
}
});
});
});
}, 1500);
it('should return true if file attachment config value is true and server is licensed, but no compliance is set', (done) => {
operator.handleConfigs({configs: [{id: 'EnableFileAttachments', value: 'true'}, {id: 'EnableMobileFileUpload', value: 'false'}], prepareRecordsOnly: false, configsToDelete: []}).then(() => {
operator.handleSystem({systems: [{id: SYSTEM_IDENTIFIERS.LICENSE, value: {IsLicensed: 'true'}}], prepareRecordsOnly: false}).then(() => {
observeCanUploadFiles(database).subscribe((data) => {
if (data === true) {
done();
} else {
done.fail();
}
});
});
});
}, 1500);
it('should return true if file attachment config value is true and server is licensed, but compliance is set to false', (done) => {
operator.handleConfigs({configs: [{id: 'EnableFileAttachments', value: 'true'}, {id: 'EnableMobileFileUpload', value: 'false'}], prepareRecordsOnly: false, configsToDelete: []}).then(() => {
operator.handleSystem({systems: [{id: SYSTEM_IDENTIFIERS.LICENSE, value: {IsLicensed: 'true', Compliance: 'false'}}], prepareRecordsOnly: false}).then(() => {
observeCanUploadFiles(database).subscribe((data) => {
if (data === true) {
done();
} else {
done.fail();
}
});
});
});
}, 1500);
it('should return true if file attachment config value is true and server is licensed and compliance is set to true, but EnableMobileFileUpload is not set', (done) => {
operator.handleConfigs({configs: [{id: 'EnableFileAttachments', value: 'true'}], prepareRecordsOnly: false, configsToDelete: []}).then(() => {
operator.handleSystem({systems: [{id: SYSTEM_IDENTIFIERS.LICENSE, value: {IsLicensed: 'true', Compliance: 'true'}}], prepareRecordsOnly: false}).then(() => {
observeCanUploadFiles(database).subscribe((data) => {
if (data === true) {
done();
} else {
done.fail();
}
});
});
});
}, 1500);
it('should return false if file attachment config value is true and server is licensed and compliance is set to true, but EnableMobileFileUpload is set to false', (done) => {
operator.handleConfigs({configs: [{id: 'EnableFileAttachments', value: 'true'}, {id: 'EnableMobileFileUpload', value: 'false'}], prepareRecordsOnly: false, configsToDelete: []}).then(() => {
operator.handleSystem({systems: [{id: SYSTEM_IDENTIFIERS.LICENSE, value: {IsLicensed: 'true', Compliance: 'true'}}], prepareRecordsOnly: false}).then(() => {
observeCanUploadFiles(database).subscribe((data) => {
if (data === false) {
done();
} else {
done.fail();
}
});
});
});
}, 1500);
it('should return true if file attachment config value is true and server is licensed and compliance is set to true, but EnableMobileFileUpload is set to true', (done) => {
operator.handleConfigs({configs: [{id: 'EnableFileAttachments', value: 'true'}, {id: 'EnableMobileFileUpload', value: 'true'}], prepareRecordsOnly: false, configsToDelete: []}).then(() => {
operator.handleSystem({systems: [{id: SYSTEM_IDENTIFIERS.LICENSE, value: {IsLicensed: 'true', Compliance: 'true'}}], prepareRecordsOnly: false}).then(() => {
observeCanUploadFiles(database).subscribe((data) => {
if (data === true) {
done();
} else {
done.fail();
}
});
});
});
}, 1500);
});
describe('observeCanDownloadFiles', () => {
const serverUrl = 'baseHandler.test.com';
let database: Database;
let operator: ServerDataOperator;
beforeEach(async () => {
await DatabaseManager.init([serverUrl]);
const serverDatabaseAndOperator = DatabaseManager.getServerDatabaseAndOperator(serverUrl);
database = serverDatabaseAndOperator.database;
operator = serverDatabaseAndOperator.operator;
});
afterEach(async () => {
await DatabaseManager.destroyServerDatabase(serverUrl);
});
it('should return true if there is no license', (done) => {
operator.handleConfigs({configs: [{id: 'EnableMobileFileDownload', value: 'false'}], prepareRecordsOnly: false, configsToDelete: []}).then(() => {
observeCanDownloadFiles(database).subscribe((data) => {
if (data === true) {
done();
} else {
done.fail();
}
});
});
}, 1500);
it('should return true if server is not licensed', (done) => {
operator.handleConfigs({configs: [{id: 'EnableMobileFileDownload', value: 'false'}], prepareRecordsOnly: false, configsToDelete: []}).then(() => {
operator.handleSystem({systems: [{id: SYSTEM_IDENTIFIERS.LICENSE, value: {isLicensed: false}}], prepareRecordsOnly: false}).then(() => {
observeCanDownloadFiles(database).subscribe((data) => {
if (data === true) {
done();
} else {
done.fail();
}
});
});
});
}, 1500);
it('should return true if server is licensed, but no compliance is set', (done) => {
operator.handleConfigs({configs: [{id: 'EnableMobileFileDownload', value: 'false'}], prepareRecordsOnly: false, configsToDelete: []}).then(() => {
operator.handleSystem({systems: [{id: SYSTEM_IDENTIFIERS.LICENSE, value: {IsLicensed: 'true'}}], prepareRecordsOnly: false}).then(() => {
observeCanDownloadFiles(database).subscribe((data) => {
if (data === true) {
done();
} else {
done.fail();
}
});
});
});
}, 1500);
it('should return true if server is licensed, but compliance is set to false', (done) => {
operator.handleConfigs({configs: [{id: 'EnableMobileFileDownload', value: 'false'}], prepareRecordsOnly: false, configsToDelete: []}).then(() => {
operator.handleSystem({systems: [{id: SYSTEM_IDENTIFIERS.LICENSE, value: {IsLicensed: 'true', Compliance: 'false'}}], prepareRecordsOnly: false}).then(() => {
observeCanDownloadFiles(database).subscribe((data) => {
if (data === true) {
done();
} else {
done.fail();
}
});
});
});
}, 1500);
it('should return true if is licensed and compliance is set to true, but EnableMobileFileDownload is not set', (done) => {
operator.handleSystem({systems: [{id: SYSTEM_IDENTIFIERS.LICENSE, value: {IsLicensed: 'true', Compliance: 'true'}}], prepareRecordsOnly: false}).then(() => {
observeCanDownloadFiles(database).subscribe((data) => {
if (data === true) {
done();
} else {
done.fail();
}
});
});
}, 1500);
it('should return false if server is licensed and compliance is set to true, but EnableMobileFileDownload is set to false', (done) => {
operator.handleConfigs({configs: [{id: 'EnableMobileFileDownload', value: 'false'}], prepareRecordsOnly: false, configsToDelete: []}).then(() => {
operator.handleSystem({systems: [{id: SYSTEM_IDENTIFIERS.LICENSE, value: {IsLicensed: 'true', Compliance: 'true'}}], prepareRecordsOnly: false}).then(() => {
observeCanDownloadFiles(database).subscribe((data) => {
if (data === false) {
done();
} else {
done.fail();
}
});
});
});
}, 1500);
it('should return true if server is licensed and compliance is set to true, but EnableMobileFileDownload is set to true', (done) => {
operator.handleConfigs({configs: [{id: 'EnableMobileFileDownload', value: 'true'}], prepareRecordsOnly: false, configsToDelete: []}).then(() => {
operator.handleSystem({systems: [{id: SYSTEM_IDENTIFIERS.LICENSE, value: {IsLicensed: 'true', Compliance: 'true'}}], prepareRecordsOnly: false}).then(() => {
observeCanDownloadFiles(database).subscribe((data) => {
if (data === true) {
done();
} else {
done.fail();
}
});
});
});
}, 1500);
});
describe('observeReportAProblemMetadata', () => {
const serverUrl = 'baseHandler.test.com';
let database: Database;
@ -326,3 +83,115 @@ describe('observeReportAProblemMetadata', () => {
});
});
});
describe('observeIsMinimumLicenseTier', () => {
const serverUrl = 'baseHandler.test.com';
let database: Database;
let operator: ServerDataOperator;
beforeEach(async () => {
await DatabaseManager.init([serverUrl]);
const serverDatabaseAndOperator = DatabaseManager.getServerDatabaseAndOperator(serverUrl);
database = serverDatabaseAndOperator.database;
operator = serverDatabaseAndOperator.operator;
});
afterEach(async () => {
await DatabaseManager.destroyServerDatabase(serverUrl);
});
it('should return false if no license is present', (done) => {
operator.handleConfigs({configs: [
{id: 'BuildEnterpriseReady', value: 'true'},
],
prepareRecordsOnly: false,
configsToDelete: []}).then(() => {
observeIsMinimumLicenseTier(database, License.SKU_SHORT_NAME.Professional).subscribe((isMinimumTier) => {
expect(isMinimumTier).toBe(false);
done();
});
});
});
it('should return false if license tier is below the required tier', (done) => {
operator.handleConfigs({configs: [
{id: 'BuildEnterpriseReady', value: 'true'},
],
prepareRecordsOnly: false,
configsToDelete: []}).then(() => {
operator.handleSystem({
systems: [
{id: SYSTEM_IDENTIFIERS.LICENSE, value: {IsLicensed: 'true', SkuShortName: License.SKU_SHORT_NAME.Starter}},
],
prepareRecordsOnly: false,
}).then(() => {
observeIsMinimumLicenseTier(database, License.SKU_SHORT_NAME.Professional).subscribe((isMinimumTier) => {
expect(isMinimumTier).toBe(false);
done();
});
});
});
});
it('should return true if license tier matches the required tier', (done) => {
operator.handleConfigs({configs: [
{id: 'BuildEnterpriseReady', value: 'true'},
],
prepareRecordsOnly: false,
configsToDelete: []}).then(() => {
operator.handleSystem({
systems: [
{id: SYSTEM_IDENTIFIERS.LICENSE, value: {IsLicensed: 'true', SkuShortName: License.SKU_SHORT_NAME.Professional}},
],
prepareRecordsOnly: false,
}).then(() => {
observeIsMinimumLicenseTier(database, License.SKU_SHORT_NAME.Professional).subscribe((isMinimumTier) => {
expect(isMinimumTier).toBe(true);
done();
});
});
});
});
it('should return true if license tier is above the required tier', (done) => {
operator.handleConfigs({configs: [
{id: 'BuildEnterpriseReady', value: 'true'},
],
prepareRecordsOnly: false,
configsToDelete: []}).then(() => {
operator.handleSystem({
systems: [
{id: SYSTEM_IDENTIFIERS.LICENSE, value: {IsLicensed: 'true', SkuShortName: License.SKU_SHORT_NAME.Enterprise}},
],
prepareRecordsOnly: false,
}).then(() => {
observeIsMinimumLicenseTier(database, License.SKU_SHORT_NAME.Professional).subscribe((isMinimumTier) => {
expect(isMinimumTier).toBe(true);
done();
});
});
});
});
it('should return false if BuildEnterpriseReady is false', (done) => {
operator.handleConfigs({
configs: [
{id: 'BuildEnterpriseReady', value: 'false'},
],
prepareRecordsOnly: false,
configsToDelete: [],
}).then(() => {
operator.handleSystem({
systems: [
{id: SYSTEM_IDENTIFIERS.LICENSE, value: {IsLicensed: 'true', SkuShortName: 'Professional'}},
],
prepareRecordsOnly: false,
}).then(() => {
observeIsMinimumLicenseTier(database, 'Professional').subscribe((isMinimumTier) => {
expect(isMinimumTier).toBe(false);
done();
});
});
});
});
});

View file

@ -1,6 +1,8 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
/* eslint-disable max-lines */
import {Database, Q} from '@nozbe/watermelondb';
import {nativeApplicationVersion, nativeBuildVersion} from 'expo-application';
import {Platform} from 'react-native';
@ -541,28 +543,6 @@ export const observeLastDismissedAnnouncement = (database: Database) => {
);
};
export const observeCanUploadFiles = (database: Database) => {
const enableFileAttachments = observeConfigBooleanValue(database, 'EnableFileAttachments', true);
const enableMobileFileUpload = observeConfigBooleanValue(database, 'EnableMobileFileUpload', true);
const license = observeLicense(database);
return combineLatest([enableFileAttachments, enableMobileFileUpload, license]).pipe(
switchMap(([efa, emfu, l]) => of$(
efa &&
(l?.IsLicensed !== 'true' || l?.Compliance !== 'true' || emfu),
)),
);
};
export const observeCanDownloadFiles = (database: Database) => {
const enableMobileFileDownload = observeConfigBooleanValue(database, 'EnableMobileFileDownload', true);
const license = observeLicense(database);
return combineLatest([enableMobileFileDownload, license]).pipe(
switchMap(([emfd, l]) => of$((l?.IsLicensed !== 'true' || l?.Compliance !== 'true' || emfd))),
);
};
export const observeLastServerVersionCheck = (database: Database) => {
return querySystemValue(database, SYSTEM_IDENTIFIERS.LAST_SERVER_VERSION_CHECK).observeWithColumns(['value']).pipe(
switchMap((result) => (result.length ? result[0].observe() : of$({value: 0}))),
@ -647,3 +627,27 @@ export const observeReportAProblemMetadata = (database: Database) => {
})),
);
};
export const observeIsMinimumLicenseTier = (database: Database, shortSku: string) => {
const license = observeLicense(database);
const isEnterpriseReady = observeConfigBooleanValue(database, 'BuildEnterpriseReady', false);
return combineLatest([license, isEnterpriseReady]).pipe(
switchMap(([lic, isEnt]) => {
if (!shortSku) {
return of$(false);
}
const isLicensed = lic?.IsLicensed === 'true';
if (!isEnt || !isLicensed) {
return of$(false);
}
const tier = License.LicenseSkuTier[lic.SkuShortName];
const targetTier = License.LicenseSkuTier[shortSku] || 0;
const isMinimumTier = Boolean(tier) && Boolean(targetTier) && isEnt && isLicensed && tier >= targetTier;
return of$(isMinimumTier);
}),
distinctUntilChanged(),
);
};

View file

@ -30,6 +30,7 @@ type Props = {
channel: ChannelModel;
componentId: AvailableScreens;
canDownloadFiles: boolean;
enableSecureFilePreview: boolean;
publicLinkEnabled: boolean;
}
@ -76,6 +77,7 @@ function ChannelFiles({
channel,
componentId,
canDownloadFiles,
enableSecureFilePreview,
publicLinkEnabled,
}: Props) {
const theme = useTheme();
@ -172,6 +174,7 @@ function ChannelFiles({
{!loading &&
<FileResults
canDownloadFiles={canDownloadFiles}
enableSecureFilePreview={enableSecureFilePreview}
fileChannels={fileChannels}
fileInfos={fileInfos}
paddingTop={styles.noPaddingTop}

View file

@ -4,7 +4,8 @@
import {withDatabase, withObservables} from '@nozbe/watermelondb/react';
import {observeChannel} from '@queries/servers/channel';
import {observeCanDownloadFiles, observeConfigBooleanValue} from '@queries/servers/system';
import {observeCanDownloadFiles, observeEnableSecureFilePreview} from '@queries/servers/security';
import {observeConfigBooleanValue} from '@queries/servers/system';
import ChannelFiles from './channel_files';
@ -19,6 +20,7 @@ const enhance = withObservables(['channelId'], ({channelId, database}: Props) =>
return {
channel,
canDownloadFiles: observeCanDownloadFiles(database),
enableSecureFilePreview: observeEnableSecureFilePreview(database),
publicLinkEnabled: observeConfigBooleanValue(database, 'EnablePublicLink'),
};
});

View file

@ -2,7 +2,7 @@
// See LICENSE.txt for license information.
import React, {useCallback, useMemo, useState} from 'react';
import {useIntl} from 'react-intl';
import {defineMessages, useIntl} from 'react-intl';
import {DeviceEventEmitter, StyleSheet, Text, View} from 'react-native';
import {RectButton, TouchableWithoutFeedback} from 'react-native-gesture-handler';
import Animated from 'react-native-reanimated';
@ -10,7 +10,7 @@ import Animated from 'react-native-reanimated';
import FileIcon from '@components/files/file_icon';
import {Events, Preferences} from '@constants';
import {buttonBackgroundStyle, buttonTextStyle} from '@utils/buttonStyles';
import {isDocument} from '@utils/file';
import {isDocument, isPdf} from '@utils/file';
import {galleryItemToFileInfo} from '@utils/gallery';
import {changeOpacity} from '@utils/theme';
import {typography} from '@utils/typography';
@ -21,6 +21,7 @@ import type {GalleryAction, GalleryItemType} from '@typings/screens/gallery';
type Props = {
canDownloadFiles: boolean;
enableSecureFilePreview: boolean;
item: GalleryItemType;
onShouldHideControls: (hide: boolean) => void;
}
@ -49,19 +50,46 @@ const styles = StyleSheet.create({
},
});
const DocumentRenderer = ({canDownloadFiles, item, onShouldHideControls}: Props) => {
const messages = defineMessages({
unsupported: {
id: 'gallery.unsupported',
defaultMessage: "Preview isn't supported for this file type. Try downloading or sharing to open it in another app.",
},
openFile: {
id: 'gallery.open_file',
defaultMessage: 'Open file',
},
onlyPdf: {
id: 'gallery.only_pdf',
defaultMessage: 'Only PDF files are supported for secure file preview.',
},
});
const DocumentRenderer = ({canDownloadFiles, enableSecureFilePreview, item, onShouldHideControls}: Props) => {
const {formatMessage} = useIntl();
const file = useMemo(() => galleryItemToFileInfo(item), [item]);
const [controls, setControls] = useState(true);
const [enabled, setEnabled] = useState(true);
const isSupported = useMemo(() => isDocument(file), [file]);
const optionText = isSupported ? formatMessage({
id: 'gallery.open_file',
defaultMessage: 'Open file',
}) : formatMessage({
id: 'gallery.unsupported',
defaultMessage: "Preview isn't supported for this file type. Try downloading or sharing to open it in another app.",
});
const canOpenFile = useMemo(() => {
if (!isSupported) {
return false;
}
if (enableSecureFilePreview && isPdf(file)) {
return true;
}
return !enableSecureFilePreview && canDownloadFiles;
}, [canDownloadFiles, enableSecureFilePreview, file, isSupported]);
const optionText = useMemo(() => {
if (enableSecureFilePreview && !isPdf(file)) {
return formatMessage(messages.onlyPdf);
} else if (!isSupported) {
return formatMessage(messages.unsupported);
}
return formatMessage(messages.openFile);
}, [enableSecureFilePreview, file, formatMessage, isSupported]);
const handleHideControls = useCallback(() => {
onShouldHideControls(controls);
@ -79,6 +107,12 @@ const DocumentRenderer = ({canDownloadFiles, item, onShouldHideControls}: Props)
setEnabled(false);
}, []);
const handlePdfPreview = useCallback(() => {
if (enableSecureFilePreview && isPdf(file)) {
DeviceEventEmitter.emit(Events.CLOSE_GALLERY);
}
}, [file, enableSecureFilePreview]);
return (
<>
<TouchableWithoutFeedback onPress={handleHideControls}>
@ -97,7 +131,7 @@ const DocumentRenderer = ({canDownloadFiles, item, onShouldHideControls}: Props)
{!isSupported &&
<Text style={styles.unsupported}>{optionText}</Text>
}
{isSupported && canDownloadFiles &&
{canOpenFile &&
<View style={{marginTop: 16}}>
<RectButton
enabled={enabled}
@ -116,8 +150,10 @@ const DocumentRenderer = ({canDownloadFiles, item, onShouldHideControls}: Props)
{!enabled &&
<DownloadWithAction
action='opening'
enableSecureFilePreview={enableSecureFilePreview}
setAction={setGalleryAction}
item={item}
onDownloadSuccess={handlePdfPreview}
/>
}
</>

View file

@ -3,7 +3,7 @@
import {withDatabase, withObservables} from '@nozbe/watermelondb/react';
import {observeCanDownloadFiles} from '@queries/servers/system';
import {observeCanDownloadFiles, observeEnableSecureFilePreview} from '@queries/servers/security';
import DocumentRenderer from './document_renderer';
@ -12,6 +12,7 @@ import type {WithDatabaseArgs} from '@typings/database/database';
const enhanced = withObservables([], ({database}: WithDatabaseArgs) => {
return {
canDownloadFiles: observeCanDownloadFiles(database),
enableSecureFilePreview: observeEnableSecureFilePreview(database),
};
});

View file

@ -21,11 +21,13 @@ import ProgressBar from '@components/progress_bar';
import Toast from '@components/toast';
import {GALLERY_FOOTER_HEIGHT} from '@constants/gallery';
import {useServerUrl} from '@context/server';
import {alertFailedToOpenDocument} from '@utils/document';
import {useTheme} from '@context/theme';
import {alertFailedToOpenDocument, alertOnlyPDFSupported} from '@utils/document';
import {getFullErrorMessage} from '@utils/errors';
import {fileExists, getLocalFilePathFromFile, hasWriteStoragePermission, pathWithPrefix} from '@utils/file';
import {fileExists, getLocalFilePathFromFile, hasWriteStoragePermission, isPdf, pathWithPrefix} from '@utils/file';
import {galleryItemToFileInfo} from '@utils/gallery';
import {logDebug} from '@utils/log';
import {previewPdf} from '@utils/navigation';
import {typography} from '@utils/typography';
import type {ClientResponse, ProgressPromise} from '@mattermost/react-native-network-client';
@ -34,6 +36,7 @@ import type {GalleryAction, GalleryItemType} from '@typings/screens/gallery';
type Props = {
action: GalleryAction;
galleryView?: boolean;
enableSecureFilePreview: boolean;
item: GalleryItemType;
setAction: (action: GalleryAction) => void;
onDownloadSuccess?: (path: string) => void;
@ -70,9 +73,10 @@ const styles = StyleSheet.create({
},
});
const DownloadWithAction = ({action, item, onDownloadSuccess, setAction, galleryView = true}: Props) => {
const DownloadWithAction = ({action, enableSecureFilePreview, item, onDownloadSuccess, setAction, galleryView = true}: Props) => {
const intl = useIntl();
const serverUrl = useServerUrl();
const theme = useTheme();
const insets = useSafeAreaInsets();
const [showToast, setShowToast] = useState<boolean|undefined>();
const [error, setError] = useState('');
@ -153,14 +157,22 @@ const DownloadWithAction = ({action, item, onDownloadSuccess, setAction, gallery
if (response.data?.path) {
const path = response.data.path as string;
onDownloadSuccess?.(path);
FileViewer.open(path, {
displayName: item.name,
showAppsSuggestions: true,
showOpenWithDialog: true,
}).catch(() => {
const file = galleryItemToFileInfo(item);
alertFailedToOpenDocument(file, intl);
});
if (enableSecureFilePreview) {
if (isPdf(galleryItemToFileInfo(item))) {
previewPdf(item, path, theme);
} else {
alertOnlyPDFSupported(intl);
}
} else {
FileViewer.open(path, {
displayName: item.name,
showAppsSuggestions: true,
showOpenWithDialog: true,
}).catch(() => {
const file = galleryItemToFileInfo(item);
alertFailedToOpenDocument(file, intl);
});
}
}
setShowToast(false);
}

View file

@ -30,6 +30,7 @@ type Props = {
enablePostIconOverride: boolean;
enablePostUsernameOverride: boolean;
enablePublicLink: boolean;
enableSecureFilePreview: boolean;
hideActions: boolean;
isDirectChannel: boolean;
item: GalleryItemType;
@ -60,7 +61,7 @@ const styles = StyleSheet.create({
const Footer = ({
author, canDownloadFiles, channelName, currentUserId,
enablePostIconOverride, enablePostUsernameOverride, enablePublicLink,
enablePostIconOverride, enablePostUsernameOverride, enablePublicLink, enableSecureFilePreview,
hideActions, isDirectChannel, item, post, style, teammateNameDisplay,
hasCaptions, captionEnabled, onCaptionsPress,
}: Props) => {
@ -110,14 +111,15 @@ const Footer = ({
edges={edges}
style={[style]}
>
{['downloading', 'sharing'].includes(action) &&
{['downloading', 'sharing'].includes(action) && !enableSecureFilePreview && canDownloadFiles &&
<DownloadWithAction
action={action}
enableSecureFilePreview={enableSecureFilePreview}
item={item}
setAction={setAction}
/>
}
{action === 'copying' &&
{action === 'copying' && !enableSecureFilePreview && enablePublicLink &&
<CopyPublicLink
item={item}
setAction={setAction}
@ -141,8 +143,8 @@ const Footer = ({
{showActions &&
<Actions
disabled={action !== 'none'}
canDownloadFiles={canDownloadFiles}
enablePublicLinks={enablePublicLink && item.type !== 'avatar'}
canDownloadFiles={!enableSecureFilePreview && canDownloadFiles}
enablePublicLinks={!enableSecureFilePreview && enablePublicLink && item.type !== 'avatar'}
fileId={item.id!}
onCopyPublicLink={handleCopyLink}
onDownload={handleDownload}

View file

@ -8,7 +8,8 @@ import {switchMap} from 'rxjs/operators';
import {General} from '@constants';
import {observeChannel} from '@queries/servers/channel';
import {observePost} from '@queries/servers/post';
import {observeCanDownloadFiles, observeConfigBooleanValue, observeCurrentChannelId, observeCurrentUserId} from '@queries/servers/system';
import {observeCanDownloadFiles, observeEnableSecureFilePreview} from '@queries/servers/security';
import {observeConfigBooleanValue, observeCurrentChannelId, observeCurrentUserId} from '@queries/servers/system';
import {observeTeammateNameDisplay, observeUser} from '@queries/servers/user';
import Footer from './footer';
@ -58,6 +59,7 @@ const enhanced = withObservables(['item'], ({database, item}: FooterProps) => {
enablePostIconOverride,
enablePostUsernameOverride,
enablePublicLink,
enableSecureFilePreview: observeEnableSecureFilePreview(database),
isDirectChannel,
post,
teammateNameDisplay,

View file

@ -3,10 +3,11 @@
import RNUtils from '@mattermost/rnutils';
import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react';
import {Platform, StyleSheet, View} from 'react-native';
import {DeviceEventEmitter, Platform, StyleSheet, View} from 'react-native';
import {CaptionsEnabledContext} from '@calls/context';
import {hasCaptions} from '@calls/utils';
import {Events} from '@constants';
import useAndroidHardwareBackHandler from '@hooks/android_back_handler';
import {useIsTablet, useWindowDimensions} from '@hooks/device';
import {useGalleryControls} from '@hooks/gallery';
@ -79,12 +80,22 @@ const GalleryScreen = ({componentId, galleryIdentifier, hideActions, initialInde
requestAnimationFrame(async () => {
dismissOverlay(componentId);
});
}, [isTablet]);
}, [componentId, isTablet]);
const onIndexChange = useCallback((index: number) => {
setLocalIndex(index);
}, []);
useEffect(() => {
const listener = DeviceEventEmitter.addListener(Events.CLOSE_GALLERY, () => {
onClose();
});
return () => {
listener.remove();
};
}, [onClose]);
useAndroidHardwareBackHandler(componentId, close);
return (

View file

@ -44,6 +44,8 @@ const styles = StyleSheet.create({
});
type Props = {
canDownloadFiles: boolean;
enableSecureFilePreview: boolean;
filename: string;
height: number;
isDownloading: boolean;
@ -54,7 +56,7 @@ type Props = {
width: number;
}
const VideoError = ({filename, height, isDownloading, isRemote, onShouldHideControls, posterUri, setDownloading, width}: Props) => {
const VideoError = ({canDownloadFiles, enableSecureFilePreview, filename, height, isDownloading, isRemote, onShouldHideControls, posterUri, setDownloading, width}: Props) => {
const [hasPoster, setHasPoster] = useState(false);
const [loadPosterError, setLoadPosterError] = useState(false);
const dimensions = useWindowDimensions();
@ -93,20 +95,22 @@ const VideoError = ({filename, height, isDownloading, isRemote, onShouldHideCont
);
}
return (
<TouchableWithoutFeedback
onPress={onShouldHideControls}
style={styles.container}
>
<Animated.View style={styles.container}>
{poster}
<Text
numberOfLines={2}
style={styles.filename}
>
{filename}
</Text>
{isRemote &&
let remoteVideo;
if (isRemote) {
if (enableSecureFilePreview) {
remoteVideo = (
<View style={styles.marginTop}>
<View style={styles.marginBottom}>
<FormattedText
defaultMessage='Only PDF files can be previewed. Downloads are not allowed on this server.'
id='mobile.document_preview.only_pdf_description'
style={styles.unsupported}
/>
</View>
</View>
);
} else if (canDownloadFiles) {
remoteVideo = (
<View style={styles.marginTop}>
<View style={styles.marginBottom}>
<FormattedText
@ -123,7 +127,36 @@ const VideoError = ({filename, height, isDownloading, isRemote, onShouldHideCont
text={intl.formatMessage({id: 'video.download', defaultMessage: 'Download video'})}
/>
</View>
}
);
} else {
remoteVideo = (
<View style={styles.marginTop}>
<View style={styles.marginBottom}>
<FormattedText
defaultMessage='File downloads are disabled on this server. Please contact your System Admin for more details.'
id='mobile.downloader.disabled_description'
style={styles.unsupported}
/>
</View>
</View>
);
}
}
return (
<TouchableWithoutFeedback
onPress={onShouldHideControls}
style={styles.container}
>
<Animated.View style={styles.container}>
{poster}
<Text
numberOfLines={2}
style={styles.filename}
>
{filename}
</Text>
{isRemote && remoteVideo}
{!isRemote &&
<FormattedText
defaultMessage='An error occurred while trying to play the video.'

View file

@ -0,0 +1,17 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {withDatabase, withObservables} from '@nozbe/watermelondb/react';
import {observeEnableSecureFilePreview, observeCanDownloadFiles} from '@queries/servers/security';
import VideoRenderer from './video_renderer';
import type {WithDatabaseArgs} from '@typings/database/database';
const enhance = withObservables([], ({database}: WithDatabaseArgs) => ({
canDownloadFiles: observeCanDownloadFiles(database),
enableSecureFilePreview: observeEnableSecureFilePreview(database),
}));
export default withDatabase(enhance(VideoRenderer));

View file

@ -27,7 +27,9 @@ import VideoError from './error';
import type {ImageRendererProps} from '../image_renderer';
import type {GalleryAction} from '@typings/screens/gallery';
interface VideoRendererProps extends ImageRendererProps {
export interface VideoRendererProps extends ImageRendererProps {
canDownloadFiles: boolean;
enableSecureFilePreview: boolean;
index: number;
initialIndex: number;
onShouldHideControls: (hide: boolean) => void;
@ -46,7 +48,7 @@ const styles = StyleSheet.create({
},
});
const VideoRenderer = ({height, index, initialIndex, item, isPageActive, onShouldHideControls, width}: VideoRendererProps) => {
const VideoRenderer = ({canDownloadFiles, enableSecureFilePreview, height, index, initialIndex, item, isPageActive, onShouldHideControls, width}: VideoRendererProps) => {
const fullscreen = useSharedValue(false);
const {bottom} = useSafeAreaInsets();
const serverUrl = useServerUrl();
@ -176,6 +178,8 @@ const VideoRenderer = ({height, index, initialIndex, item, isPageActive, onShoul
/>
{hasError &&
<VideoError
canDownloadFiles={canDownloadFiles}
enableSecureFilePreview={enableSecureFilePreview}
filename={item.name}
isDownloading={downloading}
isRemote={videoUri.startsWith('http')}
@ -189,6 +193,7 @@ const VideoRenderer = ({height, index, initialIndex, item, isPageActive, onShoul
{downloading &&
<DownloadWithAction
action='external'
enableSecureFilePreview={enableSecureFilePreview}
setAction={setGalleryAction}
onDownloadSuccess={onDownloadSuccess}
item={item}

View file

@ -8,7 +8,8 @@ import {switchMap} from 'rxjs/operators';
import {queryChannelsById} from '@queries/servers/channel';
import {queryAllCustomEmojis} from '@queries/servers/custom_emoji';
import {observeConfigBooleanValue, observeCanDownloadFiles} from '@queries/servers/system';
import {observeCanDownloadFiles, observeEnableSecureFilePreview} from '@queries/servers/security';
import {observeConfigBooleanValue} from '@queries/servers/system';
import {observeCurrentUser} from '@queries/servers/user';
import {mapCustomEmojiNames} from '@utils/emoji/helpers';
import {getTimezone} from '@utils/user';
@ -33,6 +34,7 @@ const enhance = withObservables(['fileChannelIds'], ({database, fileChannelIds}:
),
fileChannels,
canDownloadFiles: observeCanDownloadFiles(database),
enableSecureFilePreview: observeEnableSecureFilePreview(database),
publicLinkEnabled: observeConfigBooleanValue(database, 'EnablePublicLink'),
};
});

View file

@ -43,6 +43,7 @@ type Props = {
canDownloadFiles: boolean;
currentTimezone: string;
customEmojiNames: string[];
enableSecureFilePreview: boolean;
fileChannels: ChannelModel[];
fileInfos: FileInfo[];
loading: boolean;
@ -59,6 +60,7 @@ const Results = ({
canDownloadFiles,
currentTimezone,
customEmojiNames,
enableSecureFilePreview,
fileChannels,
fileInfos,
loading,
@ -117,6 +119,7 @@ const Results = ({
<Freeze freeze={selectedTab !== TabTypes.FILES}>
<FileResults
canDownloadFiles={canDownloadFiles}
enableSecureFilePreview={enableSecureFilePreview}
fileChannels={fileChannels}
fileInfos={fileInfos}
paddingTop={paddingTop}

View file

@ -180,6 +180,9 @@ Navigation.setLazyComponentRegistrator((screenName) => {
case Screens.SELECT_TEAM:
screen = withServerDatabase(require('@screens/select_team').default);
break;
case Screens.PDF_VIEWER:
screen = withServerDatabase(require('@screens/pdf_viewer').default);
break;
case Screens.PERMALINK:
screen = withServerDatabase(require('@screens/permalink').default);
break;

View file

@ -0,0 +1,68 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {View, StyleSheet, Text} from 'react-native';
import CompassIcon from '@components/compass_icon';
import FormattedText from '@components/formatted_text';
import {useTheme} from '@context/theme';
import {makeStyleSheetFromTheme} from '@utils/theme';
import {typography} from '@utils/typography';
type Props = {
message?: string;
};
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({
container: {
...StyleSheet.absoluteFillObject,
justifyContent: 'center',
alignItems: 'center',
backgroundColor: theme.centerChannelBg,
zIndex: 1,
},
icon: {
marginBottom: 10,
},
title: {
...typography('Body', 100, 'Regular'),
color: theme.centerChannelColor,
marginBottom: 5,
textAlign: 'center',
},
message: {
...typography('Body', 75, 'Regular'),
color: theme.centerChannelColor,
marginBottom: 15,
textAlign: 'center',
paddingHorizontal: 20,
},
}));
const PdfLoadError = ({message}: Props) => {
const theme = useTheme();
const styles = getStyleSheet(theme);
return (
<View style={styles.container}>
<CompassIcon
name='file-pdf-outline-large'
size={52}
color={theme.errorTextColor}
style={styles.icon}
/>
<FormattedText
id='mobile.pdf_viewer.load_failed'
defaultMessage='Unable to open the PDF file.'
style={styles.title}
/>
{Boolean(message) && (
<Text style={styles.message}>
{message}
</Text>
)}
</View>
);
};
export default PdfLoadError;

View file

@ -0,0 +1,22 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {withDatabase, withObservables} from '@nozbe/watermelondb/react';
import {observeAllowPdfLinkNavigation} from '@queries/servers/security';
import {observeConfigValue} from '@queries/servers/system';
import PdfViewer from './pdf_viewer';
import type {WithDatabaseArgs} from '@typings/database/database';
type Props = WithDatabaseArgs & {
fileId: string;
}
const enhance = withObservables([], ({database}: Props) => ({
siteURL: observeConfigValue(database, 'SiteURL'),
allowPdfLinkNavigation: observeAllowPdfLinkNavigation(database),
}));
export default withDatabase(enhance(PdfViewer));

View file

@ -0,0 +1,209 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {useHardwareKeyboardEvents} from '@mattermost/hardware-keyboard';
import {forwardRef, useCallback, useImperativeHandle, useState} from 'react';
import {defineMessages, useIntl} from 'react-intl';
import {StyleSheet, TextInput, View, type Insets, type NativeSyntheticEvent, type TextInputSubmitEditingEventData} from 'react-native';
import Button from '@components/button';
import CompassIcon from '@components/compass_icon';
import FormattedText from '@components/formatted_text';
import {useTheme} from '@context/theme';
import {changeOpacity, getKeyboardAppearanceFromTheme, makeStyleSheetFromTheme} from '@utils/theme';
import {typography} from '@utils/typography';
type Props = {
setPassword: (text: string | undefined) => void;
maxAttempts?: number;
remainingAttempts?: number;
}
export type PasswordRef = {
clear: () => void;
}
const hitSlop: Insets = {top: 10, bottom: 10, left: 10, right: 10};
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({
container: {
...StyleSheet.absoluteFillObject,
justifyContent: 'center',
alignItems: 'center',
zIndex: 1,
backgroundColor: theme.centerChannelBg,
},
required: {
...typography('Body', 100, 'Regular'),
color: theme.centerChannelColor,
marginVertical: 10,
},
unlock: {
...typography('Body', 50, 'Regular'),
color: theme.centerChannelColor,
},
limit: {
...typography('Body', 100, 'Regular'),
color: theme.errorTextColor,
},
input: {
...typography('Body', 100, 'Regular'),
backgroundColor: changeOpacity(theme.centerChannelColor, 0.1),
color: theme.centerChannelColor,
borderRadius: 4,
borderWidth: 1,
borderColor: changeOpacity(theme.centerChannelColor, 0.2),
paddingHorizontal: 10,
marginTop: 10,
textAlign: 'center',
textAlignVertical: 'center',
height: 40,
width: '80%',
maxWidth: 400,
marginBottom: 10,
},
}));
const messages = defineMessages({
password: {
id: 'mobile.pdf_viewer.password',
defaultMessage: 'Password',
},
enter_password: {
id: 'mobile.pdf_viewer.enter_password',
defaultMessage: 'Please enter the password to unlock it.',
},
password_failed: {
id: 'mobile.pdf_viewer.password_failed',
defaultMessage: 'Incorrect password. You have {count} {count, plural, one {attempt} other {attempts}} remaining.',
},
password_limit_reached: {
id: 'mobile.pdf_viewer.password_limit_reached',
defaultMessage: 'Youve entered the wrong password too many times.',
},
unlock: {
id: 'mobile.pdf_viewer.unlock',
defaultMessage: 'Unlock',
},
});
const PdfPassword = forwardRef<PasswordRef, Props>(({maxAttempts, remainingAttempts, setPassword}, ref) => {
const intl = useIntl();
const theme = useTheme();
const styles = getStyleSheet(theme);
const [inputValue, setInputValue] = useState<string | undefined>(undefined);
const limitReached = (maxAttempts !== undefined && maxAttempts > 0 && remainingAttempts === 0);
const hasFailed = maxAttempts !== undefined && maxAttempts > 0 && remainingAttempts !== undefined && remainingAttempts < maxAttempts;
const disabled = inputValue === undefined || inputValue.length === 0 || limitReached;
let subtextComponent;
if (limitReached) {
subtextComponent = (
<FormattedText
id={messages.password_limit_reached.id}
defaultMessage={messages.password_limit_reached.defaultMessage}
accessibilityLiveRegion='polite'
accessible={true}
style={styles.limit}
/>
);
} else if (hasFailed) {
subtextComponent = (
<FormattedText
id={messages.password_failed.id}
defaultMessage={messages.password_failed.defaultMessage}
values={{count: remainingAttempts}}
accessibilityLiveRegion='polite'
accessible={true}
style={styles.unlock}
/>
);
} else {
subtextComponent = (
<FormattedText
id={messages.enter_password.id}
defaultMessage={messages.enter_password.defaultMessage}
accessibilityLiveRegion='polite'
accessible={true}
style={styles.unlock}
/>
);
}
useImperativeHandle(ref, () => ({
clear: () => {
setInputValue(undefined);
},
}), []);
const onSubmitEditing = useCallback((e: NativeSyntheticEvent<TextInputSubmitEditingEventData>) => {
const value = e.nativeEvent.text.trim();
if (value) {
setPassword(value);
}
}, [setPassword]);
const onPress = useCallback(() => {
setPassword(inputValue?.trim());
}, [inputValue, setPassword]);
useHardwareKeyboardEvents({onEnterPressed: onPress});
return (
<View style={styles.container}>
<CompassIcon
name='lock-outline'
size={52}
color={theme.centerChannelColor}
/>
<FormattedText
id='mobile.pdf_viewer.password_required'
defaultMessage='This document is password protected.'
style={styles.required}
/>
{subtextComponent}
<TextInput
accessible={true}
accessibilityLabel={intl.formatMessage(messages.password)}
accessibilityHint={intl.formatMessage(messages.enter_password)}
accessibilityRole='text'
autoFocus={true}
editable={!limitReached}
enablesReturnKeyAutomatically={true}
enterKeyHint='done'
keyboardAppearance={getKeyboardAppearanceFromTheme(theme)}
numberOfLines={1}
onChangeText={setInputValue}
onSubmitEditing={onSubmitEditing}
placeholder={intl.formatMessage(messages.password)}
placeholderTextColor={changeOpacity(theme.centerChannelColor, 0.5)}
returnKeyType='done'
secureTextEntry={true}
style={styles.input}
submitBehavior='submit'
testID='pdf_password_input'
textContentType='password'
value={inputValue}
/>
<Button
accessible={true}
accessibilityLabel={intl.formatMessage(messages.unlock)}
accessibilityHint={intl.formatMessage(messages.enter_password)}
accessibilityRole='button'
disabled={disabled}
emphasis={'primary'}
hitSlop={hitSlop}
iconName='key-variant'
onPress={onPress}
size='m'
testID='pdf_password_unlock'
text={intl.formatMessage(messages.unlock)}
theme={theme}
/>
</View>
);
});
PdfPassword.displayName = 'PdfPassword';
export default PdfPassword;

View file

@ -0,0 +1,166 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {
SecurePdfViewer,
type OnLinkPressedEvent,
type OnLoadErrorEvent,
type OnPasswordFailedEvent,
type OnPasswordLimitReachedEvent,
type OnPasswordRequiredEvent,
} from '@mattermost/secure-pdf-viewer';
import {deleteAsync} from 'expo-file-system';
import {useCallback, useEffect, useRef, useState} from 'react';
import {useIntl} from 'react-intl';
import {Alert} from 'react-native';
import {Navigation} from 'react-native-navigation';
import {SafeAreaView} from 'react-native-safe-area-context';
import {setFileAsBlocked} from '@actions/local/file';
import {useServerUrl} from '@context/server';
import {useTheme} from '@context/theme';
import useAndroidHardwareBackHandler from '@hooks/android_back_handler';
import useNavButtonPressed from '@hooks/navigation_button_pressed';
import SecurityManager from '@managers/security_manager';
import {dismissModal} from '@screens/navigation';
import {logError} from '@utils/log';
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
import {openLink} from '@utils/url/links';
import PdfLoadError from './error';
import PdfPassword, {type PasswordRef} from './password';
import type {AvailableScreens} from '@typings/screens/navigation';
type Props = {
allowPdfLinkNavigation: boolean;
componentId: AvailableScreens;
closeButtonId: string;
fileId: string;
filePath: string;
onDismiss?: () => void;
siteURL: string;
}
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({
flex: {
flex: 1,
},
pdfView: {
flex: 1,
backgroundColor: changeOpacity(theme.centerChannelColor, 0.1),
},
}));
const PdfViewer = ({allowPdfLinkNavigation, closeButtonId, componentId, fileId, filePath, onDismiss, siteURL}: Props) => {
const theme = useTheme();
const serverUrl = useServerUrl();
const intl = useIntl();
const styles = getStyleSheet(theme);
const [password, setPassword] = useState<string | undefined>(undefined);
const passwordRef = useRef<PasswordRef>(null);
const [maxAttempts, setMaxAttempts] = useState<number | undefined>(undefined);
const [remainingAttempts, setRemainingAttempts] = useState<number | undefined>(undefined);
const [navBarVisible, setNavBarVisible] = useState(true);
const [errorMessage, setErrorMessage] = useState<string | undefined>(undefined);
const toggleNavbar = useCallback((visible: boolean) => {
Navigation.mergeOptions(componentId, {
topBar: {
visible,
animate: true,
}});
}, [componentId]);
const onClose = useCallback(() => {
onDismiss?.();
return dismissModal({componentId});
}, [componentId, onDismiss]);
const onLinkPressed = useCallback((event: OnLinkPressedEvent) => {
openLink(event.nativeEvent.url, serverUrl, siteURL, intl);
}, [intl, serverUrl, siteURL]);
const onLinkPressedDisabled = useCallback(() => {
logError('Error opening link');
Alert.alert(
intl.formatMessage({id: 'mobile.pdf_viewer.link_disabled_title', defaultMessage: 'Link Disabled'}),
intl.formatMessage({id: 'mobile.pdf_viewer.link_disabled_detail', defaultMessage: "Opening links inside this PDF is not allowed by your organization's security settings."}),
);
}, [intl]);
const onLoad = useCallback(() => {
setMaxAttempts(undefined);
setRemainingAttempts(undefined);
}, []);
const onLoadError = useCallback((event: OnLoadErrorEvent) => {
logError('Error loading PDF', event.nativeEvent.message);
setErrorMessage(event.nativeEvent.message);
deleteAsync(filePath, {idempotent: true});
}, [filePath]);
const onPasswordFailed = useCallback((event: OnPasswordFailedEvent) => {
setRemainingAttempts(event.nativeEvent.remainingAttempts);
setPassword(undefined);
passwordRef.current?.clear();
}, []);
const onPasswordFailureLimitReached = useCallback((event: OnPasswordLimitReachedEvent) => {
passwordRef.current?.clear();
setMaxAttempts(event.nativeEvent.maxAttempts);
setRemainingAttempts(0);
setFileAsBlocked(serverUrl, fileId);
}, [serverUrl, fileId]);
const onPasswordRequired = useCallback((event: OnPasswordRequiredEvent) => {
setMaxAttempts(event.nativeEvent.maxAttempts);
setRemainingAttempts(event.nativeEvent.remainingAttempts);
}, []);
const onTap = useCallback(() => {
setNavBarVisible((prev) => !prev);
}, []);
useEffect(() => {
toggleNavbar(navBarVisible);
}, [navBarVisible, toggleNavbar]);
useNavButtonPressed(closeButtonId, componentId, onClose, [onClose]);
useAndroidHardwareBackHandler(componentId, onClose);
const promptForPassword = (maxAttempts !== undefined && maxAttempts > 0);
return (
<SafeAreaView
nativeID={SecurityManager.getShieldScreenId(componentId)}
style={styles.flex}
>
{Boolean(errorMessage) && <PdfLoadError message={errorMessage}/>}
{promptForPassword &&
<PdfPassword
maxAttempts={maxAttempts}
ref={passwordRef}
remainingAttempts={remainingAttempts}
setPassword={setPassword}
/>
}
<SecurePdfViewer
allowLinks={allowPdfLinkNavigation}
onLinkPressed={onLinkPressed}
onLinkPressedDisabled={onLinkPressedDisabled}
onLoad={onLoad}
onLoadError={onLoadError}
onPasswordFailed={onPasswordFailed}
onPasswordFailureLimitReached={onPasswordFailureLimitReached}
onPasswordRequired={onPasswordRequired}
onTap={onTap}
password={password}
source={filePath}
style={styles.pdfView}
/>
</SafeAreaView>
);
};
export default PdfViewer;

View file

@ -5,7 +5,7 @@ import Clipboard from '@react-native-clipboard/clipboard';
import {applicationId, nativeApplicationVersion, nativeBuildVersion} from 'expo-application';
import React, {useCallback, useEffect, useMemo, useState} from 'react';
import {useIntl} from 'react-intl';
import {Alert, Text, View} from 'react-native';
import {Text, View} from 'react-native';
import {getLicenseLoadMetric} from '@actions/remote/license';
import Config from '@assets/config.json';
@ -25,6 +25,7 @@ import {showSnackBar} from '@utils/snack_bar';
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
import {typography} from '@utils/typography';
import {tryOpenURL} from '@utils/url';
import {onOpenLinkError} from '@utils/url/links';
import LearnMore from './learn_more';
import Subtitle from './subtitle';
@ -139,16 +140,7 @@ const About = ({componentId, config, license}: AboutProps) => {
const openURL = useCallback((url: string) => {
const onError = () => {
Alert.alert(
intl.formatMessage({
id: 'settings.link.error.title',
defaultMessage: 'Error',
}),
intl.formatMessage({
id: 'settings.link.error.text',
defaultMessage: 'Unable to open the link.',
}),
);
onOpenLinkError(intl);
};
tryOpenURL(url, onError);

View file

@ -45,6 +45,25 @@ export function alertDownloadDocumentDisabled(intl: IntlShape) {
);
}
export function alertOnlyPDFSupported(intl: IntlShape) {
Alert.alert(
intl.formatMessage({
id: 'mobile.document_preview.only_pdf_title',
defaultMessage: 'Preview not supported',
}),
intl.formatMessage({
id: 'mobile.document_preview.only_pdf_description',
defaultMessage: 'Only PDF files can be previewed. Downloads are not allowed on this server.\n',
}),
[{
text: intl.formatMessage({
id: 'mobile.server_upgrade.button',
defaultMessage: 'OK',
}),
}],
);
}
export function alertDownloadFailed(intl: IntlShape) {
Alert.alert(
intl.formatMessage({

View file

@ -298,6 +298,21 @@ export const isDocument = (file?: FileInfo | FileModel) => {
return SUPPORTED_DOCS_FORMAT!.includes(mime);
};
export const isPdf = (file?: FileInfo | FileModel) => {
if (!file) {
return false;
}
let mime = 'mime_type' in file ? file.mime_type : file.mimeType;
if (mime && mime.includes(';')) {
mime = mime.split(';')[0];
} else if (!mime && file?.name) {
mime = lookupMimeType(file.name);
}
return mime === 'application/pdf';
};
export const isVideo = (file?: FileInfo | FileModel) => {
if (!file) {
return false;

View file

@ -50,10 +50,14 @@ describe('Files utils', () => {
};
test('getNumberFileMenuOptions', () => {
expect(getNumberFileMenuOptions(false, false)).toBe(1);
expect(getNumberFileMenuOptions(true, false)).toBe(2);
expect(getNumberFileMenuOptions(false, true)).toBe(2);
expect(getNumberFileMenuOptions(true, true)).toBe(3);
expect(getNumberFileMenuOptions(false, false, false)).toBe(1);
expect(getNumberFileMenuOptions(true, false, false)).toBe(2);
expect(getNumberFileMenuOptions(false, false, true)).toBe(2);
expect(getNumberFileMenuOptions(true, false, true)).toBe(3);
expect(getNumberFileMenuOptions(true, true, true)).toBe(1);
expect(getNumberFileMenuOptions(false, true, true)).toBe(1);
expect(getNumberFileMenuOptions(false, true, false)).toBe(1);
});
test('getChannelNamesWithID', () => {

View file

@ -5,10 +5,10 @@ import {fileToGalleryItem} from '@utils/gallery';
import type ChannelModel from '@typings/database/models/servers/channel';
export const getNumberFileMenuOptions = (canDownloadFiles: boolean, publicLinkEnabled: boolean) => {
export const getNumberFileMenuOptions = (canDownloadFiles: boolean, enableSecureFilePreview: boolean, publicLinkEnabled: boolean) => {
let numberItems = 1;
numberItems += canDownloadFiles ? 1 : 0;
numberItems += publicLinkEnabled ? 1 : 0;
numberItems += !enableSecureFilePreview && canDownloadFiles ? 1 : 0;
numberItems += !enableSecureFilePreview && publicLinkEnabled ? 1 : 0;
return numberItems;
};

View file

@ -2,11 +2,14 @@
// See LICENSE.txt for license information.
import {Alert} from 'react-native';
import {Navigation, type Options} from 'react-native-navigation';
import {Navigation, OptionsModalPresentationStyle, type Options} from 'react-native-navigation';
import CompassIcon from '@components/compass_icon';
import {Screens, ServerErrors} from '@constants';
import {showModal} from '@screens/navigation';
import {isErrorWithMessage, isServerError} from '@utils/errors';
import type {GalleryItemType} from '@typings/screens/gallery';
import type {AvailableScreens} from '@typings/screens/navigation';
import type {IntlShape} from 'react-intl';
@ -95,3 +98,27 @@ export function alertTeamAddError(error: unknown, intl: IntlShape) {
errMsg,
);
}
export function previewPdf(item: FileInfo | GalleryItemType, path: string, theme: Theme, onDismiss?: () => void) {
const closeButton = CompassIcon.getImageSourceSync('close', 24, theme.sidebarHeaderTextColor);
const closeButtonId = 'close-pdf-viewer';
const options: Options = {
modalPresentationStyle: OptionsModalPresentationStyle.currentContext,
topBar: {
visible: false,
animate: false,
leftButtons: [{
id: closeButtonId,
icon: closeButton,
testID: closeButtonId,
}],
},
};
showModal(Screens.PDF_VIEWER, item.name, {
fileId: item.id,
filePath: path,
closeButtonId,
onDismiss,
}, options);
}

109
app/utils/url/links.test.ts Normal file
View file

@ -0,0 +1,109 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {createIntl} from 'react-intl';
import {Alert} from 'react-native';
import {onOpenLinkError, openLink} from './links';
describe('onOpenLinkError', () => {
const intl = createIntl({
locale: 'en',
messages: {},
});
intl.formatMessage = jest.fn();
beforeEach(() => {
jest.clearAllMocks();
});
it('should display an alert with the correct title and message', () => {
const alertSpy = jest.spyOn(Alert, 'alert').mockImplementation(() => {});
// using jest.Mock as formatMessage has an implementation overload
// and we want to mock the implementation that returns a string
(intl.formatMessage as jest.Mock).
mockReturnValueOnce('Error').
mockReturnValueOnce('Unable to open the link.'); // Mock message
onOpenLinkError(intl);
expect(intl.formatMessage).toHaveBeenCalledTimes(2);
expect(intl.formatMessage).toHaveBeenCalledWith({
id: 'mobile.link.error.title',
defaultMessage: 'Error',
});
expect(intl.formatMessage).toHaveBeenCalledWith({
id: 'mobile.link.error.text',
defaultMessage: 'Unable to open the link.',
});
expect(alertSpy).toHaveBeenCalledWith('Error', 'Unable to open the link.');
});
});
describe('openLink', () => {
const intl = createIntl({
locale: 'en',
messages: {},
});
intl.formatMessage = jest.fn();
beforeEach(() => {
jest.clearAllMocks();
});
it('should call normalizeProtocol and return early if the URL is invalid', async () => {
const normalizeProtocolMock = jest.spyOn(require('.'), 'normalizeProtocol').mockReturnValue(null);
await openLink('', 'https://server-url.com', 'https://site-url.com', intl);
expect(normalizeProtocolMock).toHaveBeenCalledWith('');
normalizeProtocolMock.mockRestore();
});
it('should handle deep link if matchDeepLink returns a match', async () => {
const normalizeProtocolMock = jest.spyOn(require('.'), 'normalizeProtocol').mockReturnValue('https://example.com');
const matchDeepLinkMock = jest.spyOn(require('@utils/deep_link'), 'matchDeepLink').mockReturnValue({url: 'https://example.com'});
const handleDeepLinkMock = jest.spyOn(require('@utils/deep_link'), 'handleDeepLink').mockResolvedValue({error: null});
await openLink('https://example.com', 'https://server-url.com', 'https://site-url.com', intl);
expect(normalizeProtocolMock).toHaveBeenCalledWith('https://example.com');
expect(matchDeepLinkMock).toHaveBeenCalledWith('https://example.com', 'https://server-url.com', 'https://site-url.com');
expect(handleDeepLinkMock).toHaveBeenCalledWith('https://example.com', intl);
normalizeProtocolMock.mockRestore();
matchDeepLinkMock.mockRestore();
handleDeepLinkMock.mockRestore();
});
it('should call tryOpenURL with onOpenLinkError if handleDeepLink returns an error', async () => {
const normalizeProtocolMock = jest.spyOn(require('.'), 'normalizeProtocol').mockReturnValue('https://example.com');
const matchDeepLinkMock = jest.spyOn(require('@utils/deep_link'), 'matchDeepLink').mockReturnValue({url: 'https://example.com'});
const handleDeepLinkMock = jest.spyOn(require('@utils/deep_link'), 'handleDeepLink').mockResolvedValue({error: true});
const tryOpenURLMock = jest.spyOn(require('.'), 'tryOpenURL').mockImplementation(() => {});
await openLink('https://example.com', 'https://server-url.com', 'https://site-url.com', intl);
expect(tryOpenURLMock).toHaveBeenCalledWith('https://example.com', onOpenLinkError);
normalizeProtocolMock.mockRestore();
matchDeepLinkMock.mockRestore();
handleDeepLinkMock.mockRestore();
tryOpenURLMock.mockRestore();
});
it('should call tryOpenURL with onOpenLinkError if matchDeepLink does not return a match', async () => {
const normalizeProtocolMock = jest.spyOn(require('.'), 'normalizeProtocol').mockReturnValue('https://example.com');
const matchDeepLinkMock = jest.spyOn(require('@utils/deep_link'), 'matchDeepLink').mockReturnValue(null);
const tryOpenURLMock = jest.spyOn(require('.'), 'tryOpenURL').mockImplementation(() => {});
await openLink('https://example.com', 'https://server-url.com', 'https://site-url.com', intl);
expect(tryOpenURLMock).toHaveBeenCalledWith('https://example.com', onOpenLinkError);
normalizeProtocolMock.mockRestore();
matchDeepLinkMock.mockRestore();
tryOpenURLMock.mockRestore();
});
});

41
app/utils/url/links.ts Normal file
View file

@ -0,0 +1,41 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {Alert} from 'react-native';
import {handleDeepLink, matchDeepLink} from '@utils/deep_link';
import {normalizeProtocol, tryOpenURL} from '.';
import type {IntlShape} from 'react-intl';
export const onOpenLinkError = (intl: IntlShape) => {
Alert.alert(
intl.formatMessage({
id: 'mobile.link.error.title',
defaultMessage: 'Error',
}),
intl.formatMessage({
id: 'mobile.link.error.text',
defaultMessage: 'Unable to open the link.',
}),
);
};
export const openLink = async (link: string, serverUrl: string, siteURL: string, intl: IntlShape) => {
const url = normalizeProtocol(link);
if (!url) {
return;
}
const match = matchDeepLink(url, serverUrl, siteURL);
if (match) {
const {error} = await handleDeepLink(match.url, intl);
if (error) {
tryOpenURL(match.url, onOpenLinkError);
}
} else {
tryOpenURL(url, onOpenLinkError);
}
};

View file

@ -373,6 +373,7 @@
"gallery.downloading": "Downloading...",
"gallery.footer.channel_name": "Shared in {channelName}",
"gallery.image_saved": "Image saved",
"gallery.only_pdf": "Only PDF files are supported for secure file preview.",
"gallery.open_file": "Open file",
"gallery.opening": "Opening...",
"gallery.preparing": "Preparing...",
@ -650,6 +651,8 @@
"mobile.display_settings.timezone": "Timezone",
"mobile.document_preview.failed_description": "An error occurred while opening the document. Please make sure you have a {fileType} viewer installed and try again.\n",
"mobile.document_preview.failed_title": "Open Document failed",
"mobile.document_preview.only_pdf_description": "Only PDF files can be previewed. Downloads are not allowed on this server.\n",
"mobile.document_preview.only_pdf_title": "Preview not supported",
"mobile.downloader.disabled_description": "File downloads are disabled on this server. Please contact your System Admin for more details.\n",
"mobile.downloader.disabled_title": "Download disabled",
"mobile.downloader.failed_description": "An error occurred while downloading the file. Please check your internet connection and try again.\n",
@ -758,6 +761,15 @@
"mobile.open_dm.error": "We couldn't open a direct message with {displayName}. Please check your connection and try again.",
"mobile.open_gm.error": "We couldn't open a group message with those users. Please check your connection and try again.",
"mobile.participants.header": "Thread Participants",
"mobile.pdf_viewer.enter_password": "Please enter the password to unlock it.",
"mobile.pdf_viewer.link_disabled_detail": "Opening links inside this PDF is not allowed by your organization's security settings.",
"mobile.pdf_viewer.link_disabled_title": "Link Disabled",
"mobile.pdf_viewer.load_failed": "Unable to open the PDF file.",
"mobile.pdf_viewer.password": "Password",
"mobile.pdf_viewer.password_failed": "Incorrect password. You have {count} {count, plural, one {attempt} other {attempts}} remaining.",
"mobile.pdf_viewer.password_limit_reached": "Youve entered the wrong password too many times.",
"mobile.pdf_viewer.password_required": "This document is password protected.",
"mobile.pdf_viewer.unlock": "Unlock",
"mobile.permission_denied_dismiss": "Don't Allow",
"mobile.permission_denied_retry": "Settings",
"mobile.post_info.add_reaction": "Add Reaction",
@ -1175,8 +1187,6 @@
"settings.advanced.delete_data": "Delete local files",
"settings.advanced.delete_message.confirmation": "\nThis will delete all files downloaded through the app for this server. Please confirm to proceed.\n",
"settings.display": "Display",
"settings.link.error.text": "Unable to open the link.",
"settings.link.error.title": "Error",
"settings.notice_mobile_link": "mobile apps",
"settings.notice_platform_link": "server",
"settings.notice_text": "Mattermost is made possible by the open source software used in our {platform} and {mobile}.",

View file

@ -18,12 +18,12 @@
"android.debug": {
"type": "android.apk",
"binaryPath": "../android/app/build/outputs/apk/debug/app-debug.apk",
"build": "cd .. && ./node_modules/.bin/jetify && cd android && ./gradlew clean && ./gradlew assembleDebug assembleAndroidTest -DtestBuildType=debug && cd ../detox"
"build": "cd ../android && ./gradlew clean && ./gradlew assembleDebug assembleAndroidTest -DtestBuildType=debug && cd ../detox"
},
"android.release": {
"type": "android.apk",
"binaryPath": "../android/app/build/outputs/apk/release/app-release.apk",
"build": "cd .. && ./node_modules/.bin/jetify && cd android && ./gradlew clean && ./gradlew assembleRelease assembleAndroidTest -DtestBuildType=release && cd ../detox"
"build": "cd ../android && ./gradlew clean && ./gradlew assembleRelease assembleAndroidTest -DtestBuildType=release && cd ../detox"
}
},
"devices": {

View file

@ -1454,7 +1454,7 @@ PODS:
- ReactCommon/turbomodule/bridging
- ReactCommon/turbomodule/core
- Yoga
- react-native-emm (1.6.1):
- react-native-emm (1.6.2):
- DoubleConversion
- glog
- hermes-engine
@ -1498,7 +1498,7 @@ PODS:
- Yoga
- react-native-netinfo (11.4.1):
- React-Core
- react-native-network-client (1.8.2):
- react-native-network-client (1.8.3):
- Alamofire (~> 5.10.2)
- DoubleConversion
- glog
@ -2191,6 +2191,27 @@ PODS:
- SDWebImage/Core (5.19.7)
- SDWebImageSVGCoder (1.7.0):
- SDWebImage/Core (~> 5.6)
- secure-pdf-viewer (0.0.0):
- DoubleConversion
- glog
- hermes-engine
- RCT-Folly (= 2024.10.14.00)
- RCTRequired
- RCTTypeSafety
- React-Core
- React-debug
- React-Fabric
- React-featureflags
- React-graphics
- React-ImageManager
- React-NativeModulesApple
- React-RCTFabric
- React-rendererdebug
- React-utils
- ReactCodegen
- ReactCommon/turbomodule/bridging
- ReactCommon/turbomodule/core
- Yoga
- Sentry/HybridSDK (8.48.0)
- simdjson (3.9.4)
- SocketRocket (0.7.1)
@ -2318,6 +2339,7 @@ DEPENDENCIES:
- RNShare (from `../node_modules/react-native-share`)
- RNSVG (from `../node_modules/react-native-svg`)
- RNVectorIcons (from `../node_modules/react-native-vector-icons`)
- "secure-pdf-viewer (from `../node_modules/@mattermost/secure-pdf-viewer`)"
- Sentry/HybridSDK (= 8.48.0)
- "simdjson (from `../node_modules/@nozbe/simdjson`)"
- TurboLogIOSNative (from `https://github.com/larkox/react-native-turbo-log-ios-native.git`)
@ -2561,6 +2583,8 @@ EXTERNAL SOURCES:
:path: "../node_modules/react-native-svg"
RNVectorIcons:
:path: "../node_modules/react-native-vector-icons"
secure-pdf-viewer:
:path: "../node_modules/@mattermost/secure-pdf-viewer"
simdjson:
:path: "../node_modules/@nozbe/simdjson"
TurboLogIOSNative:
@ -2636,10 +2660,10 @@ SPEC CHECKSUMS:
react-native-cameraroll: e44a37bd8dd502cb4e9017b78e15bdc658dff2e6
react-native-cookies: d648ab7025833b977c0b19e142503034f5f29411
react-native-document-picker: befd8fe34fcfc606ec7ee427ab21747fbdb6bc44
react-native-emm: 4fc2959929839f2bb253e3e26127989ad7f7a687
react-native-emm: 983a347e31aef8a93cbc51b6cea08708dacada84
react-native-image-picker: 32515318a4ecfbc864f5f94a1c67df1ce4b8d854
react-native-netinfo: cec9c4e86083cb5b6aba0e0711f563e2fbbff187
react-native-network-client: fa65831f32ebc3ece1fc68e6302c52c09434fba2
react-native-network-client: fef3bd3f68ce3416057e53959a4e5b50284e45c7
react-native-notifications: 3bafa1237ae8a47569a84801f17d80242fe9f6a5
react-native-paste-input: e151e40812741262c997fcb859ee806cbfabf617
react-native-performance: 125a96c145e29918b55b45ce25cbba54f1e24dcd
@ -2693,6 +2717,7 @@ SPEC CHECKSUMS:
RNVectorIcons: 4330d8f8f8f4184f436e0c08ae9950431ffe466e
SDWebImage: 8a6b7b160b4d710e2a22b6900e25301075c34cb3
SDWebImageSVGCoder: 15a300a97ec1c8ac958f009c02220ac0402e936c
secure-pdf-viewer: 63aa9df5ba9d7df506614f5228469f38c98f1c50
Sentry: 1ca8405451040482877dcd344dfa3ef80b646631
simdjson: 7bb9e33d87737cec966e7b427773c67baa4458fe
SocketRocket: d4aabe649be1e368d1318fdf28a022d714d65748

View file

@ -1,5 +1,6 @@
{
"compilerOptions": {
"jsx": "react",
"allowJs": true,
"allowSyntheticDefaultImports": true
},

View file

@ -0,0 +1 @@
.mupdf-cache/

View file

@ -0,0 +1,71 @@
buildscript {
ext.safeExtGet = {prop, fallback ->
rootProject.ext.has(prop) ? rootProject.ext.get(prop) : fallback
}
repositories {
google()
gradlePluginPortal()
}
def kotlin_version = rootProject.ext.has('kotlinVersion') ? rootProject.ext.get('kotlinVersion') : "1.9.25"
dependencies {
classpath("com.android.tools.build:gradle:8.6.0")
classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version")
}
}
def isNewArchitectureEnabled() {
return project.hasProperty("newArchEnabled") && project.newArchEnabled == "true"
}
apply plugin: 'com.android.library'
apply plugin: 'org.jetbrains.kotlin.android'
if (isNewArchitectureEnabled()) {
apply plugin: 'com.facebook.react'
}
android {
compileSdkVersion safeExtGet('compileSdkVersion', 34)
namespace "com.mattermost.securepdfviewer"
defaultConfig {
minSdkVersion safeExtGet('minSdkVersion', 24)
targetSdkVersion safeExtGet('targetSdkVersion', 34)
buildConfigField("boolean", "IS_NEW_ARCHITECTURE_ENABLED", isNewArchitectureEnabled().toString())
ndk {
abiFilters 'arm64-v8a', 'armeabi-v7a', 'x86', 'x86_64'
}
}
packagingOptions {
exclude 'lib/arm64-v8a/libc++_shared.so'
exclude 'lib/x86_64/libc++_shared.so'
exclude 'lib/armeabi-v7a/libc++_shared.so'
exclude 'lib/x86/libc++_shared.so'
}
sourceSets {
main {
if (isNewArchitectureEnabled()) {
java.srcDirs += ['src/newarch']
} else {
java.srcDirs += ['src/oldarch']
}
}
}
}
repositories {
google()
mavenCentral()
maven {
// All of React Native (JS, Obj-C sources, Android binaries) is installed from npm
url "$projectDir/../node_modules/react-native/android"
}
}
dependencies {
implementation 'com.facebook.react:react-native'
implementation 'androidx.security:security-crypto:1.0.0'
implementation "org.jetbrains.kotlin:kotlin-stdlib"
implementation 'com.github.mattermost:mattermost-android-pdfium:v1.0.1'
}

View file

@ -0,0 +1,2 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest package="com.mattermost.securepdfviewer" />

View file

@ -0,0 +1,59 @@
package com.mattermost.securepdfviewer
import com.facebook.react.ReactPackage
import com.facebook.react.bridge.NativeModule
import com.facebook.react.uimanager.ViewManager
/**
* React Native package definition for the Secure PDF Viewer component.
*
* This class serves as the entry point for registering the Secure PDF Viewer
* with React Native's module system. It implements the ReactPackage interface
* to provide the framework with information about what native modules and
* view managers this package contributes to the application.
*
* The package follows React Native's standard architecture by separating:
* - Native Modules: For imperative APIs and background services
* - View Managers: For declarative UI components that can be rendered in JSX
*
* In this implementation, we only provide view managers since the PDF viewer
* is primarily a UI component that integrates with React Native's declarative
* rendering system.
*/
class SecurePdfViewerPackage : ReactPackage {
/**
* Creates the list of native modules provided by this package.
*
* Native modules expose imperative APIs to JavaScript and typically handle
* background services, device APIs, or complex business logic that doesn't
* directly relate to UI rendering.
*
* For the PDF viewer, all functionality is encapsulated within the view
* component itself, so no additional native modules are required.
*
* @param reactContext The React Native application context
* @return Empty list since this package only provides UI components
*/
override fun createNativeModules(reactContext: com.facebook.react.bridge.ReactApplicationContext): List<NativeModule> {
return emptyList()
}
/**
* Creates the list of view managers provided by this package.
*
* View managers are responsible for creating, updating, and managing
* native UI components that can be used declaratively in React Native JSX.
* They handle the bridge between React Native's component props and
* the native Android view system.
*
* This package provides the SecurePdfViewerViewManager which manages
* instances of the SecurePdfViewerView component.
*
* @param reactContext The React Native application context
* @return List containing the PDF viewer view manager
*/
override fun createViewManagers(reactContext: com.facebook.react.bridge.ReactApplicationContext): List<ViewManager<*, *>> {
return listOf(SecurePdfViewerViewManager())
}
}

View file

@ -0,0 +1,122 @@
package com.mattermost.securepdfviewer
import com.facebook.react.uimanager.ThemedReactContext
import com.mattermost.securepdfviewer.view.SecurePdfViewerView
/**
* Implementation object for the Secure PDF Viewer view manager.
*
* This object contains the core implementation logic for managing SecurePdfViewerView
* instances that is shared between the legacy React Native architecture and the new
* architecture (Fabric). By centralizing the implementation here, we ensure consistent
* behavior across both architectures while allowing each to have its own specific
* view manager wrapper.
*
* Architecture compatibility:
* - Legacy Architecture: Uses ViewGroupManager with manual property setting
* - New Architecture (Fabric): Uses ViewManager with generated interfaces
*
* This pattern follows React Native's recommended approach for supporting both
* architectures during the transition period, allowing applications to migrate
* to Fabric at their own pace while maintaining full functionality.
*/
object SecurePdfViewerViewManagerImpl {
// ================================================================================================
// COMPONENT IDENTIFICATION
// ================================================================================================
/**
* Returns the name that React Native will use to identify this component.
*
* This name must match the component name used in JavaScript/TypeScript code
* when importing or referencing the native component. It serves as the bridge
* identifier between the JavaScript layer and the native Android implementation.
*
* @return The component name as it appears in React Native
*/
fun getName(): String = "SecurePdfViewer"
// ================================================================================================
// VIEW LIFECYCLE MANAGEMENT
// ================================================================================================
/**
* Creates a new instance of the SecurePdfViewerView component.
*
* This method is called by React Native whenever a new instance of the PDF viewer
* component is rendered in the JavaScript layer. Each instance is independent and
* maintains its own state for document loading, scroll position, zoom level, etc.
*
* The ThemedReactContext provides access to React Native's theming system and
* application context, allowing the component to integrate properly with the
* overall application environment.
*
* @param reactContext The themed React Native context for this view instance
* @return A new SecurePdfViewerView instance ready for configuration and rendering
*/
fun createViewInstance(reactContext: ThemedReactContext): SecurePdfViewerView {
return SecurePdfViewerView(reactContext)
}
// ================================================================================================
// PROPERTY SETTERS
// ================================================================================================
/**
* Sets the source file path for the PDF document to be displayed.
*
* This method handles the 'source' prop from React Native, which should contain
* the absolute file path to a PDF document. The path must point to a file within
* the application's allowed directories (cache directories) for security reasons.
*
* Setting the source will trigger document validation and loading if all required
* parameters (source, password if needed) are available.
*
* @param view The SecurePdfViewerView instance to update
* @param source The absolute file path to the PDF document, or null to clear
*/
fun setSource(view: SecurePdfViewerView, source: String?) {
view.setSource(source)
}
/**
* Sets the password for encrypted PDF documents.
*
* This method handles the 'password' prop from React Native. The password is used
* to authenticate and decrypt password-protected PDF documents. If a document
* requires a password but none is provided, the component will emit a password
* required event to the JavaScript layer.
*
* Password attempts are tracked and limited to prevent brute force attacks on
* encrypted documents. After exceeding the attempt limit, the document will be
* locked out for security.
*
* @param view The SecurePdfViewerView instance to update
* @param password The decryption password for the PDF document, or null for unprotected documents
*/
fun setPassword(view: SecurePdfViewerView, password: String?) {
view.setPassword(password)
}
/**
* Configures whether external links within the PDF should be interactive.
*
* This method handles the 'allowLinks' prop from React Native. When enabled,
* tapping on external links (URLs) within the PDF will emit link pressed events
* to the JavaScript layer, allowing the application to handle link navigation.
* When disabled, link taps will emit "link disabled" events instead.
*
* Internal links (page references within the same document) are always enabled
* and handled automatically by the PDF viewer for document navigation.
*
* Security consideration: Disabling external links can prevent potentially
* malicious PDF documents from redirecting users to harmful websites.
*
* @param view The SecurePdfViewerView instance to update
* @param allow Whether to enable external link interaction (true) or disable it (false)
*/
fun setAllowLink(view: SecurePdfViewerView, allow: Boolean) {
view.setAllowLinks(allow)
}
}

View file

@ -0,0 +1,70 @@
package com.mattermost.securepdfviewer.enums
/**
* Enumeration of all events that can be emitted from the PDF viewer to React Native.
*
* This enum defines the complete set of events that the native PDF viewer component
* can emit to the React Native layer, enabling proper communication between the
* native Android implementation and the JavaScript application layer.
*
* Each event represents a specific user interaction or system state change that
* the React Native side needs to be aware of for proper application flow.
*/
enum class Events(val event: String) {
// Link interaction events
/**
* Emitted when a user taps on an external link within the PDF document.
* Contains the URL of the tapped link for the application to handle.
*/
ON_LINK_PRESSED("onLinkPressed"),
/**
* Emitted when a user taps on a link but link navigation is disabled.
* Allows the application to inform users that link navigation is not permitted.
*/
ON_LINK_PRESSED_DISABLED("onLinkPressedDisabled"),
// Document loading events
/**
* Emitted when a PDF document has been successfully loaded and is ready for display.
* Indicates that the document is fully initialized and can be interacted with.
*/
ON_LOAD_EVENT("onLoad"),
/**
* Emitted when an error occurs during document loading.
* Contains error details to help diagnose loading failures.
*/
ON_LOAD_ERROR_EVENT("onLoadError"),
// Password authentication events
/**
* Emitted when a PDF document requires a password for access.
* Provides information about remaining password attempts and maximum allowed attempts.
*/
ON_PASSWORD_REQUIRED("onPasswordRequired"),
/**
* Emitted when an incorrect password is provided for a protected document.
* Includes the number of remaining password attempts before lockout.
*/
ON_PASSWORD_FAILED("onPasswordFailed"),
/**
* Emitted when the maximum number of password attempts has been exceeded.
* Indicates that the document is now locked and cannot be accessed.
*/
ON_PASSWORD_LIMIT_REACHED("onPasswordFailureLimitReached"),
// User interaction events
/**
* Emitted when a user taps on the PDF viewer (not on a link).
* Contains tap coordinates and pointer type information for gesture handling.
*/
ON_TAP("onTap")
}

View file

@ -0,0 +1,39 @@
package com.mattermost.securepdfviewer.event
import com.facebook.react.bridge.WritableMap
import com.facebook.react.uimanager.events.Event
/**
* Custom event class for dispatching PDF viewer events to React Native.
*
* This class serves as a bridge between the native Android PDF viewer and the React Native
* event system, enabling seamless communication of user interactions and system state changes
* from the native layer to the JavaScript application layer.
*
* The event system supports React Native's new architecture (Fabric) by properly handling
* surface IDs and view IDs for event routing.
*
* @param name The event name that React Native will receive (must match JavaScript event handler names)
* @param surfaceId The React Native surface identifier for proper event routing in new architecture
* @param viewId The specific view instance identifier that generated this event
* @param payload Optional data payload containing event-specific information (coordinates, URLs, error messages, etc.)
*/
class PdfViewerEvent(
val name: String,
surfaceId: Int,
viewId: Int,
private val payload: WritableMap?
) : Event<PdfViewerEvent>(surfaceId, viewId) {
/**
* Returns the event name for React Native event system routing.
* This name must match the event handler names defined in the React Native component.
*/
override fun getEventName() = name
/**
* Returns the event payload data that will be passed to React Native event handlers.
* Contains event-specific information such as coordinates, URLs, error messages, etc.
*/
override fun getEventData() = payload
}

View file

@ -0,0 +1,137 @@
package com.mattermost.securepdfviewer.manager
import android.content.Context
import android.content.SharedPreferences
import androidx.core.content.edit
import androidx.security.crypto.EncryptedSharedPreferences
import androidx.security.crypto.MasterKeys
/**
* Secure manager for tracking PDF password authentication failures per document.
*
* This class provides a secure, encrypted storage mechanism for tracking failed password
* attempts on protected PDF documents. It implements a security measure to prevent
* brute force attacks by limiting the number of password attempts allowed per document.
*
* Key security features:
* - All data is encrypted at rest using AndroidX Security library
* - Uses AES-256 encryption with hardware-backed keys when available
* - Tracks attempts per unique document identifier (file hash)
* - Implements automatic lockout after maximum attempts exceeded
* - Provides secure cleanup of attempt history on successful authentication
*
* The store uses the document's SHA-256 hash as the unique identifier, ensuring
* that attempt tracking is tied to specific documents rather than file paths
* which could be manipulated.
*/
class PasswordAttemptStore(context: Context) {
companion object {
private const val PREF_NAME = "secure_pdf_attempts"
private const val MAX_ATTEMPTS = 10
private const val RESET_DELAY_MILLIS = 10 * 60 * 1000L
private const val TIMESTAMP_SUFFIX = "_ts"
}
// Lazy initialization of encrypted preferences to avoid blocking the main thread
private val prefs: SharedPreferences by lazy { createEncryptedPrefs(context) }
// Attempt tracking operations
/**
* Gets the number of remaining password attempts for a specific document.
*
* @param fileKey Unique identifier for the document (typically SHA-256 hash of file path)
* @return Number of attempts remaining before lockout (0 if already locked out)
*/
fun getRemainingAttempts(fileKey: String): Int {
maybeResetIfExpired(fileKey)
val failed = prefs.getInt(fileKey, 0)
return (MAX_ATTEMPTS - failed).coerceAtLeast(0)
}
/**
* Registers a failed password attempt for a document and returns remaining attempts.
*
* @param fileKey Unique identifier for the document
* @return Number of attempts remaining after this failure (0 if now locked out)
*/
fun registerFailedAttempt(fileKey: String): Int {
maybeResetIfExpired(fileKey)
val current = prefs.getInt(fileKey, 0) + 1
prefs.edit {
putInt(fileKey, current)
if (current >= MAX_ATTEMPTS) {
putLong(fileKey + TIMESTAMP_SUFFIX, System.currentTimeMillis())
}
}
return (MAX_ATTEMPTS - current).coerceAtLeast(0)
}
/**
* Checks if a document has exceeded the maximum allowed password attempts.
*
* @param fileKey Unique identifier for the document
* @return true if the document is locked out due to too many failed attempts
*/
fun hasExceededLimit(fileKey: String): Boolean {
maybeResetIfExpired(fileKey)
return prefs.getInt(fileKey, 0) >= MAX_ATTEMPTS
}
/**
* Resets the failed attempt counter for a document.
* Called when a correct password is provided to clear the attempt history.
*
* @param fileKey Unique identifier for the document
*/
fun resetAttempts(fileKey: String) {
prefs.edit {
remove(fileKey)
remove(fileKey + TIMESTAMP_SUFFIX)
}
}
/**
* Returns the maximum number of password attempts allowed per document.
*
* @return Maximum allowed attempts before lockout
*/
fun maxAllowedAttempts(): Int = MAX_ATTEMPTS
// Private encryption setup
private fun maybeResetIfExpired(fileKey: String) {
val timestamp = prefs.getLong(fileKey + TIMESTAMP_SUFFIX, -1L)
if (timestamp <= 0) return
val now = System.currentTimeMillis()
if (now - timestamp >= RESET_DELAY_MILLIS) {
resetAttempts(fileKey)
}
}
/**
* Creates encrypted SharedPreferences instance with strong encryption.
*
* Uses AndroidX Security library to provide:
* - AES-256 encryption for both keys and values
* - Hardware-backed master keys when available
* - Automatic key rotation and management
*
* @param context Application context for accessing encryption services
* @return Encrypted SharedPreferences instance
*/
private fun createEncryptedPrefs(context: Context): SharedPreferences {
val masterKeyAlias = MasterKeys.getOrCreate(MasterKeys.AES256_GCM_SPEC)
return EncryptedSharedPreferences.create(
PREF_NAME,
masterKeyAlias,
context,
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
)
}
}

View file

@ -0,0 +1,25 @@
package com.mattermost.securepdfviewer.model
/**
* Data class representing the current memory state of the Android device.
*
* This class encapsulates device memory information used for making intelligent
* decisions about PDF processing limits and memory management strategies within
* the secure PDF viewer.
*
* The memory information is used to:
* - Calculate safe PDF file size limits based on available memory
* - Determine appropriate bitmap cache sizes for page rendering
* - Implement adaptive memory management strategies
* - Prevent out-of-memory crashes during large document processing
*
* Values are obtained from Android's ActivityManager.MemoryInfo and represent
* the device's memory state at the time of measurement.
*
* @property totalMem Total amount of RAM available to the system in bytes
* @property availMem Amount of available (free) RAM in bytes that can be allocated
*/
data class DeviceMemory(
val totalMem: Long,
val availMem: Long
)

View file

@ -0,0 +1,177 @@
package com.mattermost.securepdfviewer.pdfium
import android.util.Log
import com.mattermost.pdfium.PdfBridge
import com.mattermost.pdfium.exceptions.DocumentOpenException
import com.mattermost.pdfium.exceptions.InvalidPasswordException
import com.mattermost.pdfium.exceptions.PasswordRequiredException
import com.mattermost.securepdfviewer.pdfium.shared.PdfContext
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.atomic.AtomicBoolean
/**
* Represents a PDF document, managing its lifecycle, pages, and caching.
*/
class PdfDocument private constructor(
private val context: PdfContext,
private val pdfBridge: PdfBridge,
) {
companion object {
private const val TAG = "PdfDocument"
/**
* Opens a PDF document from the specified file path with optional password authentication.
*
* This method uses PdfBridge for native document loading and authentication.
*
* @param filePath Absolute path to the PDF file to open
* @param password Optional password for encrypted documents (null for unencrypted)
* @return PdfDocument instance.
* @throws PasswordRequiredException if document requires password but none provided
* @throws InvalidPasswordException if provided password is incorrect
* @throws DocumentOpenException for other opening errors (corrupted file, unsupported format, etc.)
*/
@Throws(PasswordRequiredException::class, InvalidPasswordException::class, DocumentOpenException::class)
fun openDocument(context: PdfContext, filePath: String, password: String? = null): PdfDocument {
try {
val bridge = PdfBridge.open(filePath, password)
return PdfDocument(context, bridge)
} catch (e: Exception) {
Log.e(TAG, "Failed to open document", e)
when (e) {
is PasswordRequiredException,
is InvalidPasswordException -> throw e
else -> throw DocumentOpenException("Failed to open document: ${e.message}", e)
}
}
}
}
// Thread-safe destruction state
private val isDestroyed = AtomicBoolean(false)
// Page instance cache
private val pageInstances = ConcurrentHashMap<Int, PdfPage>()
/**
* Gets the total number of pages, using the cache.
*
* @return Page count or 0 if destroyed.
*/
fun getPageCount(): Int {
return if (isDestroyed.get()) {
Log.w(TAG, "Document destroyed, returning 0 pages")
0
} else {
context.cacheManager.getPageCount() ?: pdfBridge.getPageCount().also {
context.cacheManager.setPageCount(it)
}
}
}
/**
* Gets the size of a specific page using native coordinator for thread safety.
*/
suspend fun getPageSizeSafe(pageNumber: Int, skipCache: Boolean? = false): Pair<Float, Float>? {
if (isDestroyed.get()) {
Log.w(TAG, "Document destroyed")
return null
}
// Check cache first
if (skipCache != true) {
val cached = context.cacheManager.withSynchronizedCache {
context.cacheManager.getPageSize(pageNumber)
}
if (cached != null) {
return cached
}
}
// Use native coordinator for safe access
return context.nativeCoordinator.withNativeAccess("get-page-size-$pageNumber") {
try {
pdfBridge.getPageSize(pageNumber).also {
context.cacheManager.withSynchronizedCache {
context.cacheManager.setPageSize(pageNumber, it)
}
}
} catch (e: Exception) {
Log.e(TAG, "Error getting page size for page $pageNumber", e)
null
}
}
}
/**
* Gets the size of a specific page.
*
* @param pageNumber Page index.
* @return Page size or null if destroyed.
*/
fun getPageSize(pageNumber: Int, skipCache: Boolean? = false): Pair<Float, Float>? {
if (isDestroyed.get()) {
Log.w(TAG, "Document destroyed")
return null
}
if (skipCache == true) {
return pdfBridge.getPageSize(pageNumber)
}
return context.cacheManager.getPageSize(pageNumber) ?: pdfBridge.getPageSize(pageNumber).also {
context.cacheManager.setPageSize(pageNumber, it)
}
}
/**
* Gets a PdfPage instance for the given page number, with caching.
*
* @param pageNumber Page index.
* @return PdfPage instance.
*/
fun getPage(pageNumber: Int): PdfPage {
if (isDestroyed.get()) {
throw IllegalStateException("Document has been destroyed")
}
val pageCount = getPageCount()
if (pageNumber !in 0 until pageCount) {
throw IndexOutOfBoundsException("Page $pageNumber out of range [0, ${pageCount - 1}]")
}
return pageInstances.computeIfAbsent(pageNumber) {
PdfPage(context, pdfBridge, pageNumber)
}
}
/**
* Checks if the document is valid (not destroyed + native valid).
*
* @return True if valid, false otherwise.
*/
fun isValid(): Boolean {
if (isDestroyed.get()) return false
return try {
pdfBridge.isValid()
} catch (e: Exception) {
Log.w(TAG, "Document validation failed", e)
false
}
}
/**
* Closes the document and clears all caches.
*/
fun destroy() {
if (isDestroyed.compareAndSet(false, true)) {
Log.d(TAG, "Destroying document")
pageInstances.clear()
try {
pdfBridge.close()
Log.d(TAG, "Document destroyed successfully")
} catch (e: Exception) {
Log.w(TAG, "Error destroying document", e)
}
}
}
}

View file

@ -0,0 +1,73 @@
package com.mattermost.securepdfviewer.pdfium
import android.graphics.Bitmap
import android.graphics.Bitmap.createBitmap
import android.util.Log
import com.mattermost.pdfium.PdfBridge
import com.mattermost.pdfium.model.PdfLink
import com.mattermost.securepdfviewer.pdfium.shared.PdfContext
/**
* Wrapper for PDFium page functionality, providing optimized rendering and link extraction.
*/
class PdfPage internal constructor(
private val context: PdfContext,
private val pdfBridge: PdfBridge,
private val pageNumber: Int
) {
companion object {
private const val TAG = "PdfPage"
}
/**
* Asynchronous rendering of the page to a Bitmap.
*
* @param width Target width in pixels.
* @param height Target height in pixels.
* @param scale Optional scaling factor (default is calculated to fit width/height).
* @return Rendered Bitmap or null if rendering fails.
*/
suspend fun renderToBitmap(width: Int, height: Int, scale: Float = 1.0f): Bitmap? {
if (width <= 0 || height <= 0) {
Log.e(TAG, "Invalid dimensions: ${width}x${height}")
return null
}
Log.d(TAG, "Rendering page $pageNumber to ${width}x${height}")
return context.nativeCoordinator.withNativeAccess("render-page-$pageNumber-${width}x${height}") {
try {
val bitmap = createBitmap(width, height, Bitmap.Config.ARGB_8888)
val success = pdfBridge.renderPageToBitmap(pageNumber, bitmap, scale)
if (success) {
Log.d(TAG, "Successfully rendered page $pageNumber")
bitmap
} else {
Log.e(TAG, "Failed to render page $pageNumber")
bitmap.recycle()
null
}
} catch (e: Exception) {
Log.e(TAG, "Error rendering page $pageNumber", e)
null
}
}
}
/**
* Extracts all links present on this page using native coordinator.
*/
suspend fun getLinksSafe(): List<PdfLink> {
return context.nativeCoordinator.withNativeAccess("get-links-$pageNumber") {
try {
pdfBridge.getLinksForPage(pageNumber).toList()
} catch (e: Exception) {
Log.e(TAG, "Error getting links for page $pageNumber", e)
emptyList()
}
} ?: emptyList()
}
}

View file

@ -0,0 +1,227 @@
package com.mattermost.securepdfviewer.pdfium
import android.content.Context
import android.graphics.Canvas
import android.util.AttributeSet
import android.util.Log
import android.view.GestureDetector
import android.view.MotionEvent
import android.view.ScaleGestureDetector
import android.view.View
import android.widget.Scroller
import com.mattermost.pdfium.model.PdfLink
import com.mattermost.securepdfviewer.pdfium.gesture.ScaleListener
import com.mattermost.securepdfviewer.pdfium.gesture.ScrollGestureListener
import com.mattermost.securepdfviewer.pdfium.interaction.LinkHandler
import com.mattermost.securepdfviewer.pdfium.interaction.ScrollHandler
import com.mattermost.securepdfviewer.pdfium.interaction.ZoomAnimator
import com.mattermost.securepdfviewer.pdfium.layout.CoordinateConverter
import com.mattermost.securepdfviewer.pdfium.layout.LayoutCalculator
import com.mattermost.securepdfviewer.pdfium.manager.PdfDocumentManager
import com.mattermost.securepdfviewer.pdfium.manager.PdfRenderManager
import com.mattermost.securepdfviewer.pdfium.shared.PdfContext
import com.mattermost.securepdfviewer.pdfium.shared.PdfViewInterface
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.runBlocking
class PdfView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0
) : View(context, attrs, defStyleAttr), PdfViewInterface {
companion object {
private const val TAG = "PdfView"
// Layout constants
internal const val PAGE_SPACING = 20
}
// Coroutine scope for async operations
private val viewScope = CoroutineScope(Dispatchers.Main + SupervisorJob())
private val pdfContext: PdfContext
private val scaleGestureDetector: ScaleGestureDetector
private val gestureDetector: GestureDetector
private val scroller: Scroller = Scroller(context)
// Interface properties
override val viewWidth: Int get() = super.getWidth()
override val viewHeight: Int get() = super.getHeight()
// Callbacks
/**
* Callback invoked when the document finishes loading successfully.
*
* @property onLoadComplete A lambda with no arguments, called when load completes.
* Can be set to null to disable.
*/
var onLoadComplete: (() -> Unit)? = null
/**
* Callback invoked when an error occurs during document loading.
*
* @property onLoadError A lambda receiving the Exception that caused the error.
* Can be set to null to disable.
*/
var onLoadError: ((Exception) -> Unit)? = null
/**
* Callback invoked when a tap event occurs on the view.
*
* @property onTap A lambda receiving the MotionEvent of the tap.
* Can be set to null to disable.
*/
var onTap: ((MotionEvent) -> Unit)? = null
/**
* Callback invoked when a link in the document is tapped.
*
* @property onLinkTapped A lambda receiving the PdfLink object for the tapped link.
* Can be set to null to disable.
*/
var onLinkTapped: ((PdfLink) -> Unit)? = null
/**
* Callback invoked when the current page changes during scrolling.
*
* Callback invoked when the scroll position changes.
*
* @property onPageChanged A lambda receiving the new current page (0-based index) .
* Can be set to null to disable the callback.
*/
var onPageChanged: ((Int) -> Unit)? = null
/**
* Callback invoked when the scroll position changes.
*
* @property onScrollChanged A lambda receiving the new scroll offset (Float).
* Can be set to null to disable the callback.
*/
var onScrollChanged: ((Float) -> Unit)? = null
fun markViewReady() = pdfContext.markViewReady()
fun loadDocument(filePath: String, password: String?) =
pdfContext.documentManager.loadDocument(pdfContext, filePath, password)
fun getCurrentPage() = pdfContext.documentManager.currentPage
fun getPageCount() = pdfContext.documentManager.getPageCount()
fun getCurrentZoomScale() = pdfContext.zoomAnimator.currentZoomScale
fun getCurrentScrollY() = pdfContext.scrollHandler.scrollY
fun getTotalDocumentHeight() = pdfContext.scrollHandler.totalDocumentHeight
fun getEstimatedPageBounds(pageNum: Int) = pdfContext.layoutCalculator.getEstimatedPageBounds(pageNum)
// Initialization
init {
setWillNotDraw(false)
Log.d(TAG, "PdfView initialized")
pdfContext = PdfContext(context, viewScope, scroller)
pdfContext.documentManager = PdfDocumentManager(pdfContext, this)
pdfContext.layoutCalculator = LayoutCalculator(pdfContext, this)
pdfContext.coordinateConverter = CoordinateConverter(pdfContext, this)
pdfContext.renderManager = PdfRenderManager(pdfContext, this)
pdfContext.zoomAnimator = ZoomAnimator(pdfContext, this)
pdfContext.linkHandler = LinkHandler(pdfContext)
pdfContext.scrollHandler = ScrollHandler(pdfContext, this)
pdfContext.scrollGestureListener = ScrollGestureListener(pdfContext, this)
pdfContext.scaleListener = ScaleListener(pdfContext, this)
gestureDetector = GestureDetector(context, pdfContext.scrollGestureListener)
scaleGestureDetector = ScaleGestureDetector(context, pdfContext.scaleListener)
}
override fun onDetachedFromWindow() {
super.onDetachedFromWindow()
// Cancel all ongoing renders first
runBlocking {
try {
pdfContext.renderManager.cancelAllRendersAndWait()
pdfContext.layoutCalculator.stopCalculations()
pdfContext.markViewDestroyedAndShutdown()
pdfContext.documentManager.safeCleanupWithWait()
Log.d(TAG, "All cleanup completed successfully")
} catch (e: Exception) {
Log.e(TAG, "Error during cleanup", e)
}
}
Log.d(TAG, "View detached")
scroller.forceFinished(true)
pdfContext.scrollGestureListener.stopCustomFlinging()
pdfContext.zoomAnimator.reset()
pdfContext.cacheManager.cleanup()
pdfContext.scrollHandler.reset()
viewScope.cancel()
}
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
super.onMeasure(widthMeasureSpec, heightMeasureSpec)
Log.d(TAG, "onMeasure called: ${MeasureSpec.getSize(widthMeasureSpec)}x${MeasureSpec.getSize(heightMeasureSpec)}")
}
override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
super.onSizeChanged(w, h, oldw, oldh)
Log.d(TAG, "Size changed: ${oldw}x${oldh} -> ${w}x${h}")
if (
!pdfContext.documentManager.isDocumentLoading() &&
pdfContext.document.isValid() &&
w > 0 && h > 0
) {
pdfContext.markViewReady()
pdfContext.layoutCalculator.stopCalculations()
pdfContext.zoomAnimator.calculateBaseZoom()
pdfContext.layoutCalculator.preCalculateInitialPageSizes()
pdfContext.layoutCalculator.updateDocumentLayout()
pdfContext.layoutCalculator.launchDeferredPageSizeCalculation(viewScope)
}
}
override fun onDraw(canvas: Canvas) {
pdfContext.layoutCalculator.notifyRenderingStarted()
super.onDraw(canvas)
pdfContext.renderManager.drawView(canvas)
pdfContext.layoutCalculator.notifyRenderingEnded()
}
override fun onTouchEvent(event: MotionEvent): Boolean {
val scaledHandled = scaleGestureDetector.onTouchEvent(event)
val gestureHandled = gestureDetector.onTouchEvent(event)
if (event.action == MotionEvent.ACTION_UP && !scaleGestureDetector.isInProgress) {
performClick()
}
return scaledHandled || gestureHandled
}
override fun performClick(): Boolean {
super.performClick()
return true
}
override fun computeScroll() {
super.computeScroll()
pdfContext.scrollHandler.computeScroll()
}
fun setContextCallbacks() {
pdfContext.onTap = this.onTap
pdfContext.onPageChanged = this.onPageChanged
pdfContext.onScrollChanged = this.onScrollChanged
pdfContext.onLinkTapped = this.onLinkTapped
pdfContext.onLoadComplete = this.onLoadComplete
pdfContext.onLoadError = this.onLoadError
}
}

View file

@ -0,0 +1,230 @@
package com.mattermost.securepdfviewer.pdfium.cache
import android.graphics.Bitmap
import android.util.Log
import com.mattermost.pdfium.model.PdfLink
import java.util.concurrent.ConcurrentHashMap
/**
* Manages caching for PDF document rendering and metadata.
*
* Responsibilities include:
* - Caching rendered bitmaps per page
* - Caching extracted links per page
* - Caching page sizes and offsets for layout calculations
* - Caching page count for document-level information
* - Providing thread-safe access to cache data
*/
class PdfCacheManager {
companion object {
private const val TAG = "PdfCacheManager"
}
// Private caches
private var pageCount: Int? = null
private val pageSizes = mutableMapOf<Int, Pair<Float, Float>>()
private val pageOffsets = mutableMapOf<Int, Float>()
private var lastVisiblePages = emptyList<Int>()
private val pageCache = mutableMapOf<Int, Bitmap>()
private val linkCache = ConcurrentHashMap<Int, List<PdfLink>>()
private val cacheAccessLock = Any()
/**
* Gets the cached page count.
*
* @return Cached page count or null if not set.
*/
fun getPageCount(): Int? = pageCount
/**
* Sets the cached page count.
*
* @param count Total page count to cache.
*/
fun setPageCount(count: Int) {
pageCount = count
}
/**
* Gets the cached page size for a specific page.
*
* @param pageNum Page number.
* @return Cached size (width, height) or null if not cached.
*/
fun getPageSize(pageNum: Int): Pair<Float, Float>? = pageSizes[pageNum]
/**
* Sets the cached page size for a specific page.
*
* @param pageNum Page number.
* @param size Pair of (width, height).
*/
fun setPageSize(pageNum: Int, size: Pair<Float, Float>) {
pageSizes[pageNum] = size
}
/**
* Clears all page sizes in the cache
*/
fun clearPageSizes() {
pageSizes.clear()
}
/**
* Gets all page sizes in the cache
*/
fun getPageSizes() = pageSizes
/**
* Gets the maximum page width from all pages in the document.
*/
fun getMaxPageWidth(): Float = pageSizes.values.maxOfOrNull { it.first } ?: 0f
/**
* Gets the cached page offset for a specific page.
*
* @param pageNum Page number.
* @return Cached offset or null if not cached.
*/
fun getPageOffset(pageNum: Int): Float? = pageOffsets[pageNum]
/**
* Sets the cached page offset for a specific page.
*
* @param pageNum Page number.
* @param offset Page offset.
*/
fun setPageOffset(pageNum: Int, offset: Float) {
pageOffsets[pageNum] = offset
}
/**
* Clears all page offsets
*/
fun clearPageOffsets() {
pageOffsets.clear()
}
/**
* Gets the cached page offsets
*
* @return Cached list of page offsets cached.
*/
fun getPageOffsets(): Map<Int, Float> = pageOffsets
/**
* Gets the cached list of last visible pages.
*
* @return List of last visible pages.
*/
fun getLastVisiblePages(): List<Int> = lastVisiblePages.toList()
/**
* Sets the list of last visible pages.
*
* @param pages List of page numbers.
*/
fun setLastVisiblePages(pages: List<Int>) {
lastVisiblePages = pages.toList()
}
/**
* Caches a rendered bitmap for a specific page.
*
* @param pageNum Page number.
* @param bitmap Rendered bitmap.
*/
fun cachePage(pageNum: Int, bitmap: Bitmap?) {
withSynchronizedCache {
pageCache[pageNum]?.recycle()
if (bitmap != null) {
pageCache[pageNum] = bitmap
} else {
pageCache.remove(pageNum)
}
}
Log.d(TAG, "Page $pageNum cached")
}
/**
* Retrieves a cached bitmap for a specific page.
*
* @param pageNum Page number.
* @return Cached bitmap or null if not cached.
*/
fun getCachedPage(pageNum: Int): Bitmap? = withSynchronizedCache { pageCache[pageNum] }
/**
*
*/
fun clearNonVisiblePages(visible: List<Int>) {
withSynchronizedCache {
val toRemove = pageCache.keys.filter { it !in visible }
toRemove.forEach { pageNum ->
pageCache[pageNum]?.recycle()
pageCache.remove(pageNum)
}
Log.d(TAG, "Cleared ${toRemove.size} pages, kept ${visible.size} visible pages")
}
}
/**
* Caches extracted links for a specific page.
*
* @param pageNum Page number.
* @param links List of PdfLink objects.
*/
fun cacheLinks(pageNum: Int, links: List<PdfLink>) {
if (!linkCache.contains(pageNum)) {
linkCache[pageNum] = links
Log.d(TAG, "Links for page $pageNum cached")
}
}
/**
* Retrieves cached links for a specific page.
*
* @param pageNum Page number.
* @return Cached list of PdfLink objects, or null if not cached.
*/
fun getCachedLinks(pageNum: Int): List<PdfLink>? = linkCache[pageNum]
/**
* Utility method to perform actions within the synchronized cache lock.
*
* @param action Callback to execute inside synchronized block.
* @return Result of the action.
*/
fun <T> withSynchronizedCache(action: () -> T): T {
synchronized(cacheAccessLock) {
return action()
}
}
/**
* Clears all cached data, including bitmaps, sizes, offsets, and links.
*/
fun cleanup() {
Log.d(TAG, "Cache cleanup started")
withSynchronizedCache {
pageCache.values.forEach { bitmap ->
if (!bitmap.isRecycled) {
bitmap.recycle()
}
}
pageCache.clear()
pageSizes.clear()
pageOffsets.clear()
}
lastVisiblePages = emptyList()
linkCache.clear()
pageCount = null
Log.d(TAG, "Cache cleanup completed")
}
}

View file

@ -0,0 +1,115 @@
package com.mattermost.securepdfviewer.pdfium.gesture
import android.util.Log
import android.view.ScaleGestureDetector
import com.mattermost.securepdfviewer.pdfium.interaction.ZoomAnimator.Companion.MAX_ZOOM_SCALE
import com.mattermost.securepdfviewer.pdfium.interaction.ZoomAnimator.Companion.MIN_ZOOM_SCALE
import com.mattermost.securepdfviewer.pdfium.shared.PdfContext
import com.mattermost.securepdfviewer.pdfium.shared.PdfViewInterface
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
/**
* Scale gesture listener for pinch-to-zoom with focal point preservation.
*
* Implements smooth pinch-to-zoom with real-time focal point calculation
* to keep the pinched area centered under the user's fingers throughout
* the scaling operation.
*/
class ScaleListener(
private val context: PdfContext,
private val view: PdfViewInterface
) : ScaleGestureDetector.SimpleOnScaleGestureListener() {
companion object {
private const val TAG = "ScaleListener"
}
private var focusDocX = 0f
private var focusDocY = 0f
private var focusLayoutY = 0f
private var startPageNum: Int? = null
override fun onScaleBegin(detector: ScaleGestureDetector): Boolean {
context.scroller.forceFinished(true)
context.scrollGestureListener.stopCustomFlinging()
// Ensure layout offsets are up-to-date with current zoom and scroll
context.layoutCalculator.updateDocumentLayout()
val baseScale = context.zoomAnimator.baseZoom * context.zoomAnimator.currentZoomScale
// Get the correct page based on current scroll
startPageNum = context.coordinateConverter.getPageAtScreenCoordinates(detector.focusX, detector.focusY) ?: 0
val pageOffsetY = context.cacheManager.getPageOffset(startPageNum!!) ?: 0f
val pageSize = context.cacheManager.getPageSize(startPageNum!!) ?: return false
val scaledWidth = pageSize.first * baseScale
val scaledHeight = pageSize.second * baseScale
val pageLeft = (view.viewWidth - scaledWidth) / 2f
// Calculate relative position within the page for focal point preservation
val relativeX = ((detector.focusX + context.scrollHandler.scrollX - pageLeft) / scaledWidth).coerceIn(0f, 1f)
val relativeY = ((detector.focusY + context.scrollHandler.scrollY - pageOffsetY) / scaledHeight).coerceIn(0f, 1f)
focusDocX = relativeX * pageSize.first
focusDocY = relativeY * pageSize.second
focusLayoutY = pageOffsetY
return true
}
override fun onScale(detector: ScaleGestureDetector): Boolean {
val scaleFactor = detector.scaleFactor
val newScale = (context.zoomAnimator.currentZoomScale * scaleFactor).coerceIn(MIN_ZOOM_SCALE, MAX_ZOOM_SCALE)
if (kotlin.math.abs(newScale - context.zoomAnimator.currentZoomScale) < 0.001f) {
return true
}
context.zoomAnimator.currentZoomScale = newScale
val baseScale = context.zoomAnimator.baseZoom * context.zoomAnimator.currentZoomScale
// Recalculate layout at new scale
context.layoutCalculator.updateDocumentLayout()
val pageOffsetY = context.cacheManager.getPageOffset(startPageNum!!) ?: focusLayoutY
val pageSize = context.cacheManager.getPageSize(startPageNum!!) ?: return false
val scaledWidth = pageSize.first * baseScale
val scaledHeight = pageSize.second * baseScale
val pageLeft = (view.viewWidth - scaledWidth) / 2f
// Calculate new scroll position to maintain focal point
val targetX = pageLeft + (focusDocX / pageSize.first) * scaledWidth
val targetY = pageOffsetY + (focusDocY / pageSize.second) * scaledHeight
context.scrollHandler.scrollX = targetX - detector.focusX
context.scrollHandler.scrollY = targetY - detector.focusY
// Apply constraints
context.scrollHandler.constrainHorizontalScroll()
val maxScrollY = maxOf(0f, context.scrollHandler.totalDocumentHeight - view.viewHeight)
context.scrollHandler.scrollY = context.scrollHandler.scrollY.coerceIn(0f, maxScrollY)
// Update scroll handle periodically during pinch
if (System.currentTimeMillis() % 10 == 0L) {
context.scrollHandler.updateScrollHandleImmediate()
}
view.invalidate()
return true
}
override fun onScaleEnd(detector: ScaleGestureDetector) {
context.layoutCalculator.updateDocumentLayout()
context.scrollHandler.updateScrollHandleAfterZoom()
// Clear and re-render pages at new zoom level
context.viewScope.launch {
delay(50)
context.renderManager.clearAndRerenderPages()
}
startPageNum = null
Log.d(TAG, "Scale ended at zoom: ${context.zoomAnimator.currentZoomScale}")
}
}

View file

@ -0,0 +1,157 @@
package com.mattermost.securepdfviewer.pdfium.gesture
import android.util.Log
import android.view.GestureDetector
import android.view.MotionEvent
import com.mattermost.securepdfviewer.pdfium.interaction.ZoomAnimator.Companion.MIN_ZOOM_SCALE
import com.mattermost.securepdfviewer.pdfium.shared.PdfContext
import com.mattermost.securepdfviewer.pdfium.shared.PdfViewInterface
import kotlin.math.abs
/**
* Gesture listener for scroll handling with link tap detection and double tap zoom.
*
* Handles all single-touch gestures including scrolling, flinging, tapping,
* and double-tap zoom while maintaining proper interaction with the zoom system.
*/
class ScrollGestureListener(
private val context: PdfContext,
private val view: PdfViewInterface
) : GestureDetector.SimpleOnGestureListener() {
companion object {
private const val TAG = "ScrollGestureListener"
}
// Fling state management
var isCustomFlinging = false
private set
var flingVelocity = 0f
private set
var flingStartTime = 0L
private set
internal val flingDeceleration = 2000f
fun stopCustomFlinging() {
isCustomFlinging = false
}
override fun onDown(e: MotionEvent): Boolean {
// Stop any ongoing fling/scroll animation when user touches
if (!context.scroller.isFinished) {
context.scroller.abortAnimation()
}
// Stop custom fling
if (isCustomFlinging) {
isCustomFlinging = false
}
return true
}
override fun onSingleTapConfirmed(e: MotionEvent): Boolean {
// Handle link taps first, then general taps
val linkHandled = context.linkHandler.handleLinkTap(e.x, e.y)
if (!linkHandled) {
context.onTap?.invoke(e)
}
return true
}
override fun onDoubleTap(e: MotionEvent): Boolean {
context.zoomAnimator.handleDoubleTapZoom(e.x, e.y)
return true
}
override fun onScroll(
e1: MotionEvent?,
e2: MotionEvent,
distanceX: Float,
distanceY: Float
): Boolean {
if (context.zoomAnimator.isZooming) return true
// Scale vertical distance for consistent feel across zoom levels
val adjustedDistanceY = distanceY / context.zoomAnimator.currentZoomScale
context.scrollHandler.scrollY += adjustedDistanceY
// Handle horizontal scrolling when zoomed
if (context.zoomAnimator.currentZoomScale > MIN_ZOOM_SCALE + 0.1f) {
context.scrollHandler.scrollX += distanceX
context.scrollHandler.constrainHorizontalScroll()
} else {
context.scrollHandler.scrollX = 0f
}
val maxScroll = maxOf(0f, context.scrollHandler.totalDocumentHeight - view.viewHeight)
context.scrollHandler.scrollY = context.scrollHandler.scrollY.coerceIn(0f, maxScroll)
context.scrollHandler.updateScrollHandleImmediate()
view.invalidate()
return true
}
override fun onFling(
e1: MotionEvent?,
e2: MotionEvent,
velocityX: Float,
velocityY: Float
): Boolean {
if (context.zoomAnimator.isZooming) return true
// Stop any existing fling
context.scroller.forceFinished(true)
isCustomFlinging = false
// Handle fling based on zoom state and velocity direction
if (context.zoomAnimator.currentZoomScale > MIN_ZOOM_SCALE + 0.1f && abs(velocityX) > abs(velocityY)) {
// Horizontal fling when zoomed
val maxPageWidth = context.layoutCalculator.getMaxPageWidth()
val effectiveZoom = context.zoomAnimator.baseZoom * context.zoomAnimator.currentZoomScale
val scaledContentWidth = maxPageWidth * effectiveZoom
val overflowWidth = scaledContentWidth - view.viewWidth
if (overflowWidth > 0f) {
val maxHorizontalScroll = overflowWidth / 2f
context.scroller.fling(
context.scrollHandler.scrollX.toInt(), 0,
(-velocityX).toInt(), 0, // Flip X velocity, no Y velocity
(-maxHorizontalScroll).toInt(), maxHorizontalScroll.toInt(),
0, 0
)
view.invalidate()
return true
}
}
// Vertical fling
val initialVelocity = -velocityY
if (abs(initialVelocity) > 100) {
startCustomFling(initialVelocity)
}
return true
}
/**
* Starts a custom fling animation with consistent deceleration.
*
* @param initialVelocity Starting velocity in pixels per second
*/
private fun startCustomFling(initialVelocity: Float) {
isCustomFlinging = true
flingVelocity = initialVelocity
flingStartTime = System.currentTimeMillis()
Log.d(TAG, "Starting custom fling with velocity: $flingVelocity")
view.invalidate()
}
}

View file

@ -0,0 +1,153 @@
package com.mattermost.securepdfviewer.pdfium.interaction
import android.graphics.RectF
import android.util.Log
import com.mattermost.pdfium.model.PdfLink
import com.mattermost.securepdfviewer.pdfium.PdfPage
import com.mattermost.securepdfviewer.pdfium.shared.PdfContext
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
/**
* Handles link detection and interaction within PDF pages.
*
* This class manages the complete link handling pipeline:
* - Coordinate conversion from screen space to page space
* - Link hit testing with configurable tolerance for easier tapping
* - Navigation handling for both internal page links and external URLs
* - Hit slop implementation for improved touch accuracy
*
* The link handler integrates with the coordinate conversion system to accurately
* map touch events to PDF page coordinates, accounting for zoom level, scroll
* position, and page layout.
*/
class LinkHandler(private val context: PdfContext) {
companion object {
private const val TAG = "LinkHandler"
private const val LINK_HIT_SLOP_DP = 8
}
/**
* Handles link tap detection and navigation.
*
* Performs coordinate conversion and link hit testing with tolerance
* for easier link activation. Handles both internal page navigation
* and external URL callbacks.
*
* @param screenX X coordinate of tap on screen
* @param screenY Y coordinate of tap on screen
* @return true if a link was found and handled, false otherwise
*/
fun handleLinkTap(screenX: Float, screenY: Float): Boolean {
return try {
val doc = context.document
// Find which page was tapped
val pageNum = context.coordinateConverter.getPageAtScreenCoordinates(screenX, screenY)
if (pageNum == null) {
Log.d(TAG, "No page found at screen coordinates")
return false
}
Log.d(TAG, "Found page $pageNum at tap location")
val pageCoordinates = context.coordinateConverter.screenToPageCoordinates(screenX, screenY, pageNum)
if (pageCoordinates == null) {
Log.d(TAG, "Failed to convert to page coordinates")
return false
}
Log.d(TAG, "Page coordinates: (${pageCoordinates.x}, ${pageCoordinates.y})")
val page = doc.getPage(pageNum)
val link = runBlocking { findLinkWithHitSlop(page, pageCoordinates.x, pageCoordinates.y, pageNum) }
if (link != null) {
Log.d(TAG, "Link found: ${link.getType()} - ${link.uri ?: "page ${link.destinationPage}"}")
when {
link.isInternal() && link.destinationPage != null -> {
context.viewScope.launch {
context.zoomAnimator.zoomToBase()
context.layoutCalculator.jumpToPage(link.destinationPage!!)
}
}
link.isExternal() -> {
context.onLinkTapped?.invoke(link)
}
else -> {
Log.w(TAG, "Unknown link type: $link")
}
}
return true
} else {
Log.d(TAG, "No link found at page coordinates")
}
false
} catch (e: Exception) {
Log.e(TAG, "Error handling link tap", e)
false
}
}
/**
* Finds a link near the specified point with hit slop tolerance.
*
* Implements a two-pass link detection system: first tries exact hit,
* then expands search area with configurable hit slop for easier tapping.
*
* @param page The page to search for links
* @param pageX X coordinate in page space
* @param pageY Y coordinate in page space
* @param pageNum Page number for coordinate conversion
* @return Link if found within hit slop area, null otherwise
*/
private suspend fun findLinkWithHitSlop(page: PdfPage, pageX: Float, pageY: Float, pageNum: Int): PdfLink? {
return try {
val links = context.cacheManager.getCachedLinks(pageNum) ?: context.nativeCoordinator.withNativeAccess("get-links-$pageNum") { page.getLinksSafe() }
if (links.isNullOrEmpty()) return null
context.cacheManager.cacheLinks(pageNum, links)
// Convert hit slop from screen pixels to page coordinates
val hitSlopPx = context.dpToPx(LINK_HIT_SLOP_DP)
val pageSize = context.cacheManager.withSynchronizedCache { context.cacheManager.getPageSize(pageNum) } ?: return null
val scaledWidth = pageSize.first * context.zoomAnimator.baseZoom
// Calculate hit slop in page coordinate space
val hitSlopPage = (hitSlopPx / scaledWidth) * pageSize.first
Log.d(TAG, "Checking ${links.size} links with hit slop: $hitSlopPage page units")
// Then try with hit slop expansion
links.find { link ->
val correctedBounds = RectF(
link.bounds.left,
minOf(link.bounds.top, link.bounds.bottom),
link.bounds.right,
maxOf(link.bounds.top, link.bounds.bottom)
)
val expandedBounds = RectF(
correctedBounds.left - hitSlopPage,
correctedBounds.top - hitSlopPage,
correctedBounds.right + hitSlopPage,
correctedBounds.bottom + hitSlopPage
)
Log.d(TAG, "Link expanded bounds expandedBounds")
val contains = expandedBounds.contains(pageX, pageY)
if (contains) {
Log.d(TAG, "Link hit with slop - original: ${link.bounds}, expanded: $expandedBounds, tap: ($pageX, $pageY)")
}
contains
}
} catch (e: Exception) {
Log.e(TAG, "Error finding link with hit slop", e)
null
}
}
}

View file

@ -0,0 +1,211 @@
package com.mattermost.securepdfviewer.pdfium.interaction
import android.util.Log
import com.mattermost.securepdfviewer.pdfium.interaction.ZoomAnimator.Companion.MIN_ZOOM_SCALE
import com.mattermost.securepdfviewer.pdfium.shared.PdfContext
import com.mattermost.securepdfviewer.pdfium.shared.PdfViewInterface
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
/**
* Handles all scrolling operations, constraints, and scroll handle integration for the PDF viewer.
*
* This class manages:
* - Scroll animation with custom fling implementation and zoom animation
* - Scroll constraint system for horizontal and vertical bounds
* - Scroll handle integration with smooth tracking during user interaction
* - Page change detection and current page tracking
* - Scroll position calculations and conversions
*
* The handler orchestrates multiple animation systems and provides smooth scrolling
* experience while maintaining proper integration with zoom and layout systems.
*/
class ScrollHandler(private val context: PdfContext, private val view: PdfViewInterface) {
companion object {
private const val TAG = "ScrollHandler"
}
// Scroll State
var scrollY = 0f
internal set
var scrollX = 0f
internal set
var totalDocumentHeight = 0f
internal set
// Scroll Animation System
/**
* Handles scroll animation with custom fling implementation and zoom animation.
*
* Orchestrates multiple animation systems:
* - Zoom animations with smooth interpolation
* - Standard Android scroller for smooth scrolling
* - Custom fling implementation for consistent behavior
* - Scroll handle updates during all animation types
*/
fun computeScroll() {
var shouldInvalidate = false
var scrollChanged = false
// Handle zoom animation first
if (context.zoomAnimator.updateZoomAnimation()) {
shouldInvalidate = true
}
// Handle standard scroller (for non-fling animations) - only if not zooming
if (context.scroller.computeScrollOffset() && !context.scrollGestureListener.isCustomFlinging && !context.zoomAnimator.isZooming) {
if (context.zoomAnimator.currentZoomScale > MIN_ZOOM_SCALE + 0.1f) {
val newScrollX = context.scroller.currX.toFloat()
scrollX = newScrollX
constrainHorizontalScroll()
} else {
scrollY = context.scroller.currY.toFloat()
val maxScroll = maxOf(0f, totalDocumentHeight - view.viewHeight)
scrollY = scrollY.coerceIn(0f, maxScroll)
}
shouldInvalidate = true
scrollChanged = true
}
// Handle custom fling - only if not zooming
if (context.scrollGestureListener.isCustomFlinging && !context.zoomAnimator.isZooming) {
val currentTime = System.currentTimeMillis()
val elapsedTime = (currentTime - context.scrollGestureListener.flingStartTime) / 1000f // Convert to seconds
// Calculate current velocity: v = v0 - at
val currentVelocity = context.scrollGestureListener.flingVelocity - (context.scrollGestureListener.flingDeceleration * elapsedTime * kotlin.math.sign(context.scrollGestureListener.flingVelocity))
// Stop if velocity has reversed or is too small
if (currentVelocity * context.scrollGestureListener.flingVelocity <= 0 || kotlin.math.abs(currentVelocity) < 50) {
context.scrollGestureListener.stopCustomFlinging()
Log.d(TAG, "Custom fling finished")
} else {
val newScrollY = scrollY + (currentVelocity * 0.016f) // Approximate frame time
val maxScroll = maxOf(0f, totalDocumentHeight - view.viewHeight)
val constrainedScrollY = newScrollY.coerceIn(0f, maxScroll)
// Stop fling immediately if we hit a boundary
if (constrainedScrollY != newScrollY) {
context.scrollGestureListener.stopCustomFlinging()
Log.d(TAG, "Custom fling stopped at boundary")
}
scrollY = constrainedScrollY
shouldInvalidate = true
scrollChanged = true
}
}
if (shouldInvalidate) {
if (scrollChanged) {
updateScrollHandleImmediate()
}
view.invalidate()
}
}
// Scroll Constraint system
/**
* Constrains horizontal scroll to valid bounds when zoomed.
*
* Prevents horizontal panning beyond page edges while allowing
* full page content to be accessible through panning gestures.
*/
fun constrainHorizontalScroll() {
if (context.zoomAnimator.currentZoomScale <= MIN_ZOOM_SCALE + 0.1f) {
scrollX = 0f
return
}
try {
val maxPageWidth = context.layoutCalculator.getMaxPageWidth()
if (maxPageWidth <= 0f) return
val effectiveZoom = context.zoomAnimator.baseZoom * context.zoomAnimator.currentZoomScale
val scaledContentWidth = maxPageWidth * effectiveZoom
// Calculate how much of the page extends beyond the screen
val overflowWidth = scaledContentWidth - view.viewWidth
if (overflowWidth <= 0f) {
// Page fits within screen width, center it
scrollX = 0f
} else {
// Allow scrolling to show the overflow content
val maxHorizontalScroll = overflowWidth / 2f
scrollX = scrollX.coerceIn(-maxHorizontalScroll, maxHorizontalScroll)
}
} catch (e: Exception) {
Log.e(TAG, "Error constraining horizontal scroll", e)
scrollX = 0f
}
}
// Scroll handle integration
/**
* Gets the current scroll percentage for scroll handle positioning.
*
* @return Current scroll position as percentage (0.0 to 1.0)
*/
private fun getCurrentScrollPercentage(): Float {
return try {
val currentDocHeight = if (context.zoomAnimator.currentZoomScale > MIN_ZOOM_SCALE + 0.1f) {
context.layoutCalculator.calculateTotalDocumentHeightWithZoom(context.zoomAnimator.baseZoom * context.zoomAnimator.currentZoomScale)
} else {
totalDocumentHeight
}
val maxScroll = maxOf(0f, currentDocHeight - view.viewHeight)
if (maxScroll > 0) scrollY / maxScroll else 0f
} catch (e: Exception) {
0f
}
}
/**
* Updates scroll handle immediately for smooth tracking during user interaction.
*/
fun updateScrollHandleImmediate() {
try {
val currentPageNum = context.layoutCalculator.getCurrentVisiblePage()
val scrollPercentage = getCurrentScrollPercentage()
context.documentManager.currentPage = currentPageNum
context.onPageChanged?.invoke(currentPageNum)
context.onScrollChanged?.invoke(scrollPercentage)
} catch (e: Exception) {
Log.e(TAG, "Error updating scroll handle", e)
}
}
/**
* Updates scroll handle with delay for zoom operations to let layout settle.
*/
fun updateScrollHandleAfterZoom() {
context.viewScope.launch {
delay(100) // Allow layout to settle after zoom
updateScrollHandleImmediate()
}
}
// Scroll management
/**
* Resets scroll state to initial values.
*/
fun reset() {
scrollY = 0f
scrollX = 0f
totalDocumentHeight = 0f
}
}

View file

@ -0,0 +1,327 @@
package com.mattermost.securepdfviewer.pdfium.interaction
import android.util.Log
import com.mattermost.securepdfviewer.pdfium.PdfView.Companion.PAGE_SPACING
import com.mattermost.securepdfviewer.pdfium.shared.PdfContext
import com.mattermost.securepdfviewer.pdfium.shared.PdfViewInterface
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
class ZoomAnimator(private val context: PdfContext, private val view: PdfViewInterface) {
companion object {
private const val TAG = "ZoomAnimator"
// Zoom constants
const val MIN_ZOOM_SCALE = 1.0f
const val MAX_ZOOM_SCALE = 3.0f
private const val ZOOM_ANIMATION_DURATION = 300
}
/**
* Base zoom level required to fit page width to screen width.
*/
var baseZoom = 1.0f
internal set
/**
* Current zoom scale factor (1.0 = fit-to-width, 3.0 = maximum zoom).
*/
var currentZoomScale = MIN_ZOOM_SCALE
internal set
/**
* Flag indicating if zoom animation is currently in progress.
*/
var isZooming = false
private set
// Animation state
private var zoomStartTime = 0L
private var zoomStartScale = MIN_ZOOM_SCALE
private var zoomTargetScale = MIN_ZOOM_SCALE
private var zoomStartScrollY = 0f
private var zoomStartScrollX = 0f
private var zoomTargetScrollY = 0f
private var zoomTargetScrollX = 0f
/**
* Calculates the base zoom level required to fit page width to screen width.
*
* This zoom level serves as the foundation for all scaling operations,
* ensuring pages are properly sized for the current device screen.
*/
fun calculateBaseZoom() {
val doc = context.document
if (!doc.isValid() || view.viewWidth <= 0) {
Log.d(TAG,"Cannot calculate zoom: doc=${doc.isValid()}, width=${view.viewWidth}")
return
}
try {
val pageSize = doc.getPageSize(0)
if (pageSize == null) {
Log.e(TAG, "Could not get page 0 size")
baseZoom = 1.0f
return
}
val availableWidth = view.viewWidth - (PAGE_SPACING * 2)
baseZoom = availableWidth / pageSize.first
Log.d(TAG, "Calculated base zoom: $baseZoom (page: ${pageSize.first}x${pageSize.second}, view: ${view.viewWidth}x${view.viewHeight})")
} catch (e: Exception) {
Log.e(TAG, "Error calculating zoom", e)
baseZoom = 1.0f
}
}
/**
* Handles double tap zoom toggle with focal point preservation.
*
* Implements intelligent zoom toggling between fit-width and 2x zoom,
* with precise focal point calculation to keep the tapped area centered
* during the zoom transition.
*
* @param focusX X coordinate of the double tap
* @param focusY Y coordinate of the double tap
*/
fun handleDoubleTapZoom(focusX: Float, focusY: Float) {
try {
// Stop any ongoing animations
context.scroller.forceFinished(true)
context.scrollGestureListener.stopCustomFlinging()
// Determine target zoom level
val targetScale = if (currentZoomScale > MIN_ZOOM_SCALE + 0.1f) {
MIN_ZOOM_SCALE // Zoom out to fit width
} else {
2.0f // Zoom in
}
if (kotlin.math.abs(targetScale - currentZoomScale) < 0.1f) {
Log.d(TAG, "Already at target zoom level")
return
}
context.layoutCalculator.updateDocumentLayout()
val baseScale = baseZoom * currentZoomScale
// Get the specific page that was tapped
val tappedPageNum = context.coordinateConverter.getPageAtScreenCoordinates(focusX, focusY) ?: 0
val pageOffsetY = context.cacheManager.getPageOffset(tappedPageNum) ?: 0f
val pageSize = context.cacheManager.getPageSize(tappedPageNum) ?: return
val scaledWidth = pageSize.first * baseScale
val scaledHeight = pageSize.second * baseScale
val pageLeft = (view.viewWidth - scaledWidth) / 2f
// Calculate relative position within the tapped page
val relativeX = ((focusX + context.scrollHandler.scrollX - pageLeft) / scaledWidth).coerceIn(0f, 1f)
val relativeY = ((focusY + context.scrollHandler.scrollY - pageOffsetY) / scaledHeight).coerceIn(0f, 1f)
val focusDocX = relativeX * pageSize.first
val focusDocY = relativeY * pageSize.second
// Calculate target scroll position
val tempCurrentZoom = currentZoomScale
currentZoomScale = targetScale
val newBaseScale = baseZoom * currentZoomScale
// Temporarily update layout to calculate target position
context.layoutCalculator.updateDocumentLayout()
val newPageOffsetY = context.cacheManager.getPageOffset(tappedPageNum) ?: 0f
val newScaledWidth = pageSize.first * newBaseScale
val newScaledHeight = pageSize.second * newBaseScale
val newPageLeft = (view.viewWidth - newScaledWidth) / 2f
// Calculate where the focal point should be
val targetX = newPageLeft + (focusDocX / pageSize.first) * newScaledWidth
val targetY = newPageOffsetY + (focusDocY / pageSize.second) * newScaledHeight
val targetScrollX = targetX - focusX
val targetScrollY = targetY - focusY
// Restore original zoom and layout for animation
currentZoomScale = tempCurrentZoom
context.layoutCalculator.updateDocumentLayout()
// Constrain target scroll position
val tempZoom = currentZoomScale
currentZoomScale = targetScale
context.layoutCalculator.updateDocumentLayout()
val maxScrollY = maxOf(0f, context.scrollHandler.totalDocumentHeight - view.viewHeight)
val constrainedTargetScrollY = targetScrollY.coerceIn(0f, maxScrollY)
// Constrain horizontal scroll
val constrainedTargetScrollX = if (targetScale > MIN_ZOOM_SCALE) {
val maxPageWidth = context.layoutCalculator.getMaxPageWidth()
val effectiveZoom = baseZoom * targetScale
val scaledZoomedWidth = maxPageWidth * effectiveZoom
val overflowWidth = scaledZoomedWidth - view.viewWidth
val maxHorizontalScroll = if (overflowWidth > 0f) overflowWidth / 2f else 0f
targetScrollX.coerceIn(-maxHorizontalScroll, maxHorizontalScroll)
} else {
0f
}
// Restore for animation
currentZoomScale = tempZoom
context.layoutCalculator.updateDocumentLayout()
// Start smooth animation to calculated target
startZoomAnimation(targetScale, constrainedTargetScrollY, constrainedTargetScrollX)
Log.d(TAG, "Double tap zoom: $currentZoomScale -> $targetScale")
Log.d(TAG, "Page $tappedPageNum, relative pos: ($relativeX, $relativeY)")
Log.d(TAG, "Target scroll: ($constrainedTargetScrollX, $constrainedTargetScrollY)")
} catch (e: Exception) {
Log.e(TAG, "Error handling double tap zoom", e)
}
}
/**
* Updates zoom animation state and applies interpolated values.
*
* Uses ease-out interpolation for natural animation feel and applies
* real-time constraints to prevent scrolling beyond document bounds.
*
* @return true if animation should continue, false if complete
*/
fun updateZoomAnimation(): Boolean {
if (!isZooming) return false
try {
val currentTime = System.currentTimeMillis()
val elapsed = (currentTime - zoomStartTime).toFloat()
val progress = (elapsed / ZOOM_ANIMATION_DURATION).coerceIn(0f, 1f)
// Use ease-out interpolation for smooth animation
val interpolatedProgress = 1f - (1f - progress) * (1f - progress)
// Calculate current values
val newScale = zoomStartScale + (zoomTargetScale - zoomStartScale) * interpolatedProgress
val newScrollY = zoomStartScrollY + (zoomTargetScrollY - zoomStartScrollY) * interpolatedProgress
val newScrollX = zoomStartScrollX + (zoomTargetScrollX - zoomStartScrollX) * interpolatedProgress
// Apply new values
currentZoomScale = newScale
context.scrollHandler.scrollX = newScrollX
context.scrollHandler.scrollY = newScrollY
// Apply real-time constraints
val maxScrollY = maxOf(0f, context.scrollHandler.totalDocumentHeight - view.viewHeight)
context.scrollHandler.scrollY = context.scrollHandler.scrollY.coerceIn(0f, maxScrollY)
if (currentZoomScale > MIN_ZOOM_SCALE + 0.1f) {
val maxPageWidth = context.layoutCalculator.getMaxPageWidth()
val currentEffectiveZoom = baseZoom * currentZoomScale
val scaledContentWidth = maxPageWidth * currentEffectiveZoom
val overflowWidth = scaledContentWidth - view.viewWidth
val maxHorizontalScroll = if (overflowWidth > 0f) overflowWidth / 2f else 0f
context.scrollHandler.scrollX = context.scrollHandler.scrollX.coerceIn(-maxHorizontalScroll, maxHorizontalScroll)
} else {
context.scrollHandler.scrollX = 0f
}
context.layoutCalculator.updateDocumentLayout()
// Update scroll handle periodically during animation
if (System.currentTimeMillis() % 3 == 0L) {
context.scrollHandler.updateScrollHandleImmediate()
}
// Check if animation is complete
if (progress >= 1f) {
isZooming = false
currentZoomScale = zoomTargetScale
context.layoutCalculator.updateDocumentLayout()
context.scrollHandler.updateScrollHandleAfterZoom()
context.viewScope.launch {
delay(100)
context.renderManager.clearAndRerenderPages()
}
Log.d(TAG, "Zoom animation completed at scale: $currentZoomScale")
return false
}
return true
} catch (e: Exception) {
Log.e(TAG, "Error updating zoom animation", e)
isZooming = false
return false
}
}
/**
* Resets zoom state to initial values.
*/
fun reset() {
currentZoomScale = MIN_ZOOM_SCALE
isZooming = false
baseZoom = 1.0f
// Reset animation state
zoomStartTime = 0L
zoomStartScale = MIN_ZOOM_SCALE
zoomTargetScale = MIN_ZOOM_SCALE
zoomStartScrollY = 0f
zoomStartScrollX = 0f
zoomTargetScrollY = 0f
zoomTargetScrollX = 0f
}
/**
* Smoothly animates zoom back to base level and waits until it completes.
*/
suspend fun zoomToBase() {
if (currentZoomScale <= MIN_ZOOM_SCALE + 0.01f) return
val latch = CompletableDeferred<Unit>()
startZoomAnimation(
targetScale = MIN_ZOOM_SCALE,
targetScrollY = 0f, // or maintain scroll position if needed
targetScrollX = 0f
)
// Wait until zoom finishes
while (isZooming) {
delay(16)
}
latch.complete(Unit)
}
// Private methods
/**
* Starts a smooth zoom animation to the calculated target state.
*
* @param targetScale Target zoom scale
* @param targetScrollY Target vertical scroll position
* @param targetScrollX Target horizontal scroll position
*/
private fun startZoomAnimation(
targetScale: Float,
targetScrollY: Float,
targetScrollX: Float,
) {
isZooming = true
zoomStartTime = System.currentTimeMillis()
zoomStartScale = currentZoomScale
zoomTargetScale = targetScale
zoomStartScrollY = context.scrollHandler.scrollY
zoomTargetScrollY = targetScrollY
zoomStartScrollX = context.scrollHandler.scrollX
zoomTargetScrollX = targetScrollX
view.invalidate()
}
}

View file

@ -0,0 +1,105 @@
package com.mattermost.securepdfviewer.pdfium.layout
import android.graphics.PointF
import android.util.Log
import com.mattermost.securepdfviewer.pdfium.shared.PdfContext
import com.mattermost.securepdfviewer.pdfium.shared.PdfViewInterface
/**
* Utility class for converting coordinates between different coordinate systems in the PDF viewer.
*
* Handles conversions between:
* - Screen coordinates (relative to the view)
* - Page coordinates (relative to individual PDF pages)
* - Document coordinates (relative to the entire document layout)
*
* Accounts for scroll position, zoom level, and page centering to accurately
* map coordinates between the different coordinate spaces used throughout the viewer.
*/
class CoordinateConverter(private val context: PdfContext, private val view: PdfViewInterface) {
companion object {
private const val TAG = "CoordinateConverter"
}
/**
* Converts screen coordinates to page coordinates for link detection.
*
* Accounts for scroll position, zoom level, and page centering to accurately
* map touch coordinates to positions within the PDF page coordinate system.
*
* @param screenX X coordinate on screen
* @param screenY Y coordinate on screen (relative to view)
* @param pageNum Page number to convert coordinates for
* @return Point in page coordinate space, or null if conversion fails
*/
fun screenToPageCoordinates(screenX: Float, screenY: Float, pageNum: Int): PointF? {
return try {
context.cacheManager.withSynchronizedCache {
val pageOffset = context.cacheManager.getPageOffset(pageNum) ?: return@withSynchronizedCache null
val originalPageSize = context.cacheManager.getPageSize(pageNum) ?: return@withSynchronizedCache null
val effectiveZoom = context.zoomAnimator.baseZoom * context.zoomAnimator.currentZoomScale
val scaledWidth = originalPageSize.first * effectiveZoom
val scaledHeight = originalPageSize.second * effectiveZoom
val pageLeft = (view.viewWidth - scaledWidth) / 2f
// Check if coordinates are within page bounds
if (screenX < pageLeft - context.scrollHandler.scrollX || screenX > pageLeft + scaledWidth - context.scrollHandler.scrollX ||
screenY < pageOffset - context.scrollHandler.scrollY || screenY > pageOffset + scaledHeight - context.scrollHandler.scrollY) {
return@withSynchronizedCache null
}
// Convert to relative coordinates within the page
val relativeX = (screenX - (pageLeft - context.scrollHandler.scrollX)) / scaledWidth
val relativeY = (screenY - (pageOffset - context.scrollHandler.scrollY)) / scaledHeight
// Convert to actual page coordinates
val pageX = originalPageSize.first * relativeX
val pageY = originalPageSize.second * (1f - relativeY)
PointF(pageX, pageY)
}
} catch (e: Exception) {
Log.e(TAG, "Error converting screen coordinates to page coordinates", e)
null
}
}
/**
* Finds which page contains the given screen coordinates.
*
* @param screenX X coordinate on screen
* @param screenY Y coordinate on screen (relative to view)
* @return Page number, or null if no page contains the point
*/
fun getPageAtScreenCoordinates(screenX: Float, screenY: Float): Int? {
return try {
val result = context.cacheManager.withSynchronizedCache {
context.cacheManager.getPageOffsets().forEach { (pageNum, pageOffset) ->
val originalPageSize = context.cacheManager.getPageSize(pageNum) ?: return@forEach
val effectiveZoom = context.zoomAnimator.baseZoom * context.zoomAnimator.currentZoomScale
val scaledWidth = originalPageSize.first * effectiveZoom
val scaledHeight = originalPageSize.second * effectiveZoom
val pageLeft = (view.viewWidth - scaledWidth) / 2f
val screenPageLeft = pageLeft - context.scrollHandler.scrollX
val screenPageTop = pageOffset - context.scrollHandler.scrollY
val screenPageRight = screenPageLeft + scaledWidth
val screenPageBottom = screenPageTop + scaledHeight
if (screenX in screenPageLeft..screenPageRight &&
screenY >= screenPageTop && screenY <= screenPageBottom) {
return@withSynchronizedCache pageNum
}
}
null
}
result
} catch (e: Exception) {
Log.e(TAG, "Error finding page at screen coordinates", e)
null
}
}
}

View file

@ -0,0 +1,501 @@
package com.mattermost.securepdfviewer.pdfium.layout
import android.util.Log
import com.mattermost.securepdfviewer.pdfium.PdfView.Companion.PAGE_SPACING
import com.mattermost.securepdfviewer.pdfium.interaction.ZoomAnimator.Companion.MIN_ZOOM_SCALE
import com.mattermost.securepdfviewer.pdfium.shared.PdfContext
import com.mattermost.securepdfviewer.pdfium.shared.PdfViewInterface
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import java.util.concurrent.atomic.AtomicBoolean
import java.util.concurrent.atomic.AtomicLong
import kotlin.coroutines.cancellation.CancellationException
/**
* Handles all document layout calculations for the PDF viewer.
*
* This class manages:
* - Page positioning and offset calculations
* - Document height calculations at various zoom levels
* - Page visibility detection and bounds calculations
* - Multi-page and single-page layout logic
* - Real-time layout calculations during animations
*
* The calculator handles both cached layout values for performance and
* real-time calculations during zoom animations to prevent layout jumping.
*/
class LayoutCalculator(
private val context: PdfContext,
private val view: PdfViewInterface
) {
companion object {
private const val TAG = "LayoutCalculator"
private const val DEFERRED_PAGE_SIZE_START_INDEX = 10
private const val IDLE_THRESHOLD_MS = 500L
private const val RENDER_SETTLE_CHECK_DELAY = 60L
}
private val estimatedPageSizeSet = mutableSetOf<Int>()
private val isPaused = AtomicBoolean(false)
private val lastRenderTime = AtomicLong(0L)
private var deferredJob: Job? = null
/**
* Jumps to the specified page with smooth scrolling animation.
*
* Handles both zoomed and normal view states, with immediate positioning
* when zoomed for accuracy and animated scrolling at base zoom.
*
* @param pageNum 0-based page number to navigate to
*/
fun jumpToPage(pageNum: Int) {
try {
val doc = context.document
val pageCount = doc.getPageCount()
if (pageNum < 0 || pageNum >= pageCount) {
Log.w(TAG, "Invalid page number for jump: $pageNum (valid: 0-${pageCount-1})")
return
}
context.cacheManager.withSynchronizedCache {
val targetOffset = if (context.zoomAnimator.currentZoomScale > MIN_ZOOM_SCALE + 0.1f) {
calculatePageOffsetRealTime(pageNum, context.zoomAnimator.baseZoom * context.zoomAnimator.currentZoomScale)
} else {
context.cacheManager.getPageOffset(pageNum) ?: return@withSynchronizedCache
}
val currentDocumentHeight = if (context.zoomAnimator.currentZoomScale > MIN_ZOOM_SCALE + 0.1f) {
calculateTotalDocumentHeightWithZoom(context.zoomAnimator.baseZoom * context.zoomAnimator.currentZoomScale)
} else {
context.scrollHandler.totalDocumentHeight
}
val maxScroll = maxOf(0f, currentDocumentHeight - view.viewHeight)
val targetScroll = targetOffset.coerceIn(0f, maxScroll)
// Stop any ongoing animations
context.scroller.forceFinished(true)
context.scrollGestureListener.stopCustomFlinging()
if (context.zoomAnimator.currentZoomScale > MIN_ZOOM_SCALE + 0.1f) {
// Immediate positioning when zoomed for accuracy
context.scrollHandler.scrollY = targetScroll
view.invalidate()
Log.d(TAG, "Jumped to page $pageNum at zoom ${context.zoomAnimator.currentZoomScale} (immediate)")
} else {
// Smooth animation at base zoom
context.scroller.startScroll(
0, context.scrollHandler.scrollY.toInt(),
0, (targetScroll - context.scrollHandler.scrollY).toInt(),
300
)
view.invalidate()
Log.d(TAG, "Jumping to page $pageNum with animation")
}
view.invalidate()
Log.d(TAG, "Jumping to page $pageNum")
}
} catch (e: Exception) {
Log.e(TAG, "Error jumping to page $pageNum", e)
}
}
/**
* Updates the document layout based on current zoom scale.
*
* Recalculates page positions and total document height when zoom changes,
* with special handling for single-page documents which are vertically centered.
*/
fun updateDocumentLayout() {
val doc = context.document
try {
val pageCount = doc.getPageCount()
var currentOffset = PAGE_SPACING.toFloat()
val effectiveZoom = context.zoomAnimator.baseZoom * context.zoomAnimator.currentZoomScale
context.cacheManager.withSynchronizedCache {
context.cacheManager.clearPageOffsets()
if (pageCount == 1) {
val pageSize = context.cacheManager.getPageSize(0)
if (pageSize != null) {
val scaledHeight = pageSize.second * effectiveZoom
// Center single pages vertically for better presentation
val centeredOffset = maxOf(PAGE_SPACING.toFloat(), (view.viewHeight - scaledHeight) / 2f)
context.cacheManager.setPageOffset(0, centeredOffset)
context.scrollHandler.totalDocumentHeight = centeredOffset + scaledHeight + PAGE_SPACING
}
} else {
// Multi-page documents use sequential layout
for (i in 0 until pageCount) {
val originalPageSize = context.cacheManager.getPageSize(i)
if (originalPageSize == null) {
Log.w(TAG, "No page size for page $i during layout update")
continue
}
context.cacheManager.setPageOffset(i, currentOffset)
val scaledHeight = originalPageSize.second * effectiveZoom
currentOffset += scaledHeight + PAGE_SPACING
}
context.scrollHandler.totalDocumentHeight = currentOffset
}
}
} catch (e: Exception) {
Log.e(TAG, "Error updating document layout", e)
}
}
/**
* Calculates page offset in real-time during zoom animations.
*
* Used during zoom animations to prevent layout jumping by calculating
* page positions dynamically instead of using cached values.
*/
fun calculatePageOffsetRealTime(pageNum: Int, effectiveZoom: Float): Float {
val doc = context.document
val pageCount = doc.getPageCount()
try {
// Special handling for single-page documents
if (pageCount == 1 && pageNum == 0) {
val pageSize = doc.getPageSize(0) ?: return PAGE_SPACING.toFloat()
val scaledHeight = pageSize.second * effectiveZoom
return maxOf(PAGE_SPACING.toFloat(), (view.viewHeight - scaledHeight) / 2f)
}
// Sequential calculation for multi-page documents
var currentOffset = PAGE_SPACING.toFloat()
for (i in 0 until pageNum) {
val pageSize = doc.getPageSize(i) ?: continue
val scaledHeight = pageSize.second * effectiveZoom
currentOffset += scaledHeight + PAGE_SPACING
}
return currentOffset
} catch (e: Exception) {
Log.e(TAG, "Error calculating real-time offset for page $pageNum", e)
return PAGE_SPACING.toFloat()
}
}
/**
* Calculates total document height with specified zoom level.
*
* Used for scroll constraint calculations and scroll handle positioning
* when zoom level differs from current cached layout.
*/
fun calculateTotalDocumentHeightWithZoom(effectiveZoom: Float): Float {
val doc = context.document
val pageCount = doc.getPageCount()
try {
if (pageCount == 1) {
val pageSize = doc.getPageSize(0) ?: return PAGE_SPACING.toFloat() * 2
val scaledHeight = pageSize.second * effectiveZoom
val centeredOffset = maxOf(PAGE_SPACING.toFloat(), (view.viewHeight - scaledHeight) / 2f)
return centeredOffset + scaledHeight + PAGE_SPACING
}
var currentOffset = PAGE_SPACING.toFloat()
for (i in 0 until pageCount) {
val pageSize = doc.getPageSize(i) ?: continue
val scaledHeight = pageSize.second * effectiveZoom
currentOffset += scaledHeight + PAGE_SPACING
}
return currentOffset
} catch (e: Exception) {
Log.e(TAG, "Error calculating document height with zoom", e)
return PAGE_SPACING.toFloat()
}
}
/**
* Gets the maximum page width from all pages in the document.
*
* Used for horizontal scroll constraint calculations when zoomed.
*/
fun getMaxPageWidth(): Float {
return try {
context.cacheManager.withSynchronizedCache {
val maxWidth = context.cacheManager.getMaxPageWidth()
if (maxWidth <= 0f) {
view.viewWidth.toFloat()
} else {
maxWidth
}
}
} catch (e: Exception) {
Log.e(TAG, "Error getting max page width", e)
view.viewWidth.toFloat()
}
}
/**
* Gets the list of currently visible page numbers based on viewport.
*
* Determines which pages intersect with the current viewport, accounting
* for zoom level and scroll position. Includes a buffer zone for smooth
* scrolling experience.
*/
fun getVisiblePages(): List<Int> {
val doc = context.document
val pageCount = doc.getPageCount()
val visiblePages = mutableListOf<Int>()
val viewTop = context.scrollHandler.scrollY
val viewBottom = context.scrollHandler.scrollY + view.viewHeight
val effectiveZoom = context.zoomAnimator.baseZoom * context.zoomAnimator.currentZoomScale
try {
context.cacheManager.withSynchronizedCache {
for (i in 0 until pageCount) {
val pageOffset = if (context.zoomAnimator.isZooming) {
calculatePageOffsetRealTime(i, effectiveZoom)
} else {
context.cacheManager.getPageOffset(i) ?: continue
}
val originalPageSize = context.cacheManager.getPageSize(i) ?: continue
val scaledHeight = originalPageSize.second * effectiveZoom
val pageBottom = pageOffset + scaledHeight
// Check if page intersects with visible area (with buffer for smooth scrolling)
if (pageBottom >= viewTop - view.viewHeight && pageOffset <= viewBottom + view.viewHeight) {
visiblePages.add(i)
}
}
}
} catch (e: Exception) {
Log.e(TAG, "Error getting visible pages", e)
}
return visiblePages
}
/**
* Determines the primary visible page number based on current scroll position.
*
* Uses intelligent heuristics to determine which page should be considered
* the "current" page, with special handling for short pages and edge cases.
*/
fun getCurrentVisiblePage(): Int {
val doc = context.document
val pageCount = doc.getPageCount()
// Handle edge cases for document boundaries
if (context.scrollHandler.scrollY <= 1f) {
return 0 // At top of document should always be page 1
}
val maxScroll = maxOf(0f, context.scrollHandler.totalDocumentHeight - view.viewHeight)
if (maxScroll > 0 && context.scrollHandler.scrollY >= maxScroll - 1f) {
return pageCount - 1 // At bottom should show last page
}
val effectiveZoom = context.zoomAnimator.baseZoom * context.zoomAnimator.currentZoomScale
context.cacheManager.withSynchronizedCache {
val currentPageSize = context.cacheManager.getPageSize(context.documentManager.currentPage) ?: return@withSynchronizedCache 0
val scaledPageHeight = currentPageSize.second * effectiveZoom
val viewportHeight = view.viewHeight.toFloat()
// Adjust detection point based on page height
val isShortPage = scaledPageHeight < (viewportHeight * 0.5f)
val detectionPoint = if (isShortPage) {
context.scrollHandler.scrollY + (viewportHeight / 3f)
} else {
context.scrollHandler.scrollY + (viewportHeight / 2f)
}
// Find page containing the detection point
for (i in 0 until pageCount) {
val pageOffset = if (context.zoomAnimator.isZooming) {
calculatePageOffsetRealTime(i, effectiveZoom)
} else {
context.cacheManager.getPageOffset(i) ?: continue
}
val pageSize = context.cacheManager.getPageSize(i) ?: continue
val scaledHeight = pageSize.second * effectiveZoom
val pageBottom = pageOffset + scaledHeight
if (detectionPoint >= pageOffset && detectionPoint < pageBottom) {
return@withSynchronizedCache i
}
}
}
// Fallback: estimate based on scroll percentage
return try {
val totalHeight = if (context.zoomAnimator.currentZoomScale > MIN_ZOOM_SCALE + 0.1f) {
calculateTotalDocumentHeightWithZoom(context.zoomAnimator.baseZoom * context.zoomAnimator.currentZoomScale)
} else {
context.scrollHandler.totalDocumentHeight
}
val scrollRatio = if (totalHeight > 0) context.scrollHandler.scrollY / totalHeight else 0f
(scrollRatio * pageCount).toInt().coerceIn(0, pageCount - 1)
} catch (e: Exception) {
0
}
}
/**
* Gets estimated page bounds for visibility calculations.
*
* @param pageNum Page number to get bounds for
* @return Pair of (top, bottom) coordinates, or null if invalid
*/
fun getEstimatedPageBounds(pageNum: Int): Pair<Float, Float>? {
return try {
val pageCount = context.cacheManager.getPageCount()
if (pageCount == null || pageNum < 0 || pageNum >= pageCount) return null
context.cacheManager.withSynchronizedCache {
val pageOffset = if (context.zoomAnimator.isZooming) {
calculatePageOffsetRealTime(pageNum, context.zoomAnimator.baseZoom * context.zoomAnimator.currentZoomScale)
} else {
context.cacheManager.getPageOffset(pageNum) ?: return@withSynchronizedCache null
}
val pageSize = context.cacheManager.getPageSize(pageNum) ?: return@withSynchronizedCache null
val effectiveZoom = context.zoomAnimator.baseZoom * context.zoomAnimator.currentZoomScale
val scaledHeight = pageSize.second * effectiveZoom
Pair(pageOffset, pageOffset + scaledHeight)
}
} catch (e: Exception) {
null
}
}
/**
* Pre-calculates and caches all page sizes for efficient layout operations.
*
* This optimization prevents repeated PDF calls during scrolling and zoom operations
* by loading all page dimensions once during document initialization.
*/
fun preCalculateInitialPageSizes() {
val doc = context.document
try {
if (!doc.isValid()) return
context.cacheManager.withSynchronizedCache {
context.cacheManager.clearPageSizes()
estimatedPageSizeSet.clear()
}
val pageCount = doc.getPageCount()
for (i in 0 until minOf(DEFERRED_PAGE_SIZE_START_INDEX, pageCount)) {
try {
runBlocking {
if (!doc.isValid() || context.isViewDestroyed()) return@runBlocking
doc.getPageSizeSafe(i)
}
} catch (e: Exception) {
Log.e(TAG, "Error getting page size for page $i", e)
}
}
context.cacheManager.withSynchronizedCache {
val avgWidth =
context.cacheManager.getPageSizes().values.map { it.first }.average()
.toFloat()
val avgHeight =
context.cacheManager.getPageSizes().values.map { it.second }.average()
.toFloat()
for (i in 5 until pageCount) {
setEstimatedPageSize(i, Pair(avgWidth, avgHeight))
}
}
Log.d(
TAG,
"Pre-calculated ${context.cacheManager.getPageSizes().size} page sizes"
)
} catch (e: Exception) {
Log.e(TAG, "Error pre-calculating page sizes", e)
}
}
fun notifyRenderingStarted() {
isPaused.compareAndSet(false, true)
lastRenderTime.set(System.currentTimeMillis())
Log.d(TAG, "Rendering Started")
}
fun notifyRenderingEnded() {
isPaused.compareAndSet(true, false)
lastRenderTime.set(System.currentTimeMillis())
Log.d(TAG, "Rendering Ended")
// We don't resume immediately — we wait for idle threshold in defer loop
}
fun stopCalculations() {
deferredJob?.cancel()
}
fun launchDeferredPageSizeCalculation(scope: CoroutineScope) {
deferredJob?.cancel()
deferredJob = scope.launch(Dispatchers.Default) {
delay(IDLE_THRESHOLD_MS * 2L) // start after half a second
try {
deferRemainingPageSizeCalculation()
} catch (e: CancellationException) {
Log.d(TAG, "Deferred page size calculation cancelled")
} catch (e: Exception) {
Log.e(TAG, "Deferred page size calculation crashed", e)
}
}
}
private fun setEstimatedPageSize(pageNum: Int, size: Pair<Float, Float>) {
context.cacheManager.setPageSize(pageNum, size)
estimatedPageSizeSet.add(pageNum)
}
private fun hasSettledSinceLastRender(): Boolean {
val now = System.currentTimeMillis()
return now - lastRenderTime.get() >= IDLE_THRESHOLD_MS
}
private suspend fun deferRemainingPageSizeCalculation() {
if (estimatedPageSizeSet.isEmpty()) return
val doc = context.document
val pageCount = doc.getPageCount()
if (DEFERRED_PAGE_SIZE_START_INDEX >= pageCount) return
for (i in DEFERRED_PAGE_SIZE_START_INDEX until pageCount) {
// Wait until rendering settles
while(isPaused.get() || !hasSettledSinceLastRender()) {
delay(RENDER_SETTLE_CHECK_DELAY)
}
try {
if (!doc.isValid() || context.isViewDestroyed()) return
// We skip checking for the cached value, but we do cache the result
doc.getPageSizeSafe(i, true)
} catch (e: Exception) {
Log.w(TAG, "Error on page $i: ${e.message}")
}
if (i % 3 == 0) {
delay(5)
}
}
estimatedPageSizeSet.clear()
}
}

View file

@ -0,0 +1,142 @@
package com.mattermost.securepdfviewer.pdfium.manager
import android.util.Log
import com.mattermost.securepdfviewer.pdfium.PdfDocument
import com.mattermost.securepdfviewer.pdfium.shared.PdfContext
import com.mattermost.securepdfviewer.pdfium.shared.PdfViewInterface
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.util.concurrent.atomic.AtomicBoolean
class PdfDocumentManager(private val context: PdfContext, private val view: PdfViewInterface) {
companion object {
private const val TAG = "PdfDocumentManager"
}
private val isDocumentLoading = AtomicBoolean(false)
var currentPage = 0
internal set
/**
* Gets whether the document is currently loading.
*/
fun isDocumentLoading(): Boolean = isDocumentLoading.get()
/**
* Gets the total number of pages in the currently loaded document.
*
* @return Number of pages, or 0 if no document is loaded
*/
fun getPageCount(): Int = context.document.getPageCount()
/**
* Loads a PDF document from the specified file path with optional password protection.
*
* This method handles the complete document loading lifecycle including:
* - Cleanup of any previously loaded document
* - Background loading to avoid blocking the UI thread
* - Password authentication for protected documents
* - Initial page size calculation and layout setup
* - Error handling with appropriate callback notifications
*
* @param context the PdfContext
* @param filePath Absolute path to the PDF file on the device storage
* @param password Optional password for encrypted PDF documents
*/
fun loadDocument(context: PdfContext, filePath: String, password: String? = null) {
if (!isDocumentLoading.compareAndSet(false, true)) {
Log.w(TAG, "Document already loading, ignoring request")
return
}
context.viewScope.launch {
try {
Log.d(TAG, "Loading document")
context.useDocumentIfInitialized {
safeCleanup()
}
val newDocument = withContext(Dispatchers.IO) {
PdfDocument.openDocument(context, filePath, password)
}
if (context.isViewDestroyed()) {
Log.d(TAG, "View destroyed during load")
newDocument.destroy()
return@launch
}
if (newDocument.isValid()) {
val pageCount = newDocument.getPageCount()
if (pageCount <= 0) {
Log.e(TAG, "Document loaded but has no pages")
newDocument.destroy()
context.onLoadError?.invoke(Exception("Document has no pages"))
return@launch
}
context.document = newDocument
currentPage = 0
Log.d(TAG, "Document loaded: $pageCount pages")
if (view.viewWidth > 0 && view.viewHeight > 0) {
context.markViewReady()
context.zoomAnimator.calculateBaseZoom()
context.layoutCalculator.preCalculateInitialPageSizes()
context.layoutCalculator.updateDocumentLayout()
context.layoutCalculator.launchDeferredPageSizeCalculation(context.viewScope)
}
isDocumentLoading.set(false)
context.onLoadComplete?.invoke()
} else {
val message = "Failed to load document"
Log.e(TAG, message)
context.onLoadError?.invoke(Exception(message))
}
} catch (e: Exception) {
Log.e(TAG, "Error loading document", e)
context.onLoadError?.invoke(e)
} finally {
isDocumentLoading.set(false)
}
}
}
/**
* Safe cleanup that waits for renders to complete.
*/
suspend fun safeCleanupWithWait() {
if (!isDocumentLoading.compareAndSet(true, false)) {
isDocumentLoading.set(false)
}
try {
// Wait for all renders to complete before destroying document
context.renderManager.cancelAllRendersAndWait()
context.useDocumentIfInitialized { doc ->
doc.destroy()
}
context.cacheManager.cleanup()
context.scrollHandler.reset()
context.zoomAnimator.reset()
currentPage = 0
Log.d(TAG, "Document cleanup completed safely")
} catch (e: Exception) {
Log.e(TAG, "Error during safe cleanup", e)
}
}
/**
* Non-blocking cleanup for synchronous contexts.
*/
private fun safeCleanup() {
context.viewScope.launch {
safeCleanupWithWait()
}
}
}

View file

@ -0,0 +1,505 @@
package com.mattermost.securepdfviewer.pdfium.manager
import android.graphics.Canvas
import android.graphics.Color
import android.graphics.Paint
import android.graphics.RectF
import android.util.Log
import androidx.core.graphics.withTranslation
import com.mattermost.securepdfviewer.pdfium.shared.PdfContext
import com.mattermost.securepdfviewer.pdfium.shared.PdfViewInterface
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.atomic.AtomicBoolean
import java.util.concurrent.atomic.AtomicInteger
import kotlin.coroutines.cancellation.CancellationException
class PdfRenderManager(private val context: PdfContext, private val view: PdfViewInterface) {
companion object {
private const val TAG = "PdfRenderManager"
private const val PRELOAD_RADIUS = 2
private const val MAX_BITMAP_SIZE = 4096
private const val MAX_BITMAP_MEMORY = 32 * 1024 * 1024
private const val MAX_CONCURRENT_RENDERS = 3
private const val ZOOM_TOLERANCE = 0.25f // Allow 50% difference before requiring re-render
}
// Cancellation support
private val isDestroyed = AtomicBoolean(false)
private val activeRenderJobs = ConcurrentHashMap<Int, Job>() // pageNum -> Job
// Thread-safe rendering state
private val currentlyRenderingPages = ConcurrentHashMap<Int, Float>() // pageNum -> zoomScale
private val concurrentRenderCount = AtomicInteger(0)
// Render queue system
private val pendingRenders = ConcurrentHashMap<Int, Float>() // pageNum -> zoomScale
// Pre-allocated Paint objects for optimal performance
private val backgroundPaint = Paint().apply { color = Color.LTGRAY }
private val bitmapPaint = Paint(Paint.ANTI_ALIAS_FLAG or Paint.FILTER_BITMAP_FLAG)
private val placeholderPaint = Paint().apply { color = Color.WHITE }
private val borderPaint = Paint().apply {
color = Color.GRAY
style = Paint.Style.STROKE
strokeWidth = 2f
}
private val textPaint = Paint().apply {
color = Color.GRAY
textAlign = Paint.Align.CENTER
textSize = 32f
isAntiAlias = true
}
/**
* Cancels all ongoing renders and clears queues.
* Call this when the view is destroyed or document changes.
*/
suspend fun cancelAllRendersAndWait() {
if (isDestroyed.compareAndSet(false, true)) {
Log.d(TAG, "Cancelling all renders - active: ${activeRenderJobs.size}, pending: ${pendingRenders.size}")
// Cancel all active render jobs
val jobs = activeRenderJobs.values.toList()
activeRenderJobs.clear()
pendingRenders.clear()
currentlyRenderingPages.clear()
jobs.forEach { job ->
try {
job.cancel()
} catch (e: Exception) {
Log.w(TAG, "Error cancelling render job", e)
}
}
// Wait for all native calls to complete with timeout
val startTime = System.currentTimeMillis()
while (concurrentRenderCount.get() > 0 && (System.currentTimeMillis() - startTime) < 2000) {
kotlinx.coroutines.delay(50)
}
concurrentRenderCount.set(0)
Log.d(TAG, "All renders cancelled")
}
}
/**
* Checks if the manager is destroyed/cancelled.
*/
private fun isActive(): Boolean = !isDestroyed.get() && !context.isViewDestroyed()
/**
* Intelligently clears and re-renders pages after zoom changes.
*
* Maintains visible pages in cache while clearing distant pages
* to balance memory usage with user experience.
*/
fun clearAndRerenderPages() {
if (!isActive()) return
try {
val visiblePages = context.layoutCalculator.getVisiblePages()
val currentZoom = context.zoomAnimator.baseZoom * context.zoomAnimator.currentZoomScale
// Cancel any pending renders for pages that are no longer visible
val iterator = pendingRenders.iterator()
while (iterator.hasNext()) {
val (pageNum, _) = iterator.next()
if (pageNum !in visiblePages) {
iterator.remove()
cancelRenderJob(pageNum)
}
}
// Don't clear bitmaps immediately - let them serve as placeholders
// Just mark visible pages for high-priority re-rendering
visiblePages.forEach { pageNum ->
if (shouldRenderPage(pageNum, currentZoom)) {
queueHighPriorityRender(pageNum, currentZoom)
}
}
// Clear non-visible pages to free memory
context.cacheManager.clearNonVisiblePages(visiblePages)
preRenderDocument()
view.invalidate()
} catch (e: Exception) {
Log.e(TAG, "Error in smart cache clearing", e)
}
}
/**
* Manages cache for visible pages and preloading.
*
* This method orchestrates the cache management system:
* - Identifies currently visible pages
* - Triggers rendering for uncached visible pages
* - Preloads adjacent pages for smooth scrolling
* - Updates tracking of visible pages to reduce logging spam
*/
private fun preRenderDocument() {
if (!isActive()) return
val doc = context.document
if (!doc.isValid() || !context.isViewReady) {
Log.d(TAG, "Cannot render: valid=${doc.isValid()}, destroyed=${context.isViewDestroyed()}, ready=${context.isViewReady}")
return
}
val visiblePages = context.layoutCalculator.getVisiblePages()
if (visiblePages.isEmpty()) {
Log.w(TAG, "No visible pages found - scrollY=${context.scrollHandler.scrollY}, totalHeight=${context.scrollHandler.totalDocumentHeight}, viewHeight=${view.viewHeight}")
Log.w(TAG, "Page offsets size: ${context.cacheManager.getPageOffsets().size}, Page sizes size: ${context.cacheManager.getPageSizes().size}")
}
// Log visible pages changes to reduce spam
if (visiblePages != context.cacheManager.getLastVisiblePages()) {
Log.d(TAG, "Managing cache for visible pages: $visiblePages")
context.cacheManager.setLastVisiblePages(visiblePages.toList())
}
val currentPageNum = context.layoutCalculator.getCurrentVisiblePage()
val currentZoom = context.zoomAnimator.baseZoom * context.zoomAnimator.currentZoomScale
// Immediately start rendering visible pages that need it
visiblePages.forEach { pageNum ->
if (isActive() && shouldRenderPage(pageNum, currentZoom)) {
if (pageNum == currentPageNum) {
startRenderImmediate(pageNum, currentZoom, highPriority = true)
} else {
queueRender(pageNum, currentZoom)
}
}
}
// Preload adjacent pages for smooth scrolling
for (i in 1..PRELOAD_RADIUS) {
if (!isActive()) break
val prevPage = currentPageNum - i
val nextPage = currentPageNum + i
if (prevPage >= 0 && shouldRenderPage(prevPage, currentZoom)) {
queueRender(prevPage, currentZoom)
}
if (nextPage < doc.getPageCount() && shouldRenderPage(nextPage, currentZoom)) {
queueRender(nextPage, currentZoom)
}
}
}
/**
* Check if a page should be rendered based on cache state and zoom level
*/
private fun shouldRenderPage(pageNum: Int, currentZoom: Float): Boolean {
if (!isActive()) return false
// Check if already rendering at current zoom
val renderingZoom = currentlyRenderingPages[pageNum]
if (renderingZoom != null && kotlin.math.abs(renderingZoom - currentZoom) < 0.1f) {
return false
}
// Check if cache at correct zoom level
val cached = context.cacheManager.getCachedPage(pageNum)
if (cached == null || cached.isRecycled) {
return true
}
val pageSize =
context.cacheManager.withSynchronizedCache { context.cacheManager.getPageSize(pageNum) }
?: return true
val expectedWidth = (pageSize.first * currentZoom).toInt()
val expectedHeight = (pageSize.second * currentZoom).toInt()
val widthDiff = kotlin.math.abs(cached.width - expectedWidth).toFloat() /
maxOf(expectedWidth, cached.width)
val heightDiff = kotlin.math.abs(cached.height - expectedHeight).toFloat() /
maxOf(expectedHeight, cached.height)
// More permissive tolerance - only re-render if significantly different
return widthDiff > ZOOM_TOLERANCE || heightDiff > ZOOM_TOLERANCE
}
/**
* Queue high priority render (for visible pages).
*/
private fun queueHighPriorityRender(pageNum: Int, zoomScale: Float) {
if (!isActive()) return
// Cancel existing render if different zoom
val existingZoom = currentlyRenderingPages[pageNum]
if (existingZoom != null && kotlin.math.abs(existingZoom - zoomScale) > 0.1f) {
cancelRenderJob(pageNum)
}
pendingRenders[pageNum] = zoomScale
startRenderImmediate(pageNum, zoomScale, highPriority = true)
}
/**
* Queue normal priority render (for preload pages).
*/
private fun queueRender(pageNum: Int, zoomScale: Float) {
if (!isActive()) return
if (concurrentRenderCount.get() < MAX_CONCURRENT_RENDERS) {
startRenderImmediate(pageNum, zoomScale, highPriority = false)
} else {
pendingRenders[pageNum] = zoomScale
}
}
/**
* Cancels a specific render job.
*/
private fun cancelRenderJob(pageNum: Int) {
activeRenderJobs[pageNum]?.let { job ->
try {
job.cancel()
activeRenderJobs.remove(pageNum)
currentlyRenderingPages.remove(pageNum)
concurrentRenderCount.decrementAndGet()
Log.d(TAG, "Cancelled render job for page $pageNum")
} catch (e: Exception) {
Log.w(TAG, "Error cancelling job for page $pageNum", e)
}
}
}
/**
* Start rendering immediately if possible, with priority handling.
*/
private fun startRenderImmediate(pageNum: Int, zoomScale: Float, highPriority: Boolean) {
if (!isActive()) return
if (!highPriority && concurrentRenderCount.get() >= MAX_CONCURRENT_RENDERS) {
pendingRenders[pageNum] = zoomScale
return
}
// For high priority, allow one extra concurrent render
val maxConcurrent = if (highPriority) MAX_CONCURRENT_RENDERS + 1 else MAX_CONCURRENT_RENDERS
if (concurrentRenderCount.get() >= maxConcurrent) {
pendingRenders[pageNum] = zoomScale
return
}
// Cancel existing render for this page if different zoom
cancelRenderJob(pageNum)
currentlyRenderingPages[pageNum] = zoomScale
concurrentRenderCount.incrementAndGet()
pendingRenders.remove(pageNum)
val renderJob = context.viewScope.launch {
try {
if (!isActive()) {
Log.d(TAG, "Skipping render for page $pageNum - not active")
return@launch
}
val pageSize = context.cacheManager.withSynchronizedCache {
context.cacheManager.getPageSize(pageNum)
}
if (pageSize == null) {
Log.w(TAG, "No page size for page $pageNum")
return@launch
}
val bitmap = context.nativeCoordinator.withNativeAccess("render-page-$pageNum") {
if (!context.document.isValid()) {
Log.d(TAG, "Document invalid for page $pageNum")
return@withNativeAccess null
}
try {
val page = context.document.getPage(pageNum)
val rawTargetWidth = (pageSize.first * zoomScale).toInt()
val rawTargetHeight = (pageSize.second * zoomScale).toInt()
val targetWidth = rawTargetWidth.coerceAtMost(MAX_BITMAP_SIZE)
val targetHeight = rawTargetHeight.coerceAtMost(MAX_BITMAP_SIZE)
val estimatedMemory = targetWidth * targetHeight * 4
if (estimatedMemory > MAX_BITMAP_MEMORY) {
val scaleFactor =
kotlin.math.sqrt(MAX_BITMAP_MEMORY.toFloat() / estimatedMemory)
val safeWidth = (targetWidth * scaleFactor).toInt()
val safeHeight = (targetHeight * scaleFactor).toInt()
page.renderToBitmap(safeWidth, safeHeight, zoomScale)
} else {
page.renderToBitmap(targetWidth, targetHeight, zoomScale)
}
} catch (e: Exception) {
Log.e(TAG, "Native render error for page $pageNum", e)
null
}
}
if (isActive() && bitmap != null) {
val currentZoom = context.zoomAnimator.baseZoom * context.zoomAnimator.currentZoomScale
if (kotlin.math.abs(currentZoom - zoomScale) < 0.5f) {
context.cacheManager.cachePage(pageNum, bitmap)
view.invalidate()
Log.d(TAG, "Page $pageNum rendered at zoom $zoomScale")
} else {
bitmap.recycle()
Log.d(TAG, "Page $pageNum render discarded - zoom changed")
}
} else {
bitmap?.recycle()
}
} catch (e: CancellationException) {
Log.d(TAG, "Render cancelled for page $pageNum")
// Don't log as error - cancellation is expected
} catch (e: Exception) {
Log.e(TAG, "Error rendering page $pageNum", e)
} finally {
activeRenderJobs.remove(pageNum)
currentlyRenderingPages.remove(pageNum)
concurrentRenderCount.decrementAndGet()
if (isActive()) {
processNextQueuedRender()
}
}
}
activeRenderJobs[pageNum] = renderJob
}
/**
* Process the next item from the render queue.
*/
private fun processNextQueuedRender() {
if (!isActive() || concurrentRenderCount.get() >= MAX_CONCURRENT_RENDERS || pendingRenders.isEmpty()) {
return
}
val iterator = pendingRenders.iterator()
if (iterator.hasNext()) {
val (pageNum, zoomScale) = iterator.next()
iterator.remove()
if (shouldRenderPage(pageNum, zoomScale)) {
startRenderImmediate(pageNum, zoomScale, highPriority = false)
}
}
}
/**
* Draws the complete PDF view on the provided canvas.
*
* Handles different view states and delegates to appropriate drawing methods.
*/
fun drawView(canvas: Canvas) {
if (!isActive()) return
// Draw background
canvas.drawRect(0f, 0f, view.viewWidth.toFloat(), view.viewHeight.toFloat(), backgroundPaint)
when {
context.isViewDestroyed() -> return
context.documentManager.isDocumentLoading() -> {
drawMessage(canvas, "Loading Document...")
return
}
!context.document.isValid() -> {
drawMessage(canvas, "No Document")
return
}
!context.isViewReady -> {
drawMessage(canvas, "Preparing...")
return
}
else -> {
drawPages(canvas)
preRenderDocument()
}
}
}
/**
* Draws a centered message on the canvas for status display.
*
* @param canvas Canvas to draw on
* @param message Message text to display
*/
private fun drawMessage(canvas: Canvas, message: String) {
canvas.drawText(message, view.viewWidth / 2f, view.viewHeight / 2f, textPaint)
}
/**
* Draws a single page at its calculated position.
*
* Handles both cached bitmap rendering and placeholder display
* when pages are still being rendered.
*
* @param canvas Canvas to draw on (already translated)
* @param pageNum Page number to draw
*/
private fun drawPage(canvas: Canvas, pageNum: Int) {
if (!isActive()) return
try {
val originalPageSize = context.cacheManager.withSynchronizedCache { context.cacheManager.getPageSize(pageNum) } ?: return
val effectiveZoom = context.zoomAnimator.baseZoom * context.zoomAnimator.currentZoomScale
val scaledWidth = originalPageSize.first * effectiveZoom
val scaledHeight = originalPageSize.second * effectiveZoom
val left = (view.viewWidth - scaledWidth) / 2f
val pageOffset = if (context.zoomAnimator.isZooming) {
// During animation, calculate offset in real-time to prevent layout jumps
context.layoutCalculator.calculatePageOffsetRealTime(pageNum, effectiveZoom)
} else {
// Use cached offset when not animating
context.cacheManager.getPageOffset(pageNum) ?: return
}
val destRect = RectF(left, pageOffset, left + scaledWidth, pageOffset + scaledHeight)
val bitmap = context.cacheManager.getCachedPage(pageNum)
if (bitmap?.isRecycled == false) {
canvas.drawBitmap(bitmap, null, destRect, bitmapPaint)
} else if (!context.zoomAnimator.isZooming) {
// Only draw placeholder when not zooming to reduce visual noise
canvas.drawRect(destRect, placeholderPaint)
canvas.drawRect(destRect, borderPaint)
}
} catch (e: Exception) {
Log.e(TAG, "Error drawing page $pageNum", e)
}
}
/**
* Draws all visible pages with proper coordinate transformation.
*
* Uses canvas translation to handle scroll offsets efficiently,
* allowing pages to be drawn at their calculated positions.
*/
private fun drawPages(canvas: Canvas) {
if (!isActive()) return
val visiblePages = context.layoutCalculator.getVisiblePages()
canvas.withTranslation(-context.scrollHandler.scrollX, -context.scrollHandler.scrollY) {
context.cacheManager.withSynchronizedCache {
visiblePages.forEach { pageNum ->
drawPage(this, pageNum)
}
}
}
}
}

View file

@ -0,0 +1,99 @@
package com.mattermost.securepdfviewer.pdfium.shared
import android.util.Log
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.asCoroutineDispatcher
import kotlinx.coroutines.withContext
import java.util.concurrent.Executors
import java.util.concurrent.atomic.AtomicBoolean
import java.util.concurrent.atomic.AtomicInteger
/**
* Coordinates all native PDF library access to prevent race conditions and memory corruption.
* Uses a dedicated single thread for all native operations to ensure thread safety.
*/
class NativeAccessCoordinator {
companion object {
private const val TAG = "NativeAccessCoordinator"
}
// Single thread executor for all native operations
private val nativeExecutor = Executors.newSingleThreadExecutor { r ->
Thread(r, "PDFNativeThread").apply {
isDaemon = true
priority = Thread.NORM_PRIORITY + 1 // Slightly higher priority for responsiveness
}
}
private val nativeDispatcher: CoroutineDispatcher = nativeExecutor.asCoroutineDispatcher()
private val isShutdown = AtomicBoolean(false)
private val activeOperations = AtomicInteger(0)
/**
* Executes a native operation safely on the dedicated native thread.
* Operations are naturally serialized by the single thread executor.
*/
suspend fun <T> withNativeAccess(
operation: String,
block: suspend () -> T
): T? {
if (isShutdown.get()) {
Log.d(TAG, "Skipping $operation - coordinator is shutdown")
return null
}
return try {
val operationId = activeOperations.incrementAndGet()
Log.v(TAG, "Starting $operation (id: $operationId, active: ${activeOperations.get()})")
// Execute on dedicated single thread - operations are naturally serialized
withContext(nativeDispatcher) {
if (isShutdown.get()) {
Log.d(TAG, "Aborting $operation - shutdown during execution")
return@withContext null
}
try {
block()
} catch (e: Exception) {
Log.e(TAG, "Error in native operation $operation", e)
null
}
}
} finally {
val remaining = activeOperations.decrementAndGet()
Log.v(TAG, "Completed $operation (remaining: $remaining)")
}
}
/**
* Shuts down the coordinator and waits for all operations to complete.
*/
suspend fun shutdown() {
if (isShutdown.compareAndSet(false, true)) {
Log.d(TAG, "Shutting down coordinator, waiting for ${activeOperations.get()} operations")
// Wait for all operations to complete with timeout
val startTime = System.currentTimeMillis()
while (activeOperations.get() > 0) {
val elapsed = System.currentTimeMillis() - startTime
if (elapsed > 3000) { // 3 second timeout
Log.w(TAG, "Timeout waiting for operations to complete. Remaining: ${activeOperations.get()}")
break
}
kotlinx.coroutines.delay(50)
}
// Shutdown the executor
try {
nativeExecutor.shutdown()
Log.d(TAG, "Native executor shutdown")
} catch (e: Exception) {
Log.w(TAG, "Error shutting down native executor", e)
}
Log.d(TAG, "Coordinator shutdown complete")
}
}
}

View file

@ -0,0 +1,80 @@
package com.mattermost.securepdfviewer.pdfium.shared
import android.content.Context
import android.widget.Scroller
import com.mattermost.pdfium.model.PdfLink
import com.mattermost.securepdfviewer.pdfium.PdfDocument
import com.mattermost.securepdfviewer.pdfium.cache.PdfCacheManager
import com.mattermost.securepdfviewer.pdfium.gesture.ScaleListener
import com.mattermost.securepdfviewer.pdfium.gesture.ScrollGestureListener
import com.mattermost.securepdfviewer.pdfium.interaction.LinkHandler
import com.mattermost.securepdfviewer.pdfium.interaction.ScrollHandler
import com.mattermost.securepdfviewer.pdfium.interaction.ZoomAnimator
import com.mattermost.securepdfviewer.pdfium.layout.CoordinateConverter
import com.mattermost.securepdfviewer.pdfium.layout.LayoutCalculator
import com.mattermost.securepdfviewer.pdfium.manager.PdfDocumentManager
import com.mattermost.securepdfviewer.pdfium.manager.PdfRenderManager
import com.mattermost.securepdfviewer.pdfium.util.ViewUtils
import kotlinx.coroutines.CoroutineScope
import java.util.concurrent.atomic.AtomicBoolean
class PdfContext(
private val context: Context,
val viewScope: CoroutineScope,
val scroller: Scroller,
) {
val cacheManager = PdfCacheManager()
val nativeCoordinator = NativeAccessCoordinator()
private val isViewDestroyed = AtomicBoolean(false)
var isViewReady = false
private set
lateinit var document: PdfDocument
lateinit var documentManager: PdfDocumentManager
lateinit var layoutCalculator: LayoutCalculator
lateinit var coordinateConverter: CoordinateConverter
lateinit var renderManager: PdfRenderManager
lateinit var zoomAnimator: ZoomAnimator
lateinit var linkHandler: LinkHandler
lateinit var scrollHandler: ScrollHandler
lateinit var scrollGestureListener: ScrollGestureListener
lateinit var scaleListener: ScaleListener
// Optional callbacks (to be set from PdfView)
var onLoadComplete: (() -> Unit)? = null
var onLoadError: ((Exception) -> Unit)? = null
var onTap: ((android.view.MotionEvent) -> Unit)? = null
var onLinkTapped: ((PdfLink) -> Unit)? = null
var onPageChanged: ((Int) -> Unit)? = null
var onScrollChanged: ((Float) -> Unit)? = null
/**
* Gets whether the view has been destroyed.
*/
fun isViewDestroyed(): Boolean = isViewDestroyed.get()
/**
* Marks the view as destroyed and shuts down native access coordinator.
*/
suspend fun markViewDestroyedAndShutdown() {
isViewReady = false
isViewDestroyed.set(true)
nativeCoordinator.shutdown()
}
/**
* Marks the view as ready for rendering.
*/
fun markViewReady() {
isViewReady = true
}
fun dpToPx(dp: Int): Float = ViewUtils.dpToPx(context, dp)
fun useDocumentIfInitialized(action: (PdfDocument) -> Unit) {
if (::document.isInitialized) {
action(document)
}
}
}

View file

@ -0,0 +1,7 @@
package com.mattermost.securepdfviewer.pdfium.shared
interface PdfViewInterface {
val viewWidth: Int
val viewHeight: Int
fun invalidate()
}

View file

@ -0,0 +1,90 @@
package com.mattermost.securepdfviewer.pdfium.util
import android.content.Context
import android.util.TypedValue
/**
* Utility functions for view-related operations and UI calculations.
*
* This utility object provides essential conversion functions for working with
* different Android measurement units, particularly for creating density-independent
* user interfaces that work consistently across devices with varying screen densities.
*
* Android's display system uses multiple measurement units:
* - **px (pixels)**: Physical screen pixels, varies by device density
* - **dp (density-independent pixels)**: Abstract units based on 160 DPI reference
* - **sp (scale-independent pixels)**: Similar to dp but scales with user font preferences
*
* The utility functions in this object handle the conversion between these units,
* ensuring that UI elements maintain consistent physical sizes across different
* devices while respecting user accessibility preferences.
*
* Common use cases:
* - Converting design specifications (in dp) to pixel values for drawing
* - Creating touch targets with consistent physical sizes
* - Implementing spacing and padding that scales appropriately
* - Building responsive layouts that work across device categories
*/
object ViewUtils {
/**
* Converts density-independent pixels (dp) to actual pixels based on device density.
*
* This function performs the fundamental conversion from abstract density-independent
* units to concrete pixel values that can be used for drawing, positioning, and
* measurement operations. It accounts for the device's screen density to ensure
* that UI elements maintain consistent physical sizes across different devices.
*
* The conversion process:
* 1. Retrieves the device's display metrics from the provided context
* 2. Applies Android's density conversion algorithm
* 3. Returns the pixel equivalent rounded to the nearest integer
*
* Usage examples:
* - Converting margin specifications: `dp(context, 16f)` for 16dp margins
* - Setting view dimensions: `dp(context, 48f)` for 48dp touch targets
* - Defining spacing values: `dp(context, 8f)` for 8dp spacing
*
* @param context Android context containing display metrics information
* @param value The value in density-independent pixels to convert
* @return The equivalent value in actual screen pixels (rounded to integer)
*/
fun dp(context: Context, value: Float): Int {
return TypedValue.applyDimension(
TypedValue.COMPLEX_UNIT_DIP,
value,
context.resources.displayMetrics
).toInt()
}
/**
* Converts density-independent pixels (dp) to actual pixels with floating-point precision.
*
* This function provides an alternative conversion method that preserves floating-point
* precision in the result, useful for calculations that require sub-pixel accuracy
* or when the result will be used in further mathematical operations before final
* application.
*
* Key differences from the dp() function:
* - **Input Type**: Accepts integer dp values for convenience
* - **Output Precision**: Returns floating-point result without rounding
* - **Use Cases**: Calculations requiring precision, animation values, touch calculations
*
* The function uses direct multiplication by the density factor, which is
* computationally efficient and provides precise results for mathematical
* operations that may involve multiple conversions or transformations.
*
* Common applications:
* - Touch event coordinate calculations requiring precision
* - Animation target calculations that need smooth interpolation
* - Complex layout calculations involving multiple conversion steps
* - Hit-testing operations where sub-pixel accuracy matters
*
* @param context Android context for accessing display density information
* @param dp The value in density-independent pixels (as integer for convenience)
* @return The equivalent value in actual screen pixels (as floating-point)
*/
fun dpToPx(context: Context, dp: Int): Float {
return dp * context.resources.displayMetrics.density
}
}

View file

@ -0,0 +1,140 @@
package com.mattermost.securepdfviewer.util
import android.util.Log
import androidx.core.net.toUri
import java.io.File
/**
* Secure file validation utility for PDF file access control.
*
* This object provides comprehensive security validation for PDF file access within
* the secure PDF viewer. It implements multiple layers of security checks to prevent
* common file system attacks and ensure that only authorized files from trusted
* locations can be accessed by the PDF viewer.
*
* Security measures implemented:
* - **Path Traversal Protection**: Prevents "../" attacks and symbolic link exploitation
* - **Directory Sandboxing**: Restricts file access to explicitly allowed directories
* - **File Type Validation**: Ensures only actual files (not directories) are processed
* - **Unicode Attack Prevention**: Detects and blocks malicious Unicode control characters
* - **Canonical Path Resolution**: Uses canonical paths to prevent path manipulation
* - **Existence and Readability Checks**: Validates file exists and is accessible
*
* The validator is designed to work with the React Native file system bridge and
* handles various file URI formats that may be passed from the JavaScript layer.
*/
object FileValidator {
private const val TAG = "FileValidator"
// File path validation and security
/**
* Securely parses and validates a file source string into a File object.
*
* This method performs comprehensive security validation on file paths received
* from potentially untrusted sources (such as React Native JavaScript layer).
* It implements multiple security checks to prevent various file system attacks
* while ensuring the requested file is within authorized access boundaries.
*
* Validation process:
* 1. **Input Sanitization**: Handles various URI formats (file://, relative paths)
* 2. **Canonical Path Resolution**: Resolves symbolic links and relative references
* 3. **Existence Verification**: Confirms file exists and is accessible
* 4. **Type Validation**: Ensures target is a file, not a directory
* 5. **Directory Authorization**: Validates file is within allowed directory boundaries
* 6. **Unicode Security**: Detects malicious Unicode control characters in filenames
*
* @param source The file path or URI string to validate (may be null)
* @param allowedDirectories List of directories where file access is permitted
* @return Validated File object if all security checks pass, null if validation fails
*/
fun parseSourceToFile(source: String?, allowedDirectories: List<File>): File? {
if (source.isNullOrEmpty()) {
Log.e(TAG, "Source is null or empty")
return null
}
return try {
var path = source
// Handle file:// URI format from React Native
if (source.startsWith("file://")) {
path = source.toUri().path
}
if (path.isNullOrEmpty()) {
Log.e(TAG, "Parsed path is null or empty")
return null
}
// Resolve to canonical path to prevent path traversal attacks
val file = File(path).canonicalFile
// Validate file existence
if (!file.exists()) {
Log.e(TAG, "File does not exist: ${file.absolutePath}")
return null
}
// Ensure target is a file, not a directory
if (!file.isFile) {
Log.e(TAG, "Path is not a file: ${file.absolutePath}")
return null
}
// Enforce directory sandboxing - file must be within allowed directories
val allowed = allowedDirectories.any { allowedDir ->
file.absolutePath.startsWith(allowedDir.canonicalPath)
}
if (!allowed) {
Log.e(TAG, "Access denied: outside allowed directories")
return null
}
// Security hardening: detect Unicode control character attacks
// Prevents RTL override attacks and other Unicode-based filename spoofing
if (file.name.any { it.isISOControl() }) {
Log.e(TAG, "File name contains invalid control characters")
return null
}
file
} catch (e: Exception) {
Log.e(TAG, "Error securely parsing source file", e)
null
}
}
// File metadata access
/**
* Safely retrieves the file size with comprehensive error handling.
*
* This method provides secure access to file size information with multiple
* validation layers to prevent security issues and ensure reliable operation.
* It performs additional safety checks beyond basic file.length() to handle
* edge cases and potential security concerns.
*
* Safety measures:
* - **File Existence Check**: Verifies file still exists before accessing
* - **File Type Validation**: Ensures target is still a regular file
* - **Read Permission Check**: Confirms file is readable by the application
* - **Size Validation**: Ensures file has content (size > 0)
* - **Exception Handling**: Gracefully handles I/O errors and permission issues
*
* @param file The File object to get size information for
* @return File size in bytes if valid and accessible, null if file is invalid or inaccessible
*/
fun getSafeFileSize(file: File): Long? {
return try {
if (file.exists() && file.isFile && file.canRead()) {
file.length().takeIf { it > 0 }
} else {
null
}
} catch (e: Exception) {
null
}
}
}

View file

@ -0,0 +1,51 @@
package com.mattermost.securepdfviewer.util
import java.security.MessageDigest
/**
* Cryptographic hashing utilities for secure PDF viewer operations.
*
* This utility object provides secure hashing functions used throughout the PDF viewer
* for various security and identification purposes. The primary use case is generating
* secure, consistent identifiers for PDF documents to track password attempt histories
* without storing sensitive file path information.
*
* Key security considerations:
* - **Consistent Encoding**: Uses UTF-8 encoding to ensure consistent hash results across platforms
* - **Cryptographic Security**: Employs SHA-256 for cryptographically secure hashing
* - **Collision Resistance**: SHA-256 provides strong protection against hash collisions
* - **One-Way Function**: Hash values cannot be reversed to reveal original input
* - **Deterministic**: Same input always produces the same hash output
*
* The generated hashes serve as anonymous identifiers that allow the system to track
* document-specific state (like password attempts) without storing potentially sensitive
* file paths in persistent storage.
*/
object HashUtils {
/**
* Generates a SHA-256 hash of the input string.
*
* This method creates a cryptographically secure hash of the input string using
* the SHA-256 algorithm. The hash is deterministic (same input produces same output)
* and provides strong collision resistance, making it suitable for creating unique
* identifiers for documents based on their file paths.
*
* Usage in the PDF viewer:
* - **Document Identification**: Creates unique keys for password attempt tracking
* - **Privacy Protection**: Avoids storing actual file paths in persistent storage
* - **Security**: Prevents reverse engineering of file locations from stored data
* - **Consistency**: Ensures same document always has same identifier
*
* The output is a lowercase hexadecimal string representation of the hash,
* providing a consistent, readable format for use as storage keys.
*
* @param input The string to hash (typically a file path)
* @return SHA-256 hash as a lowercase hexadecimal string (64 characters)
*/
fun sha256(input: String): String {
val digest = MessageDigest.getInstance("SHA-256")
val hash = digest.digest(input.toByteArray(Charsets.UTF_8))
return hash.joinToString("") { "%02x".format(it) }
}
}

View file

@ -0,0 +1,105 @@
package com.mattermost.securepdfviewer.util
import android.app.ActivityManager
import android.content.Context
import com.mattermost.securepdfviewer.model.DeviceMemory
/**
* Memory management utilities for safe PDF processing operations.
*
* This utility object provides intelligent memory analysis and PDF file size limit
* calculations to prevent out-of-memory crashes during PDF processing. It implements
* adaptive memory management strategies that adjust processing limits based on the
* current device capabilities and memory availability.
*
* The utility is essential for maintaining application stability when dealing with
* large PDF documents, as PDF rendering can be memory-intensive due to:
* - High-resolution page rendering and bitmap caching
* - Multiple page preloading for smooth scrolling
* - Document structure parsing and metadata extraction
* - Vector graphics and image processing within PDFs
*
* Memory management strategy:
* - **Device Profiling**: Categorizes devices by total RAM capacity
* - **Dynamic Assessment**: Considers current available memory
* - **Conservative Limits**: Applies safety margins to prevent crashes
* - **Adaptive Scaling**: Adjusts limits based on real-time memory conditions
*/
object MemoryUtil {
// Device memory information access
/**
* Retrieves comprehensive device memory information.
*
* This method queries the Android system's ActivityManager to obtain current
* memory statistics, providing both total device RAM and currently available
* memory. This information is essential for making informed decisions about
* PDF processing limits and memory allocation strategies.
*
* The returned data represents the memory state at the time of the call and
* can be used to assess whether the device has sufficient resources to safely
* process a PDF document of a given size.
*
* @param context Application context for accessing system services
* @return DeviceMemory object containing total and available memory information
*/
private fun getDeviceMemoryInfo(context: Context): DeviceMemory {
val activityManager = context.getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager
val memoryInfo = ActivityManager.MemoryInfo()
activityManager.getMemoryInfo(memoryInfo)
return DeviceMemory(memoryInfo.totalMem, memoryInfo.availMem)
}
// PDF size limit calculation
/**
* Calculates the maximum safe PDF file size for processing on the current device.
*
* This method implements an intelligent, multi-factor algorithm to determine
* the largest PDF file size that can be safely processed without risking
* out-of-memory crashes. The calculation considers both hardware capabilities
* and current memory conditions to provide adaptive limits.
*
* Algorithm factors:
* 1. **Device RAM Tier**: Base limits scaled by total device memory
* - 6GB+ devices: 500MB base limit (high-end devices)
* - 4-6GB devices: 300MB base limit (mid-range devices)
* - 2-4GB devices: 150MB base limit (budget devices)
* - <2GB devices: 75MB base limit (low-end devices)
*
* 2. **Available Memory Adjustment**: Dynamic scaling based on current conditions
* - Low memory (<500MB available): Conservative limit (max 100MB)
* - High memory (>2GB available): Generous boost (+20%)
* - Normal conditions: Use base limit
*
* 3. **Safety Margins**: Built-in buffers to account for:
* - System memory fragmentation
* - Other application memory usage
* - PDF rendering overhead (typically 3-5x file size)
* - Android system memory requirements
*
* @param context Application context for accessing memory information
* @return Maximum recommended PDF file size in bytes
*/
fun getMaxPdfSize(context: Context): Long {
val memoryInfo = getDeviceMemoryInfo(context)
val totalMem = memoryInfo.totalMem
val availMem = memoryInfo.availMem
// Determine base limit based on device RAM tier
val baseLimit = when {
totalMem >= 6L * 1024 * 1024 * 1024 -> 500L * 1024 * 1024 // 6GB+: 500MB
totalMem >= 4L * 1024 * 1024 * 1024 -> 300L * 1024 * 1024 // 46GB: 300MB
totalMem >= 2L * 1024 * 1024 * 1024 -> 150L * 1024 * 1024 // 24GB: 150MB
else -> 75L * 1024 * 1024 // <2GB: 75MB
}
// Apply dynamic adjustments based on current memory availability
return when {
availMem < 500L * 1024 * 1024 -> minOf(baseLimit, 100L * 1024 * 1024) // Low available RAM
availMem > 2L * 1024 * 1024 * 1024 -> (baseLimit * 1.2).toLong() // Generous boost
else -> baseLimit
}
}
}

View file

@ -0,0 +1,224 @@
package com.mattermost.securepdfviewer.view
import android.content.Context
import android.graphics.drawable.ColorDrawable
import android.widget.FrameLayout
import com.mattermost.securepdfviewer.manager.PasswordAttemptStore
import com.mattermost.securepdfviewer.pdfium.PdfView
import com.mattermost.securepdfviewer.view.callbacks.PdfViewCallbacks
import com.mattermost.securepdfviewer.view.emitter.PdfEventEmitter
import com.mattermost.securepdfviewer.view.interaction.ScrollBarHandler
import com.mattermost.securepdfviewer.view.manager.PdfLoadManager
/**
* Main PDF viewer component that integrates with React Native.
*
* This is the primary view component that gets mounted in React Native applications.
* It serves as a container and coordinator between the native PDFium rendering engine,
* the scroll handle UI, and the React Native bridge for event communication.
*
* Key responsibilities:
* - Managing PDF document loading and authentication
* - Coordinating between PdfView and ScrollHandle components
* - Handling security features (password attempts, file validation)
* - Emitting events to React Native layer
* - Managing component lifecycle and cleanup
*
* Security features:
* - File path validation to prevent unauthorized access
* - Password attempt limiting with lockout mechanism
* - Memory usage validation to prevent DoS attacks
* - Secure event emission with payload validation
*/
class SecurePdfViewerView(context: Context) : FrameLayout(context) {
private var isAttached = false
private var pendingLoad = false
/** The main PDF rendering view powered by PDFium */
private val pdfView: PdfView = PdfView(context)
/** Store for tracking password attempt limits per document */
private val attemptStore = PasswordAttemptStore(context)
/** Optional scroll handle for document navigation */
private var scrollBarHandle: ScrollBarHandler? = null
// Component Managers
/** Handles all React Native event emission for PDF viewer interactions */
private var eventEmitter: PdfEventEmitter? = null
/** Manages PDF view event callbacks and coordination between components */
private val pdfCallbacks = PdfViewCallbacks(
this,
pdfView,
attemptStore,
this::eventEmitter,
)
/** Handles PDF document loading, validation, and security checks */
private val loadManager = PdfLoadManager(
context,
pdfView,
this::eventEmitter,
attemptStore,
this.background as ColorDrawable?,
)
// Configuration properties
/** Path to the PDF document file */
private var source: String? = null
/** Password for encrypted PDF documents */
private var password: String? = null
/** Whether to allow navigation via external links */
private var allowLinks: Boolean = false
init {
pdfCallbacks.setupCallbacks()
}
/**
* Initializes the scroll handle component and integrates it with the PDF viewer.
*/
private fun setupScrollHandle() {
scrollBarHandle = ScrollBarHandler(context).apply {
setupLayout(this@SecurePdfViewerView)
}
}
// React Native property setters
/**
* Sets the source file path for the PDF document.
* Triggers document loading if all required parameters are available.
*
* @param path Absolute file path to the PDF document
*/
fun setSource(path: String?) {
source = path
if (isAttached) {
maybeLoadPdf()
} else {
pendingLoad = true
}
}
/**
* Sets the password for encrypted PDF documents.
* Triggers document loading if source path is already set.
*
* @param pass Password string for document decryption
*/
fun setPassword(pass: String?) {
password = pass
if (source != null && pass != null) {
if (isAttached) {
maybeLoadPdf()
} else {
pendingLoad = true
}
}
}
/**
* Configures whether external links should be enabled.
* When disabled, external link taps emit "link disabled" events instead of navigation events.
*
* @param allow Whether to allow external link navigation
*/
fun setAllowLinks(allow: Boolean) {
allowLinks = allow
}
// Getters
/**
* Gets the current PDF document source path.
*
* Used by callback handlers and loading managers to access the document path
* for security validation, error handling, and attempt tracking.
*
* @return The current PDF file path, or null if no source is set
*/
fun getSource(): String? = source
/**
* Gets the current document password.
*
* Used by callback handlers to determine authentication state and manage
* password attempt tracking during error handling scenarios.
*
* @return The current password string, or null if no password is set
*/
fun getPassword(): String? = password
/**
* Gets the current external link navigation permission setting.
*
* Used by callback handlers to determine whether external link taps should
* trigger navigation events or disabled link events.
*
* @return true if external links are allowed, false otherwise
*/
fun getAllowLinks(): Boolean = allowLinks
/**
* Provides access to the scroll bar handle component for internal coordination.
*
* This getter is used by callback handlers and other internal components to interact
* with the scroll handle for synchronization during document navigation and scrolling.
* Returns null if the scroll handle hasn't been initialized yet.
*
* @return The current ScrollBarHandler instance, or null if not yet initialized
*/
fun getScrollBarHandle(): ScrollBarHandler? {
return scrollBarHandle
}
// Document loading
/**
* Attempts to load the PDF document with comprehensive security validation.
*
* This method performs multiple security checks before loading:
* - File path validation against allowed directories
* - Password attempt limit checking
* - File size validation to prevent memory exhaustion
* - Document authentication if password protected
*/
private fun maybeLoadPdf() {
loadManager.loadPdf(source, password)
}
// Lifecycle management
override fun onAttachedToWindow() {
super.onAttachedToWindow()
isAttached = true
eventEmitter = PdfEventEmitter(context, this.id, attemptStore)
setupScrollHandle()
if (pendingLoad) {
maybeLoadPdf()
pendingLoad = false
}
}
/**
* Handles view detachment and performs comprehensive cleanup.
*
* This method is called when the view is removed from the React Native view hierarchy
* and ensures proper cleanup of all native resources to prevent memory leaks.
*/
override fun onDetachedFromWindow() {
super.onDetachedFromWindow()
// Clean up scroll handle resources
scrollBarHandle?.destroy()
scrollBarHandle = null
}
}

View file

@ -0,0 +1,210 @@
package com.mattermost.securepdfviewer.view.callbacks
import android.util.Log
import android.view.View.MeasureSpec
import android.widget.FrameLayout
import com.mattermost.pdfium.exceptions.InvalidPasswordException
import com.mattermost.pdfium.exceptions.PasswordRequiredException
import com.mattermost.securepdfviewer.manager.PasswordAttemptStore
import com.mattermost.securepdfviewer.pdfium.PdfView
import com.mattermost.securepdfviewer.util.HashUtils
import com.mattermost.securepdfviewer.view.SecurePdfViewerView
import com.mattermost.securepdfviewer.view.emitter.PdfEventEmitter
/**
* Manages all callback handlers for PDF view events.
*
* This class establishes the communication bridge between the native PDF view
* and both the scroll handle component and the React Native layer through event emission.
*
* Key responsibilities:
* - User interaction event handling (taps, links)
* - Scroll handle synchronization during navigation and scrolling
* - Document lifecycle event coordination
* - Security validation for link interactions
*/
class PdfViewCallbacks(
private val viewer: SecurePdfViewerView,
private val pdfView: PdfView,
private val attemptStore: PasswordAttemptStore,
private val eventEmitter: () -> PdfEventEmitter?,
) {
companion object {
private const val TAG = "PdfViewCallbacks"
}
/**
* Sets up all callback handlers for PDF view events.
*
* This method establishes the communication bridge between the native PDF view
* and both the scroll handle component and the React Native layer.
*/
fun setupCallbacks() {
// ===== USER INTERACTION EVENTS =====
/**
* Handle tap events on the PDF viewer.
* Shows scroll handle on tap and emits tap coordinates to React Native.
*/
pdfView.onTap = { event ->
viewer.getScrollBarHandle()?.let { handle ->
if (!handle.shown()) {
handle.show()
}
handle.hideDelayed()
}
val screenLocation = IntArray(2)
viewer.getLocationOnScreen(screenLocation)
eventEmitter()?.emitTapEvent(event, screenLocation)
}
/**
* Handle link tap events with security validation.
* Routes external links through permission checking and handles internal navigation.
*/
pdfView.onLinkTapped = { link ->
when {
link.isExternal() && viewer.getAllowLinks() -> {
eventEmitter()?.emitLinkPressed(link.uri ?: "")
}
link.isExternal() && !viewer.getAllowLinks() -> {
eventEmitter()?.emitLinkPressedDisabled()
}
link.isInternal() -> {
// Internal links are handled automatically by PdfView
Log.d(TAG, "Internal link handled: page ${link.destinationPage}")
}
}
}
// ===== SCROLL HANDLE SYNCHRONIZATION =====
/**
* Update scroll handle when page changes during navigation.
*/
pdfView.onPageChanged = { pageNumber ->
viewer.getScrollBarHandle()?.setPageNum(pageNumber + 1) // Convert to 1-based page numbering
}
/**
* Update scroll handle position and appearance during document scrolling.
* Handles zoom state changes and provides real-time scroll position feedback.
*/
pdfView.onScrollChanged = { scrollPercentage ->
viewer.getScrollBarHandle()?.let { handle ->
if (!handle.shown()) {
handle.show()
}
// Update thumb size when zoom changes
if (pdfView.getCurrentZoomScale() != 1.0f) {
handle.updateForDocumentChange()
}
handle.setScroll(scrollPercentage)
}
}
// ===== DOCUMENT LIFECYCLE EVENTS =====
/**
* Handle successful document loading.
* Resets security state and initializes scroll handle display.
*/
pdfView.onLoadComplete = {
if (pdfView.parent == viewer) {
viewer.removeView(pdfView)
}
val layoutParams = FrameLayout.LayoutParams(
FrameLayout.LayoutParams.MATCH_PARENT,
FrameLayout.LayoutParams.MATCH_PARENT
)
val width = viewer.measuredWidth
val height = viewer.measuredHeight
if (width > 0 && height > 0) {
pdfView.markViewReady()
viewer.addView(pdfView, layoutParams)
pdfView.measure(
MeasureSpec.makeMeasureSpec(width, MeasureSpec.EXACTLY),
MeasureSpec.makeMeasureSpec(height, MeasureSpec.EXACTLY)
)
pdfView.layout(0, 0, width, height)
pdfView.invalidate()
} else {
Log.w(TAG, "Cannot layout PdfView, parent has 0 size")
}
eventEmitter()?.emitLoadComplete()
// Reset password attempts on successful load
val fileKey = viewer.getSource()?.let { HashUtils.sha256(it) }
if (!viewer.getPassword().isNullOrEmpty() && !fileKey.isNullOrEmpty()) {
attemptStore.resetAttempts(fileKey)
}
// Initialize scroll handle for newly loaded document
viewer.getScrollBarHandle()?.let { handle ->
if (handle.parent != null) {
handle.bringToFront()
handle.invalidate()
}
handle.updateForDocumentChange()
handle.setPageNum(1) // Show "1 / total pages"
handle.show()
handle.hideDelayed() // Auto-hide after delay
}
}
/**
* Handle document loading errors with detailed error categorization.
*/
pdfView.onLoadError = { exception ->
Log.e(TAG, "PDF load error", exception)
val fileKey = viewer.getSource()?.let { HashUtils.sha256(it) }
if (fileKey != null) {
handleLoadError(exception, fileKey)
} else {
eventEmitter()?.emitLoadFailed(exception.message ?: "Unknown error")
}
}
pdfView.setContextCallbacks()
}
/**
* Handles document loading errors with intelligent error categorization.
*
* Distinguishes between password-related errors and other loading failures,
* managing security features like password attempt limiting.
*
* @param throwable The exception that occurred during loading
* @param fileKey Unique identifier for the document (for attempt tracking)
*/
private fun handleLoadError(throwable: Throwable, fileKey: String) {
val message = throwable.message ?: ""
val remainingAttempts = attemptStore.getRemainingAttempts(fileKey)
val maxAllowedAttempts = attemptStore.maxAllowedAttempts()
when(throwable) {
is PasswordRequiredException -> {
if (remainingAttempts <= maxAllowedAttempts && viewer.getPassword() == null) {
eventEmitter()?.emitPasswordRequired(fileKey)
} else {
eventEmitter()?.emitPasswordFailed(remainingAttempts)
}
}
is InvalidPasswordException -> {
attemptStore.registerFailedAttempt(fileKey)
if ((remainingAttempts - 1) < maxAllowedAttempts) {
eventEmitter()?.emitPasswordFailed(maxOf(remainingAttempts - 1, 0))
} else {
eventEmitter()?.emitPasswordFailureLimitReached()
}
}
else -> {
eventEmitter()?.emitLoadFailed(message)
}
}
}
}

Some files were not shown because too many files have changed in this diff Show more