diff --git a/mobile/app/(observer)/(app)/form-questionnaire/[questionId].tsx b/mobile/app/(observer)/(app)/form-questionnaire/[questionId].tsx index 54b8fd991..b9ae858f0 100644 --- a/mobile/app/(observer)/(app)/form-questionnaire/[questionId].tsx +++ b/mobile/app/(observer)/(app)/form-questionnaire/[questionId].tsx @@ -20,7 +20,7 @@ import { useFormSubmissionMutation } from "../../../../services/mutations/form-s import OptionsSheet from "../../../../components/OptionsSheet"; import AddAttachment from "../../../../components/AddAttachment"; import { FileMetadata, useCamera } from "../../../../hooks/useCamera"; -import { addAttachmentMutation } from "../../../../services/mutations/attachments/add-attachment.mutation"; +// import { addAttachmentMutation } from "../../../../services/mutations/attachments/add-attachment.mutation"; import QuestionAttachments from "../../../../components/QuestionAttachments"; import QuestionNotes from "../../../../components/QuestionNotes"; import * as DocumentPicker from "expo-document-picker"; @@ -311,108 +311,108 @@ const FormQuestionnaire = () => { } const { uploadCameraOrMedia } = useCamera(); - const { - mutate: addAttachment, - isPending: isLoadingAddAttachmentt, - isPaused, - } = addAttachmentMutation( - `Attachment_${questionId}_${selectedPollingStation?.pollingStationId}_${formId}_${questionId}`, - ); - - const handleCameraUpload = async (type: "library" | "cameraPhoto") => { - setIsPreparingFile(true); - const cameraResult = await uploadCameraOrMedia(type); - - if (!cameraResult) { - setIsPreparingFile(false); - return; - } - - if ( - activeElectionRound && - selectedPollingStation?.pollingStationId && - formId && - activeQuestion.question.id - ) { - addAttachment( - { - id: Crypto.randomUUID(), - electionRoundId: activeElectionRound.id, - pollingStationId: selectedPollingStation.pollingStationId, - formId, - questionId: activeQuestion.question.id, - fileMetadata: cameraResult, - }, - { - onSettled: () => setIsOptionsSheetOpen(false), - onError: (err) => { - Sentry.captureException(err); - Toast.show({ - type: "error", - text2: t("attachments.error"), - }); - }, - }, - ); - - setIsPreparingFile(false); - - if (!onlineManager.isOnline()) { - setIsOptionsSheetOpen(false); - } - } - }; - - const handleUploadAudio = async () => { - const doc = await DocumentPicker.getDocumentAsync({ - type: "audio/*", - multiple: false, - }); - - if (doc?.assets?.[0]) { - const file = doc?.assets?.[0]; - - const fileMetadata: FileMetadata = { - name: file.name, - type: file.mimeType || "audio/mpeg", - uri: file.uri, - }; - - if ( - activeElectionRound && - selectedPollingStation?.pollingStationId && - formId && - activeQuestion.question.id - ) { - addAttachment( - { - id: Crypto.randomUUID(), - electionRoundId: activeElectionRound.id, - pollingStationId: selectedPollingStation.pollingStationId, - formId, - questionId: activeQuestion.question.id, - fileMetadata, - }, - { - onSettled: () => setIsOptionsSheetOpen(false), - onError: (err) => { - Sentry.captureException(err); - Toast.show({ - type: "error", - text2: t("attachments.error"), - }); - }, - }, - ); - - if (!onlineManager.isOnline()) { - setIsOptionsSheetOpen(false); - } - } - } else { - // Cancelled - } - }; + // const { + // mutate: addAttachment, + // isPending: isLoadingAddAttachmentt, + // isPaused, + // } = addAttachmentMutation( + // `Attachment_${questionId}_${selectedPollingStation?.pollingStationId}_${formId}_${questionId}`, + // ); + + // const handleCameraUpload = async (type: "library" | "cameraPhoto") => { + // setIsPreparingFile(true); + // const cameraResult = await uploadCameraOrMedia(type); + + // if (!cameraResult) { + // setIsPreparingFile(false); + // return; + // } + + // if ( + // activeElectionRound && + // selectedPollingStation?.pollingStationId && + // formId && + // activeQuestion.question.id + // ) { + // addAttachment( + // { + // id: Crypto.randomUUID(), + // electionRoundId: activeElectionRound.id, + // pollingStationId: selectedPollingStation.pollingStationId, + // formId, + // questionId: activeQuestion.question.id, + // fileMetadata: cameraResult, + // }, + // { + // onSettled: () => setIsOptionsSheetOpen(false), + // onError: (err) => { + // Sentry.captureException(err); + // Toast.show({ + // type: "error", + // text2: t("attachments.error"), + // }); + // }, + // }, + // ); + + // setIsPreparingFile(false); + + // if (!onlineManager.isOnline()) { + // setIsOptionsSheetOpen(false); + // } + // } + // }; + + // const handleUploadAudio = async () => { + // const doc = await DocumentPicker.getDocumentAsync({ + // type: "audio/*", + // multiple: false, + // }); + + // if (doc?.assets?.[0]) { + // const file = doc?.assets?.[0]; + + // const fileMetadata: FileMetadata = { + // name: file.name, + // type: file.mimeType || "audio/mpeg", + // uri: file.uri, + // }; + + // if ( + // activeElectionRound && + // selectedPollingStation?.pollingStationId && + // formId && + // activeQuestion.question.id + // ) { + // addAttachment( + // { + // id: Crypto.randomUUID(), + // electionRoundId: activeElectionRound.id, + // pollingStationId: selectedPollingStation.pollingStationId, + // formId, + // questionId: activeQuestion.question.id, + // fileMetadata, + // }, + // { + // onSettled: () => setIsOptionsSheetOpen(false), + // onError: (err) => { + // Sentry.captureException(err); + // Toast.show({ + // type: "error", + // text2: t("attachments.error"), + // }); + // }, + // }, + // ); + + // if (!onlineManager.isOnline()) { + // setIsOptionsSheetOpen(false); + // } + // } + // } else { + // // Cancelled + // } + // }; // scroll view ref const scrollViewRef = useRef(null); @@ -537,7 +537,7 @@ const FormQuestionnaire = () => { onPreviousButtonPress={onBackButtonPress} /> {/* //todo: remove this once tamagui fixes sheet issue #2585 */} - {isOptionsSheetOpen && ( + {/* {isOptionsSheetOpen && ( { @@ -611,7 +611,7 @@ const FormQuestionnaire = () => { actionBtnText={t("warning_modal.unsaved_answer.actions.save")} cancelBtnText={t("warning_modal.unsaved_answer.actions.cancel")} /> - )} + )} */} ); }; diff --git a/mobile/app/(observer)/(app)/report-issue.tsx b/mobile/app/(observer)/(app)/report-issue.tsx index 2dbf2bcd7..a62d41d86 100644 --- a/mobile/app/(observer)/(app)/report-issue.tsx +++ b/mobile/app/(observer)/(app)/report-issue.tsx @@ -1,5 +1,5 @@ import React, { useMemo, useState } from "react"; -import { XStack, YStack } from "tamagui"; +import { Spinner, XStack, YStack } from "tamagui"; import { Screen } from "../../../components/Screen"; import { Icon } from "../../../components/Icon"; import { router } from "expo-router"; @@ -19,19 +19,21 @@ import { Typography } from "../../../components/Typography"; import { useAddQuickReport } from "../../../services/mutations/quick-report/add-quick-report.mutation"; import * as Crypto from "expo-crypto"; import { FileMetadata, useCamera } from "../../../hooks/useCamera"; -import { addAttachmentQuickReportMutation } from "../../../services/mutations/quick-report/add-attachment-quick-report.mutation"; +import { useUploadAttachmentQuickReportMutation } from "../../../services/mutations/quick-report/add-attachment-quick-report.mutation"; import { QuickReportLocationType } from "../../../services/api/quick-report/post-quick-report.api"; import * as DocumentPicker from "expo-document-picker"; import { onlineManager, useMutationState, useQueryClient } from "@tanstack/react-query"; import Card from "../../../components/Card"; import { QuickReportKeys } from "../../../services/queries/quick-reports.query"; import * as Sentry from "@sentry/react-native"; -import { AddAttachmentQuickReportAPIPayload } from "../../../services/api/quick-report/add-attachment-quick-report.api"; +import { addAttachmentQuickReportMultipartAbort, addAttachmentQuickReportMultipartComplete, AddAttachmentQuickReportStartAPIPayload } from "../../../services/api/quick-report/add-attachment-quick-report.api"; import { useTranslation } from "react-i18next"; import i18n from "../../../common/config/i18n"; import { KeyboardAwareScrollView } from "react-native-keyboard-aware-scroll-view"; -import MediaLoading from "../../../components/MediaLoading"; import Toast from "react-native-toast-message"; +import { uploadS3Chunk } from "../../../services/api/add-attachment.api"; +import { t } from "i18next"; +import { MULTIPART_FILE_UPLOAD_SIZE } from "../../../common/constants"; const mapVisitsToSelectPollingStations = (visits: PollingStationVisitVM[] = []) => { const pollingStationsForSelect = visits.map((visit) => { @@ -71,8 +73,10 @@ const ReportIssue = () => { const { visits, activeElectionRound } = useUserData(); const pollingStations = useMemo(() => mapVisitsToSelectPollingStations(visits), [visits]); const [optionsSheetOpen, setOptionsSheetOpen] = useState(false); - const [isPreparingFile, setIsPreparingFile] = useState(false); const { t } = useTranslation("report_new_issue"); + const [isLoadingAttachment, setIsLoadingAttachment] = useState(false); + const [isPreparingFile, setIsPreparingFile] = useState(false); + const [uploadProgress, setUploadProgress] = useState(""); const [attachments, setAttachments] = useState>( [], @@ -83,11 +87,9 @@ const ReportIssue = () => { isPending: isPendingAddQuickReport, isPaused: isPausedAddQuickReport, } = useAddQuickReport(); - const { - mutateAsync: addAttachmentQReport, - isPending: isLoadingAddAttachment, - isPaused, - } = addAttachmentQuickReportMutation(); + + const { mutateAsync: addAttachmentQReport, isPaused: isPausedStartAddAttachment } = + useUploadAttachmentQuickReportMutation(`Quick_Report_${activeElectionRound?.id}}`); const addAttachmentsMutationState = useMutationState({ filters: { mutationKey: QuickReportKeys.addAttachment() }, @@ -123,10 +125,12 @@ const ReportIssue = () => { const handleCameraUpload = async (type: "library" | "cameraPhoto" | "cameraVideo") => { setIsPreparingFile(true); + setUploadProgress(t("upload.preparing")); + const cameraResult = await uploadCameraOrMedia(type); if (!cameraResult || !activeElectionRound) { - setIsPreparingFile(false); + setUploadProgress(""); return; } @@ -140,6 +144,7 @@ const ReportIssue = () => { const handleUploadAudio = async () => { setIsPreparingFile(true); + setUploadProgress(t("upload.preparing")); const doc = await DocumentPicker.getDocumentAsync({ type: "audio/*", multiple: false, @@ -156,6 +161,7 @@ const ReportIssue = () => { setOptionsSheetOpen(false); setAttachments((attachments) => [...attachments, { fileMetadata, id: Crypto.randomUUID() }]); + setIsPreparingFile(false); } else { // Cancelled } @@ -163,6 +169,56 @@ const ReportIssue = () => { setIsPreparingFile(false); }; + const handleChunkUpload = async ( + filePath: string, + uploadUrls: Record, + uploadId: string, + attachmentId: string, + quickReportId: string, + ) => { + try { + let etags: Record = {}; + const urls = Object.values(uploadUrls); + for (const [index, url] of urls.entries()) { + const chunk = await FileSystem.readAsStringAsync(filePath, { + length: MULTIPART_FILE_UPLOAD_SIZE, + position: index * MULTIPART_FILE_UPLOAD_SIZE, + encoding: FileSystem.EncodingType.Base64, + }); + const buffer = Buffer.from(chunk, "base64"); + const data = await uploadS3Chunk(url, buffer); + etags = { ...etags, [index + 1]: data.ETag }; + } + + if (activeElectionRound?.id) { + await addAttachmentQuickReportMultipartComplete({ + uploadId, + etags, + electionRoundId: activeElectionRound?.id, + id: attachmentId, + quickReportId, + }); + } + } catch (err) { + // If error try to abort the upload + if (activeElectionRound?.id) { + setUploadProgress(t("upload.aborted")); + await addAttachmentQuickReportMultipartAbort({ + id: attachmentId, + uploadId, + electionRoundId: activeElectionRound.id, + quickReportId, + }); + } + } finally { + if (activeElectionRound?.id) { + queryClient.invalidateQueries({ + queryKey: QuickReportKeys.byElectionRound(activeElectionRound.id), + }); + } + } + }; + const onSubmit = async (formData: ReportIssueFormType) => { if (!visits || !activeElectionRound) { return; @@ -184,49 +240,45 @@ const ReportIssue = () => { const uuid = Crypto.randomUUID(); // Use the attachments to optimistically update the UI - const optimisticAttachments: AddAttachmentQuickReportAPIPayload[] = []; + const optimisticAttachments: AddAttachmentQuickReportStartAPIPayload[] = []; + if (attachments.length > 0) { - setIsPreparingFile(true); setOptionsSheetOpen(true); - const attachmentsMutations = attachments.map( - ({ fileMetadata, id }: { fileMetadata: FileMetadata; id: string }) => { - const payload: AddAttachmentQuickReportAPIPayload = { - id, - fileMetadata, + setIsLoadingAttachment(true); + try { + // Upload each attachment + setUploadProgress(`${t("upload.starting")}`); + for (const [index, attachment] of attachments.entries()) { + const payload: AddAttachmentQuickReportStartAPIPayload = { + id: attachment.id, + fileName: attachment.fileMetadata.name, + filePath: attachment.fileMetadata.uri, + contentType: attachment.fileMetadata.type, + numberOfUploadParts: Math.ceil( + attachment.fileMetadata.size! / MULTIPART_FILE_UPLOAD_SIZE, + ), electionRoundId: activeElectionRound.id, quickReportId: uuid, }; + const data = await addAttachmentQReport(payload); + await handleChunkUpload( + attachment.fileMetadata.uri, + data.uploadUrls, + data.uploadId, + attachment.id, + uuid, + ); + setUploadProgress( + `${t("upload.progress")} ${Math.round(((index + 1) / attachments.length) * 100 * 10) / 10} %`, + ); optimisticAttachments.push(payload); - return addAttachmentQReport(payload); - }, - ); - try { - if (!onlineManager.isOnline()) { - // No internet - setIsPreparingFile(false); - setOptionsSheetOpen(false); - Promise.all(attachmentsMutations); - } else { - // Internet - await Promise.all(attachmentsMutations).then(() => { - queryClient.invalidateQueries({ - queryKey: QuickReportKeys.byElectionRound(activeElectionRound.id), - }); - }); } + setUploadProgress(t("upload.completed")); } catch (err) { - Sentry.captureMessage("Failed to upload some attachments"); - Sentry.captureException(err); - - setIsPreparingFile(false); + console.log(err); + } finally { + setIsLoadingAttachment(false); setOptionsSheetOpen(false); - Toast.show({ - type: "error", - text2: t("media.error"), - }); - - // Stop the flow right here. - return; } } mutate( @@ -438,44 +490,38 @@ const ReportIssue = () => { - {optionsSheetOpen && ( - - {isPreparingFile || (isLoadingAddAttachment && !isPaused) ? ( - - ) : ( - - - {t("media.menu.load")} - - - {t("media.menu.take_picture")} - - - {t("media.menu.upload_audio")} - - - )} - - )} + + {(isLoadingAttachment && !isPausedStartAddAttachment) || isPreparingFile ? ( + + ) : ( + + + {t("media.menu.load")} + + + {t("media.menu.take_picture")} + + + {t("media.menu.upload_audio")} + + + )} + { ); }; +const MediaLoading = ({ progress }: { progress?: string }) => { + const { t } = useTranslation("polling_station_form_wizard"); + return ( + + + + {progress || t("attachments.loading")} + + + ); +}; + export default ReportIssue; diff --git a/mobile/assets/locales/en/translations.json b/mobile/assets/locales/en/translations.json index 367984a46..b1400c1bb 100644 --- a/mobile/assets/locales/en/translations.json +++ b/mobile/assets/locales/en/translations.json @@ -312,7 +312,17 @@ "attachments": { "heading": "Uploaded media", "loading": "Adding attachment... ", +<<<<<<< HEAD + "upload": { + "preparing": "Preparing attachment...", + "starting": "Starting the upload...", + "progress": "Upload progress:", + "completed": "Upload completed.", + "aborted": "Upload aborted." + }, +======= "error": "Error while sending the attachment!", +>>>>>>> mobile "add": "Add notes and media", "menu": { "add_note": "Add note", @@ -424,6 +434,13 @@ }, "report_new_issue": { "title": "Report new issue", + "upload": { + "preparing": "Preparing attachment...", + "starting": "Starting the upload...", + "progress": "Upload progress:", + "completed": "Upload completed.", + "aborted": "Upload aborted." + }, "media": { "heading": "Uploaded media", "add": "Add media", diff --git a/mobile/common/constants.ts b/mobile/common/constants.ts index b64cc8dcf..73ff729c5 100644 --- a/mobile/common/constants.ts +++ b/mobile/common/constants.ts @@ -10,3 +10,5 @@ export const SECURE_STORAGE_KEYS = { }; export const I18N_LANGUAGE = "i18n-language"; + +export const MULTIPART_FILE_UPLOAD_SIZE = 10 * 1024 * 1024; // 10MB. diff --git a/mobile/contexts/persist-query/PersistQueryContext.provider.tsx b/mobile/contexts/persist-query/PersistQueryContext.provider.tsx index 48690c0f5..db82551ed 100644 --- a/mobile/contexts/persist-query/PersistQueryContext.provider.tsx +++ b/mobile/contexts/persist-query/PersistQueryContext.provider.tsx @@ -5,7 +5,9 @@ import AsyncStorage from "@react-native-async-storage/async-storage"; import { notesKeys, pollingStationsKeys } from "../../services/queries.service"; import * as API from "../../services/definitions.api"; import { PersistGate } from "../../components/PersistGate"; -import { AddAttachmentAPIPayload, addAttachment } from "../../services/api/add-attachment.api"; +import { + AddAttachmentStartAPIPayload, +} from "../../services/api/add-attachment.api"; import { deleteAttachment } from "../../services/api/delete-attachment.api"; import { Note } from "../../common/models/note"; import { QuickReportKeys } from "../../services/queries/quick-reports.query"; @@ -14,20 +16,21 @@ import { addQuickReport, } from "../../services/api/quick-report/post-quick-report.api"; import { - AddAttachmentQuickReportAPIPayload, - addAttachmentQuickReport, + AddAttachmentQuickReportStartAPIPayload, + addAttachmentQuickReportMultipartStart, } from "../../services/api/quick-report/add-attachment-quick-report.api"; import { AttachmentApiResponse } from "../../services/api/get-attachments.api"; import { AttachmentsKeys } from "../../services/queries/attachments.query"; import { ASYNC_STORAGE_KEYS } from "../../common/constants"; import * as Sentry from "@sentry/react-native"; import SuperJSON from "superjson"; +import { uploadAttachmentMutationFn } from "../../services/mutations/attachments/add-attachment.mutation"; const queryClient = new QueryClient({ mutationCache: new MutationCache({ // There is also QueryCache // onSuccess: (data: unknown) => { - // console.log("MutationCache ", data); + // console.log("MutationCache ", data); // }, onError: (error: Error, _vars, _context, mutation) => { console.log("MutationCache error ", error); @@ -104,14 +107,14 @@ const PersistQueryContextProvider = ({ children }: React.PropsWithChildren) => { }); queryClient.setMutationDefaults(AttachmentsKeys.addAttachmentMutation(), { - mutationFn: async (payload: AddAttachmentAPIPayload) => { - return addAttachment(payload); + mutationFn: async (payload: AddAttachmentStartAPIPayload) => { + return uploadAttachmentMutationFn(payload); }, }); queryClient.setMutationDefaults(AttachmentsKeys.deleteAttachment(), { mutationFn: async (payload: AttachmentApiResponse) => { - return payload.isNotSynched ? () => {} : deleteAttachment(payload); + return payload.isNotSynched ? () => { } : deleteAttachment(payload); }, }); @@ -129,7 +132,7 @@ const PersistQueryContextProvider = ({ children }: React.PropsWithChildren) => { queryClient.setMutationDefaults(notesKeys.deleteNote(), { mutationFn: async (payload: Note) => { - return payload.isNotSynched ? () => {} : API.deleteNote(payload); + return payload.isNotSynched ? () => { } : API.deleteNote(payload); }, }); @@ -137,14 +140,14 @@ const PersistQueryContextProvider = ({ children }: React.PropsWithChildren) => { mutationFn: async ({ attachments: _, ...payload - }: AddQuickReportAPIPayload & { attachments: AddAttachmentQuickReportAPIPayload[] }) => { + }: AddQuickReportAPIPayload & { attachments: AddAttachmentQuickReportStartAPIPayload[] }) => { return addQuickReport(payload); }, }); queryClient.setMutationDefaults(QuickReportKeys.addAttachment(), { - mutationFn: async (payload: AddAttachmentQuickReportAPIPayload) => { - return addAttachmentQuickReport(payload); + mutationFn: async (payload: AddAttachmentQuickReportStartAPIPayload) => { + return addAttachmentQuickReportMultipartStart(payload); }, }); diff --git a/mobile/package-lock.json b/mobile/package-lock.json index e150f929f..1cc8b0bda 100644 --- a/mobile/package-lock.json +++ b/mobile/package-lock.json @@ -24,7 +24,12 @@ "@tanstack/react-query": "^5.36.0", "@tanstack/react-query-persist-client": "^5.36.0", "axios": "^1.6.8", +<<<<<<< HEAD + "buffer": "^6.0.3", + "expo": "~50.0.17", +======= "expo": "~50.0.20", +>>>>>>> mobile "expo-application": "~5.8.4", "expo-build-properties": "~0.11.1", "expo-clipboard": "~5.0.1", @@ -64,8 +69,14 @@ "react-native-svg": "14.1.0", "react-native-toast-message": "^2.2.0", "superjson": "^2.2.1", +<<<<<<< HEAD + "tamagui": "^1.93.2", + "zod": "^3.23.3", + "zustand": "^4.5.2" +======= "tamagui": "^1.100.0", "zod": "^3.23.3" +>>>>>>> mobile }, "devDependencies": { "@babel/core": "^7.20.0", @@ -10944,6 +10955,29 @@ "readable-stream": "^3.4.0" } }, + "node_modules/bl/node_modules/buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, "node_modules/bl/node_modules/readable-stream": { "version": "3.6.2", "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", @@ -11045,9 +11079,9 @@ } }, "node_modules/buffer": { - "version": "5.7.1", - "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", - "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", + "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", "funding": [ { "type": "github", @@ -11064,7 +11098,7 @@ ], "dependencies": { "base64-js": "^1.3.1", - "ieee754": "^1.1.13" + "ieee754": "^1.2.1" } }, "node_modules/buffer-alloc": { @@ -22304,6 +22338,29 @@ "node": ">=10" } }, + "node_modules/whatwg-url-without-unicode/node_modules/buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, "node_modules/whatwg-url-without-unicode/node_modules/webidl-conversions": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-5.0.0.tgz", diff --git a/mobile/package.json b/mobile/package.json index 08df9779f..a8725fc58 100644 --- a/mobile/package.json +++ b/mobile/package.json @@ -36,6 +36,7 @@ "@tanstack/react-query": "^5.36.0", "@tanstack/react-query-persist-client": "^5.36.0", "axios": "^1.6.8", + "buffer": "^6.0.3", "expo": "~50.0.20", "expo-application": "~5.8.4", "expo-build-properties": "~0.11.1", diff --git a/mobile/services/api/add-attachment.api.ts b/mobile/services/api/add-attachment.api.ts index e89b6c4d8..6534a1662 100644 --- a/mobile/services/api/add-attachment.api.ts +++ b/mobile/services/api/add-attachment.api.ts @@ -1,20 +1,36 @@ -import { FileMetadata } from "../../hooks/useCamera"; import API from "../api"; +import axios from "axios"; /** ======================================================================== ================= POST addAttachment ==================== ======================================================================== @description Sends a photo/video to the backend to be saved - @param {AddAttachmentAPIPayload} payload + @param {AddAttachmentStartAPIPayload} payload @returns {AddAttachmentAPIResponse} */ -export type AddAttachmentAPIPayload = { +export type AddAttachmentStartAPIPayload = { id: string; + filePath: string; electionRoundId: string; pollingStationId: string; formId: string; questionId: string; - fileMetadata: FileMetadata; + fileName: string; + contentType: string; + numberOfUploadParts: number; +}; + +export type AddAttachmentCompleteAPIPayload = { + electionRoundId: string; + id: string; + uploadId: string; + etags: Record; +}; + +export type AddAttachmentAbortAPIPayload = { + electionRoundId: string; + id: string; + uploadId: string; }; export type AddAttachmentAPIResponse = { @@ -25,30 +41,71 @@ export type AddAttachmentAPIResponse = { urlValidityInSeconds: number; }; -export const addAttachment = ({ - id, +// Multipart Upload - Add Attachment - Question +export const addAttachmentMultipartStart = ({ electionRoundId, pollingStationId, - fileMetadata: cameraResult, + id, formId, questionId, -}: AddAttachmentAPIPayload): Promise => { - const formData = new FormData(); - - formData.append("attachment", { - uri: cameraResult.uri, - name: cameraResult.name, - type: cameraResult.type, - } as unknown as Blob); - - formData.append("id", id); - formData.append("pollingStationId", pollingStationId); - formData.append("formId", formId); - formData.append("questionId", questionId); - - return API.postForm(`election-rounds/${electionRoundId}/attachments`, formData, { - headers: { - "Content-Type": "multipart/form-data", + fileName, + contentType, + numberOfUploadParts, +}: AddAttachmentStartAPIPayload): Promise<{ + uploadId: string; + uploadUrls: Record; +}> => { + return API.post( + `election-rounds/${electionRoundId}/attachments:init`, + { + pollingStationId, + electionRoundId, + id, + formId, + questionId, + fileName, + contentType, + numberOfUploadParts, }, - }).then((res) => res.data); + {}, + ).then((res) => res.data); +}; + +export const addAttachmentMultipartComplete = async ({ + uploadId, + id, + etags, + electionRoundId, +}: AddAttachmentCompleteAPIPayload): Promise => { + return API.post( + `election-rounds/${electionRoundId}/attachments/${id}:complete`, + { uploadId, etags }, + {}, + ).then((res) => res.data); +}; + +export const addAttachmentMultipartAbort = async ({ + uploadId, + id, + electionRoundId, +}: AddAttachmentAbortAPIPayload): Promise => { + return API.post( + `election-rounds/${electionRoundId}/attachments/${id}:abort`, + { uploadId }, + {}, + ).then((res) => res.data); +}; + +// Upload S3 Chunk of bytes (Buffer (array of bytes) - not Base64 - still bytes but written differently) +export const uploadS3Chunk = async (url: string, chunk: any): Promise<{ ETag: string }> => { + return axios + .put(url, chunk, { + timeout: 100000, + headers: { + "Content-Type": "application/json", + }, + }) + .then((res) => { + return { ETag: res.headers.etag }; + }); }; diff --git a/mobile/services/api/quick-report/add-attachment-quick-report.api.ts b/mobile/services/api/quick-report/add-attachment-quick-report.api.ts index 719435f6c..2c9df9dc5 100644 --- a/mobile/services/api/quick-report/add-attachment-quick-report.api.ts +++ b/mobile/services/api/quick-report/add-attachment-quick-report.api.ts @@ -1,4 +1,3 @@ -import { FileMetadata } from "../../../hooks/useCamera"; import API from "../../api"; import { QuickReportAttachmentAPIResponse } from "./get-quick-reports.api"; @@ -6,41 +5,82 @@ import { QuickReportAttachmentAPIResponse } from "./get-quick-reports.api"; ================= POST addAttachmentQuickReport ==================== ======================================================================== @description Sends a photo/video to the backend to be saved - @param {AddAttachmentQuickReportAPIPayload} payload + @param {AddAttachmentQuickReportStartAPIPayload} payload @returns {AddAttachmentQuickReportAPIResponse} */ -export type AddAttachmentQuickReportAPIPayload = { +export type AddAttachmentQuickReportStartAPIPayload = { electionRoundId: string; quickReportId: string; id: string; - fileMetadata: FileMetadata; + fileName: string; + filePath: string; + contentType: string; + numberOfUploadParts: number; +}; + +export type AddAttachmentQuickReportCompleteAPIPayload = { + electionRoundId: string; + id: string; + quickReportId: string; + uploadId: string; + etags: Record; +}; + +export type AddAttachmentQuickReportAbortAPIPayload = { + electionRoundId: string; + id: string; + quickReportId: string; + uploadId: string; }; export type AddAttachmentQuickReportAPIResponse = QuickReportAttachmentAPIResponse; -export const addAttachmentQuickReport = ({ +// Multipart Upload - Add Attachment - Question +export const addAttachmentQuickReportMultipartStart = ({ electionRoundId, - quickReportId, id, - fileMetadata, -}: AddAttachmentQuickReportAPIPayload): Promise => { - const formData = new FormData(); - - formData.append("attachment", { - uri: fileMetadata.uri, - name: fileMetadata.name, - type: fileMetadata.type, - } as unknown as Blob); - - formData.append("id", id); - - return API.postForm( - `election-rounds/${electionRoundId}/quick-reports/${quickReportId}/attachments`, - formData, + quickReportId, + fileName, + contentType, + numberOfUploadParts, +}: AddAttachmentQuickReportStartAPIPayload): Promise<{ + uploadId: string; + uploadUrls: Record; +}> => { + return API.post( + `election-rounds/${electionRoundId}/quick-reports/${quickReportId}/attachments/${id}:init`, { - headers: { - "Content-Type": "multipart/form-data", - }, + fileName, + contentType, + numberOfUploadParts, }, + {}, + ).then((res) => res.data); +}; + +export const addAttachmentQuickReportMultipartComplete = async ({ + uploadId, + id, + etags, + electionRoundId, + quickReportId, +}: AddAttachmentQuickReportCompleteAPIPayload): Promise => { + return API.post( + `election-rounds/${electionRoundId}/quick-reports/${quickReportId}/attachments/${id}:complete`, + { uploadId, etags }, + {}, + ).then((res) => res.data); +}; + +export const addAttachmentQuickReportMultipartAbort = async ({ + uploadId, + id, + electionRoundId, + quickReportId, +}: AddAttachmentQuickReportAbortAPIPayload): Promise => { + return API.post( + `election-rounds/${electionRoundId}/quick-reports/${quickReportId}/attachments/${id}:abort`, + { uploadId }, + {}, ).then((res) => res.data); }; diff --git a/mobile/services/mutations/attachments/add-attachment.mutation.ts b/mobile/services/mutations/attachments/add-attachment.mutation.ts index 32bb8d4c1..e7ecf766b 100644 --- a/mobile/services/mutations/attachments/add-attachment.mutation.ts +++ b/mobile/services/mutations/attachments/add-attachment.mutation.ts @@ -1,13 +1,102 @@ import { useMutation, useQueryClient } from "@tanstack/react-query"; import { - AddAttachmentAPIPayload, - AddAttachmentAPIResponse, - addAttachment, + AddAttachmentStartAPIPayload, + addAttachmentMultipartAbort, + addAttachmentMultipartComplete, + addAttachmentMultipartStart, + uploadS3Chunk, } from "../../api/add-attachment.api"; import { AttachmentApiResponse } from "../../api/get-attachments.api"; import { AttachmentsKeys } from "../../queries/attachments.query"; +import * as FileSystem from "expo-file-system"; +import { MULTIPART_FILE_UPLOAD_SIZE } from "../../../common/constants"; +import * as Sentry from "@sentry/react-native"; +import { Buffer } from "buffer"; +// export const handleChunkUpload = async ( +// filePath: string, +// uploadUrls: Record, +// queryClient: QueryClient, +// ) => { +// console.log("Handle chunk upload"); -export const addAttachmentMutation = (scopeId: string) => { +// let etags: Record = {}; +// const urls = Object.values(uploadUrls); +// for (const [index, url] of urls.entries()) { +// const chunk = await FileSystem.readAsStringAsync(filePath, { +// length: MULTIPART_FILE_UPLOAD_SIZE, +// position: index * MULTIPART_FILE_UPLOAD_SIZE, +// encoding: FileSystem.EncodingType.Base64, +// }); +// const buffer = Buffer.from(chunk, "base64"); +// const data = await uploadS3Chunk(url, buffer); + +// const progress = Math.round(((index + 1) / urls.length) * 100 * 10) / 10; +// queryClient.setQueryData( +// AttachmentsKeys.addAttachments(), +// (oldData) => { +// const toReturn: UploadAttachmentProgress = { +// ...oldData, +// progress, +// status: progress === 100 ? "completed" : "inprogress", +// }; +// console.log("toReturnProgress in handleChunkUpload", toReturn); + +// return toReturn; +// }, +// ); + +// etags = { ...etags, [index + 1]: data.ETag }; +// } + +// return etags; +// }; + +export const uploadAttachmentMutationFn = async (payload: AddAttachmentStartAPIPayload) => { + const start = await addAttachmentMultipartStart(payload); + try { + let etags: Record = {}; + const urls = Object.values(start.uploadUrls); + + for (const [index, url] of urls.entries()) { + const chunk = await FileSystem.readAsStringAsync(payload.filePath, { + length: MULTIPART_FILE_UPLOAD_SIZE, + position: index * MULTIPART_FILE_UPLOAD_SIZE, + encoding: FileSystem.EncodingType.Base64, + }); + const buffer = Buffer.from(chunk, "base64"); + // const progress = Math.round(((index + 1) / urls.length) * 100 * 10) / 10; + + const data = await uploadS3Chunk(url, buffer); + + etags = { ...etags, [index + 1]: data.ETag }; + } + const completed = await addAttachmentMultipartComplete({ + uploadId: start.uploadId, + etags, + electionRoundId: payload.electionRoundId, + id: payload.id, + }); + + return completed; + } catch (err) { + Sentry.captureMessage("Upload failed, aborting!"); + Sentry.captureException(err); + + const aborted = addAttachmentMultipartAbort({ + id: payload.id, + uploadId: start.uploadId, + electionRoundId: payload.electionRoundId, + }); + + return aborted; + } +}; + +export type UploadAttachmentProgress = { + progress: number; +}; + +export const useUploadAttachmentMutation = (scopeId: string) => { const queryClient = useQueryClient(); return useMutation({ @@ -15,10 +104,8 @@ export const addAttachmentMutation = (scopeId: string) => { scope: { id: scopeId, }, - mutationFn: async (payload: AddAttachmentAPIPayload): Promise => { - return addAttachment(payload); - }, - onMutate: async (payload: AddAttachmentAPIPayload) => { + mutationFn: (payload: AddAttachmentStartAPIPayload) => uploadAttachmentMutationFn(payload), + onMutate: async (payload: AddAttachmentStartAPIPayload) => { const attachmentsQK = AttachmentsKeys.attachments( payload.electionRoundId, payload.pollingStationId, @@ -37,9 +124,9 @@ export const addAttachmentMutation = (scopeId: string) => { pollingStationId: payload.pollingStationId, formId: payload.formId, questionId: payload.questionId, - fileName: `${payload.fileMetadata.name}`, - mimeType: payload.fileMetadata.type, - presignedUrl: payload.fileMetadata.uri, // TODO @radulescuandrew is this working to display the media? + fileName: `${payload.fileName}`, + mimeType: payload.contentType, + presignedUrl: payload.filePath, urlValidityInSeconds: 3600, isNotSynched: true, }, @@ -48,7 +135,7 @@ export const addAttachmentMutation = (scopeId: string) => { return { previousData, attachmentsQK }; }, onError: (err, payload, context) => { - console.log(err); + console.log("onError", err); const attachmentsQK = AttachmentsKeys.attachments( payload.electionRoundId, payload.pollingStationId, diff --git a/mobile/services/mutations/quick-report/add-attachment-quick-report.mutation.ts b/mobile/services/mutations/quick-report/add-attachment-quick-report.mutation.ts index 3a28eeca3..6b2589dca 100644 --- a/mobile/services/mutations/quick-report/add-attachment-quick-report.mutation.ts +++ b/mobile/services/mutations/quick-report/add-attachment-quick-report.mutation.ts @@ -1,16 +1,19 @@ import { useMutation } from "@tanstack/react-query"; import { QuickReportKeys } from "../../queries/quick-reports.query"; import { - AddAttachmentQuickReportAPIPayload, - addAttachmentQuickReport, + AddAttachmentQuickReportStartAPIPayload, + addAttachmentQuickReportMultipartStart, } from "../../api/quick-report/add-attachment-quick-report.api"; -export const addAttachmentQuickReportMutation = () => { +// Multipart Upload - Start +export const useUploadAttachmentQuickReportMutation = (scopeId: string) => { return useMutation({ mutationKey: QuickReportKeys.addAttachment(), - mutationFn: async (payload: AddAttachmentQuickReportAPIPayload) => { - return addAttachmentQuickReport(payload); + scope: { + id: scopeId, }, + mutationFn: (payload: AddAttachmentQuickReportStartAPIPayload) => + addAttachmentQuickReportMultipartStart(payload), onError: (err, _variables, _context) => { console.log(err); }, diff --git a/mobile/services/mutations/quick-report/add-quick-report.mutation.ts b/mobile/services/mutations/quick-report/add-quick-report.mutation.ts index 9bcc309d6..40285efaf 100644 --- a/mobile/services/mutations/quick-report/add-quick-report.mutation.ts +++ b/mobile/services/mutations/quick-report/add-quick-report.mutation.ts @@ -5,7 +5,7 @@ import { QuickReportsAPIResponse, } from "../../api/quick-report/get-quick-reports.api"; import { AddQuickReportAPIPayload } from "../../api/quick-report/post-quick-report.api"; -import { AddAttachmentQuickReportAPIPayload } from "../../api/quick-report/add-attachment-quick-report.api"; +import { AddAttachmentQuickReportStartAPIPayload } from "../../api/quick-report/add-attachment-quick-report.api"; export const useAddQuickReport = () => { const queryClient = useQueryClient(); @@ -15,7 +15,7 @@ export const useAddQuickReport = () => { onMutate: async ({ attachments, ...payload - }: AddQuickReportAPIPayload & { attachments: AddAttachmentQuickReportAPIPayload[] }) => { + }: AddQuickReportAPIPayload & { attachments: AddAttachmentQuickReportStartAPIPayload[] }) => { // Cancel any outgoing refetches // (so they don't overwrite our optimistic update) const queryKey = QuickReportKeys.byElectionRound(payload.electionRoundId); @@ -27,10 +27,10 @@ export const useAddQuickReport = () => { const attachmentsToUpdate: QuickReportAttachmentAPIResponse[] = attachments.map((attach) => { return { electionRoundId: attach.electionRoundId, - fileName: attach.fileMetadata.name, + fileName: attach.fileName, id: attach.id, - mimeType: attach.fileMetadata.type, - presignedUrl: attach.fileMetadata.uri, + mimeType: attach.contentType, + presignedUrl: attach.filePath, quickReportId: attach.quickReportId, urlValidityInSeconds: 0, }; @@ -60,7 +60,9 @@ export const useAddQuickReport = () => { onSettled: ( _data, _err, - variables: AddQuickReportAPIPayload & { attachments: AddAttachmentQuickReportAPIPayload[] }, + variables: AddQuickReportAPIPayload & { + attachments: AddAttachmentQuickReportStartAPIPayload[]; + }, ) => { const queryKey = QuickReportKeys.byElectionRound(variables.electionRoundId); return queryClient.invalidateQueries({ queryKey }); diff --git a/mobile/services/queries/attachments.query.ts b/mobile/services/queries/attachments.query.ts index e91d9683e..c958db521 100644 --- a/mobile/services/queries/attachments.query.ts +++ b/mobile/services/queries/attachments.query.ts @@ -17,8 +17,9 @@ export const AttachmentsKeys = { "formId", formId, ] as const, - addAttachmentMutation: () => [...AttachmentsKeys.all, "add"] as const, + addAttachmentMutation: () => [...AttachmentsKeys.all, "add", "mutation"] as const, deleteAttachment: () => [...AttachmentsKeys.all, "delete"] as const, + addAttachments: () => [...AttachmentsKeys.all, "progress"] as const, }; export const GuidesKeys = {