From 9c240f498c80dbe859fa9ee1678268719dde9f22 Mon Sep 17 00:00:00 2001 From: Umberto Pepato Date: Mon, 11 Mar 2024 17:26:29 +0100 Subject: [PATCH] fix(teaching): detect corrupted files, refresh on click (#444) * fix(teaching): detect corrupted files, refresh on click * feat(teaching): prevent downloaded files from opening after leaving page --- lib/ui/components/FileListItem.tsx | 16 ++- src/core/hooks/useDownloadCourseFile.ts | 113 ++++++++++-------- .../courses/components/CourseFileListItem.tsx | 40 ++++++- .../screens/ProvisionalGradeScreen.tsx | 2 +- 4 files changed, 112 insertions(+), 59 deletions(-) diff --git a/lib/ui/components/FileListItem.tsx b/lib/ui/components/FileListItem.tsx index f665c4b5..b128d8e4 100644 --- a/lib/ui/components/FileListItem.tsx +++ b/lib/ui/components/FileListItem.tsx @@ -5,6 +5,7 @@ import { Pie as ProgressIndicator } from 'react-native-progress'; import { IconDefinition } from '@fortawesome/fontawesome-svg-core'; import { faCheckCircle, + faExclamationCircle, faFile, faFileAudio, faFileCode, @@ -69,6 +70,7 @@ interface Props { downloadProgress?: number; containerStyle?: StyleProp; mimeType?: string; + isCorrupted?: boolean; } export const FileListItem = ({ @@ -76,6 +78,7 @@ export const FileListItem = ({ downloadProgress, subtitle, mimeType, + isCorrupted = false, ...rest }: ListItemProps & Props) => { const { palettes, fontSizes } = useTheme(); @@ -101,7 +104,8 @@ export const FileListItem = ({ /> ) : ( - isDownloaded && ( + isDownloaded && + (!isCorrupted ? ( - ) + ) : ( + + + + )) )} } diff --git a/src/core/hooks/useDownloadCourseFile.ts b/src/core/hooks/useDownloadCourseFile.ts index 01d47ea3..73319455 100644 --- a/src/core/hooks/useDownloadCourseFile.ts +++ b/src/core/hooks/useDownloadCourseFile.ts @@ -88,53 +88,58 @@ export const useDownloadCourseFile = ( updateDownload, ]); - const startDownload = useCallback(async () => { - if (!download.isDownloaded && download.downloadProgress == null) { - updateDownload({ downloadProgress: 0 }); - try { - await mkdir(dirname(toFile)); - const { jobId, promise } = downloadFile({ - fromUrl, - toFile, - headers: { - Authorization: `Bearer ${token}`, - }, - progressInterval: 200, - begin: () => { - /* Needed for progress updates to work */ - }, - progress: ({ bytesWritten, contentLength }) => { - updateDownload({ downloadProgress: bytesWritten / contentLength }); - }, - }); - updateDownload({ jobId }); - const result = await promise; - if (result.statusCode !== 200) { - // noinspection ExceptionCaughtLocallyJS - throw new Error(t('common.downloadError')); + const startDownload = useCallback( + async (force = false) => { + if ( + force || + (!download.isDownloaded && download.downloadProgress == null) + ) { + updateDownload({ downloadProgress: 0 }); + try { + await mkdir(dirname(toFile)); + + const { jobId, promise } = downloadFile({ + fromUrl, + toFile, + headers: { + Authorization: `Bearer ${token}`, + }, + progressInterval: 200, + begin: () => { + /* Needed for progress updates to work */ + }, + progress: ({ bytesWritten, contentLength }) => { + updateDownload({ + downloadProgress: bytesWritten / contentLength, + }); + }, + }); + updateDownload({ jobId }); + const result = await promise; + if (result.statusCode !== 200) { + // noinspection ExceptionCaughtLocallyJS + throw new Error(t('common.downloadError')); + } + updateDownload({ + isDownloaded: true, + downloadProgress: undefined, + }); + } catch (e) { + if (!(e as Error).message?.includes('aborted')) { + Alert.alert( + t('common.error'), + t('courseScreen.fileDownloadFailed'), + ); + } + updateDownload({ + isDownloaded: false, + downloadProgress: undefined, + }); } - updateDownload({ - isDownloaded: true, - downloadProgress: undefined, - }); - } catch (e) { - Alert.alert(t('common.error'), t('courseScreen.fileDownloadFailed')); - updateDownload({ - isDownloaded: false, - downloadProgress: undefined, - }); - throw e; } - } - }, [ - download.downloadProgress, - download.isDownloaded, - fromUrl, - t, - toFile, - token, - updateDownload, - ]); + }, + [download, fromUrl, t, toFile, token, updateDownload], + ); const stopDownload = useCallback(() => { const jobId = download.jobId; @@ -157,7 +162,7 @@ export const useDownloadCourseFile = ( isDownloaded: false, downloadProgress: undefined, }); - return startDownload(); + return startDownload(true); }, [download.isDownloaded, startDownload, toFile, updateDownload]); const removeDownload = useCallback(async () => { @@ -169,13 +174,15 @@ export const useDownloadCourseFile = ( }); }, [toFile, updateDownload]); - const openFile = useCallback(() => { - return open(toFile).catch(async e => { - if (e.message === 'No app associated with this mime type') { - throw new UnsupportedFileTypeError(`Cannot open file ${fromUrl}`); - } - }); - }, [fromUrl, toFile]); + const openFile = useCallback( + () => + open(toFile).catch(async e => { + if (e.message === 'No app associated with this mime type') { + throw new UnsupportedFileTypeError(`Cannot open file ${fromUrl}`); + } + }), + [fromUrl, toFile], + ); return { ...(download ?? {}), diff --git a/src/features/courses/components/CourseFileListItem.tsx b/src/features/courses/components/CourseFileListItem.tsx index f3f80a9e..87597cd3 100644 --- a/src/features/courses/components/CourseFileListItem.tsx +++ b/src/features/courses/components/CourseFileListItem.tsx @@ -1,6 +1,7 @@ -import { memo, useCallback, useMemo } from 'react'; +import { memo, useCallback, useEffect, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { Alert, Platform } from 'react-native'; +import { stat } from 'react-native-fs'; import { extension, lookup } from 'react-native-mime-types'; import { @@ -15,6 +16,7 @@ import { useTheme } from '@lib/ui/hooks/useTheme'; import { BASE_PATH, CourseFileOverview } from '@polito/api-client'; import { MenuView } from '@react-native-menu/menu'; import { MenuComponentProps } from '@react-native-menu/menu/src/types'; +import { useNavigation } from '@react-navigation/native'; import { IS_IOS } from '../../../core/constants'; import { useDownloadCourseFile } from '../../../core/hooks/useDownloadCourseFile'; @@ -94,6 +96,7 @@ export const CourseFileListItem = memo( ...rest }: Props) => { const { t } = useTranslation(); + const navigation = useNavigation(); const { colors, fontSizes, spacing } = useTheme(); const iconProps = useMemo( () => ({ @@ -109,6 +112,7 @@ export const CourseFileListItem = memo( () => ['teaching', 'courses', courseId.toString(), 'files', item.id], [courseId, item.id], ); + const [isCorrupted, setIsCorrupted] = useState(false); const fileUrl = `${BASE_PATH}/courses/${courseId}/files/${item.id}`; const cachedFilePath = useMemo(() => { let ext: string | null = extension(item.mimeType!); @@ -137,6 +141,21 @@ export const CourseFileListItem = memo( openFile, } = useDownloadCourseFile(fileUrl, cachedFilePath, item.id); + useEffect(() => { + (async () => { + if (!isDownloaded) { + setIsCorrupted(false); + return; + } + const fileStats = await stat(cachedFilePath); + setIsCorrupted( + Math.abs(fileStats.size - item.sizeInKiloBytes * 1024) / + Math.max(fileStats.size, item.sizeInKiloBytes * 1024) > + 0.1, + ); + })(); + }, [cachedFilePath, isDownloaded, item.sizeInKiloBytes]); + const metrics = useMemo( () => [ @@ -161,12 +180,26 @@ export const CourseFileListItem = memo( const downloadFile = useCallback(async () => { if (downloadProgress == null) { + if (isCorrupted) { + await refreshDownload(); + return; + } if (!isDownloaded) { await startDownload(); } - openDownloadedFile(); + if (navigation.isFocused()) { + openDownloadedFile(); + } } - }, [downloadProgress, isDownloaded, openDownloadedFile, startDownload]); + }, [ + downloadProgress, + isCorrupted, + isDownloaded, + navigation, + openDownloadedFile, + refreshDownload, + startDownload, + ]); const trailingItem = useMemo( () => @@ -246,6 +279,7 @@ export const CourseFileListItem = memo( trailingItem={trailingItem} mimeType={item.mimeType} unread={!!getUnreadsCount(fileNotificationScope)} + isCorrupted={isCorrupted} /> ); diff --git a/src/features/transcript/screens/ProvisionalGradeScreen.tsx b/src/features/transcript/screens/ProvisionalGradeScreen.tsx index 22130b29..3c7f137b 100644 --- a/src/features/transcript/screens/ProvisionalGradeScreen.tsx +++ b/src/features/transcript/screens/ProvisionalGradeScreen.tsx @@ -228,7 +228,7 @@ const createStyles = ({ fontWeight: fontWeights.semibold, }, longGradeText: { - fontSize: fontSizes['md'], + fontSize: fontSizes.md, fontWeight: fontWeights.semibold, }, rejectionTime: {