Secure Files and preview PDF inline (#8844)
This commit is contained in:
parent
b365cb34d6
commit
b91e42615f
137 changed files with 9520 additions and 582 deletions
28
NOTICE.txt
28
NOTICE.txt
|
|
@ -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.
|
||||
---
|
||||
|
|
|
|||
|
|
@ -33,9 +33,12 @@ buildscript {
|
|||
|
||||
allprojects {
|
||||
repositories {
|
||||
mavenLocal()
|
||||
google()
|
||||
maven {
|
||||
url "$rootDir/../detox/node_modules/detox/Detox-android"
|
||||
}
|
||||
maven { url 'https://jitpack.io' }
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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'})}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
]);
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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'),
|
||||
};
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
};
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ const Toasts = ({
|
|||
return (
|
||||
<DownloadWithAction
|
||||
action={action}
|
||||
enableSecureFilePreview={false}
|
||||
galleryView={false}
|
||||
item={galleryItem}
|
||||
setAction={setAction}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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') {
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
||||
|
|
|
|||
|
|
@ -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}>
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -1,16 +1,34 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
export default {
|
||||
SKU_SHORT_NAME: {
|
||||
export const SKU_SHORT_NAME = {
|
||||
E10: 'E10',
|
||||
E20: 'E20',
|
||||
Starter: 'starter',
|
||||
Professional: 'professional',
|
||||
Enterprise: 'enterprise',
|
||||
EnterpriseAdvanced: 'advanced',
|
||||
},
|
||||
SelfHostedProducts: {
|
||||
STARTER: 'starter',
|
||||
},
|
||||
};
|
||||
|
||||
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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
]);
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
438
app/queries/servers/security.test.ts
Normal file
438
app/queries/servers/security.test.ts
Normal 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);
|
||||
});
|
||||
55
app/queries/servers/security.ts
Normal file
55
app/queries/servers/security.ts
Normal 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(),
|
||||
);
|
||||
};
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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'),
|
||||
};
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
}
|
||||
</>
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
};
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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,6 +157,13 @@ const DownloadWithAction = ({action, item, onDownloadSuccess, setAction, gallery
|
|||
if (response.data?.path) {
|
||||
const path = response.data.path as string;
|
||||
onDownloadSuccess?.(path);
|
||||
if (enableSecureFilePreview) {
|
||||
if (isPdf(galleryItemToFileInfo(item))) {
|
||||
previewPdf(item, path, theme);
|
||||
} else {
|
||||
alertOnlyPDFSupported(intl);
|
||||
}
|
||||
} else {
|
||||
FileViewer.open(path, {
|
||||
displayName: item.name,
|
||||
showAppsSuggestions: true,
|
||||
|
|
@ -162,6 +173,7 @@ const DownloadWithAction = ({action, item, onDownloadSuccess, setAction, gallery
|
|||
alertFailedToOpenDocument(file, intl);
|
||||
});
|
||||
}
|
||||
}
|
||||
setShowToast(false);
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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.'
|
||||
|
|
|
|||
17
app/screens/gallery/video_renderer/index.ts
Normal file
17
app/screens/gallery/video_renderer/index.ts
Normal 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));
|
||||
|
|
@ -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}
|
||||
|
|
@ -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'),
|
||||
};
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
68
app/screens/pdf_viewer/error.tsx
Normal file
68
app/screens/pdf_viewer/error.tsx
Normal 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;
|
||||
22
app/screens/pdf_viewer/index.ts
Normal file
22
app/screens/pdf_viewer/index.ts
Normal 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));
|
||||
209
app/screens/pdf_viewer/password.tsx
Normal file
209
app/screens/pdf_viewer/password.tsx
Normal 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: 'You’ve 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;
|
||||
166
app/screens/pdf_viewer/pdf_viewer.tsx
Normal file
166
app/screens/pdf_viewer/pdf_viewer.tsx
Normal 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;
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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
109
app/utils/url/links.test.ts
Normal 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
41
app/utils/url/links.ts
Normal 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);
|
||||
}
|
||||
};
|
||||
|
|
@ -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": "You’ve 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}.",
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"jsx": "react",
|
||||
"allowJs": true,
|
||||
"allowSyntheticDefaultImports": true
|
||||
},
|
||||
|
|
|
|||
1
libraries/@mattermost/secure-pdf-viewer/.gitignore
vendored
Normal file
1
libraries/@mattermost/secure-pdf-viewer/.gitignore
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
.mupdf-cache/
|
||||
71
libraries/@mattermost/secure-pdf-viewer/android/build.gradle
Normal file
71
libraries/@mattermost/secure-pdf-viewer/android/build.gradle
Normal 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'
|
||||
}
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest package="com.mattermost.securepdfviewer" />
|
||||
|
|
@ -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())
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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")
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
)
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
|
@ -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}")
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
package com.mattermost.securepdfviewer.pdfium.shared
|
||||
|
||||
interface PdfViewInterface {
|
||||
val viewWidth: Int
|
||||
val viewHeight: Int
|
||||
fun invalidate()
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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) }
|
||||
}
|
||||
}
|
||||
|
|
@ -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 // 4–6GB: 300MB
|
||||
totalMem >= 2L * 1024 * 1024 * 1024 -> 150L * 1024 * 1024 // 2–4GB: 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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,149 @@
|
|||
package com.mattermost.securepdfviewer.view.emitter
|
||||
|
||||
import android.content.Context
|
||||
import android.view.MotionEvent
|
||||
import com.facebook.react.bridge.Arguments
|
||||
import com.facebook.react.bridge.ReactContext
|
||||
import com.facebook.react.bridge.WritableMap
|
||||
import com.facebook.react.uimanager.UIManagerHelper
|
||||
import com.facebook.react.uimanager.events.RCTEventEmitter
|
||||
import com.mattermost.securepdfviewer.BuildConfig
|
||||
import com.mattermost.securepdfviewer.enums.Events
|
||||
import com.mattermost.securepdfviewer.event.PdfViewerEvent
|
||||
import com.mattermost.securepdfviewer.manager.PasswordAttemptStore
|
||||
|
||||
/**
|
||||
* Handles all React Native event emission for the PDF viewer.
|
||||
*
|
||||
* This class is responsible for bridging native PDF viewer events to the React Native layer,
|
||||
* supporting both legacy and new architecture (Fabric) event dispatch mechanisms.
|
||||
*
|
||||
* Key responsibilities:
|
||||
* - Core event emission with architecture detection
|
||||
* - User interaction event emission (taps, links)
|
||||
* - Document lifecycle event emission (load, error)
|
||||
* - Security event emission (password attempts, failures)
|
||||
* - Event payload construction and validation
|
||||
*/
|
||||
class PdfEventEmitter(
|
||||
private val context: Context,
|
||||
private val viewId: Int,
|
||||
private val attemptStore: PasswordAttemptStore
|
||||
) {
|
||||
|
||||
/**
|
||||
* Core event emission method that handles both legacy and new React Native architectures.
|
||||
*
|
||||
* @param name Event name that matches React Native component event handlers
|
||||
* @param payload Optional data payload for the event
|
||||
*/
|
||||
private fun emitEvent(name: String, payload: WritableMap?) {
|
||||
val reactContext = context as ReactContext
|
||||
|
||||
if (BuildConfig.IS_NEW_ARCHITECTURE_ENABLED) {
|
||||
// New Architecture (Fabric) event dispatch
|
||||
val surfaceId = UIManagerHelper.getSurfaceId(reactContext)
|
||||
val eventDispatcher = UIManagerHelper.getEventDispatcherForReactTag(reactContext, viewId)
|
||||
|
||||
val event = PdfViewerEvent(name, surfaceId, viewId, payload)
|
||||
eventDispatcher?.dispatchEvent(event)
|
||||
} else {
|
||||
// Legacy Architecture event dispatch
|
||||
reactContext.getJSModule(RCTEventEmitter::class.java)
|
||||
.receiveEvent(viewId, name, payload)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Emits a tap event with detailed coordinate and pointer information.
|
||||
*
|
||||
* @param event The motion event containing tap details
|
||||
* @param screenLocation Array containing the view's screen coordinates
|
||||
*/
|
||||
fun emitTapEvent(event: MotionEvent, screenLocation: IntArray) {
|
||||
emitEvent(Events.ON_TAP.event, Arguments.createMap().apply {
|
||||
putDouble("x", event.x.toDouble())
|
||||
putDouble("y", event.y.toDouble())
|
||||
putDouble("pageX", (screenLocation[0] + event.x).toDouble())
|
||||
putDouble("pageY", (screenLocation[1] + event.y).toDouble())
|
||||
putDouble("timestamp", event.eventTime.toDouble())
|
||||
|
||||
val pointerType = when (event.getToolType(0)) {
|
||||
MotionEvent.TOOL_TYPE_FINGER -> "touch"
|
||||
MotionEvent.TOOL_TYPE_STYLUS -> "pen"
|
||||
MotionEvent.TOOL_TYPE_MOUSE -> "mouse"
|
||||
else -> "unknown"
|
||||
}
|
||||
putString("pointerType", pointerType)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Emits password requirement event with attempt information.
|
||||
*
|
||||
* @param fileKey Unique document identifier for attempt tracking
|
||||
*/
|
||||
fun emitPasswordRequired(fileKey: String?) {
|
||||
val remaining = fileKey?.let { attemptStore.getRemainingAttempts(it) } ?: 0
|
||||
emitEvent(Events.ON_PASSWORD_REQUIRED.event, Arguments.createMap().apply {
|
||||
putInt("maxAttempts", attemptStore.maxAllowedAttempts())
|
||||
putInt("remainingAttempts", remaining)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Emits password failure event with remaining attempts.
|
||||
*
|
||||
* @param remainingAttempts Number of password attempts remaining before lockout
|
||||
*/
|
||||
fun emitPasswordFailed(remainingAttempts: Int) {
|
||||
emitEvent(Events.ON_PASSWORD_FAILED.event, Arguments.createMap().apply {
|
||||
putInt("remainingAttempts", remainingAttempts)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Emits password limit reached event (lockout condition).
|
||||
*/
|
||||
fun emitPasswordFailureLimitReached() {
|
||||
emitEvent(Events.ON_PASSWORD_LIMIT_REACHED.event, Arguments.createMap().apply {
|
||||
putInt("maxAttempts", attemptStore.maxAllowedAttempts())
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Emits document loading failure event.
|
||||
*
|
||||
* @param message Error message describing the failure
|
||||
*/
|
||||
fun emitLoadFailed(message: String) {
|
||||
emitEvent(Events.ON_LOAD_ERROR_EVENT.event, Arguments.createMap().apply {
|
||||
putString("message", message)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Emits external link pressed event.
|
||||
*
|
||||
* @param url The URL of the external link that was tapped
|
||||
*/
|
||||
fun emitLinkPressed(url: String) {
|
||||
emitEvent(Events.ON_LINK_PRESSED.event, Arguments.createMap().apply {
|
||||
putString("url", url)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Emits event indicating an external link was tapped but link navigation is disabled.
|
||||
*/
|
||||
fun emitLinkPressedDisabled() {
|
||||
emitEvent(Events.ON_LINK_PRESSED_DISABLED.event, null)
|
||||
}
|
||||
|
||||
/**
|
||||
* Emits successful document load completion event.
|
||||
*/
|
||||
fun emitLoadComplete() {
|
||||
emitEvent(Events.ON_LOAD_EVENT.event, null)
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue