From e766b2beca47796b1dd64d57d821fd8183fefb9c Mon Sep 17 00:00:00 2001 From: Dragos-Paul Strat Date: Mon, 27 May 2024 17:50:01 +0300 Subject: [PATCH 01/19] fix: [upload] wip multipart upload --- mobile/hooks/useCamera.tsx | 6 ++- .../add-attachment-quick-report.api.ts | 53 +++++++++++++++++++ 2 files changed, 57 insertions(+), 2 deletions(-) diff --git a/mobile/hooks/useCamera.tsx b/mobile/hooks/useCamera.tsx index 893c32527..281494873 100644 --- a/mobile/hooks/useCamera.tsx +++ b/mobile/hooks/useCamera.tsx @@ -39,6 +39,7 @@ export type FileMetadata = { uri: string; name: string; type: string; + size?: number; }; export const useCamera = () => { @@ -75,9 +76,9 @@ export const useCamera = () => { ...(specifiedMediaType || { mediaTypes: ImagePicker.MediaTypeOptions.All }), allowsEditing: true, aspect: [4, 3], - quality: 1, + // quality: 1, allowsMultipleSelection: false, - videoQuality: ImagePicker.UIImagePickerControllerQualityType.Medium, // TODO: careful here, Medium might be enough + videoQuality: ImagePicker.UIImagePickerControllerQualityType.Low, // TODO: careful here, Medium might be enough cameraType: ImagePicker.CameraType.back, }); @@ -94,6 +95,7 @@ export const useCamera = () => { uri: result.assets[0].uri, name: filename, type: file.mimeType || "", + size: file.fileSize, }; return toReturn; } 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..b6b15cfa9 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,3 +1,4 @@ +import axios from "axios"; import { FileMetadata } from "../../../hooks/useCamera"; import API from "../../api"; import { QuickReportAttachmentAPIResponse } from "./get-quick-reports.api"; @@ -44,3 +45,55 @@ export const addAttachmentQuickReport = ({ }, ).then((res) => res.data); }; + +export const addAttachmentQuickReportMultipartStart = ({ + fileMetadata, +}: AddAttachmentQuickReportAPIPayload): Promise => { + const filePartsNo = Math.ceil((fileMetadata.size! / 10) * 1024 * 1024); + + return axios + .post( + `http://localhost:3001/api/dossier/${145}/file/start`, + { fileMimeType: fileMetadata.type, fileName: fileMetadata.name, filePartsNo }, + {}, + ) + .then((res) => res.data); +}; + +export const addAttachmentQuickReportMultipartComplete = async ( + uploadId: string, + key: string, + fileName: string, + uploadedParts: { ETag: string; PartNumber: number }[], +): Promise => { + return axios + .post( + `http://localhost:3001/api/dossier/${145}/file/complete`, + { uploadId, key, fileName, uploadedParts }, + {}, + ) + .then((res) => res.data); +}; + +export const addAttachmentQuickReportMultipartAbort = async ( + uploadId: string, + key: string, +): Promise => { + return axios + .post(`http://localhost:3001/api/dossier/${145}/file/abort`, { uploadId, key }, {}) + .then((res) => res.data); +}; + +// S3 +export const uploadChunk = async (url: string, chunk: Blob): Promise<{ ETag: string }> => { + return axios + .put(url, chunk, { + timeout: 100000, + headers: { + "Content-Type": "application/json", + }, + }) + .then((res) => { + return { ETag: res.headers["etag"] }; + }); +}; From bab167f58c6bf95ed5b943e0086f8e7dc498a3ae Mon Sep 17 00:00:00 2001 From: Dragos-Paul Strat Date: Tue, 28 May 2024 14:49:00 +0300 Subject: [PATCH 02/19] fix: [upload] wip --- .../(app)/form-questionnaire/[questionId].tsx | 68 +++++++++++++++-- mobile/package-lock.json | 29 ++++++++ mobile/package.json | 1 + .../add-attachment-quick-report.api.ts | 14 ++-- .../attachments/add-attachment.mutation.ts | 74 +++++++++++++++++++ 5 files changed, 174 insertions(+), 12 deletions(-) diff --git a/mobile/app/(app)/form-questionnaire/[questionId].tsx b/mobile/app/(app)/form-questionnaire/[questionId].tsx index 40bb781d7..def90e825 100644 --- a/mobile/app/(app)/form-questionnaire/[questionId].tsx +++ b/mobile/app/(app)/form-questionnaire/[questionId].tsx @@ -1,7 +1,7 @@ import { router, useLocalSearchParams } from "expo-router"; import { Screen } from "../../../components/Screen"; -import Header from "../../../components/Header"; import { Icon } from "../../../components/Icon"; +import Header from "../../../components/Header"; import { Typography } from "../../../components/Typography"; import { XStack, YStack, ScrollView, Spinner } from "tamagui"; import LinearProgress from "../../../components/LinearProgress"; @@ -27,7 +27,7 @@ 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, useCompleteAddAttachmentUploadMutation, useUploadAttachmentMutation, useUploadS3ChunkMutation } from "../../../services/mutations/attachments/add-attachment.mutation"; import QuestionAttachments from "../../../components/QuestionAttachments"; import QuestionNotes from "../../../components/QuestionNotes"; import * as DocumentPicker from "expo-document-picker"; @@ -41,6 +41,7 @@ import { onlineManager } from "@tanstack/react-query"; import { ApiFormQuestion } from "../../../services/interfaces/question.type"; import FormInput from "../../../components/FormInputs/FormInput"; import WarningDialog from "../../../components/WarningDialog"; +import * as FileSystem from 'expo-file-system'; type SearchParamType = { questionId: string; @@ -264,11 +265,20 @@ const FormQuestionnaire = () => { const { mutate: addAttachment, isPending: isLoadingAddAttachmentt, - isPaused, + // isPaused, } = addAttachmentMutation( `Attachment_${questionId}_${selectedPollingStation?.pollingStationId}_${formId}_${questionId}`, ); + const { + mutate: addAttachmentMultipart, + isPending: isLoadingAttachment, + isPaused + } = useUploadAttachmentMutation(`Attachment_${questionId}_${selectedPollingStation?.pollingStationId}_${formId}_${questionId}`); + + const { mutateAsync: uploadChunk } = useUploadS3ChunkMutation(`Attachment_${questionId}_${selectedPollingStation?.pollingStationId}_${formId}_${questionId}`); + const { mutateAsync: completeFileUpload } = useCompleteAddAttachmentUploadMutation(`Attachment_${questionId}_${selectedPollingStation?.pollingStationId}_${formId}_${questionId}`); + const handleCameraUpload = async (type: "library" | "cameraPhoto" | "cameraVideo") => { const cameraResult = await uploadCameraOrMedia(type); @@ -282,16 +292,20 @@ const FormQuestionnaire = () => { formId && activeQuestion.question.id ) { - addAttachment( + addAttachmentMultipart( { id: Crypto.randomUUID(), electionRoundId: activeElectionRound.id, - pollingStationId: selectedPollingStation.pollingStationId, - formId, - questionId: activeQuestion.question.id, + // pollingStationId: selectedPollingStation.pollingStationId, + // formId, + // questionId: activeQuestion.question.id, + quickReportId: '1', fileMetadata: cameraResult, }, { + onSuccess: (data) => { + handleChunkUpload(cameraResult, data.urls, data.key, data.uploadId); + }, onSettled: () => setIsOptionsSheetOpen(false), onError: () => console.log("πŸ”΄πŸ”΄πŸ”΄πŸ”΄πŸ”΄πŸ”΄πŸ”΄πŸ”΄πŸ”΄πŸ”΄ERORRπŸ”΄πŸ”΄πŸ”΄πŸ”΄πŸ”΄πŸ”΄πŸ”΄πŸ”΄πŸ”΄πŸ”΄"), }, @@ -348,6 +362,46 @@ const FormQuestionnaire = () => { } }; + const handleChunkUpload = async (file: any, urls: string[], key: string, uploadId: string) => { + + try { + const uploadedParts: { ETag: string, PartNumber: number }[] = []; + // setUploadProgress(`0`) + console.log('start'); + for (const [index, url] of urls.entries()) { + // Calculate the "slice" indexes + const start = index * 10 * 1024 * 1024;; + const end = start + 10 * 1024 * 1024;; + + // const chunk = await FileSystem.rea(file.uri, 10 * 1024 * 1024, start, 'buffer'); + const chunk = await FileSystem.readAsStringAsync(file.uri, { position: start, length: 10 * 1024 * 1024, encoding: FileSystem.EncodingType.Base64 }) + + const buffer = Buffer.from(chunk, 'base64'); + + if (index === 0) { + + console.log(buffer.byteLength); + } + + await uploadChunk({ url, data: buffer }, { + onSuccess: (data) => { + uploadedParts.push({ ETag: data.ETag, PartNumber: index + 1 }); + // setUploadProgress(`${Math.round(((index + 1) / urls.length) * 100 * 10) / 10}`) + console.log(`${index + 1} / ${urls.length}`); + } + + }) + } + + await completeFileUpload({ uploadId, key, fileName: file.name, uploadedParts }) + } catch (err) { + console.log(err); + // if (selectedDossier) { + // await abortFileUpload({ dossierId: selectedDossier?.id, uploadId, key }) + // } + } + } + return ( 0.62.0" } }, + "node_modules/react-native-fs": { + "version": "2.20.0", + "resolved": "https://registry.npmjs.org/react-native-fs/-/react-native-fs-2.20.0.tgz", + "integrity": "sha512-VkTBzs7fIDUiy/XajOSNk0XazFE9l+QlMAce7lGuebZcag5CnjszB+u4BdqzwaQOdcYb5wsJIsqq4kxInIRpJQ==", + "dependencies": { + "base-64": "^0.1.0", + "utf8": "^3.0.0" + }, + "peerDependencies": { + "react-native": "*", + "react-native-windows": "*" + }, + "peerDependenciesMeta": { + "react-native-windows": { + "optional": true + } + } + }, "node_modules/react-native-gesture-handler": { "version": "2.14.1", "resolved": "https://registry.npmjs.org/react-native-gesture-handler/-/react-native-gesture-handler-2.14.1.tgz", @@ -20419,6 +20443,11 @@ "react": "^16.8.0 || ^17.0.0 || ^18.0.0" } }, + "node_modules/utf8": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/utf8/-/utf8-3.0.0.tgz", + "integrity": "sha512-E8VjFIQ/TyQgp+TZfS6l8yp/xWppSAHzidGiRrqe4bK4XP9pTRyKFgGJpO3SN7zdX4DeomTrwaseCHovfpFcqQ==" + }, "node_modules/util": { "version": "0.12.5", "resolved": "https://registry.npmjs.org/util/-/util-0.12.5.tgz", diff --git a/mobile/package.json b/mobile/package.json index 799839f60..07cc22cf6 100644 --- a/mobile/package.json +++ b/mobile/package.json @@ -61,6 +61,7 @@ "react-i18next": "^14.1.0", "react-native": "0.73.6", "react-native-date-picker": "^5.0.0", + "react-native-fs": "^2.20.0", "react-native-gesture-handler": "~2.14.0", "react-native-pager-view": "^6.3.1", "react-native-reanimated": "~3.6.2", 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 b6b15cfa9..9465479b6 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 @@ -49,11 +49,11 @@ export const addAttachmentQuickReport = ({ export const addAttachmentQuickReportMultipartStart = ({ fileMetadata, }: AddAttachmentQuickReportAPIPayload): Promise => { - const filePartsNo = Math.ceil((fileMetadata.size! / 10) * 1024 * 1024); + const filePartsNo = Math.ceil(fileMetadata.size! / (10 * 1024 * 1024)); return axios .post( - `http://localhost:3001/api/dossier/${145}/file/start`, + `https://72eb-79-115-230-202.ngrok-free.app/dossier/${145}/file/start`, { fileMimeType: fileMetadata.type, fileName: fileMetadata.name, filePartsNo }, {}, ) @@ -68,7 +68,7 @@ export const addAttachmentQuickReportMultipartComplete = async ( ): Promise => { return axios .post( - `http://localhost:3001/api/dossier/${145}/file/complete`, + `https://72eb-79-115-230-202.ngrok-free.app/dossier/${145}/file/complete`, { uploadId, key, fileName, uploadedParts }, {}, ) @@ -80,12 +80,16 @@ export const addAttachmentQuickReportMultipartAbort = async ( key: string, ): Promise => { return axios - .post(`http://localhost:3001/api/dossier/${145}/file/abort`, { uploadId, key }, {}) + .post( + `https://72eb-79-115-230-202.ngrok-free.app/dossier/${145}/file/abort`, + { uploadId, key }, + {}, + ) .then((res) => res.data); }; // S3 -export const uploadChunk = async (url: string, chunk: Blob): Promise<{ ETag: string }> => { +export const uploadChunk = async (url: string, chunk: any): Promise<{ ETag: string }> => { return axios .put(url, chunk, { timeout: 100000, diff --git a/mobile/services/mutations/attachments/add-attachment.mutation.ts b/mobile/services/mutations/attachments/add-attachment.mutation.ts index 32bb8d4c1..cb1db24fe 100644 --- a/mobile/services/mutations/attachments/add-attachment.mutation.ts +++ b/mobile/services/mutations/attachments/add-attachment.mutation.ts @@ -6,6 +6,12 @@ import { } from "../../api/add-attachment.api"; import { AttachmentApiResponse } from "../../api/get-attachments.api"; import { AttachmentsKeys } from "../../queries/attachments.query"; +import { + AddAttachmentQuickReportAPIPayload, + addAttachmentQuickReportMultipartComplete, + addAttachmentQuickReportMultipartStart, + uploadChunk, +} from "../../api/quick-report/add-attachment-quick-report.api"; export const addAttachmentMutation = (scopeId: string) => { const queryClient = useQueryClient(); @@ -67,3 +73,71 @@ export const addAttachmentMutation = (scopeId: string) => { }, }); }; + +// Multipart Upload + +export const useUploadAttachmentMutation = (scopeId: string) => { + return useMutation({ + mutationKey: AttachmentsKeys.addAttachmentMutation(), + scope: { + id: scopeId, + }, + mutationFn: (payload: AddAttachmentQuickReportAPIPayload) => + addAttachmentQuickReportMultipartStart(payload), + + onError: (error: any) => Promise.resolve(error), + }); +}; + +export const useUploadS3ChunkMutation = (scopeId: string) => { + return useMutation({ + mutationKey: AttachmentsKeys.addAttachmentMutation(), + scope: { + id: scopeId, + }, + mutationFn: ({ url, data }: { url: string; data: any }) => uploadChunk(url, data), + onError: (error: any) => { + return Promise.resolve(error); + }, + retry: 3, + }); +}; + +export const useCompleteAddAttachmentUploadMutation = (scopeId: string) => { + return useMutation({ + mutationKey: AttachmentsKeys.addAttachmentMutation(), + scope: { + id: scopeId, + }, + mutationFn: ({ + uploadId, + key, + fileName, + uploadedParts, + }: { + uploadId: string; + key: string; + fileName: string; + uploadedParts: { ETag: string; PartNumber: number }[]; + }) => addAttachmentQuickReportMultipartComplete(uploadId, key, fileName, uploadedParts), + onError: (error: any) => { + console.log("err completing"); + return Promise.resolve(error); + }, + retry: 3, + }); +}; + +// export const useAbortDossierFileUploadMutation = () => { +// return useMutation( +// ({ dossierId, uploadId, key }: { dossierId: number; uploadId: string; key: string }) => +// abortUploadDossierFile(dossierId, uploadId, key), +// { +// onError: (error: AxiosError>) => { +// console.log("err aborting"); +// return Promise.resolve(error); +// }, +// retry: 3, +// }, +// ); +// }; From 30cd5187334fca6ec4025735b33d7243137d00f1 Mon Sep 17 00:00:00 2001 From: Dragos-Paul Strat Date: Tue, 28 May 2024 17:51:30 +0300 Subject: [PATCH 03/19] fix: [upload] wip integration with backend --- .../(app)/form-questionnaire/[questionId].tsx | 34 +++----- .../PersistQueryContext.provider.tsx | 8 +- mobile/package-lock.json | 84 +++++++++++-------- mobile/package.json | 2 +- mobile/services/api/add-attachment.api.ts | 75 ++++++++++++++++- .../add-attachment-quick-report.api.ts | 19 +++-- .../attachments/add-attachment.mutation.ts | 29 ++----- 7 files changed, 160 insertions(+), 91 deletions(-) diff --git a/mobile/app/(app)/form-questionnaire/[questionId].tsx b/mobile/app/(app)/form-questionnaire/[questionId].tsx index def90e825..c079c8d7f 100644 --- a/mobile/app/(app)/form-questionnaire/[questionId].tsx +++ b/mobile/app/(app)/form-questionnaire/[questionId].tsx @@ -27,7 +27,7 @@ import OptionsSheet from "../../../components/OptionsSheet"; import AddAttachment from "../../../components/AddAttachment"; import { FileMetadata, useCamera } from "../../../hooks/useCamera"; -import { addAttachmentMutation, useCompleteAddAttachmentUploadMutation, useUploadAttachmentMutation, useUploadS3ChunkMutation } from "../../../services/mutations/attachments/add-attachment.mutation"; +import { addAttachmentMutation, useCompleteAddAttachmentUploadMutation, useUploadAttachmentMutation } from "../../../services/mutations/attachments/add-attachment.mutation"; import QuestionAttachments from "../../../components/QuestionAttachments"; import QuestionNotes from "../../../components/QuestionNotes"; import * as DocumentPicker from "expo-document-picker"; @@ -42,6 +42,8 @@ import { ApiFormQuestion } from "../../../services/interfaces/question.type"; import FormInput from "../../../components/FormInputs/FormInput"; import WarningDialog from "../../../components/WarningDialog"; import * as FileSystem from 'expo-file-system'; +import { uploadChunkDirectly } from "../../../services/api/quick-report/add-attachment-quick-report.api"; +import { Buffer } from 'buffer'; type SearchParamType = { questionId: string; @@ -271,12 +273,12 @@ const FormQuestionnaire = () => { ); const { - mutate: addAttachmentMultipart, + mutateAsync: addAttachmentMultipart, isPending: isLoadingAttachment, isPaused } = useUploadAttachmentMutation(`Attachment_${questionId}_${selectedPollingStation?.pollingStationId}_${formId}_${questionId}`); - const { mutateAsync: uploadChunk } = useUploadS3ChunkMutation(`Attachment_${questionId}_${selectedPollingStation?.pollingStationId}_${formId}_${questionId}`); + // const { mutateAsync: uploadChunk } = useUploadS3ChunkMutation(`Attachment_${questionId}_${selectedPollingStation?.pollingStationId}_${formId}_${questionId}`); const { mutateAsync: completeFileUpload } = useCompleteAddAttachmentUploadMutation(`Attachment_${questionId}_${selectedPollingStation?.pollingStationId}_${formId}_${questionId}`); const handleCameraUpload = async (type: "library" | "cameraPhoto" | "cameraVideo") => { @@ -292,7 +294,7 @@ const FormQuestionnaire = () => { formId && activeQuestion.question.id ) { - addAttachmentMultipart( + const data = await addAttachmentMultipart( { id: Crypto.randomUUID(), electionRoundId: activeElectionRound.id, @@ -304,12 +306,12 @@ const FormQuestionnaire = () => { }, { onSuccess: (data) => { - handleChunkUpload(cameraResult, data.urls, data.key, data.uploadId); }, onSettled: () => setIsOptionsSheetOpen(false), onError: () => console.log("πŸ”΄πŸ”΄πŸ”΄πŸ”΄πŸ”΄πŸ”΄πŸ”΄πŸ”΄πŸ”΄πŸ”΄ERORRπŸ”΄πŸ”΄πŸ”΄πŸ”΄πŸ”΄πŸ”΄πŸ”΄πŸ”΄πŸ”΄πŸ”΄"), }, ); + await handleChunkUpload(cameraResult, data.urls, data.key, data.uploadId); if (!onlineManager.isOnline()) { setIsOptionsSheetOpen(false); @@ -370,27 +372,17 @@ const FormQuestionnaire = () => { console.log('start'); for (const [index, url] of urls.entries()) { // Calculate the "slice" indexes - const start = index * 10 * 1024 * 1024;; - const end = start + 10 * 1024 * 1024;; + const start = index * 10 * 1024 * 1024; + const end = start + 10 * 1024 * 1024; // const chunk = await FileSystem.rea(file.uri, 10 * 1024 * 1024, start, 'buffer'); - const chunk = await FileSystem.readAsStringAsync(file.uri, { position: start, length: 10 * 1024 * 1024, encoding: FileSystem.EncodingType.Base64 }) + const chunk = await FileSystem.readAsStringAsync(file.uri, { length: 10 * 1024 * 1024, position: start, encoding: FileSystem.EncodingType.Base64 }); const buffer = Buffer.from(chunk, 'base64'); - if (index === 0) { - - console.log(buffer.byteLength); - } - - await uploadChunk({ url, data: buffer }, { - onSuccess: (data) => { - uploadedParts.push({ ETag: data.ETag, PartNumber: index + 1 }); - // setUploadProgress(`${Math.round(((index + 1) / urls.length) * 100 * 10) / 10}`) - console.log(`${index + 1} / ${urls.length}`); - } - - }) + const data = await uploadChunkDirectly(url, buffer) + console.log(index + 1); + uploadedParts.push({ ETag: data.ETag, PartNumber: index + 1 }); } await completeFileUpload({ uploadId, key, fileName: file.name, uploadedParts }) diff --git a/mobile/contexts/persist-query/PersistQueryContext.provider.tsx b/mobile/contexts/persist-query/PersistQueryContext.provider.tsx index 9ea91b867..81623183d 100644 --- a/mobile/contexts/persist-query/PersistQueryContext.provider.tsx +++ b/mobile/contexts/persist-query/PersistQueryContext.provider.tsx @@ -6,7 +6,7 @@ import { useAuth } from "../../hooks/useAuth"; 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, addAttachment } 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"; @@ -114,14 +114,14 @@ const PersistQueryContextProvider = ({ children }: React.PropsWithChildren) => { }); queryClient.setMutationDefaults(AttachmentsKeys.addAttachmentMutation(), { - mutationFn: async (payload: AddAttachmentAPIPayload) => { + mutationFn: async (payload: AddAttachmentStartAPIPayload) => { return addAttachment(payload); }, }); queryClient.setMutationDefaults(AttachmentsKeys.deleteAttachment(), { mutationFn: async (payload: AttachmentApiResponse) => { - return payload.isNotSynched ? () => {} : deleteAttachment(payload); + return payload.isNotSynched ? () => { } : deleteAttachment(payload); }, }); @@ -139,7 +139,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); }, }); diff --git a/mobile/package-lock.json b/mobile/package-lock.json index c648092b8..85752a186 100644 --- a/mobile/package-lock.json +++ b/mobile/package-lock.json @@ -24,6 +24,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.17", "expo-application": "~5.8.4", "expo-build-properties": "~0.11.1", @@ -49,7 +50,6 @@ "react-i18next": "^14.1.0", "react-native": "0.73.6", "react-native-date-picker": "^5.0.0", - "react-native-fs": "^2.20.0", "react-native-gesture-handler": "~2.14.0", "react-native-pager-view": "^6.3.1", "react-native-reanimated": "~3.6.2", @@ -9465,11 +9465,6 @@ "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==" }, - "node_modules/base-64": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/base-64/-/base-64-0.1.0.tgz", - "integrity": "sha512-Y5gU45svrR5tI2Vt/X9GPd3L0HNIKzGu202EjxrXMpuc2V2CiKgemAbUUsqYmZJvPtCXoUKjNZwBJzsNScUbXA==" - }, "node_modules/base64-js": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", @@ -9529,6 +9524,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", @@ -9631,9 +9649,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", @@ -9650,7 +9668,7 @@ ], "dependencies": { "base64-js": "^1.3.1", - "ieee754": "^1.1.13" + "ieee754": "^1.2.1" } }, "node_modules/buffer-alloc": { @@ -17929,24 +17947,6 @@ "react-native": ">0.62.0" } }, - "node_modules/react-native-fs": { - "version": "2.20.0", - "resolved": "https://registry.npmjs.org/react-native-fs/-/react-native-fs-2.20.0.tgz", - "integrity": "sha512-VkTBzs7fIDUiy/XajOSNk0XazFE9l+QlMAce7lGuebZcag5CnjszB+u4BdqzwaQOdcYb5wsJIsqq4kxInIRpJQ==", - "dependencies": { - "base-64": "^0.1.0", - "utf8": "^3.0.0" - }, - "peerDependencies": { - "react-native": "*", - "react-native-windows": "*" - }, - "peerDependenciesMeta": { - "react-native-windows": { - "optional": true - } - } - }, "node_modules/react-native-gesture-handler": { "version": "2.14.1", "resolved": "https://registry.npmjs.org/react-native-gesture-handler/-/react-native-gesture-handler-2.14.1.tgz", @@ -20443,11 +20443,6 @@ "react": "^16.8.0 || ^17.0.0 || ^18.0.0" } }, - "node_modules/utf8": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/utf8/-/utf8-3.0.0.tgz", - "integrity": "sha512-E8VjFIQ/TyQgp+TZfS6l8yp/xWppSAHzidGiRrqe4bK4XP9pTRyKFgGJpO3SN7zdX4DeomTrwaseCHovfpFcqQ==" - }, "node_modules/util": { "version": "0.12.5", "resolved": "https://registry.npmjs.org/util/-/util-0.12.5.tgz", @@ -20589,6 +20584,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 07cc22cf6..e2538eb51 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.17", "expo-application": "~5.8.4", "expo-build-properties": "~0.11.1", @@ -61,7 +62,6 @@ "react-i18next": "^14.1.0", "react-native": "0.73.6", "react-native-date-picker": "^5.0.0", - "react-native-fs": "^2.20.0", "react-native-gesture-handler": "~2.14.0", "react-native-pager-view": "^6.3.1", "react-native-reanimated": "~3.6.2", diff --git a/mobile/services/api/add-attachment.api.ts b/mobile/services/api/add-attachment.api.ts index e89b6c4d8..4beb521c2 100644 --- a/mobile/services/api/add-attachment.api.ts +++ b/mobile/services/api/add-attachment.api.ts @@ -1,3 +1,4 @@ +import { number } from "zod"; import { FileMetadata } from "../../hooks/useCamera"; import API from "../api"; @@ -5,16 +6,32 @@ import API from "../api"; ================= 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; 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: string[]; +}; + +export type AddAttachmentAbortAPIPayload = { + electionRoundId: string; + id: string; + uploadId: string; }; export type AddAttachmentAPIResponse = { @@ -32,7 +49,7 @@ export const addAttachment = ({ fileMetadata: cameraResult, formId, questionId, -}: AddAttachmentAPIPayload): Promise => { +}: AddAttachmentStartAPIPayload): Promise => { const formData = new FormData(); formData.append("attachment", { @@ -52,3 +69,55 @@ export const addAttachment = ({ }, }).then((res) => res.data); }; + +// Multipart Upload - Add Attachment - Question +export const addAttachmentMultipartStart = ({ + electionRoundId, + pollingStationId, + id, + formId, + questionId, + fileName, + contentType, + numberOfUploadParts, +}: AddAttachmentStartAPIPayload): Promise => { + return API.post( + `election-rounds/${electionRoundId}/attachments:init`, + { + pollingStationId, + electionRoundId, + id, + formId, + questionId, + fileName, + contentType, + numberOfUploadParts, + }, + {}, + ).then((res) => res.data); +}; + +export const addAttachmenttMultipartComplete = async ({ + uploadId, + id, + etags, + electionRoundId, +}: AddAttachmentCompleteAPIPayload): Promise => { + return API.post( + `election-rounds/${electionRoundId}/attachments/${id}:complete`, + { uploadId, etags }, + {}, + ).then((res) => res.data); +}; + +export const addAttachmenttMultipartAbort = async ({ + uploadId, + id, + electionRoundId, +}: AddAttachmentAbortAPIPayload): Promise => { + return API.post( + `election-rounds/${electionRoundId}/attachments/${id}:abort`, + { uploadId }, + {}, + ).then((res) => res.data); +}; 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 9465479b6..0e6a48e53 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 @@ -47,17 +47,18 @@ export const addAttachmentQuickReport = ({ }; export const addAttachmentQuickReportMultipartStart = ({ + electionRoundId, + quickReportId, + id, fileMetadata, }: AddAttachmentQuickReportAPIPayload): Promise => { const filePartsNo = Math.ceil(fileMetadata.size! / (10 * 1024 * 1024)); - return axios - .post( - `https://72eb-79-115-230-202.ngrok-free.app/dossier/${145}/file/start`, - { fileMimeType: fileMetadata.type, fileName: fileMetadata.name, filePartsNo }, - {}, - ) - .then((res) => res.data); + return API.post( + `election-rounds/${electionRoundId}/quick-reports/${quickReportId}/attachments`, + { fileMimeType: fileMetadata.type, fileName: fileMetadata.name, filePartsNo }, + {}, + ).then((res) => res.data); }; export const addAttachmentQuickReportMultipartComplete = async ( @@ -88,8 +89,8 @@ export const addAttachmentQuickReportMultipartAbort = async ( .then((res) => res.data); }; -// S3 -export const uploadChunk = async (url: string, chunk: any): Promise<{ ETag: string }> => { +// Upload S3 Chunk of bytes (Buffer (array of bytes) - not Base64 - still bytes but written differently) +export const uploadChunkDirectly = async (url: string, chunk: any): Promise<{ ETag: string }> => { return axios .put(url, chunk, { timeout: 100000, diff --git a/mobile/services/mutations/attachments/add-attachment.mutation.ts b/mobile/services/mutations/attachments/add-attachment.mutation.ts index cb1db24fe..5de5c72c0 100644 --- a/mobile/services/mutations/attachments/add-attachment.mutation.ts +++ b/mobile/services/mutations/attachments/add-attachment.mutation.ts @@ -1,6 +1,6 @@ import { useMutation, useQueryClient } from "@tanstack/react-query"; import { - AddAttachmentAPIPayload, + AddAttachmentStartAPIPayload, AddAttachmentAPIResponse, addAttachment, } from "../../api/add-attachment.api"; @@ -10,7 +10,7 @@ import { AddAttachmentQuickReportAPIPayload, addAttachmentQuickReportMultipartComplete, addAttachmentQuickReportMultipartStart, - uploadChunk, + // uploadChunk, } from "../../api/quick-report/add-attachment-quick-report.api"; export const addAttachmentMutation = (scopeId: string) => { @@ -21,10 +21,12 @@ export const addAttachmentMutation = (scopeId: string) => { scope: { id: scopeId, }, - mutationFn: async (payload: AddAttachmentAPIPayload): Promise => { + mutationFn: async ( + payload: AddAttachmentStartAPIPayload, + ): Promise => { return addAttachment(payload); }, - onMutate: async (payload: AddAttachmentAPIPayload) => { + onMutate: async (payload: AddAttachmentStartAPIPayload) => { const attachmentsQK = AttachmentsKeys.attachments( payload.electionRoundId, payload.pollingStationId, @@ -74,8 +76,7 @@ export const addAttachmentMutation = (scopeId: string) => { }); }; -// Multipart Upload - +// Multipart Upload - Start export const useUploadAttachmentMutation = (scopeId: string) => { return useMutation({ mutationKey: AttachmentsKeys.addAttachmentMutation(), @@ -89,20 +90,7 @@ export const useUploadAttachmentMutation = (scopeId: string) => { }); }; -export const useUploadS3ChunkMutation = (scopeId: string) => { - return useMutation({ - mutationKey: AttachmentsKeys.addAttachmentMutation(), - scope: { - id: scopeId, - }, - mutationFn: ({ url, data }: { url: string; data: any }) => uploadChunk(url, data), - onError: (error: any) => { - return Promise.resolve(error); - }, - retry: 3, - }); -}; - +// Multipart Upload - Complete export const useCompleteAddAttachmentUploadMutation = (scopeId: string) => { return useMutation({ mutationKey: AttachmentsKeys.addAttachmentMutation(), @@ -128,6 +116,7 @@ export const useCompleteAddAttachmentUploadMutation = (scopeId: string) => { }); }; +// Multipart Upload - Abort // export const useAbortDossierFileUploadMutation = () => { // return useMutation( // ({ dossierId, uploadId, key }: { dossierId: number; uploadId: string; key: string }) => From 1d92df45c5a2603a55adc515de1f457b84d8e9c5 Mon Sep 17 00:00:00 2001 From: Dragos-Paul Strat Date: Wed, 29 May 2024 17:20:10 +0300 Subject: [PATCH 04/19] fix: [upload] wip --- .../(app)/form-questionnaire/[questionId].tsx | 116 ++++++++-------- mobile/app/(app)/report-issue.tsx | 19 ++- mobile/common/constants.ts | 2 + .../PersistQueryContext.provider.tsx | 17 +-- mobile/services/api/add-attachment.api.ts | 58 ++++---- .../add-attachment-quick-report.api.ts | 129 ++++++++---------- .../attachments/add-attachment.mutation.ts | 80 +---------- .../add-attachment-quick-report.mutation.ts | 13 +- .../quick-report/add-quick-report.mutation.ts | 14 +- 9 files changed, 185 insertions(+), 263 deletions(-) diff --git a/mobile/app/(app)/form-questionnaire/[questionId].tsx b/mobile/app/(app)/form-questionnaire/[questionId].tsx index c079c8d7f..c17d853da 100644 --- a/mobile/app/(app)/form-questionnaire/[questionId].tsx +++ b/mobile/app/(app)/form-questionnaire/[questionId].tsx @@ -25,12 +25,10 @@ import WizardRatingFormInput from "../../../components/WizardFormInputs/WizardRa import { useFormSubmissionMutation } from "../../../services/mutations/form-submission.mutation"; import OptionsSheet from "../../../components/OptionsSheet"; import AddAttachment from "../../../components/AddAttachment"; - import { FileMetadata, useCamera } from "../../../hooks/useCamera"; -import { addAttachmentMutation, useCompleteAddAttachmentUploadMutation, useUploadAttachmentMutation } from "../../../services/mutations/attachments/add-attachment.mutation"; +import { useUploadAttachmentMutation } from "../../../services/mutations/attachments/add-attachment.mutation"; import QuestionAttachments from "../../../components/QuestionAttachments"; import QuestionNotes from "../../../components/QuestionNotes"; -import * as DocumentPicker from "expo-document-picker"; import AddNoteSheetContent from "../../../components/AddNoteSheetContent"; import { useFormById } from "../../../services/queries/forms.query"; import { useFormAnswers } from "../../../services/queries/form-submissions.query"; @@ -42,8 +40,10 @@ import { ApiFormQuestion } from "../../../services/interfaces/question.type"; import FormInput from "../../../components/FormInputs/FormInput"; import WarningDialog from "../../../components/WarningDialog"; import * as FileSystem from 'expo-file-system'; -import { uploadChunkDirectly } from "../../../services/api/quick-report/add-attachment-quick-report.api"; import { Buffer } from 'buffer'; +import { MULTIPART_FILE_UPLOAD_SIZE } from "../../../common/constants"; +import { addAttachmentMultipartAbort, addAttachmentMultipartComplete, uploadS3Chunk } from "../../../services/api/add-attachment.api"; +import * as DocumentPicker from "expo-document-picker"; type SearchParamType = { questionId: string; @@ -54,6 +54,7 @@ type SearchParamType = { const FormQuestionnaire = () => { const { t } = useTranslation(["polling_station_form_wizard", "common"]); const { questionId, formId, language } = useLocalSearchParams(); + const [isLoadingAttachment, setIsLoadingAttachment] = useState(false); if (!questionId || !formId || !language) { return Incorrect page params; @@ -264,23 +265,12 @@ const FormQuestionnaire = () => { } const { uploadCameraOrMedia } = useCamera(); - const { - mutate: addAttachment, - isPending: isLoadingAddAttachmentt, - // isPaused, - } = addAttachmentMutation( - `Attachment_${questionId}_${selectedPollingStation?.pollingStationId}_${formId}_${questionId}`, - ); - const { mutateAsync: addAttachmentMultipart, - isPending: isLoadingAttachment, + isPending: isLoadingAttachmentStart, isPaused } = useUploadAttachmentMutation(`Attachment_${questionId}_${selectedPollingStation?.pollingStationId}_${formId}_${questionId}`); - // const { mutateAsync: uploadChunk } = useUploadS3ChunkMutation(`Attachment_${questionId}_${selectedPollingStation?.pollingStationId}_${formId}_${questionId}`); - const { mutateAsync: completeFileUpload } = useCompleteAddAttachmentUploadMutation(`Attachment_${questionId}_${selectedPollingStation?.pollingStationId}_${formId}_${questionId}`); - const handleCameraUpload = async (type: "library" | "cameraPhoto" | "cameraVideo") => { const cameraResult = await uploadCameraOrMedia(type); @@ -292,26 +282,33 @@ const FormQuestionnaire = () => { activeElectionRound && selectedPollingStation?.pollingStationId && formId && - activeQuestion.question.id + activeQuestion.question.id && + cameraResult.size ) { + + // Calculate the number of parts that will be sent to the S3 Bucket. It is +1 (thus we use ceil). + const numberOfUploadParts: number = Math.ceil(cameraResult.size / MULTIPART_FILE_UPLOAD_SIZE); + const attachmentId = Crypto.randomUUID(); + const data = await addAttachmentMultipart( { - id: Crypto.randomUUID(), + id: attachmentId, electionRoundId: activeElectionRound.id, - // pollingStationId: selectedPollingStation.pollingStationId, - // formId, - // questionId: activeQuestion.question.id, - quickReportId: '1', - fileMetadata: cameraResult, + pollingStationId: selectedPollingStation.pollingStationId, + formId, + questionId: activeQuestion.question.id, + fileName: cameraResult.name, + contentType: cameraResult.type, + numberOfUploadParts, + filePath: cameraResult.uri, }, { - onSuccess: (data) => { - }, onSettled: () => setIsOptionsSheetOpen(false), onError: () => console.log("πŸ”΄πŸ”΄πŸ”΄πŸ”΄πŸ”΄πŸ”΄πŸ”΄πŸ”΄πŸ”΄πŸ”΄ERORRπŸ”΄πŸ”΄πŸ”΄πŸ”΄πŸ”΄πŸ”΄πŸ”΄πŸ”΄πŸ”΄πŸ”΄"), }, ); - await handleChunkUpload(cameraResult, data.urls, data.key, data.uploadId); + + await handleChunkUpload(cameraResult.uri, data.uploadUrls, data.uploadId, attachmentId); if (!onlineManager.isOnline()) { setIsOptionsSheetOpen(false); @@ -338,59 +335,66 @@ const FormQuestionnaire = () => { activeElectionRound && selectedPollingStation?.pollingStationId && formId && - activeQuestion.question.id + activeQuestion.question.id && + fileMetadata.size ) { - addAttachment( + + // Calculate the number of parts that will be sent to the S3 Bucket. It is +1 (thus we use ceil). + const numberOfUploadParts: number = Math.ceil(fileMetadata.size / MULTIPART_FILE_UPLOAD_SIZE); + const attachmentId = Crypto.randomUUID(); + + const data = await addAttachmentMultipart( { - id: Crypto.randomUUID(), + id: attachmentId, electionRoundId: activeElectionRound.id, pollingStationId: selectedPollingStation.pollingStationId, formId, questionId: activeQuestion.question.id, - fileMetadata, + fileName: fileMetadata.name, + contentType: fileMetadata.type, + numberOfUploadParts, + filePath: fileMetadata.uri, }, { - onSettled: () => setIsOptionsSheetOpen(false), onError: () => console.log("πŸ”΄πŸ”΄πŸ”΄πŸ”΄πŸ”΄πŸ”΄πŸ”΄πŸ”΄πŸ”΄πŸ”΄ERORRπŸ”΄πŸ”΄πŸ”΄πŸ”΄πŸ”΄πŸ”΄πŸ”΄πŸ”΄πŸ”΄πŸ”΄"), }, ); + await handleChunkUpload(fileMetadata.uri, data.uploadUrls, data.uploadId, attachmentId); + if (!onlineManager.isOnline()) { setIsOptionsSheetOpen(false); } } - } else { - // Cancelled - } + }; }; - const handleChunkUpload = async (file: any, urls: string[], key: string, uploadId: string) => { - + const handleChunkUpload = async (filePath: string, uploadUrls: Record, uploadId: string, attachmentId: string) => { + setIsLoadingAttachment(true); try { - const uploadedParts: { ETag: string, PartNumber: number }[] = []; - // setUploadProgress(`0`) - console.log('start'); + let etags: Record = {}; + const urls = Object.values(uploadUrls); for (const [index, url] of urls.entries()) { - // Calculate the "slice" indexes - const start = index * 10 * 1024 * 1024; - const end = start + 10 * 1024 * 1024; - - // const chunk = await FileSystem.rea(file.uri, 10 * 1024 * 1024, start, 'buffer'); - const chunk = await FileSystem.readAsStringAsync(file.uri, { length: 10 * 1024 * 1024, position: start, encoding: FileSystem.EncodingType.Base64 }); - + 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) + console.log(data); + etags = { ...etags, [index]: data.ETag } + }; - const data = await uploadChunkDirectly(url, buffer) - console.log(index + 1); - uploadedParts.push({ ETag: data.ETag, PartNumber: index + 1 }); + // If everything went ok, send the complete upload command to the backend. + if (activeElectionRound?.id) { + await addAttachmentMultipartComplete({ uploadId, etags, electionRoundId: activeElectionRound?.id, id: attachmentId }) } - - await completeFileUpload({ uploadId, key, fileName: file.name, uploadedParts }) } catch (err) { console.log(err); - // if (selectedDossier) { - // await abortFileUpload({ dossierId: selectedDossier?.id, uploadId, key }) - // } + // If error try to abort the upload + if (activeElectionRound?.id) { + await addAttachmentMultipartAbort({ id: attachmentId, uploadId, electionRoundId: activeElectionRound.id }) + } + } finally { + setIsLoadingAttachment(false); + setIsOptionsSheetOpen(false); } } @@ -659,12 +663,12 @@ const FormQuestionnaire = () => { setIsOptionsSheetOpen(open); addingNote && setAddingNote(false); }} - isLoading={isLoadingAddAttachmentt && !isPaused} + isLoading={(isLoadingAttachmentStart || isLoadingAttachment)} // seems that this behaviour is handled differently and the sheet will move with keyboard even if this props is set to false on android moveOnKeyboardChange={Platform.OS === "android"} disableDrag={addingNote} > - {isLoadingAddAttachmentt && !isPaused ? ( + {(isLoadingAttachmentStart || isLoadingAttachment) ? ( ) : addingNote ? ( { const pollingStationsForSelect = visits.map((visit) => { @@ -70,7 +71,7 @@ const ReportIssue = () => { const [optionsSheetOpen, setOptionsSheetOpen] = useState(false); const { t } = useTranslation("report_new_issue"); - const [attachments, setAttachments] = useState>( + const [attachments, setAttachments] = useState>( [], ); @@ -79,7 +80,8 @@ const ReportIssue = () => { isPending: isPendingAddQuickReport, isPaused: isPausedAddQuickReport, } = useAddQuickReport(); - const { mutateAsync: addAttachmentQReport } = addAttachmentQuickReportMutation(); + + const { mutateAsync: addAttachmentQReport } = useUploadAttachmentQuickReportMutation(`Quick_Report_${activeElectionRound?.id}}`); const addAttachmentsMutationState = useMutationState({ filters: { mutationKey: QuickReportKeys.addAttachment() }, @@ -170,14 +172,17 @@ const ReportIssue = () => { const uuid = Crypto.randomUUID(); // Use the attachments to optimistically update the UI - const optimisticAttachments: AddAttachmentQuickReportAPIPayload[] = []; + const optimisticAttachments: AddAttachmentQuickReportStartAPIPayload[] = []; if (attachments.length > 0) { const attachmentsMutations = attachments.map( ({ fileMetadata, id }: { fileMetadata: FileMetadata; id: string }) => { - const payload: AddAttachmentQuickReportAPIPayload = { + const payload: AddAttachmentQuickReportStartAPIPayload = { id, - fileMetadata, + fileName: fileMetadata.name, + filePath: fileMetadata.uri, + contentType: fileMetadata.type, + numberOfUploadParts: Math.ceil(fileMetadata.size! / MULTIPART_FILE_UPLOAD_SIZE), // DRAGOS CHECK AGAIN electionRoundId: activeElectionRound.id, quickReportId: uuid, }; diff --git a/mobile/common/constants.ts b/mobile/common/constants.ts index d5d97e439..6f825c05d 100644 --- a/mobile/common/constants.ts +++ b/mobile/common/constants.ts @@ -9,3 +9,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 81623183d..b35de0059 100644 --- a/mobile/contexts/persist-query/PersistQueryContext.provider.tsx +++ b/mobile/contexts/persist-query/PersistQueryContext.provider.tsx @@ -6,7 +6,7 @@ import { useAuth } from "../../hooks/useAuth"; import { notesKeys, pollingStationsKeys } from "../../services/queries.service"; import * as API from "../../services/definitions.api"; import { PersistGate } from "../../components/PersistGate"; -import { AddAttachmentStartAPIPayload, addAttachment } from "../../services/api/add-attachment.api"; +import { AddAttachmentStartAPIPayload, addAttachmentMultipartStart } 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,10 +14,7 @@ import { AddQuickReportAPIPayload, addQuickReport, } from "../../services/api/quick-report/post-quick-report.api"; -import { - AddAttachmentQuickReportAPIPayload, - addAttachmentQuickReport, -} from "../../services/api/quick-report/add-attachment-quick-report.api"; +import { 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"; @@ -28,7 +25,7 @@ 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); @@ -115,7 +112,7 @@ const PersistQueryContextProvider = ({ children }: React.PropsWithChildren) => { queryClient.setMutationDefaults(AttachmentsKeys.addAttachmentMutation(), { mutationFn: async (payload: AddAttachmentStartAPIPayload) => { - return addAttachment(payload); + return addAttachmentMultipartStart(payload); }, }); @@ -147,14 +144,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/services/api/add-attachment.api.ts b/mobile/services/api/add-attachment.api.ts index 4beb521c2..b424afe74 100644 --- a/mobile/services/api/add-attachment.api.ts +++ b/mobile/services/api/add-attachment.api.ts @@ -1,6 +1,5 @@ -import { number } from "zod"; -import { FileMetadata } from "../../hooks/useCamera"; import API from "../api"; +import axios from "axios"; /** ======================================================================== ================= POST addAttachment ==================== @@ -11,11 +10,11 @@ import API from "../api"; */ export type AddAttachmentStartAPIPayload = { id: string; + filePath: string; electionRoundId: string; pollingStationId: string; formId: string; questionId: string; - fileMetadata: FileMetadata; fileName: string; contentType: string; numberOfUploadParts: number; @@ -25,7 +24,7 @@ export type AddAttachmentCompleteAPIPayload = { electionRoundId: string; id: string; uploadId: string; - etags: string[]; + etags: Record; }; export type AddAttachmentAbortAPIPayload = { @@ -42,34 +41,6 @@ export type AddAttachmentAPIResponse = { urlValidityInSeconds: number; }; -export const addAttachment = ({ - id, - electionRoundId, - pollingStationId, - fileMetadata: cameraResult, - formId, - questionId, -}: AddAttachmentStartAPIPayload): 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", - }, - }).then((res) => res.data); -}; - // Multipart Upload - Add Attachment - Question export const addAttachmentMultipartStart = ({ electionRoundId, @@ -80,7 +51,10 @@ export const addAttachmentMultipartStart = ({ fileName, contentType, numberOfUploadParts, -}: AddAttachmentStartAPIPayload): Promise => { +}: AddAttachmentStartAPIPayload): Promise<{ + uploadId: string; + uploadUrls: Record; +}> => { return API.post( `election-rounds/${electionRoundId}/attachments:init`, { @@ -97,7 +71,7 @@ export const addAttachmentMultipartStart = ({ ).then((res) => res.data); }; -export const addAttachmenttMultipartComplete = async ({ +export const addAttachmentMultipartComplete = async ({ uploadId, id, etags, @@ -110,7 +84,7 @@ export const addAttachmenttMultipartComplete = async ({ ).then((res) => res.data); }; -export const addAttachmenttMultipartAbort = async ({ +export const addAttachmentMultipartAbort = async ({ uploadId, id, electionRoundId, @@ -121,3 +95,17 @@ export const addAttachmenttMultipartAbort = async ({ {}, ).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 0e6a48e53..15328cd7e 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 @@ -7,98 +7,85 @@ 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:init`, { - headers: { - "Content-Type": "multipart/form-data", - }, + electionRoundId, + id, + quickReportId, + fileName, + contentType, + numberOfUploadParts, }, + {}, ).then((res) => res.data); }; -export const addAttachmentQuickReportMultipartStart = ({ +export const addAttachmentQuickReportMultipartComplete = async ({ + uploadId, + id, + etags, electionRoundId, quickReportId, - id, - fileMetadata, -}: AddAttachmentQuickReportAPIPayload): Promise => { - const filePartsNo = Math.ceil(fileMetadata.size! / (10 * 1024 * 1024)); - +}: AddAttachmentQuickReportCompleteAPIPayload): Promise => { return API.post( - `election-rounds/${electionRoundId}/quick-reports/${quickReportId}/attachments`, - { fileMimeType: fileMetadata.type, fileName: fileMetadata.name, filePartsNo }, + `election-rounds/${electionRoundId}/quick-reports/${quickReportId}/${id}:complete`, + { uploadId, etags }, {}, ).then((res) => res.data); }; -export const addAttachmentQuickReportMultipartComplete = async ( - uploadId: string, - key: string, - fileName: string, - uploadedParts: { ETag: string; PartNumber: number }[], -): Promise => { - return axios - .post( - `https://72eb-79-115-230-202.ngrok-free.app/dossier/${145}/file/complete`, - { uploadId, key, fileName, uploadedParts }, - {}, - ) - .then((res) => res.data); -}; - -export const addAttachmentQuickReportMultipartAbort = async ( - uploadId: string, - key: string, -): Promise => { - return axios - .post( - `https://72eb-79-115-230-202.ngrok-free.app/dossier/${145}/file/abort`, - { uploadId, key }, - {}, - ) - .then((res) => res.data); -}; - -// Upload S3 Chunk of bytes (Buffer (array of bytes) - not Base64 - still bytes but written differently) -export const uploadChunkDirectly = 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"] }; - }); +export const addAttachmenQuickReporttMultipartAbort = 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 5de5c72c0..1ddcfd3f6 100644 --- a/mobile/services/mutations/attachments/add-attachment.mutation.ts +++ b/mobile/services/mutations/attachments/add-attachment.mutation.ts @@ -1,31 +1,20 @@ import { useMutation, useQueryClient } from "@tanstack/react-query"; import { AddAttachmentStartAPIPayload, - AddAttachmentAPIResponse, - addAttachment, + addAttachmentMultipartStart, } from "../../api/add-attachment.api"; import { AttachmentApiResponse } from "../../api/get-attachments.api"; import { AttachmentsKeys } from "../../queries/attachments.query"; -import { - AddAttachmentQuickReportAPIPayload, - addAttachmentQuickReportMultipartComplete, - addAttachmentQuickReportMultipartStart, - // uploadChunk, -} from "../../api/quick-report/add-attachment-quick-report.api"; -export const addAttachmentMutation = (scopeId: string) => { +// Multipart Upload - Start +export const useUploadAttachmentMutation = (scopeId: string) => { const queryClient = useQueryClient(); - return useMutation({ mutationKey: AttachmentsKeys.addAttachmentMutation(), scope: { id: scopeId, }, - mutationFn: async ( - payload: AddAttachmentStartAPIPayload, - ): Promise => { - return addAttachment(payload); - }, + mutationFn: (payload: AddAttachmentStartAPIPayload) => addAttachmentMultipartStart(payload), onMutate: async (payload: AddAttachmentStartAPIPayload) => { const attachmentsQK = AttachmentsKeys.attachments( payload.electionRoundId, @@ -45,9 +34,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, }, @@ -75,58 +64,3 @@ export const addAttachmentMutation = (scopeId: string) => { }, }); }; - -// Multipart Upload - Start -export const useUploadAttachmentMutation = (scopeId: string) => { - return useMutation({ - mutationKey: AttachmentsKeys.addAttachmentMutation(), - scope: { - id: scopeId, - }, - mutationFn: (payload: AddAttachmentQuickReportAPIPayload) => - addAttachmentQuickReportMultipartStart(payload), - - onError: (error: any) => Promise.resolve(error), - }); -}; - -// Multipart Upload - Complete -export const useCompleteAddAttachmentUploadMutation = (scopeId: string) => { - return useMutation({ - mutationKey: AttachmentsKeys.addAttachmentMutation(), - scope: { - id: scopeId, - }, - mutationFn: ({ - uploadId, - key, - fileName, - uploadedParts, - }: { - uploadId: string; - key: string; - fileName: string; - uploadedParts: { ETag: string; PartNumber: number }[]; - }) => addAttachmentQuickReportMultipartComplete(uploadId, key, fileName, uploadedParts), - onError: (error: any) => { - console.log("err completing"); - return Promise.resolve(error); - }, - retry: 3, - }); -}; - -// Multipart Upload - Abort -// export const useAbortDossierFileUploadMutation = () => { -// return useMutation( -// ({ dossierId, uploadId, key }: { dossierId: number; uploadId: string; key: string }) => -// abortUploadDossierFile(dossierId, uploadId, key), -// { -// onError: (error: AxiosError>) => { -// console.log("err aborting"); -// return Promise.resolve(error); -// }, -// retry: 3, -// }, -// ); -// }; 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 }); From 2e074938f0fc3661eb959a4f64ff9355bb1c9daa Mon Sep 17 00:00:00 2001 From: Dragos-Paul Strat Date: Wed, 29 May 2024 17:58:47 +0300 Subject: [PATCH 05/19] fix: [upload] wip --- mobile/app/(app)/form-questionnaire/[questionId].tsx | 2 -- 1 file changed, 2 deletions(-) diff --git a/mobile/app/(app)/form-questionnaire/[questionId].tsx b/mobile/app/(app)/form-questionnaire/[questionId].tsx index c17d853da..e7efaf874 100644 --- a/mobile/app/(app)/form-questionnaire/[questionId].tsx +++ b/mobile/app/(app)/form-questionnaire/[questionId].tsx @@ -378,7 +378,6 @@ const FormQuestionnaire = () => { 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) - console.log(data); etags = { ...etags, [index]: data.ETag } }; @@ -387,7 +386,6 @@ const FormQuestionnaire = () => { await addAttachmentMultipartComplete({ uploadId, etags, electionRoundId: activeElectionRound?.id, id: attachmentId }) } } catch (err) { - console.log(err); // If error try to abort the upload if (activeElectionRound?.id) { await addAttachmentMultipartAbort({ id: attachmentId, uploadId, electionRoundId: activeElectionRound.id }) From e93a959612561d43c2838295ad8ae34a90343359 Mon Sep 17 00:00:00 2001 From: Dragos-Paul Strat Date: Thu, 30 May 2024 11:47:24 +0300 Subject: [PATCH 06/19] fix: [upload] wip --- mobile/app/(app)/form-questionnaire/[questionId].tsx | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/mobile/app/(app)/form-questionnaire/[questionId].tsx b/mobile/app/(app)/form-questionnaire/[questionId].tsx index e7efaf874..093499124 100644 --- a/mobile/app/(app)/form-questionnaire/[questionId].tsx +++ b/mobile/app/(app)/form-questionnaire/[questionId].tsx @@ -311,7 +311,7 @@ const FormQuestionnaire = () => { await handleChunkUpload(cameraResult.uri, data.uploadUrls, data.uploadId, attachmentId); if (!onlineManager.isOnline()) { - setIsOptionsSheetOpen(false); + // setIsOptionsSheetOpen(false); } } }; @@ -377,17 +377,20 @@ const FormQuestionnaire = () => { 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]: data.ETag } + const data = await uploadS3Chunk(url, chunk) + console.log(`uplodaded part - ${index + 1}`); + etags = { ...etags, [index + 1]: data.ETag } }; // If everything went ok, send the complete upload command to the backend. if (activeElectionRound?.id) { + console.log('completing...') await addAttachmentMultipartComplete({ uploadId, etags, electionRoundId: activeElectionRound?.id, id: attachmentId }) } } catch (err) { // If error try to abort the upload if (activeElectionRound?.id) { + console.log('abandoning...') await addAttachmentMultipartAbort({ id: attachmentId, uploadId, electionRoundId: activeElectionRound.id }) } } finally { From 38a1c49134d01196c055fbc65bb6a8135907cb63 Mon Sep 17 00:00:00 2001 From: Dragos-Paul Strat Date: Thu, 30 May 2024 14:42:46 +0300 Subject: [PATCH 07/19] fix: [upload] wip --- .../(app)/form-questionnaire/[questionId].tsx | 57 ++++++++++++++----- 1 file changed, 42 insertions(+), 15 deletions(-) diff --git a/mobile/app/(app)/form-questionnaire/[questionId].tsx b/mobile/app/(app)/form-questionnaire/[questionId].tsx index 093499124..5fdb59609 100644 --- a/mobile/app/(app)/form-questionnaire/[questionId].tsx +++ b/mobile/app/(app)/form-questionnaire/[questionId].tsx @@ -44,6 +44,7 @@ import { Buffer } from 'buffer'; import { MULTIPART_FILE_UPLOAD_SIZE } from "../../../common/constants"; import { addAttachmentMultipartAbort, addAttachmentMultipartComplete, uploadS3Chunk } from "../../../services/api/add-attachment.api"; import * as DocumentPicker from "expo-document-picker"; +import ReactNativeBlobUtil from "react-native-blob-util"; type SearchParamType = { questionId: string; @@ -303,7 +304,7 @@ const FormQuestionnaire = () => { filePath: cameraResult.uri, }, { - onSettled: () => setIsOptionsSheetOpen(false), + // onSettled: () => setIsOptionsSheetOpen(false), onError: () => console.log("πŸ”΄πŸ”΄πŸ”΄πŸ”΄πŸ”΄πŸ”΄πŸ”΄πŸ”΄πŸ”΄πŸ”΄ERORRπŸ”΄πŸ”΄πŸ”΄πŸ”΄πŸ”΄πŸ”΄πŸ”΄πŸ”΄πŸ”΄πŸ”΄"), }, ); @@ -371,22 +372,49 @@ const FormQuestionnaire = () => { const handleChunkUpload = async (filePath: string, uploadUrls: Record, uploadId: string, attachmentId: string) => { setIsLoadingAttachment(true); + let currentIndex = 1; 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, chunk) - console.log(`uplodaded part - ${index + 1}`); - etags = { ...etags, [index + 1]: data.ETag } - }; + // 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 } + // }; + + ReactNativeBlobUtil.fs.readStream( + // file path + filePath, + // encoding, should be one of `base64`, `utf8`, `ascii` + 'utf8', + // (optional) buffer size, default to 4096 (4095 for BASE64 encoded data) + // when reading file in BASE64 encoding, buffer size must be multiples of 3. + MULTIPART_FILE_UPLOAD_SIZE) + .then((ifstream) => { + ifstream.open() + ifstream.onData(async (chunk) => { + const data = await uploadS3Chunk(urls[currentIndex], chunk) + etags = { ...etags, [currentIndex + 1]: data.ETag } + currentIndex++; + }) + ifstream.onError((err) => { + console.log('oops', err) + }) + ifstream.onEnd(async () => { + // If everything went ok, send the complete upload command to the backend. + if (activeElectionRound?.id) { + console.log('completing...') + await addAttachmentMultipartComplete({ uploadId, etags, electionRoundId: activeElectionRound?.id, id: attachmentId }) + + } + setIsLoadingAttachment(false); + setIsOptionsSheetOpen(false); + }) + }) + - // If everything went ok, send the complete upload command to the backend. - if (activeElectionRound?.id) { - console.log('completing...') - await addAttachmentMultipartComplete({ uploadId, etags, electionRoundId: activeElectionRound?.id, id: attachmentId }) - } } catch (err) { // If error try to abort the upload if (activeElectionRound?.id) { @@ -394,8 +422,7 @@ const FormQuestionnaire = () => { await addAttachmentMultipartAbort({ id: attachmentId, uploadId, electionRoundId: activeElectionRound.id }) } } finally { - setIsLoadingAttachment(false); - setIsOptionsSheetOpen(false); + } } From eab4289d4c787b4c8bf3ae41705ddb27ead4ae54 Mon Sep 17 00:00:00 2001 From: Dragos-Paul Strat Date: Fri, 31 May 2024 13:25:28 +0300 Subject: [PATCH 08/19] fix: [upload] revert branch to filesystem implementation --- .../(app)/form-questionnaire/[questionId].tsx | 55 +++++-------------- 1 file changed, 13 insertions(+), 42 deletions(-) diff --git a/mobile/app/(app)/form-questionnaire/[questionId].tsx b/mobile/app/(app)/form-questionnaire/[questionId].tsx index 5fdb59609..0367f3cc7 100644 --- a/mobile/app/(app)/form-questionnaire/[questionId].tsx +++ b/mobile/app/(app)/form-questionnaire/[questionId].tsx @@ -304,7 +304,6 @@ const FormQuestionnaire = () => { filePath: cameraResult.uri, }, { - // onSettled: () => setIsOptionsSheetOpen(false), onError: () => console.log("πŸ”΄πŸ”΄πŸ”΄πŸ”΄πŸ”΄πŸ”΄πŸ”΄πŸ”΄πŸ”΄πŸ”΄ERORRπŸ”΄πŸ”΄πŸ”΄πŸ”΄πŸ”΄πŸ”΄πŸ”΄πŸ”΄πŸ”΄πŸ”΄"), }, ); @@ -312,7 +311,7 @@ const FormQuestionnaire = () => { await handleChunkUpload(cameraResult.uri, data.uploadUrls, data.uploadId, attachmentId); if (!onlineManager.isOnline()) { - // setIsOptionsSheetOpen(false); + setIsOptionsSheetOpen(false); } } }; @@ -372,57 +371,29 @@ const FormQuestionnaire = () => { const handleChunkUpload = async (filePath: string, uploadUrls: Record, uploadId: string, attachmentId: string) => { setIsLoadingAttachment(true); - let currentIndex = 1; 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 } - // }; - - ReactNativeBlobUtil.fs.readStream( - // file path - filePath, - // encoding, should be one of `base64`, `utf8`, `ascii` - 'utf8', - // (optional) buffer size, default to 4096 (4095 for BASE64 encoded data) - // when reading file in BASE64 encoding, buffer size must be multiples of 3. - MULTIPART_FILE_UPLOAD_SIZE) - .then((ifstream) => { - ifstream.open() - ifstream.onData(async (chunk) => { - const data = await uploadS3Chunk(urls[currentIndex], chunk) - etags = { ...etags, [currentIndex + 1]: data.ETag } - currentIndex++; - }) - ifstream.onError((err) => { - console.log('oops', err) - }) - ifstream.onEnd(async () => { - // If everything went ok, send the complete upload command to the backend. - if (activeElectionRound?.id) { - console.log('completing...') - await addAttachmentMultipartComplete({ uploadId, etags, electionRoundId: activeElectionRound?.id, id: attachmentId }) - - } - setIsLoadingAttachment(false); - setIsOptionsSheetOpen(false); - }) - }) + 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 addAttachmentMultipartComplete({ uploadId, etags, electionRoundId: activeElectionRound?.id, id: attachmentId }) + } } catch (err) { // If error try to abort the upload if (activeElectionRound?.id) { - console.log('abandoning...') await addAttachmentMultipartAbort({ id: attachmentId, uploadId, electionRoundId: activeElectionRound.id }) } } finally { - + setIsLoadingAttachment(false); + setIsOptionsSheetOpen(false); } } From d129d55ffa990c7a309b620c4385e01229a4ad71 Mon Sep 17 00:00:00 2001 From: Dragos-Paul Strat Date: Fri, 31 May 2024 13:25:44 +0300 Subject: [PATCH 09/19] fix: [upload] remove unused import --- mobile/app/(app)/form-questionnaire/[questionId].tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/mobile/app/(app)/form-questionnaire/[questionId].tsx b/mobile/app/(app)/form-questionnaire/[questionId].tsx index 0367f3cc7..04bd8eaad 100644 --- a/mobile/app/(app)/form-questionnaire/[questionId].tsx +++ b/mobile/app/(app)/form-questionnaire/[questionId].tsx @@ -44,7 +44,6 @@ import { Buffer } from 'buffer'; import { MULTIPART_FILE_UPLOAD_SIZE } from "../../../common/constants"; import { addAttachmentMultipartAbort, addAttachmentMultipartComplete, uploadS3Chunk } from "../../../services/api/add-attachment.api"; import * as DocumentPicker from "expo-document-picker"; -import ReactNativeBlobUtil from "react-native-blob-util"; type SearchParamType = { questionId: string; From 374bc3a1278e13e02fbed7b3009044a6ff4256ca Mon Sep 17 00:00:00 2001 From: Dragos-Paul Strat Date: Fri, 31 May 2024 14:31:47 +0300 Subject: [PATCH 10/19] fix: [upload] --- .../(app)/form-questionnaire/[questionId].tsx | 194 ++++++++++-------- mobile/assets/locales/en/translations.json | 7 + mobile/hooks/useCamera.tsx | 5 +- 3 files changed, 121 insertions(+), 85 deletions(-) diff --git a/mobile/app/(app)/form-questionnaire/[questionId].tsx b/mobile/app/(app)/form-questionnaire/[questionId].tsx index edb4470c6..49a30a7c1 100644 --- a/mobile/app/(app)/form-questionnaire/[questionId].tsx +++ b/mobile/app/(app)/form-questionnaire/[questionId].tsx @@ -35,7 +35,7 @@ import { useFormAnswers } from "../../../services/queries/form-submissions.query import { useNotesForQuestionId } from "../../../services/queries/notes.query"; import * as Crypto from "expo-crypto"; import { useTranslation } from "react-i18next"; -import { onlineManager } from "@tanstack/react-query"; +import { onlineManager, useQueryClient } from "@tanstack/react-query"; import { ApiFormQuestion } from "../../../services/interfaces/question.type"; import FormInput from "../../../components/FormInputs/FormInput"; import WarningDialog from "../../../components/WarningDialog"; @@ -45,6 +45,7 @@ import { MULTIPART_FILE_UPLOAD_SIZE } from "../../../common/constants"; import { addAttachmentMultipartAbort, addAttachmentMultipartComplete, uploadS3Chunk } from "../../../services/api/add-attachment.api"; import * as DocumentPicker from "expo-document-picker"; import { KeyboardAwareScrollView } from "react-native-keyboard-aware-scroll-view"; +import { AttachmentsKeys } from "../../../services/queries/attachments.query"; type SearchParamType = { questionId: string; @@ -56,6 +57,8 @@ const FormQuestionnaire = () => { const { t } = useTranslation(["polling_station_form_wizard", "common"]); const { questionId, formId, language } = useLocalSearchParams(); const [isLoadingAttachment, setIsLoadingAttachment] = useState(false); + const [uploadProgress, setUploadProgress] = useState(''); + const queryClient = useQueryClient(); if (!questionId || !formId || !language) { return Incorrect page params; @@ -269,12 +272,12 @@ const FormQuestionnaire = () => { const { mutateAsync: addAttachmentMultipart, - isPending: isLoadingAttachmentStart, isPaused } = useUploadAttachmentMutation(`Attachment_${questionId}_${selectedPollingStation?.pollingStationId}_${formId}_${questionId}`); const handleCameraUpload = async (type: "library" | "cameraPhoto") => { setIsPreparingFile(true); + const cameraResult = await uploadCameraOrMedia(type); if (!cameraResult) { @@ -282,6 +285,7 @@ const FormQuestionnaire = () => { return; } + setUploadProgress(t("attachments.upload.preparing")) if ( activeElectionRound && selectedPollingStation?.pollingStationId && @@ -293,6 +297,7 @@ const FormQuestionnaire = () => { // Calculate the number of parts that will be sent to the S3 Bucket. It is +1 (thus we use ceil). const numberOfUploadParts: number = Math.ceil(cameraResult.size / MULTIPART_FILE_UPLOAD_SIZE); const attachmentId = Crypto.randomUUID(); + setUploadProgress(t("attachments.upload.starting")) const data = await addAttachmentMultipart( { @@ -313,6 +318,7 @@ const FormQuestionnaire = () => { await handleChunkUpload(cameraResult.uri, data.uploadUrls, data.uploadId, attachmentId); setIsPreparingFile(false); + setUploadProgress('') if (!onlineManager.isOnline()) { setIsOptionsSheetOpen(false); @@ -321,11 +327,21 @@ const FormQuestionnaire = () => { }; const handleUploadAudio = async () => { + setIsPreparingFile(true); const doc = await DocumentPicker.getDocumentAsync({ type: "audio/*", multiple: false, }); + + if (doc?.canceled) { + console.log('canceling'); + setIsPreparingFile(false); + return; + } + + setUploadProgress(t("attachments.upload.preparing")) + if (doc?.assets?.[0]) { const file = doc?.assets?.[0]; @@ -346,6 +362,7 @@ const FormQuestionnaire = () => { // Calculate the number of parts that will be sent to the S3 Bucket. It is +1 (thus we use ceil). const numberOfUploadParts: number = Math.ceil(fileMetadata.size / MULTIPART_FILE_UPLOAD_SIZE); const attachmentId = Crypto.randomUUID(); + setUploadProgress(t("attachments.upload.starting")) const data = await addAttachmentMultipart( { @@ -365,6 +382,8 @@ const FormQuestionnaire = () => { ); await handleChunkUpload(fileMetadata.uri, data.uploadUrls, data.uploadId, attachmentId); + setIsPreparingFile(false); + setUploadProgress('') if (!onlineManager.isOnline()) { setIsOptionsSheetOpen(false); @@ -382,20 +401,25 @@ const FormQuestionnaire = () => { 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) + setUploadProgress(`${t("attachments.upload.progress")} ${Math.round(((index + 1) / urls.length) * 100 * 10) / 10} %`) etags = { ...etags, [index + 1]: data.ETag } }; if (activeElectionRound?.id) { + setUploadProgress(t("attachments.upload.completed")); await addAttachmentMultipartComplete({ uploadId, etags, electionRoundId: activeElectionRound?.id, id: attachmentId }) - } } catch (err) { // If error try to abort the upload if (activeElectionRound?.id) { + setUploadProgress(t("attachments.upload.aborted")); await addAttachmentMultipartAbort({ id: attachmentId, uploadId, electionRoundId: activeElectionRound.id }) } } finally { + if (activeElectionRound?.id) { + queryClient.invalidateQueries({ queryKey: AttachmentsKeys.attachments(activeElectionRound.id, selectedPollingStation?.pollingStationId, formId) }); + } setIsLoadingAttachment(false); setIsOptionsSheetOpen(false); } @@ -425,8 +449,8 @@ const FormQuestionnaire = () => { {t("progress_bar.label")} - {`${activeQuestion?.indexInDisplayedQuestions + 1}/${displayedQuestions.length}`} - + {`${activeQuestion?.indexInDisplayedQuestions + 1} /${displayedQuestions.length}`} + { {t("progress_bar.clear_answer")} {/* delete answer button */} - {deletingAnswer && ( - setDeletingAnswer(false)} - /> - )} - + { + deletingAnswer && ( + setDeletingAnswer(false)} + /> + ) + } + { }} /> - + { onPreviousButtonPress={onBackButtonPress} /> {/* //todo: remove this once tamagui fixes sheet issue #2585 */} - {(isOptionsSheetOpen || Platform.OS === "ios") && ( - { - setIsOptionsSheetOpen(open); - addingNote && setAddingNote(false); - }} - isLoading={(isLoadingAttachment && !isPaused) || isPreparingFile} - // seems that this behaviour is handled differently and the sheet will move with keyboard even if this props is set to false on android - moveOnKeyboardChange={Platform.OS === "android"} - disableDrag={addingNote} - > - {(isLoadingAttachment && !isPaused) || isPreparingFile ? ( - - ) : addingNote ? ( - - ) : ( - - { - setAddingNote(true); - }} - > - {t("attachments.menu.add_note")} - - - {t("attachments.menu.load")} - - - {t("attachments.menu.take_picture")} - - - {t("attachments.menu.upload_audio")} - - - )} - - )} - + { + (isOptionsSheetOpen || Platform.OS === "ios") && ( + { + setIsOptionsSheetOpen(open); + addingNote && setAddingNote(false); + }} + isLoading={(isLoadingAttachment && !isPaused) || isPreparingFile} + // seems that this behaviour is handled differently and the sheet will move with keyboard even if this props is set to false on android + moveOnKeyboardChange={Platform.OS === "android"} + disableDrag={addingNote} + > + {(isLoadingAttachment && !isPaused) || isPreparingFile ? ( + + ) : addingNote ? ( + + ) : ( + + { + setAddingNote(true); + }} + > + {t("attachments.menu.add_note")} + + + {t("attachments.menu.load")} + + + {t("attachments.menu.take_picture")} + + + {t("attachments.menu.upload_audio")} + + + )} + + ) + } + ); }; @@ -739,13 +767,13 @@ const $containerStyle: ViewStyle = { export default FormQuestionnaire; -const MediaLoading = () => { +const MediaLoading = ({ progress }: { progress?: string }) => { const { t } = useTranslation("polling_station_form_wizard"); return ( - {t("attachments.loading")} + {progress ? progress : t("attachments.loading")} ); diff --git a/mobile/assets/locales/en/translations.json b/mobile/assets/locales/en/translations.json index 4d2c54cf0..9b59feafe 100644 --- a/mobile/assets/locales/en/translations.json +++ b/mobile/assets/locales/en/translations.json @@ -257,6 +257,13 @@ "attachments": { "heading": "Uploaded media", "loading": "Adding attachment... ", + "upload": { + "preparing": "Preparing attachment...", + "starting": "Starting the upload...", + "progress": "Upload progress:", + "completed": "Upload completed.", + "aborted": "Upload aborted." + }, "add": "Add notes and media", "menu": { "add_note": "Add note", diff --git a/mobile/hooks/useCamera.tsx b/mobile/hooks/useCamera.tsx index 2ca60c428..6e5b59a04 100644 --- a/mobile/hooks/useCamera.tsx +++ b/mobile/hooks/useCamera.tsx @@ -1,6 +1,6 @@ import * as ImagePicker from "expo-image-picker"; import Toast from "react-native-toast-message"; -import { Video, Image } from "react-native-compressor"; +import { Video, Image, getVideoMetaData } from "react-native-compressor"; import * as Sentry from "@sentry/react-native"; /** @@ -106,12 +106,13 @@ export const useCamera = () => { } const filename = resultCompression.split("/").pop() || ""; + const metaData = await getVideoMetaData(resultCompression); const toReturn = { uri: resultCompression, name: filename, type: file.mimeType || "", - size: file.fileSize, + size: metaData.size, }; return toReturn; } From d91112d4aae1036ec77cf52a6912bf3ab7eba330 Mon Sep 17 00:00:00 2001 From: Andrew Radulescu Date: Fri, 31 May 2024 15:25:20 +0300 Subject: [PATCH 11/19] Fix getting corect image/video metadata --- mobile/hooks/useCamera.tsx | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/mobile/hooks/useCamera.tsx b/mobile/hooks/useCamera.tsx index 6e5b59a04..72f0e1d0a 100644 --- a/mobile/hooks/useCamera.tsx +++ b/mobile/hooks/useCamera.tsx @@ -1,6 +1,6 @@ import * as ImagePicker from "expo-image-picker"; import Toast from "react-native-toast-message"; -import { Video, Image, getVideoMetaData } from "react-native-compressor"; +import { Video, Image, getVideoMetaData, getImageMetaData } from "react-native-compressor"; import * as Sentry from "@sentry/react-native"; /** @@ -92,27 +92,34 @@ export const useCamera = () => { const file = result.assets[0]; if (file) { let resultCompression = file.uri; + let fileSize = file.fileSize; + + console.log("FileSize Before Compression ", fileSize); try { if (file.type === "image") { resultCompression = await Image.compress(file.uri); + fileSize = (await getImageMetaData(resultCompression)).size; } else if (file.type === "video") { resultCompression = await Video.compress(file.uri, {}, (progress) => { console.log("Compression Progress: ", progress); }); + fileSize = (await getVideoMetaData(resultCompression)).size; } } catch (err) { + console.log(err); Sentry.captureException(err); } + console.log("FileSize AFTER Compression ", fileSize); + const filename = resultCompression.split("/").pop() || ""; - const metaData = await getVideoMetaData(resultCompression); const toReturn = { uri: resultCompression, name: filename, type: file.mimeType || "", - size: metaData.size, + size: fileSize, }; return toReturn; } From 719ffa9f5c0fdb08abf8e279f00ae3006744f34e Mon Sep 17 00:00:00 2001 From: Dragos-Paul Strat Date: Fri, 31 May 2024 16:42:08 +0300 Subject: [PATCH 12/19] fix: [upload] quick report implementation --- mobile/app/(app)/report-issue.tsx | 183 ++++++++++++------ mobile/assets/locales/en/translations.json | 7 + .../add-attachment-quick-report.api.ts | 9 +- 3 files changed, 132 insertions(+), 67 deletions(-) diff --git a/mobile/app/(app)/report-issue.tsx b/mobile/app/(app)/report-issue.tsx index 58f8a474f..54b667eb6 100644 --- a/mobile/app/(app)/report-issue.tsx +++ b/mobile/app/(app)/report-issue.tsx @@ -1,36 +1,37 @@ -import React, { useMemo, useState } from "react"; -import { XStack, YStack } from "tamagui"; -import { Screen } from "../../components/Screen"; -import { Icon } from "../../components/Icon"; +import { useMemo, useState } from "react"; import { router } from "expo-router"; -import Header from "../../components/Header"; import { useSafeAreaInsets } from "react-native-safe-area-context"; -import Button from "../../components/Button"; -import FormInput from "../../components/FormInputs/FormInput"; -import Select from "../../components/Select"; import { useUserData } from "../../contexts/user/UserContext.provider"; import { Controller, useForm } from "react-hook-form"; import { PollingStationVisitVM } from "../../common/models/polling-station.model"; -import FormElement from "../../components/FormInputs/FormElement"; -import AddAttachment from "../../components/AddAttachment"; -import { Keyboard } from "react-native"; -import OptionsSheet from "../../components/OptionsSheet"; -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 { 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 { useTranslation } from "react-i18next"; import i18n from "../../common/config/i18n"; -import { AddAttachmentQuickReportStartAPIPayload } from "../../services/api/quick-report/add-attachment-quick-report.api"; +import { AddAttachmentQuickReportStartAPIPayload, addAttachmentQuickReportMultipartAbort, addAttachmentQuickReportMultipartComplete } from "../../services/api/quick-report/add-attachment-quick-report.api"; import { useUploadAttachmentQuickReportMutation } from "../../services/mutations/quick-report/add-attachment-quick-report.mutation"; import { MULTIPART_FILE_UPLOAD_SIZE } from "../../common/constants"; import { KeyboardAwareScrollView } from "react-native-keyboard-aware-scroll-view"; +import { addAttachmentMultipartAbort, addAttachmentMultipartComplete, uploadS3Chunk } from "../../services/api/add-attachment.api"; +import * as FileSystem from 'expo-file-system'; +import { Buffer } from 'buffer'; +import { Keyboard } from "react-native"; +import { YStack, Card, XStack, Spinner } from "tamagui"; +import AddAttachment from "../../components/AddAttachment"; +import FormElement from "../../components/FormInputs/FormElement"; +import FormInput from "../../components/FormInputs/FormInput"; +import { Icon } from "../../components/Icon"; +import OptionsSheet from "../../components/OptionsSheet"; +import { Typography } from "../../components/Typography"; +import Header from "../../components/Header"; +import { Screen } from "../../components/Screen"; +import Select from "../../components/Select"; +import Button from "../../components/Button"; const mapVisitsToSelectPollingStations = (visits: PollingStationVisitVM[] = []) => { const pollingStationsForSelect = visits.map((visit) => { @@ -71,6 +72,9 @@ const ReportIssue = () => { const pollingStations = useMemo(() => mapVisitsToSelectPollingStations(visits), [visits]); const [optionsSheetOpen, setOptionsSheetOpen] = 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>( [], @@ -82,7 +86,7 @@ const ReportIssue = () => { isPaused: isPausedAddQuickReport, } = useAddQuickReport(); - const { mutateAsync: addAttachmentQReport } = useUploadAttachmentQuickReportMutation(`Quick_Report_${activeElectionRound?.id}}`); + const { mutateAsync: addAttachmentQReport, isPaused: isPausedStartAddAttachment, } = useUploadAttachmentQuickReportMutation(`Quick_Report_${activeElectionRound?.id}}`); const addAttachmentsMutationState = useMutationState({ filters: { mutationKey: QuickReportKeys.addAttachment() }, @@ -117,6 +121,9 @@ const ReportIssue = () => { const { uploadCameraOrMedia } = useCamera(); const handleCameraUpload = async (type: "library" | "cameraPhoto" | "cameraVideo") => { + setIsPreparingFile(true); + setUploadProgress(t("upload.preparing")) + const cameraResult = await uploadCameraOrMedia(type); if (!cameraResult || !activeElectionRound) { @@ -128,9 +135,12 @@ const ReportIssue = () => { ...attachments, { fileMetadata: cameraResult, id: Crypto.randomUUID() }, ]); + setIsPreparingFile(false); }; const handleUploadAudio = async () => { + setIsPreparingFile(true); + setUploadProgress(t("upload.preparing")) const doc = await DocumentPicker.getDocumentAsync({ type: "audio/*", multiple: false, @@ -147,16 +157,50 @@ const ReportIssue = () => { setOptionsSheetOpen(false); setAttachments((attachments) => [...attachments, { fileMetadata, id: Crypto.randomUUID() }]); + setIsPreparingFile(false); } else { // Cancelled } }; + 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; } + + let quickReportLocationType = QuickReportLocationType.VisitedPollingStation; let pollingStationId: string | null = formData.polling_station_id; @@ -176,30 +220,32 @@ const ReportIssue = () => { const optimisticAttachments: AddAttachmentQuickReportStartAPIPayload[] = []; if (attachments.length > 0) { - const attachmentsMutations = attachments.map( - ({ fileMetadata, id }: { fileMetadata: FileMetadata; id: string }) => { + setOptionsSheetOpen(true); + setIsLoadingAttachment(true); + try { + // Upload each attachment + setUploadProgress(`${t("upload.starting")}`) + for (const [index, attachment] of attachments.entries()) { const payload: AddAttachmentQuickReportStartAPIPayload = { - id, - fileName: fileMetadata.name, - filePath: fileMetadata.uri, - contentType: fileMetadata.type, - numberOfUploadParts: Math.ceil(fileMetadata.size! / MULTIPART_FILE_UPLOAD_SIZE), // DRAGOS CHECK AGAIN + 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 { - 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); + console.log(err); + } finally { + setIsLoadingAttachment(false); + setOptionsSheetOpen(false); } } mutate( @@ -409,32 +455,35 @@ const ReportIssue = () => { - - - {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")} + + } @@ -466,4 +515,16 @@ const ReportIssue = () => { ); }; +const MediaLoading = ({ progress }: { progress?: string }) => { + const { t } = useTranslation("polling_station_form_wizard"); + return ( + + + + {progress ? progress : t("attachments.loading")} + + + ); +}; + export default ReportIssue; diff --git a/mobile/assets/locales/en/translations.json b/mobile/assets/locales/en/translations.json index 9b59feafe..c91933925 100644 --- a/mobile/assets/locales/en/translations.json +++ b/mobile/assets/locales/en/translations.json @@ -325,6 +325,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/services/api/quick-report/add-attachment-quick-report.api.ts b/mobile/services/api/quick-report/add-attachment-quick-report.api.ts index 15328cd7e..282e1a130 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 @@ -50,11 +50,8 @@ export const addAttachmentQuickReportMultipartStart = ({ uploadUrls: Record; }> => { return API.post( - `election-rounds/${electionRoundId}/quick-reports/${quickReportId}/attachments:init`, + `election-rounds/${electionRoundId}/quick-reports/${quickReportId}/attachments/${id}:init`, { - electionRoundId, - id, - quickReportId, fileName, contentType, numberOfUploadParts, @@ -71,13 +68,13 @@ export const addAttachmentQuickReportMultipartComplete = async ({ quickReportId, }: AddAttachmentQuickReportCompleteAPIPayload): Promise => { return API.post( - `election-rounds/${electionRoundId}/quick-reports/${quickReportId}/${id}:complete`, + `election-rounds/${electionRoundId}/quick-reports/${quickReportId}/attachments/${id}:complete`, { uploadId, etags }, {}, ).then((res) => res.data); }; -export const addAttachmenQuickReporttMultipartAbort = async ({ +export const addAttachmentQuickReportMultipartAbort = async ({ uploadId, id, electionRoundId, From 8134ef51445bd4a468ddfc8bb6bf84918dad4c9f Mon Sep 17 00:00:00 2001 From: Andrew Radulescu Date: Fri, 31 May 2024 17:20:12 +0300 Subject: [PATCH 13/19] wip - upload offline --- .../(app)/form-questionnaire/[questionId].tsx | 298 ++++++++++-------- .../PersistQueryContext.provider.tsx | 31 +- mobile/hooks/useCamera.tsx | 6 +- .../attachments/add-attachment.mutation.ts | 64 +++- 4 files changed, 253 insertions(+), 146 deletions(-) diff --git a/mobile/app/(app)/form-questionnaire/[questionId].tsx b/mobile/app/(app)/form-questionnaire/[questionId].tsx index 49a30a7c1..fb4a3fe21 100644 --- a/mobile/app/(app)/form-questionnaire/[questionId].tsx +++ b/mobile/app/(app)/form-questionnaire/[questionId].tsx @@ -39,10 +39,14 @@ import { onlineManager, useQueryClient } from "@tanstack/react-query"; import { ApiFormQuestion } from "../../../services/interfaces/question.type"; import FormInput from "../../../components/FormInputs/FormInput"; import WarningDialog from "../../../components/WarningDialog"; -import * as FileSystem from 'expo-file-system'; -import { Buffer } from 'buffer'; +import * as FileSystem from "expo-file-system"; +import { Buffer } from "buffer"; import { MULTIPART_FILE_UPLOAD_SIZE } from "../../../common/constants"; -import { addAttachmentMultipartAbort, addAttachmentMultipartComplete, uploadS3Chunk } from "../../../services/api/add-attachment.api"; +import { + addAttachmentMultipartAbort, + addAttachmentMultipartComplete, + uploadS3Chunk, +} from "../../../services/api/add-attachment.api"; import * as DocumentPicker from "expo-document-picker"; import { KeyboardAwareScrollView } from "react-native-keyboard-aware-scroll-view"; import { AttachmentsKeys } from "../../../services/queries/attachments.query"; @@ -57,7 +61,7 @@ const FormQuestionnaire = () => { const { t } = useTranslation(["polling_station_form_wizard", "common"]); const { questionId, formId, language } = useLocalSearchParams(); const [isLoadingAttachment, setIsLoadingAttachment] = useState(false); - const [uploadProgress, setUploadProgress] = useState(''); + const [uploadProgress, setUploadProgress] = useState(""); const queryClient = useQueryClient(); if (!questionId || !formId || !language) { @@ -270,10 +274,9 @@ const FormQuestionnaire = () => { } const { uploadCameraOrMedia } = useCamera(); - const { - mutateAsync: addAttachmentMultipart, - isPaused - } = useUploadAttachmentMutation(`Attachment_${questionId}_${selectedPollingStation?.pollingStationId}_${formId}_${questionId}`); + const { mutate: addAttachmentMultipart, isPaused } = useUploadAttachmentMutation( + `Attachment_${questionId}_${selectedPollingStation?.pollingStationId}_${formId}_${questionId}`, + ); const handleCameraUpload = async (type: "library" | "cameraPhoto") => { setIsPreparingFile(true); @@ -285,7 +288,7 @@ const FormQuestionnaire = () => { return; } - setUploadProgress(t("attachments.upload.preparing")) + setUploadProgress(t("attachments.upload.preparing")); if ( activeElectionRound && selectedPollingStation?.pollingStationId && @@ -293,13 +296,12 @@ const FormQuestionnaire = () => { activeQuestion.question.id && cameraResult.size ) { - // Calculate the number of parts that will be sent to the S3 Bucket. It is +1 (thus we use ceil). const numberOfUploadParts: number = Math.ceil(cameraResult.size / MULTIPART_FILE_UPLOAD_SIZE); const attachmentId = Crypto.randomUUID(); - setUploadProgress(t("attachments.upload.starting")) + setUploadProgress(t("attachments.upload.starting")); - const data = await addAttachmentMultipart( + addAttachmentMultipart( { id: attachmentId, electionRoundId: activeElectionRound.id, @@ -312,16 +314,35 @@ const FormQuestionnaire = () => { filePath: cameraResult.uri, }, { + onSuccess: async (data) => { + console.log("1. On Success"); + await handleChunkUpload(cameraResult.uri, data.uploadUrls, data.uploadId, attachmentId); + queryClient.invalidateQueries({ + queryKey: AttachmentsKeys.attachments( + activeElectionRound.id, + selectedPollingStation?.pollingStationId, + formId, + ), + }); + setTimeout(() => { + setIsLoadingAttachment(false); + setIsOptionsSheetOpen(false); + setIsPreparingFile(false); + setUploadProgress(""); + }, 1000); + }, onError: () => console.log("πŸ”΄πŸ”΄πŸ”΄πŸ”΄πŸ”΄πŸ”΄πŸ”΄πŸ”΄πŸ”΄πŸ”΄ERORRπŸ”΄πŸ”΄πŸ”΄πŸ”΄πŸ”΄πŸ”΄πŸ”΄πŸ”΄πŸ”΄πŸ”΄"), }, ); - await handleChunkUpload(cameraResult.uri, data.uploadUrls, data.uploadId, attachmentId); - setIsPreparingFile(false); - setUploadProgress('') + console.log("3. Outside"); if (!onlineManager.isOnline()) { + console.log("4. Not Online"); + setIsLoadingAttachment(false); setIsOptionsSheetOpen(false); + setIsPreparingFile(false); + setUploadProgress(""); } } }; @@ -333,14 +354,13 @@ const FormQuestionnaire = () => { multiple: false, }); - if (doc?.canceled) { - console.log('canceling'); + console.log("canceling"); setIsPreparingFile(false); return; } - setUploadProgress(t("attachments.upload.preparing")) + setUploadProgress(t("attachments.upload.preparing")); if (doc?.assets?.[0]) { const file = doc?.assets?.[0]; @@ -358,13 +378,14 @@ const FormQuestionnaire = () => { activeQuestion.question.id && fileMetadata.size ) { - // Calculate the number of parts that will be sent to the S3 Bucket. It is +1 (thus we use ceil). - const numberOfUploadParts: number = Math.ceil(fileMetadata.size / MULTIPART_FILE_UPLOAD_SIZE); + const numberOfUploadParts: number = Math.ceil( + fileMetadata.size / MULTIPART_FILE_UPLOAD_SIZE, + ); const attachmentId = Crypto.randomUUID(); - setUploadProgress(t("attachments.upload.starting")) + setUploadProgress(t("attachments.upload.starting")); - const data = await addAttachmentMultipart( + await addAttachmentMultipart( { id: attachmentId, electionRoundId: activeElectionRound.id, @@ -377,53 +398,80 @@ const FormQuestionnaire = () => { filePath: fileMetadata.uri, }, { + onSuccess: async (data) => { + await handleChunkUpload( + fileMetadata.uri, + data.uploadUrls, + data.uploadId, + attachmentId, + ); + }, onError: () => console.log("πŸ”΄πŸ”΄πŸ”΄πŸ”΄πŸ”΄πŸ”΄πŸ”΄πŸ”΄πŸ”΄πŸ”΄ERORRπŸ”΄πŸ”΄πŸ”΄πŸ”΄πŸ”΄πŸ”΄πŸ”΄πŸ”΄πŸ”΄πŸ”΄"), + onSettled: () => { + setIsPreparingFile(false); + setUploadProgress(""); + }, }, ); - await handleChunkUpload(fileMetadata.uri, data.uploadUrls, data.uploadId, attachmentId); - setIsPreparingFile(false); - setUploadProgress('') - if (!onlineManager.isOnline()) { setIsOptionsSheetOpen(false); + setIsPreparingFile(false); + setUploadProgress(""); } } - }; + } }; - const handleChunkUpload = async (filePath: string, uploadUrls: Record, uploadId: string, attachmentId: string) => { + const handleChunkUpload = async ( + filePath: string, + uploadUrls: Record, + uploadId: string, + attachmentId: string, + ) => { setIsLoadingAttachment(true); 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) - setUploadProgress(`${t("attachments.upload.progress")} ${Math.round(((index + 1) / urls.length) * 100 * 10) / 10} %`) - etags = { ...etags, [index + 1]: data.ETag } - }; - + 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); + setUploadProgress( + `${t("attachments.upload.progress")} ${Math.round(((index + 1) / urls.length) * 100 * 10) / 10} %`, + ); + etags = { ...etags, [index + 1]: data.ETag }; + } if (activeElectionRound?.id) { + await addAttachmentMultipartComplete({ + uploadId, + etags, + electionRoundId: activeElectionRound?.id, + id: attachmentId, + }); setUploadProgress(t("attachments.upload.completed")); - await addAttachmentMultipartComplete({ uploadId, etags, electionRoundId: activeElectionRound?.id, id: attachmentId }) } } catch (err) { // If error try to abort the upload if (activeElectionRound?.id) { setUploadProgress(t("attachments.upload.aborted")); - await addAttachmentMultipartAbort({ id: attachmentId, uploadId, electionRoundId: activeElectionRound.id }) - } - } finally { - if (activeElectionRound?.id) { - queryClient.invalidateQueries({ queryKey: AttachmentsKeys.attachments(activeElectionRound.id, selectedPollingStation?.pollingStationId, formId) }); + setIsLoadingAttachment(false); + setIsOptionsSheetOpen(false); + setIsPreparingFile(false); + setUploadProgress(""); + await addAttachmentMultipartAbort({ + id: attachmentId, + uploadId, + electionRoundId: activeElectionRound.id, + }); } - setIsLoadingAttachment(false); - setIsOptionsSheetOpen(false); } - } + }; return ( { {t("progress_bar.label")} - {`${activeQuestion?.indexInDisplayedQuestions + 1} /${displayedQuestions.length}`} - + {`${activeQuestion?.indexInDisplayedQuestions + 1} /${displayedQuestions.length}`} + { {t("progress_bar.clear_answer")} {/* delete answer button */} - { - deletingAnswer && ( - setDeletingAnswer(false)} - /> - ) - } - + {deletingAnswer && ( + setDeletingAnswer(false)} + /> + )} + { }} /> - + { onPreviousButtonPress={onBackButtonPress} /> {/* //todo: remove this once tamagui fixes sheet issue #2585 */} - { - (isOptionsSheetOpen || Platform.OS === "ios") && ( - { - setIsOptionsSheetOpen(open); - addingNote && setAddingNote(false); - }} - isLoading={(isLoadingAttachment && !isPaused) || isPreparingFile} - // seems that this behaviour is handled differently and the sheet will move with keyboard even if this props is set to false on android - moveOnKeyboardChange={Platform.OS === "android"} - disableDrag={addingNote} - > - {(isLoadingAttachment && !isPaused) || isPreparingFile ? ( - - ) : addingNote ? ( - - ) : ( - - { - setAddingNote(true); - }} - > - {t("attachments.menu.add_note")} - - - {t("attachments.menu.load")} - - - {t("attachments.menu.take_picture")} - - - {t("attachments.menu.upload_audio")} - - - )} - - ) - } - + {(isOptionsSheetOpen || Platform.OS === "ios") && ( + { + setIsOptionsSheetOpen(open); + addingNote && setAddingNote(false); + }} + isLoading={(isLoadingAttachment && !isPaused) || isPreparingFile} + // seems that this behaviour is handled differently and the sheet will move with keyboard even if this props is set to false on android + moveOnKeyboardChange={Platform.OS === "android"} + disableDrag={addingNote} + > + {(isLoadingAttachment && !isPaused) || isPreparingFile ? ( + + ) : addingNote ? ( + + ) : ( + + { + setAddingNote(true); + }} + > + {t("attachments.menu.add_note")} + + + {t("attachments.menu.load")} + + + {t("attachments.menu.take_picture")} + + + {t("attachments.menu.upload_audio")} + + + )} + + )} + ); }; @@ -773,7 +817,7 @@ const MediaLoading = ({ progress }: { progress?: string }) => { - {progress ? progress : t("attachments.loading")} + {progress || t("attachments.loading")} ); diff --git a/mobile/contexts/persist-query/PersistQueryContext.provider.tsx b/mobile/contexts/persist-query/PersistQueryContext.provider.tsx index 4077a002f..b5eee93ec 100644 --- a/mobile/contexts/persist-query/PersistQueryContext.provider.tsx +++ b/mobile/contexts/persist-query/PersistQueryContext.provider.tsx @@ -5,7 +5,10 @@ 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 { AddAttachmentStartAPIPayload, addAttachmentMultipartStart } from "../../services/api/add-attachment.api"; +import { + AddAttachmentStartAPIPayload, + addAttachmentMultipartStart, +} 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"; @@ -13,19 +16,23 @@ import { AddQuickReportAPIPayload, addQuickReport, } from "../../services/api/quick-report/post-quick-report.api"; -import { AddAttachmentQuickReportStartAPIPayload, addAttachmentQuickReportMultipartStart } from "../../services/api/quick-report/add-attachment-quick-report.api"; +import { + 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 { handleChunkUpload } 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); - }, + // onSuccess: (data: unknown) => { + // console.log("MutationCache ", data); + // }, onError: (error: Error, _vars, _context, mutation) => { console.log("MutationCache error ", error); console.log( @@ -102,13 +109,21 @@ const PersistQueryContextProvider = ({ children }: React.PropsWithChildren) => { queryClient.setMutationDefaults(AttachmentsKeys.addAttachmentMutation(), { mutationFn: async (payload: AddAttachmentStartAPIPayload) => { - return addAttachmentMultipartStart(payload); + const data = await addAttachmentMultipartStart(payload); + console.log("Init ran"); + return handleChunkUpload( + payload.electionRoundId, + payload.filePath, + data.uploadUrls, + data.uploadId, + payload.id, + ); }, }); queryClient.setMutationDefaults(AttachmentsKeys.deleteAttachment(), { mutationFn: async (payload: AttachmentApiResponse) => { - return payload.isNotSynched ? () => { } : deleteAttachment(payload); + return payload.isNotSynched ? () => {} : deleteAttachment(payload); }, }); @@ -126,7 +141,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); }, }); diff --git a/mobile/hooks/useCamera.tsx b/mobile/hooks/useCamera.tsx index 72f0e1d0a..fd07ddffc 100644 --- a/mobile/hooks/useCamera.tsx +++ b/mobile/hooks/useCamera.tsx @@ -79,12 +79,14 @@ export const useCamera = () => { ...(specifiedMediaType || { mediaTypes: ImagePicker.MediaTypeOptions.All }), allowsEditing: true, aspect: [4, 3], - quality: 0.1, + quality: 0.2, allowsMultipleSelection: false, videoQuality: ImagePicker.UIImagePickerControllerQualityType.Low, // TODO: careful here, Medium might be enough cameraType: ImagePicker.CameraType.back, }); + console.log("FileSize Before Compression ", result?.assets?.[0].fileSize); + if (result.canceled) { return; } @@ -94,8 +96,6 @@ export const useCamera = () => { let resultCompression = file.uri; let fileSize = file.fileSize; - console.log("FileSize Before Compression ", fileSize); - try { if (file.type === "image") { resultCompression = await Image.compress(file.uri); diff --git a/mobile/services/mutations/attachments/add-attachment.mutation.ts b/mobile/services/mutations/attachments/add-attachment.mutation.ts index 1ddcfd3f6..b75dcbce4 100644 --- a/mobile/services/mutations/attachments/add-attachment.mutation.ts +++ b/mobile/services/mutations/attachments/add-attachment.mutation.ts @@ -1,10 +1,58 @@ import { useMutation, useQueryClient } from "@tanstack/react-query"; import { 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 ( + electionRoundId: string, + filePath: string, + uploadUrls: Record, + uploadId: string, + attachmentId: string, +) => { + try { + console.log("Handle chunk upload"); + + 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 }; + } + + await addAttachmentMultipartComplete({ + uploadId, + etags, + electionRoundId, + id: attachmentId, + }); + } catch (err) { + console.log(err); + Sentry.captureMessage("Upload failed, aborting!"); + Sentry.captureException(err); + await addAttachmentMultipartAbort({ + id: attachmentId, + uploadId, + electionRoundId, + }); + } +}; // Multipart Upload - Start export const useUploadAttachmentMutation = (scopeId: string) => { @@ -53,14 +101,14 @@ export const useUploadAttachmentMutation = (scopeId: string) => { ); queryClient.setQueryData(attachmentsQK, context?.previousData); }, - onSettled: (_data, _err, variables) => { - return queryClient.invalidateQueries({ - queryKey: AttachmentsKeys.attachments( - variables.electionRoundId, - variables.pollingStationId, - variables.formId, - ), - }); + onSettled: (_data, _err, _variables) => { + // return queryClient.invalidateQueries({ + // queryKey: AttachmentsKeys.attachments( + // variables.electionRoundId, + // variables.pollingStationId, + // variables.formId, + // ), + // }); }, }); }; From 2356ead1df8b43062409eb315dde91db7e5c7f3b Mon Sep 17 00:00:00 2001 From: Andrew Radulescu Date: Sat, 1 Jun 2024 16:50:28 +0300 Subject: [PATCH 14/19] wip - upload attachments offline + progress --- .../(app)/form-questionnaire/[questionId].tsx | 154 ++++++---------- mobile/services/api/add-attachment.api.ts | 2 +- .../attachments/add-attachment.mutation.ts | 174 ++++++++++++++---- mobile/services/queries/attachments.query.ts | 1 + 4 files changed, 202 insertions(+), 129 deletions(-) diff --git a/mobile/app/(app)/form-questionnaire/[questionId].tsx b/mobile/app/(app)/form-questionnaire/[questionId].tsx index fb4a3fe21..2afb656a3 100644 --- a/mobile/app/(app)/form-questionnaire/[questionId].tsx +++ b/mobile/app/(app)/form-questionnaire/[questionId].tsx @@ -26,7 +26,11 @@ import { useFormSubmissionMutation } from "../../../services/mutations/form-subm import OptionsSheet from "../../../components/OptionsSheet"; import AddAttachment from "../../../components/AddAttachment"; import { FileMetadata, useCamera } from "../../../hooks/useCamera"; -import { useUploadAttachmentMutation } from "../../../services/mutations/attachments/add-attachment.mutation"; +import { + UploadAttachmentProgress, + useUploadAttachmentMutation, + useUploadAttachmentProgressQuery, +} from "../../../services/mutations/attachments/add-attachment.mutation"; import QuestionAttachments from "../../../components/QuestionAttachments"; import QuestionNotes from "../../../components/QuestionNotes"; import AddNoteSheetContent from "../../../components/AddNoteSheetContent"; @@ -35,21 +39,13 @@ import { useFormAnswers } from "../../../services/queries/form-submissions.query import { useNotesForQuestionId } from "../../../services/queries/notes.query"; import * as Crypto from "expo-crypto"; import { useTranslation } from "react-i18next"; -import { onlineManager, useQueryClient } from "@tanstack/react-query"; +import { onlineManager } from "@tanstack/react-query"; import { ApiFormQuestion } from "../../../services/interfaces/question.type"; import FormInput from "../../../components/FormInputs/FormInput"; import WarningDialog from "../../../components/WarningDialog"; -import * as FileSystem from "expo-file-system"; -import { Buffer } from "buffer"; import { MULTIPART_FILE_UPLOAD_SIZE } from "../../../common/constants"; -import { - addAttachmentMultipartAbort, - addAttachmentMultipartComplete, - uploadS3Chunk, -} from "../../../services/api/add-attachment.api"; import * as DocumentPicker from "expo-document-picker"; import { KeyboardAwareScrollView } from "react-native-keyboard-aware-scroll-view"; -import { AttachmentsKeys } from "../../../services/queries/attachments.query"; type SearchParamType = { questionId: string; @@ -60,9 +56,10 @@ type SearchParamType = { const FormQuestionnaire = () => { const { t } = useTranslation(["polling_station_form_wizard", "common"]); const { questionId, formId, language } = useLocalSearchParams(); - const [isLoadingAttachment, setIsLoadingAttachment] = useState(false); - const [uploadProgress, setUploadProgress] = useState(""); - const queryClient = useQueryClient(); + + const { data: uploadAttachmentProgress } = useUploadAttachmentProgressQuery(); + + console.log("❌ Upload Progress", uploadAttachmentProgress); if (!questionId || !formId || !language) { return Incorrect page params; @@ -274,7 +271,11 @@ const FormQuestionnaire = () => { } const { uploadCameraOrMedia } = useCamera(); - const { mutate: addAttachmentMultipart, isPaused } = useUploadAttachmentMutation( + const { + mutate: addAttachmentMultipart, + isPaused, + isPending: isUploadingAttachments, + } = useUploadAttachmentMutation( `Attachment_${questionId}_${selectedPollingStation?.pollingStationId}_${formId}_${questionId}`, ); @@ -288,7 +289,6 @@ const FormQuestionnaire = () => { return; } - setUploadProgress(t("attachments.upload.preparing")); if ( activeElectionRound && selectedPollingStation?.pollingStationId && @@ -299,7 +299,7 @@ const FormQuestionnaire = () => { // Calculate the number of parts that will be sent to the S3 Bucket. It is +1 (thus we use ceil). const numberOfUploadParts: number = Math.ceil(cameraResult.size / MULTIPART_FILE_UPLOAD_SIZE); const attachmentId = Crypto.randomUUID(); - setUploadProgress(t("attachments.upload.starting")); + // setUploadProgress(t("attachments.upload.starting")); addAttachmentMultipart( { @@ -314,24 +314,18 @@ const FormQuestionnaire = () => { filePath: cameraResult.uri, }, { - onSuccess: async (data) => { + onSuccess: async (_data) => { console.log("1. On Success"); - await handleChunkUpload(cameraResult.uri, data.uploadUrls, data.uploadId, attachmentId); - queryClient.invalidateQueries({ - queryKey: AttachmentsKeys.attachments( - activeElectionRound.id, - selectedPollingStation?.pollingStationId, - formId, - ), - }); setTimeout(() => { - setIsLoadingAttachment(false); setIsOptionsSheetOpen(false); setIsPreparingFile(false); - setUploadProgress(""); }, 1000); }, onError: () => console.log("πŸ”΄πŸ”΄πŸ”΄πŸ”΄πŸ”΄πŸ”΄πŸ”΄πŸ”΄πŸ”΄πŸ”΄ERORRπŸ”΄πŸ”΄πŸ”΄πŸ”΄πŸ”΄πŸ”΄πŸ”΄πŸ”΄πŸ”΄πŸ”΄"), + onSettled: () => { + setIsPreparingFile(false); + setIsOptionsSheetOpen(false); + }, }, ); @@ -339,10 +333,8 @@ const FormQuestionnaire = () => { if (!onlineManager.isOnline()) { console.log("4. Not Online"); - setIsLoadingAttachment(false); setIsOptionsSheetOpen(false); setIsPreparingFile(false); - setUploadProgress(""); } } }; @@ -360,8 +352,6 @@ const FormQuestionnaire = () => { return; } - setUploadProgress(t("attachments.upload.preparing")); - if (doc?.assets?.[0]) { const file = doc?.assets?.[0]; @@ -383,7 +373,6 @@ const FormQuestionnaire = () => { fileMetadata.size / MULTIPART_FILE_UPLOAD_SIZE, ); const attachmentId = Crypto.randomUUID(); - setUploadProgress(t("attachments.upload.starting")); await addAttachmentMultipart( { @@ -398,18 +387,17 @@ const FormQuestionnaire = () => { filePath: fileMetadata.uri, }, { - onSuccess: async (data) => { - await handleChunkUpload( - fileMetadata.uri, - data.uploadUrls, - data.uploadId, - attachmentId, - ); - }, + // onSuccess: async (data) => { + // await handleChunkUpload( + // fileMetadata.uri, + // data.uploadUrls, + // data.uploadId, + // attachmentId, + // ); + // }, onError: () => console.log("πŸ”΄πŸ”΄πŸ”΄πŸ”΄πŸ”΄πŸ”΄πŸ”΄πŸ”΄πŸ”΄πŸ”΄ERORRπŸ”΄πŸ”΄πŸ”΄πŸ”΄πŸ”΄πŸ”΄πŸ”΄πŸ”΄πŸ”΄πŸ”΄"), onSettled: () => { setIsPreparingFile(false); - setUploadProgress(""); }, }, ); @@ -417,62 +405,11 @@ const FormQuestionnaire = () => { if (!onlineManager.isOnline()) { setIsOptionsSheetOpen(false); setIsPreparingFile(false); - setUploadProgress(""); } } } }; - const handleChunkUpload = async ( - filePath: string, - uploadUrls: Record, - uploadId: string, - attachmentId: string, - ) => { - setIsLoadingAttachment(true); - 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); - setUploadProgress( - `${t("attachments.upload.progress")} ${Math.round(((index + 1) / urls.length) * 100 * 10) / 10} %`, - ); - etags = { ...etags, [index + 1]: data.ETag }; - } - - if (activeElectionRound?.id) { - await addAttachmentMultipartComplete({ - uploadId, - etags, - electionRoundId: activeElectionRound?.id, - id: attachmentId, - }); - setUploadProgress(t("attachments.upload.completed")); - } - } catch (err) { - // If error try to abort the upload - if (activeElectionRound?.id) { - setUploadProgress(t("attachments.upload.aborted")); - setIsLoadingAttachment(false); - setIsOptionsSheetOpen(false); - setIsPreparingFile(false); - setUploadProgress(""); - await addAttachmentMultipartAbort({ - id: attachmentId, - uploadId, - electionRoundId: activeElectionRound.id, - }); - } - } - }; - return ( { setIsOptionsSheetOpen(open); addingNote && setAddingNote(false); }} - isLoading={(isLoadingAttachment && !isPaused) || isPreparingFile} + isLoading={(isUploadingAttachments && !isPaused) || isPreparingFile} // seems that this behaviour is handled differently and the sheet will move with keyboard even if this props is set to false on android moveOnKeyboardChange={Platform.OS === "android"} disableDrag={addingNote} > - {(isLoadingAttachment && !isPaused) || isPreparingFile ? ( - + {(isUploadingAttachments && !isPaused) || isPreparingFile ? ( + ) : addingNote ? ( { +const MediaLoading = ({ + uploadProgress, +}: { + uploadProgress: UploadAttachmentProgress | undefined; +}) => { const { t } = useTranslation("polling_station_form_wizard"); + + const message = useMemo(() => { + if (!uploadProgress) { + return ""; + } + switch (uploadProgress?.status) { + case "starting": + return t("attachments.upload.starting"); + case "inprogress": + return `${t("attachments.upload.progress")} ${uploadProgress.progress} %`; + case "completed": + return t("attachments.upload.completed"); + case "aborted": + return t("attachments.upload.aborted"); + default: + return ""; + } + }, [uploadProgress]); + return ( - {progress || t("attachments.loading")} + {message || t("attachments.loading")} ); diff --git a/mobile/services/api/add-attachment.api.ts b/mobile/services/api/add-attachment.api.ts index b424afe74..6534a1662 100644 --- a/mobile/services/api/add-attachment.api.ts +++ b/mobile/services/api/add-attachment.api.ts @@ -106,6 +106,6 @@ export const uploadS3Chunk = async (url: string, chunk: any): Promise<{ ETag: st }, }) .then((res) => { - return { ETag: res.headers["etag"] }; + return { ETag: res.headers.etag }; }); }; diff --git a/mobile/services/mutations/attachments/add-attachment.mutation.ts b/mobile/services/mutations/attachments/add-attachment.mutation.ts index b75dcbce4..5c75706e9 100644 --- a/mobile/services/mutations/attachments/add-attachment.mutation.ts +++ b/mobile/services/mutations/attachments/add-attachment.mutation.ts @@ -1,4 +1,4 @@ -import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { QueryClient, useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { AddAttachmentStartAPIPayload, addAttachmentMultipartAbort, @@ -13,48 +13,146 @@ import { MULTIPART_FILE_UPLOAD_SIZE } from "../../../common/constants"; import * as Sentry from "@sentry/react-native"; import { Buffer } from "buffer"; -export const handleChunkUpload = async ( - electionRoundId: string, - filePath: string, - uploadUrls: Record, - uploadId: string, - attachmentId: string, +// export const handleChunkUpload = async ( +// filePath: string, +// uploadUrls: Record, +// queryClient: QueryClient, +// ) => { +// console.log("Handle chunk upload"); + +// 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, + queryClient: QueryClient, ) => { + queryClient.setQueryData(AttachmentsKeys.addAttachments(), () => ({ + progress: 0, + status: "starting", + })); + const start = await addAttachmentMultipartStart(payload); try { - console.log("Handle chunk upload"); - let etags: Record = {}; - const urls = Object.values(uploadUrls); + const urls = Object.values(start.uploadUrls); + for (const [index, url] of urls.entries()) { - const chunk = await FileSystem.readAsStringAsync(filePath, { + 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; + queryClient.setQueryData( + AttachmentsKeys.addAttachments(), + (oldData) => { + const toReturn: UploadAttachmentProgress = { + ...oldData, + progress, + status: progress === 100 ? "completed" : "inprogress", + }; + console.log("toReturnProgress in handleChunkUpload", toReturn); + + return toReturn; + }, + ); + const data = await uploadS3Chunk(url, buffer); + etags = { ...etags, [index + 1]: data.ETag }; } - - await addAttachmentMultipartComplete({ - uploadId, + const completed = await addAttachmentMultipartComplete({ + uploadId: start.uploadId, etags, - electionRoundId, - id: attachmentId, + electionRoundId: payload.electionRoundId, + id: payload.id, }); + queryClient.setQueryData( + AttachmentsKeys.addAttachments(), + (oldData) => { + const toReturn: UploadAttachmentProgress = { + ...oldData, + progress: 100, + status: "completed", + }; + console.log("toReturnCompleted", toReturn); + return toReturn; + }, + ); + return completed; } catch (err) { - console.log(err); Sentry.captureMessage("Upload failed, aborting!"); Sentry.captureException(err); - await addAttachmentMultipartAbort({ - id: attachmentId, - uploadId, - electionRoundId, + + const aborted = addAttachmentMultipartAbort({ + id: payload.id, + uploadId: start.uploadId, + electionRoundId: payload.electionRoundId, }); + queryClient.setQueryData( + AttachmentsKeys.addAttachments(), + (oldData) => { + const toReturn: UploadAttachmentProgress = { + ...oldData, + progress: 0, + status: "aborted", + }; + console.log("toReturnAbort", toReturn); + return toReturn; + }, + ); + return aborted; } }; -// Multipart Upload - Start +export type UploadAttachmentProgress = { + progress: number; + status: "idle" | "starting" | "inprogress" | "aborted" | "completed"; +}; +export const useUploadAttachmentProgressQuery = () => { + return useQuery({ + queryKey: AttachmentsKeys.addAttachments(), // TODO: more specific + queryFn: () => { + console.log("QueryFn Called"); + return { status: "idle", progress: 0 }; + }, + placeholderData: { status: "idle", progress: 0 }, + initialData: { status: "idle", progress: 0 }, + staleTime: Infinity, + gcTime: Infinity, + }); +}; + export const useUploadAttachmentMutation = (scopeId: string) => { const queryClient = useQueryClient(); return useMutation({ @@ -62,7 +160,8 @@ export const useUploadAttachmentMutation = (scopeId: string) => { scope: { id: scopeId, }, - mutationFn: (payload: AddAttachmentStartAPIPayload) => addAttachmentMultipartStart(payload), + mutationFn: (payload: AddAttachmentStartAPIPayload) => + uploadAttachmentMutationFn(payload, queryClient), onMutate: async (payload: AddAttachmentStartAPIPayload) => { const attachmentsQK = AttachmentsKeys.attachments( payload.electionRoundId, @@ -101,14 +200,27 @@ export const useUploadAttachmentMutation = (scopeId: string) => { ); queryClient.setQueryData(attachmentsQK, context?.previousData); }, - onSettled: (_data, _err, _variables) => { - // return queryClient.invalidateQueries({ - // queryKey: AttachmentsKeys.attachments( - // variables.electionRoundId, - // variables.pollingStationId, - // variables.formId, - // ), - // }); + onSettled: (_data, _err, variables) => { + console.log("onSettled"); + queryClient.setQueryData( + AttachmentsKeys.addAttachments(), + (oldData) => { + const toReturn: UploadAttachmentProgress = { + ...oldData, + progress: 0, + status: "idle", + }; + console.log("toReturnOnSettled", toReturn); + return toReturn; + }, + ); + return queryClient.invalidateQueries({ + queryKey: AttachmentsKeys.attachments( + variables.electionRoundId, + variables.pollingStationId, + variables.formId, + ), + }); }, }); }; diff --git a/mobile/services/queries/attachments.query.ts b/mobile/services/queries/attachments.query.ts index e91d9683e..2cd8ce40e 100644 --- a/mobile/services/queries/attachments.query.ts +++ b/mobile/services/queries/attachments.query.ts @@ -19,6 +19,7 @@ export const AttachmentsKeys = { ] as const, addAttachmentMutation: () => [...AttachmentsKeys.all, "add"] as const, deleteAttachment: () => [...AttachmentsKeys.all, "delete"] as const, + addAttachments: () => [...AttachmentsKeys.all, "add"] as const, }; export const GuidesKeys = { From 4d7f4ecfb8d8dae03f133c86370715282c0c87cf Mon Sep 17 00:00:00 2001 From: Andrew Radulescu Date: Sun, 2 Jun 2024 14:57:14 +0300 Subject: [PATCH 15/19] wip - upload chunks - working photo/video --- .../(app)/form-questionnaire/[questionId].tsx | 63 ++++++++----------- .../PersistQueryContext.provider.tsx | 11 +--- mobile/hooks/useCamera.tsx | 22 ++++++- .../attachments/add-attachment.mutation.ts | 54 ++++++++-------- mobile/services/queries/attachments.query.ts | 4 +- 5 files changed, 78 insertions(+), 76 deletions(-) diff --git a/mobile/app/(app)/form-questionnaire/[questionId].tsx b/mobile/app/(app)/form-questionnaire/[questionId].tsx index 2afb656a3..eaa0f818e 100644 --- a/mobile/app/(app)/form-questionnaire/[questionId].tsx +++ b/mobile/app/(app)/form-questionnaire/[questionId].tsx @@ -39,13 +39,14 @@ import { useFormAnswers } from "../../../services/queries/form-submissions.query import { useNotesForQuestionId } from "../../../services/queries/notes.query"; import * as Crypto from "expo-crypto"; import { useTranslation } from "react-i18next"; -import { onlineManager } from "@tanstack/react-query"; +import { onlineManager, useQueryClient } from "@tanstack/react-query"; import { ApiFormQuestion } from "../../../services/interfaces/question.type"; import FormInput from "../../../components/FormInputs/FormInput"; import WarningDialog from "../../../components/WarningDialog"; import { MULTIPART_FILE_UPLOAD_SIZE } from "../../../common/constants"; import * as DocumentPicker from "expo-document-picker"; import { KeyboardAwareScrollView } from "react-native-keyboard-aware-scroll-view"; +import { AttachmentsKeys } from "../../../services/queries/attachments.query"; type SearchParamType = { questionId: string; @@ -56,6 +57,7 @@ type SearchParamType = { const FormQuestionnaire = () => { const { t } = useTranslation(["polling_station_form_wizard", "common"]); const { questionId, formId, language } = useLocalSearchParams(); + const queryClient = useQueryClient(); const { data: uploadAttachmentProgress } = useUploadAttachmentProgressQuery(); @@ -280,6 +282,10 @@ const FormQuestionnaire = () => { ); const handleCameraUpload = async (type: "library" | "cameraPhoto") => { + await queryClient.setQueryData(AttachmentsKeys.addAttachments(), { + progress: 0, + status: "idle", + }); setIsPreparingFile(true); const cameraResult = await uploadCameraOrMedia(type); @@ -296,43 +302,28 @@ const FormQuestionnaire = () => { activeQuestion.question.id && cameraResult.size ) { - // Calculate the number of parts that will be sent to the S3 Bucket. It is +1 (thus we use ceil). - const numberOfUploadParts: number = Math.ceil(cameraResult.size / MULTIPART_FILE_UPLOAD_SIZE); - const attachmentId = Crypto.randomUUID(); - // setUploadProgress(t("attachments.upload.starting")); - addAttachmentMultipart( { - id: attachmentId, + id: Crypto.randomUUID(), electionRoundId: activeElectionRound.id, pollingStationId: selectedPollingStation.pollingStationId, formId, questionId: activeQuestion.question.id, fileName: cameraResult.name, contentType: cameraResult.type, - numberOfUploadParts, + numberOfUploadParts: Math.ceil(cameraResult.size / MULTIPART_FILE_UPLOAD_SIZE), // Calculate the number of parts that will be sent to the S3 Bucket. It is +1 (thus we use ceil). filePath: cameraResult.uri, }, { - onSuccess: async (_data) => { - console.log("1. On Success"); - setTimeout(() => { - setIsOptionsSheetOpen(false); - setIsPreparingFile(false); - }, 1000); - }, onError: () => console.log("πŸ”΄πŸ”΄πŸ”΄πŸ”΄πŸ”΄πŸ”΄πŸ”΄πŸ”΄πŸ”΄πŸ”΄ERORRπŸ”΄πŸ”΄πŸ”΄πŸ”΄πŸ”΄πŸ”΄πŸ”΄πŸ”΄πŸ”΄πŸ”΄"), onSettled: () => { - setIsPreparingFile(false); setIsOptionsSheetOpen(false); + setIsPreparingFile(false); }, }, ); - console.log("3. Outside"); - if (!onlineManager.isOnline()) { - console.log("4. Not Online"); setIsOptionsSheetOpen(false); setIsPreparingFile(false); } @@ -340,12 +331,19 @@ const FormQuestionnaire = () => { }; const handleUploadAudio = async () => { + await queryClient.setQueryData(AttachmentsKeys.addAttachments(), { + progress: 0, + status: "idle", + }); setIsPreparingFile(true); + const doc = await DocumentPicker.getDocumentAsync({ type: "audio/*", multiple: false, }); + console.log(doc); + if (doc?.canceled) { console.log("canceling"); setIsPreparingFile(false); @@ -361,6 +359,8 @@ const FormQuestionnaire = () => { uri: file.uri, }; + console.log("FileMetadata", fileMetadata); + if ( activeElectionRound && selectedPollingStation?.pollingStationId && @@ -368,35 +368,22 @@ const FormQuestionnaire = () => { activeQuestion.question.id && fileMetadata.size ) { - // Calculate the number of parts that will be sent to the S3 Bucket. It is +1 (thus we use ceil). - const numberOfUploadParts: number = Math.ceil( - fileMetadata.size / MULTIPART_FILE_UPLOAD_SIZE, - ); - const attachmentId = Crypto.randomUUID(); - await addAttachmentMultipart( { - id: attachmentId, + id: Crypto.randomUUID(), electionRoundId: activeElectionRound.id, pollingStationId: selectedPollingStation.pollingStationId, formId, questionId: activeQuestion.question.id, fileName: fileMetadata.name, contentType: fileMetadata.type, - numberOfUploadParts, + numberOfUploadParts: Math.ceil(fileMetadata.size / MULTIPART_FILE_UPLOAD_SIZE), filePath: fileMetadata.uri, }, { - // onSuccess: async (data) => { - // await handleChunkUpload( - // fileMetadata.uri, - // data.uploadUrls, - // data.uploadId, - // attachmentId, - // ); - // }, onError: () => console.log("πŸ”΄πŸ”΄πŸ”΄πŸ”΄πŸ”΄πŸ”΄πŸ”΄πŸ”΄πŸ”΄πŸ”΄ERORRπŸ”΄πŸ”΄πŸ”΄πŸ”΄πŸ”΄πŸ”΄πŸ”΄πŸ”΄πŸ”΄πŸ”΄"), onSettled: () => { + setIsOptionsSheetOpen(false); setIsPreparingFile(false); }, }, @@ -762,6 +749,8 @@ const MediaLoading = ({ switch (uploadProgress?.status) { case "starting": return t("attachments.upload.starting"); + case "compressing": + return `Compressing progress ${uploadProgress.progress}%`; case "inprogress": return `${t("attachments.upload.progress")} ${uploadProgress.progress} %`; case "completed": @@ -776,8 +765,8 @@ const MediaLoading = ({ return ( - - {message || t("attachments.loading")} + + {message || ""} ); diff --git a/mobile/contexts/persist-query/PersistQueryContext.provider.tsx b/mobile/contexts/persist-query/PersistQueryContext.provider.tsx index b5eee93ec..7d3855379 100644 --- a/mobile/contexts/persist-query/PersistQueryContext.provider.tsx +++ b/mobile/contexts/persist-query/PersistQueryContext.provider.tsx @@ -109,15 +109,8 @@ const PersistQueryContextProvider = ({ children }: React.PropsWithChildren) => { queryClient.setMutationDefaults(AttachmentsKeys.addAttachmentMutation(), { mutationFn: async (payload: AddAttachmentStartAPIPayload) => { - const data = await addAttachmentMultipartStart(payload); - console.log("Init ran"); - return handleChunkUpload( - payload.electionRoundId, - payload.filePath, - data.uploadUrls, - data.uploadId, - payload.id, - ); + console.log("Mutation Default addAttachmentMutation"); + return uploadAttachmentMutationFn(payload, queryClient); }, }); diff --git a/mobile/hooks/useCamera.tsx b/mobile/hooks/useCamera.tsx index fd07ddffc..923dfb34c 100644 --- a/mobile/hooks/useCamera.tsx +++ b/mobile/hooks/useCamera.tsx @@ -2,6 +2,10 @@ import * as ImagePicker from "expo-image-picker"; import Toast from "react-native-toast-message"; import { Video, Image, getVideoMetaData, getImageMetaData } from "react-native-compressor"; import * as Sentry from "@sentry/react-native"; +import { useState } from "react"; +import { useQueryClient } from "@tanstack/react-query"; +import { AttachmentsKeys } from "../services/queries/attachments.query"; +import { UploadAttachmentProgress } from "../services/mutations/attachments/add-attachment.mutation"; /** * @@ -45,6 +49,8 @@ export type FileMetadata = { }; export const useCamera = () => { + const queryClient = useQueryClient(); + const [status, requestPermission] = ImagePicker.useCameraPermissions(); const uploadCameraOrMedia = async ( @@ -101,9 +107,19 @@ export const useCamera = () => { resultCompression = await Image.compress(file.uri); fileSize = (await getImageMetaData(resultCompression)).size; } else if (file.type === "video") { - resultCompression = await Video.compress(file.uri, {}, (progress) => { - console.log("Compression Progress: ", progress); - }); + resultCompression = await Video.compress( + file.uri, + { + progressDivider: 10, + }, + (progress) => { + console.log("Compression Progress: ", progress); + queryClient.setQueryData(AttachmentsKeys.addAttachments(), { + status: "compressing", + progress: +(progress * 100).toFixed(2), + }); + }, + ); fileSize = (await getVideoMetaData(resultCompression)).size; } } catch (err) { diff --git a/mobile/services/mutations/attachments/add-attachment.mutation.ts b/mobile/services/mutations/attachments/add-attachment.mutation.ts index 5c75706e9..bdfc34fe0 100644 --- a/mobile/services/mutations/attachments/add-attachment.mutation.ts +++ b/mobile/services/mutations/attachments/add-attachment.mutation.ts @@ -65,6 +65,7 @@ export const uploadAttachmentMutationFn = async ( let etags: Record = {}; const urls = Object.values(start.uploadUrls); + console.log("πŸš€ Started the FOR LOOP FOR CHUNKS"); for (const [index, url] of urls.entries()) { const chunk = await FileSystem.readAsStringAsync(payload.filePath, { length: MULTIPART_FILE_UPLOAD_SIZE, @@ -73,24 +74,26 @@ export const uploadAttachmentMutationFn = async ( }); const buffer = Buffer.from(chunk, "base64"); 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; - }, + queryClient.setQueryData(AttachmentsKeys.addAttachments(), () => { + const toReturn: UploadAttachmentProgress = { + progress, + status: progress === 100 ? "completed" : "inprogress", + }; + console.log("toReturnProgress in handleChunkUpload", toReturn); + + return toReturn; + }); + + console.log( + "Current progress state:", + queryClient.getQueryData(AttachmentsKeys.addAttachments()), ); const data = await uploadS3Chunk(url, buffer); etags = { ...etags, [index + 1]: data.ETag }; } + console.log("❌ Ended the FOR LOOP FOR CHUNKS"); const completed = await addAttachmentMultipartComplete({ uploadId: start.uploadId, etags, @@ -109,6 +112,7 @@ export const uploadAttachmentMutationFn = async ( return toReturn; }, ); + return completed; } catch (err) { Sentry.captureMessage("Upload failed, aborting!"); @@ -137,7 +141,7 @@ export const uploadAttachmentMutationFn = async ( export type UploadAttachmentProgress = { progress: number; - status: "idle" | "starting" | "inprogress" | "aborted" | "completed"; + status: "idle" | "compressing" | "starting" | "inprogress" | "aborted" | "completed"; }; export const useUploadAttachmentProgressQuery = () => { return useQuery({ @@ -202,18 +206,18 @@ export const useUploadAttachmentMutation = (scopeId: string) => { }, onSettled: (_data, _err, variables) => { console.log("onSettled"); - queryClient.setQueryData( - AttachmentsKeys.addAttachments(), - (oldData) => { - const toReturn: UploadAttachmentProgress = { - ...oldData, - progress: 0, - status: "idle", - }; - console.log("toReturnOnSettled", toReturn); - return toReturn; - }, - ); + // queryClient.setQueryData( + // AttachmentsKeys.addAttachments(), + // (oldData) => { + // const toReturn: UploadAttachmentProgress = { + // ...oldData, + // progress: 0, + // status: "idle", + // }; + // console.log("toReturnOnSettled", toReturn); + // return toReturn; + // }, + // ); return queryClient.invalidateQueries({ queryKey: AttachmentsKeys.attachments( variables.electionRoundId, diff --git a/mobile/services/queries/attachments.query.ts b/mobile/services/queries/attachments.query.ts index 2cd8ce40e..c958db521 100644 --- a/mobile/services/queries/attachments.query.ts +++ b/mobile/services/queries/attachments.query.ts @@ -17,9 +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, "add"] as const, + addAttachments: () => [...AttachmentsKeys.all, "progress"] as const, }; export const GuidesKeys = { From 3a0a79c839cf4284ad632341f4ebc4aa0eb9d91c Mon Sep 17 00:00:00 2001 From: Andrew Radulescu Date: Sun, 2 Jun 2024 15:03:11 +0300 Subject: [PATCH 16/19] fix - upload audio --- mobile/app/(app)/form-questionnaire/[questionId].tsx | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/mobile/app/(app)/form-questionnaire/[questionId].tsx b/mobile/app/(app)/form-questionnaire/[questionId].tsx index eaa0f818e..db6591c1c 100644 --- a/mobile/app/(app)/form-questionnaire/[questionId].tsx +++ b/mobile/app/(app)/form-questionnaire/[questionId].tsx @@ -357,6 +357,7 @@ const FormQuestionnaire = () => { name: file.name, type: file.mimeType || "audio/mpeg", uri: file.uri, + size: file.size, }; console.log("FileMetadata", fileMetadata); @@ -393,6 +394,9 @@ const FormQuestionnaire = () => { setIsOptionsSheetOpen(false); setIsPreparingFile(false); } + } else { + setIsOptionsSheetOpen(false); + setIsPreparingFile(false); } } }; From 1fe788407e82df693987d937ab2acef57eb85af0 Mon Sep 17 00:00:00 2001 From: Dragos-Paul Strat Date: Wed, 5 Jun 2024 14:34:00 +0300 Subject: [PATCH 17/19] fix:[upload[ add zustand for progress - wip --- .../(app)/form-questionnaire/[questionId].tsx | 49 ++++---- .../PersistQueryContext.provider.tsx | 14 ++- mobile/package-lock.json | 3 +- mobile/package.json | 3 +- .../attachments/add-attachment.mutation.ts | 112 +++++++----------- .../attachment-upload-selector.ts | 9 ++ .../attachment-upload-slice.ts | 26 ++++ mobile/services/store/store.ts | 19 +++ 8 files changed, 134 insertions(+), 101 deletions(-) create mode 100644 mobile/services/store/attachment-upload-state/attachment-upload-selector.ts create mode 100644 mobile/services/store/attachment-upload-state/attachment-upload-slice.ts create mode 100644 mobile/services/store/store.ts diff --git a/mobile/app/(app)/form-questionnaire/[questionId].tsx b/mobile/app/(app)/form-questionnaire/[questionId].tsx index db6591c1c..dee9f4c36 100644 --- a/mobile/app/(app)/form-questionnaire/[questionId].tsx +++ b/mobile/app/(app)/form-questionnaire/[questionId].tsx @@ -29,7 +29,6 @@ import { FileMetadata, useCamera } from "../../../hooks/useCamera"; import { UploadAttachmentProgress, useUploadAttachmentMutation, - useUploadAttachmentProgressQuery, } from "../../../services/mutations/attachments/add-attachment.mutation"; import QuestionAttachments from "../../../components/QuestionAttachments"; import QuestionNotes from "../../../components/QuestionNotes"; @@ -47,6 +46,8 @@ import { MULTIPART_FILE_UPLOAD_SIZE } from "../../../common/constants"; import * as DocumentPicker from "expo-document-picker"; import { KeyboardAwareScrollView } from "react-native-keyboard-aware-scroll-view"; import { AttachmentsKeys } from "../../../services/queries/attachments.query"; +import { useAttachmentUploadProgressState } from "../../../services/store/attachment-upload-state/attachment-upload-selector"; +import { AttachmentProgressStatusEnum } from "../../../services/store/attachment-upload-state/attachment-upload-slice"; type SearchParamType = { questionId: string; @@ -59,9 +60,7 @@ const FormQuestionnaire = () => { const { questionId, formId, language } = useLocalSearchParams(); const queryClient = useQueryClient(); - const { data: uploadAttachmentProgress } = useUploadAttachmentProgressQuery(); - console.log("❌ Upload Progress", uploadAttachmentProgress); if (!questionId || !formId || !language) { return Incorrect page params; @@ -72,6 +71,7 @@ const FormQuestionnaire = () => { const [addingNote, setAddingNote] = useState(false); const [deletingAnswer, setDeletingAnswer] = useState(false); const [isPreparingFile, setIsPreparingFile] = useState(false); + const [currentAttachment, setCurrentAttachment] = useState(''); const { data: currentForm, @@ -295,6 +295,8 @@ const FormQuestionnaire = () => { return; } + const attachmentId = Crypto.randomUUID(); + setCurrentAttachment(attachmentId); if ( activeElectionRound && selectedPollingStation?.pollingStationId && @@ -304,7 +306,7 @@ const FormQuestionnaire = () => { ) { addAttachmentMultipart( { - id: Crypto.randomUUID(), + id: attachmentId, electionRoundId: activeElectionRound.id, pollingStationId: selectedPollingStation.pollingStationId, formId, @@ -319,6 +321,7 @@ const FormQuestionnaire = () => { onSettled: () => { setIsOptionsSheetOpen(false); setIsPreparingFile(false); + setCurrentAttachment(''); }, }, ); @@ -342,10 +345,8 @@ const FormQuestionnaire = () => { multiple: false, }); - console.log(doc); if (doc?.canceled) { - console.log("canceling"); setIsPreparingFile(false); return; } @@ -360,7 +361,8 @@ const FormQuestionnaire = () => { size: file.size, }; - console.log("FileMetadata", fileMetadata); + const attachmentId = Crypto.randomUUID(); + setCurrentAttachment(attachmentId); if ( activeElectionRound && @@ -371,7 +373,7 @@ const FormQuestionnaire = () => { ) { await addAttachmentMultipart( { - id: Crypto.randomUUID(), + id: attachmentId, electionRoundId: activeElectionRound.id, pollingStationId: selectedPollingStation.pollingStationId, formId, @@ -386,6 +388,7 @@ const FormQuestionnaire = () => { onSettled: () => { setIsOptionsSheetOpen(false); setIsPreparingFile(false); + setCurrentAttachment(''); }, }, ); @@ -674,7 +677,7 @@ const FormQuestionnaire = () => { disableDrag={addingNote} > {(isUploadingAttachments && !isPaused) || isPreparingFile ? ( - + ) : addingNote ? ( { +const MediaLoading = ({ attachmentId }: { attachmentId: string }) => { const { t } = useTranslation("polling_station_form_wizard"); + const { progresses } = useAttachmentUploadProgressState() const message = useMemo(() => { - if (!uploadProgress) { + if (!progresses && !progresses[attachmentId]) { return ""; } - switch (uploadProgress?.status) { - case "starting": + + switch (progresses[attachmentId]?.status) { + case AttachmentProgressStatusEnum.STARTING: return t("attachments.upload.starting"); case "compressing": - return `Compressing progress ${uploadProgress.progress}%`; - case "inprogress": - return `${t("attachments.upload.progress")} ${uploadProgress.progress} %`; - case "completed": + return `Compressing progress ${progresses[attachmentId]?.progress}%`; + case AttachmentProgressStatusEnum.INPROGRESS: + return `${t("attachments.upload.progress")} ${progresses[attachmentId]?.progress} %`; + case AttachmentProgressStatusEnum.COMPLETED: return t("attachments.upload.completed"); - case "aborted": + case AttachmentProgressStatusEnum.ABORTED: return t("attachments.upload.aborted"); default: return ""; } - }, [uploadProgress]); + }, [progresses]); return ( - {message || ""} + {message || "Uploading"} ); diff --git a/mobile/contexts/persist-query/PersistQueryContext.provider.tsx b/mobile/contexts/persist-query/PersistQueryContext.provider.tsx index 7d3855379..bef54454d 100644 --- a/mobile/contexts/persist-query/PersistQueryContext.provider.tsx +++ b/mobile/contexts/persist-query/PersistQueryContext.provider.tsx @@ -25,7 +25,8 @@ 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 { handleChunkUpload } from "../../services/mutations/attachments/add-attachment.mutation"; +import { uploadAttachmentMutationFn } from "../../services/mutations/attachments/add-attachment.mutation"; +import useStore from "../../services/store/store"; const queryClient = new QueryClient({ mutationCache: new MutationCache({ @@ -109,14 +110,14 @@ const PersistQueryContextProvider = ({ children }: React.PropsWithChildren) => { queryClient.setMutationDefaults(AttachmentsKeys.addAttachmentMutation(), { mutationFn: async (payload: AddAttachmentStartAPIPayload) => { - console.log("Mutation Default addAttachmentMutation"); - return uploadAttachmentMutationFn(payload, queryClient); + const { progresses: state, setProgresses } = useStore(); + return uploadAttachmentMutationFn(payload, setProgresses, state); }, }); queryClient.setMutationDefaults(AttachmentsKeys.deleteAttachment(), { mutationFn: async (payload: AttachmentApiResponse) => { - return payload.isNotSynched ? () => {} : deleteAttachment(payload); + return payload.isNotSynched ? () => { } : deleteAttachment(payload); }, }); @@ -134,7 +135,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); }, }); @@ -202,6 +203,9 @@ const PersistQueryContextProvider = ({ children }: React.PropsWithChildren) => { // console.log("πŸ“πŸ“πŸ“πŸ“πŸ“πŸ“", SuperJSON.stringify(newPausedMutations)); if (pausedMutation?.length) { + const { setProgresses } = useStore(); + // Reset Attachment Progress + setProgresses(() => ({})); await queryClient.resumePausedMutations(); // Looks in the inmemory cache queryClient.invalidateQueries(); // Avoid using await, not to wait for queries to refetch (maybe not the case here as there are no active queries) console.log("βœ… Resume Paused Mutation & Invalidate Quries"); diff --git a/mobile/package-lock.json b/mobile/package-lock.json index 5232df753..cec5a9cfa 100644 --- a/mobile/package-lock.json +++ b/mobile/package-lock.json @@ -62,7 +62,8 @@ "react-native-toast-message": "^2.2.0", "superjson": "^2.2.1", "tamagui": "^1.93.2", - "zod": "^3.23.3" + "zod": "^3.23.3", + "zustand": "^4.5.2" }, "devDependencies": { "@babel/core": "^7.20.0", diff --git a/mobile/package.json b/mobile/package.json index fc77e161f..b6f88928f 100644 --- a/mobile/package.json +++ b/mobile/package.json @@ -74,7 +74,8 @@ "react-native-toast-message": "^2.2.0", "superjson": "^2.2.1", "tamagui": "^1.93.2", - "zod": "^3.23.3" + "zod": "^3.23.3", + "zustand": "^4.5.2" }, "devDependencies": { "@babel/core": "^7.20.0", diff --git a/mobile/services/mutations/attachments/add-attachment.mutation.ts b/mobile/services/mutations/attachments/add-attachment.mutation.ts index bdfc34fe0..627dca1fb 100644 --- a/mobile/services/mutations/attachments/add-attachment.mutation.ts +++ b/mobile/services/mutations/attachments/add-attachment.mutation.ts @@ -12,6 +12,8 @@ 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"; +import useStore from "../../store/store"; +import { AttachmentProgressStatusEnum } from "../../store/attachment-upload-state/attachment-upload-slice"; // export const handleChunkUpload = async ( // filePath: string, @@ -54,18 +56,21 @@ import { Buffer } from "buffer"; export const uploadAttachmentMutationFn = async ( payload: AddAttachmentStartAPIPayload, - queryClient: QueryClient, + setProgress: (fn: (prev: Record) => Record) => void, + state: any, ) => { - queryClient.setQueryData(AttachmentsKeys.addAttachments(), () => ({ - progress: 0, - status: "starting", + setProgress((state) => ({ + ...state, + [payload.id]: { + progress: 0, + status: AttachmentProgressStatusEnum.STARTING, + }, })); const start = await addAttachmentMultipartStart(payload); try { let etags: Record = {}; const urls = Object.values(start.uploadUrls); - console.log("πŸš€ Started the FOR LOOP FOR CHUNKS"); for (const [index, url] of urls.entries()) { const chunk = await FileSystem.readAsStringAsync(payload.filePath, { length: MULTIPART_FILE_UPLOAD_SIZE, @@ -74,44 +79,36 @@ export const uploadAttachmentMutationFn = async ( }); const buffer = Buffer.from(chunk, "base64"); const progress = Math.round(((index + 1) / urls.length) * 100 * 10) / 10; - queryClient.setQueryData(AttachmentsKeys.addAttachments(), () => { - const toReturn: UploadAttachmentProgress = { - progress, - status: progress === 100 ? "completed" : "inprogress", - }; - console.log("toReturnProgress in handleChunkUpload", toReturn); - - return toReturn; - }); - console.log( - "Current progress state:", - queryClient.getQueryData(AttachmentsKeys.addAttachments()), - ); + setProgress((state) => ({ + ...state, + [payload.id]: { + progress: progress, + status: + progress === 100 + ? AttachmentProgressStatusEnum.COMPLETED + : AttachmentProgressStatusEnum.INPROGRESS, + }, + })); const data = await uploadS3Chunk(url, buffer); etags = { ...etags, [index + 1]: data.ETag }; } - console.log("❌ Ended the FOR LOOP FOR CHUNKS"); const completed = await addAttachmentMultipartComplete({ uploadId: start.uploadId, etags, electionRoundId: payload.electionRoundId, id: payload.id, }); - queryClient.setQueryData( - AttachmentsKeys.addAttachments(), - (oldData) => { - const toReturn: UploadAttachmentProgress = { - ...oldData, - progress: 100, - status: "completed", - }; - console.log("toReturnCompleted", toReturn); - return toReturn; + + setProgress((state) => ({ + ...state, + [payload.id]: { + progress: 100, + status: AttachmentProgressStatusEnum.COMPLETED, }, - ); + })); return completed; } catch (err) { @@ -123,49 +120,32 @@ export const uploadAttachmentMutationFn = async ( uploadId: start.uploadId, electionRoundId: payload.electionRoundId, }); - queryClient.setQueryData( - AttachmentsKeys.addAttachments(), - (oldData) => { - const toReturn: UploadAttachmentProgress = { - ...oldData, - progress: 0, - status: "aborted", - }; - console.log("toReturnAbort", toReturn); - return toReturn; + setProgress((state) => ({ + ...state, + [payload.id]: { + progress: 0, + status: AttachmentProgressStatusEnum.ABORTED, }, - ); + })); return aborted; } }; export type UploadAttachmentProgress = { progress: number; - status: "idle" | "compressing" | "starting" | "inprogress" | "aborted" | "completed"; -}; -export const useUploadAttachmentProgressQuery = () => { - return useQuery({ - queryKey: AttachmentsKeys.addAttachments(), // TODO: more specific - queryFn: () => { - console.log("QueryFn Called"); - return { status: "idle", progress: 0 }; - }, - placeholderData: { status: "idle", progress: 0 }, - initialData: { status: "idle", progress: 0 }, - staleTime: Infinity, - gcTime: Infinity, - }); + status: AttachmentProgressStatusEnum; }; export const useUploadAttachmentMutation = (scopeId: string) => { const queryClient = useQueryClient(); + const { progresses: state, setProgresses } = useStore(); return useMutation({ mutationKey: AttachmentsKeys.addAttachmentMutation(), scope: { id: scopeId, }, mutationFn: (payload: AddAttachmentStartAPIPayload) => - uploadAttachmentMutationFn(payload, queryClient), + uploadAttachmentMutationFn(payload, setProgresses, state), onMutate: async (payload: AddAttachmentStartAPIPayload) => { const attachmentsQK = AttachmentsKeys.attachments( payload.electionRoundId, @@ -196,7 +176,6 @@ export const useUploadAttachmentMutation = (scopeId: string) => { return { previousData, attachmentsQK }; }, onError: (err, payload, context) => { - console.log(err); const attachmentsQK = AttachmentsKeys.attachments( payload.electionRoundId, payload.pollingStationId, @@ -205,19 +184,12 @@ export const useUploadAttachmentMutation = (scopeId: string) => { queryClient.setQueryData(attachmentsQK, context?.previousData); }, onSettled: (_data, _err, variables) => { - console.log("onSettled"); - // queryClient.setQueryData( - // AttachmentsKeys.addAttachments(), - // (oldData) => { - // const toReturn: UploadAttachmentProgress = { - // ...oldData, - // progress: 0, - // status: "idle", - // }; - // console.log("toReturnOnSettled", toReturn); - // return toReturn; - // }, - // ); + setProgresses((state) => { + const { [variables.id]: toDelete, ...rest } = state; + return { + ...rest, + }; + }); return queryClient.invalidateQueries({ queryKey: AttachmentsKeys.attachments( variables.electionRoundId, diff --git a/mobile/services/store/attachment-upload-state/attachment-upload-selector.ts b/mobile/services/store/attachment-upload-state/attachment-upload-selector.ts new file mode 100644 index 000000000..386da5c20 --- /dev/null +++ b/mobile/services/store/attachment-upload-state/attachment-upload-selector.ts @@ -0,0 +1,9 @@ +import useStore from "../store"; +import { IAttachmentProgressState } from "./attachment-upload-slice"; + +export const useAttachmentUploadProgressState = () => { + const progresses: Record = useStore( + (state) => state.progresses, + ); + return { progresses }; +}; diff --git a/mobile/services/store/attachment-upload-state/attachment-upload-slice.ts b/mobile/services/store/attachment-upload-state/attachment-upload-slice.ts new file mode 100644 index 000000000..94758278d --- /dev/null +++ b/mobile/services/store/attachment-upload-state/attachment-upload-slice.ts @@ -0,0 +1,26 @@ +export enum AttachmentProgressStatusEnum { + IDLE = "idle", + COMPRESSING = "compressing", + STARTING = "starting", + INPROGRESS = "inprogress", + ABORTED = "aborted", + COMPLETED = "completed", +} + +export interface IAttachmentProgressState { + progress: number; + status: AttachmentProgressStatusEnum; +} + +export const attachmentUploadProgressSlice = (set: any) => ({ + progresses: {}, + setProgresses: ( + fn: ( + prev: Record, + ) => Record, + ) => { + set((state: any) => ({ progresses: fn(state.progresses) })); + }, +}); + +export default { attachmentUploadProgressSlice }; diff --git a/mobile/services/store/store.ts b/mobile/services/store/store.ts new file mode 100644 index 000000000..3715aef92 --- /dev/null +++ b/mobile/services/store/store.ts @@ -0,0 +1,19 @@ +import { create } from "zustand"; +import { + AttachmentProgressStatusEnum, + IAttachmentProgressState, + attachmentUploadProgressSlice, +} from "./attachment-upload-state/attachment-upload-slice"; + +interface AttchmentUploadProgressState { + progresses: {}; + setProgresses: ( + fn: (prev: Record) => Record, + ) => void; +} + +const useStore = create()((set: any) => ({ + ...attachmentUploadProgressSlice(set), +})); + +export default useStore; From f37bac3ed9250ba3d446afe8f8c8dee32a1ff5d9 Mon Sep 17 00:00:00 2001 From: Dragos-Paul Strat Date: Tue, 8 Oct 2024 14:55:51 +0300 Subject: [PATCH 18/19] fix merge --- .../PersistQueryContext.provider.tsx | 7 +-- .../attachments/add-attachment.mutation.ts | 58 ++----------------- .../attachment-upload-selector.ts | 9 --- .../attachment-upload-slice.ts | 26 --------- mobile/services/store/store.ts | 19 ------ 5 files changed, 7 insertions(+), 112 deletions(-) delete mode 100644 mobile/services/store/attachment-upload-state/attachment-upload-selector.ts delete mode 100644 mobile/services/store/attachment-upload-state/attachment-upload-slice.ts delete mode 100644 mobile/services/store/store.ts diff --git a/mobile/contexts/persist-query/PersistQueryContext.provider.tsx b/mobile/contexts/persist-query/PersistQueryContext.provider.tsx index bef54454d..344a291ce 100644 --- a/mobile/contexts/persist-query/PersistQueryContext.provider.tsx +++ b/mobile/contexts/persist-query/PersistQueryContext.provider.tsx @@ -26,7 +26,6 @@ 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"; -import useStore from "../../services/store/store"; const queryClient = new QueryClient({ mutationCache: new MutationCache({ @@ -110,8 +109,7 @@ const PersistQueryContextProvider = ({ children }: React.PropsWithChildren) => { queryClient.setMutationDefaults(AttachmentsKeys.addAttachmentMutation(), { mutationFn: async (payload: AddAttachmentStartAPIPayload) => { - const { progresses: state, setProgresses } = useStore(); - return uploadAttachmentMutationFn(payload, setProgresses, state); + return uploadAttachmentMutationFn(payload); }, }); @@ -203,9 +201,6 @@ const PersistQueryContextProvider = ({ children }: React.PropsWithChildren) => { // console.log("πŸ“πŸ“πŸ“πŸ“πŸ“πŸ“", SuperJSON.stringify(newPausedMutations)); if (pausedMutation?.length) { - const { setProgresses } = useStore(); - // Reset Attachment Progress - setProgresses(() => ({})); await queryClient.resumePausedMutations(); // Looks in the inmemory cache queryClient.invalidateQueries(); // Avoid using await, not to wait for queries to refetch (maybe not the case here as there are no active queries) console.log("βœ… Resume Paused Mutation & Invalidate Quries"); diff --git a/mobile/services/mutations/attachments/add-attachment.mutation.ts b/mobile/services/mutations/attachments/add-attachment.mutation.ts index 627dca1fb..9aa8a7b5c 100644 --- a/mobile/services/mutations/attachments/add-attachment.mutation.ts +++ b/mobile/services/mutations/attachments/add-attachment.mutation.ts @@ -12,9 +12,6 @@ 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"; -import useStore from "../../store/store"; -import { AttachmentProgressStatusEnum } from "../../store/attachment-upload-state/attachment-upload-slice"; - // export const handleChunkUpload = async ( // filePath: string, // uploadUrls: Record, @@ -54,18 +51,7 @@ import { AttachmentProgressStatusEnum } from "../../store/attachment-upload-stat // return etags; // }; -export const uploadAttachmentMutationFn = async ( - payload: AddAttachmentStartAPIPayload, - setProgress: (fn: (prev: Record) => Record) => void, - state: any, -) => { - setProgress((state) => ({ - ...state, - [payload.id]: { - progress: 0, - status: AttachmentProgressStatusEnum.STARTING, - }, - })); +export const uploadAttachmentMutationFn = async (payload: AddAttachmentStartAPIPayload) => { const start = await addAttachmentMultipartStart(payload); try { let etags: Record = {}; @@ -78,18 +64,7 @@ export const uploadAttachmentMutationFn = async ( encoding: FileSystem.EncodingType.Base64, }); const buffer = Buffer.from(chunk, "base64"); - const progress = Math.round(((index + 1) / urls.length) * 100 * 10) / 10; - - setProgress((state) => ({ - ...state, - [payload.id]: { - progress: progress, - status: - progress === 100 - ? AttachmentProgressStatusEnum.COMPLETED - : AttachmentProgressStatusEnum.INPROGRESS, - }, - })); + // const progress = Math.round(((index + 1) / urls.length) * 100 * 10) / 10; const data = await uploadS3Chunk(url, buffer); @@ -102,14 +77,6 @@ export const uploadAttachmentMutationFn = async ( id: payload.id, }); - setProgress((state) => ({ - ...state, - [payload.id]: { - progress: 100, - status: AttachmentProgressStatusEnum.COMPLETED, - }, - })); - return completed; } catch (err) { Sentry.captureMessage("Upload failed, aborting!"); @@ -120,32 +87,24 @@ export const uploadAttachmentMutationFn = async ( uploadId: start.uploadId, electionRoundId: payload.electionRoundId, }); - setProgress((state) => ({ - ...state, - [payload.id]: { - progress: 0, - status: AttachmentProgressStatusEnum.ABORTED, - }, - })); + return aborted; } }; export type UploadAttachmentProgress = { progress: number; - status: AttachmentProgressStatusEnum; }; export const useUploadAttachmentMutation = (scopeId: string) => { const queryClient = useQueryClient(); - const { progresses: state, setProgresses } = useStore(); + return useMutation({ mutationKey: AttachmentsKeys.addAttachmentMutation(), scope: { id: scopeId, }, - mutationFn: (payload: AddAttachmentStartAPIPayload) => - uploadAttachmentMutationFn(payload, setProgresses, state), + mutationFn: (payload: AddAttachmentStartAPIPayload) => uploadAttachmentMutationFn(payload), onMutate: async (payload: AddAttachmentStartAPIPayload) => { const attachmentsQK = AttachmentsKeys.attachments( payload.electionRoundId, @@ -176,6 +135,7 @@ export const useUploadAttachmentMutation = (scopeId: string) => { return { previousData, attachmentsQK }; }, onError: (err, payload, context) => { + console.log("onError", err); const attachmentsQK = AttachmentsKeys.attachments( payload.electionRoundId, payload.pollingStationId, @@ -184,12 +144,6 @@ export const useUploadAttachmentMutation = (scopeId: string) => { queryClient.setQueryData(attachmentsQK, context?.previousData); }, onSettled: (_data, _err, variables) => { - setProgresses((state) => { - const { [variables.id]: toDelete, ...rest } = state; - return { - ...rest, - }; - }); return queryClient.invalidateQueries({ queryKey: AttachmentsKeys.attachments( variables.electionRoundId, diff --git a/mobile/services/store/attachment-upload-state/attachment-upload-selector.ts b/mobile/services/store/attachment-upload-state/attachment-upload-selector.ts deleted file mode 100644 index 386da5c20..000000000 --- a/mobile/services/store/attachment-upload-state/attachment-upload-selector.ts +++ /dev/null @@ -1,9 +0,0 @@ -import useStore from "../store"; -import { IAttachmentProgressState } from "./attachment-upload-slice"; - -export const useAttachmentUploadProgressState = () => { - const progresses: Record = useStore( - (state) => state.progresses, - ); - return { progresses }; -}; diff --git a/mobile/services/store/attachment-upload-state/attachment-upload-slice.ts b/mobile/services/store/attachment-upload-state/attachment-upload-slice.ts deleted file mode 100644 index 94758278d..000000000 --- a/mobile/services/store/attachment-upload-state/attachment-upload-slice.ts +++ /dev/null @@ -1,26 +0,0 @@ -export enum AttachmentProgressStatusEnum { - IDLE = "idle", - COMPRESSING = "compressing", - STARTING = "starting", - INPROGRESS = "inprogress", - ABORTED = "aborted", - COMPLETED = "completed", -} - -export interface IAttachmentProgressState { - progress: number; - status: AttachmentProgressStatusEnum; -} - -export const attachmentUploadProgressSlice = (set: any) => ({ - progresses: {}, - setProgresses: ( - fn: ( - prev: Record, - ) => Record, - ) => { - set((state: any) => ({ progresses: fn(state.progresses) })); - }, -}); - -export default { attachmentUploadProgressSlice }; diff --git a/mobile/services/store/store.ts b/mobile/services/store/store.ts deleted file mode 100644 index 3715aef92..000000000 --- a/mobile/services/store/store.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { create } from "zustand"; -import { - AttachmentProgressStatusEnum, - IAttachmentProgressState, - attachmentUploadProgressSlice, -} from "./attachment-upload-state/attachment-upload-slice"; - -interface AttchmentUploadProgressState { - progresses: {}; - setProgresses: ( - fn: (prev: Record) => Record, - ) => void; -} - -const useStore = create()((set: any) => ({ - ...attachmentUploadProgressSlice(set), -})); - -export default useStore; From 798ce902add12b75749d8f9c684baae90bd15c23 Mon Sep 17 00:00:00 2001 From: Dragos-Paul Strat Date: Tue, 8 Oct 2024 16:26:20 +0300 Subject: [PATCH 19/19] fix: [upload] remove zustand and make it run with linter --- .../(app)/form-questionnaire/[questionId].tsx | 42 ++------- mobile/app/(app)/report-issue.tsx | 94 +++++++++++++------ .../PersistQueryContext.provider.tsx | 1 - mobile/hooks/useCamera.tsx | 3 +- .../add-attachment-quick-report.api.ts | 2 - .../attachments/add-attachment.mutation.ts | 2 +- 6 files changed, 74 insertions(+), 70 deletions(-) diff --git a/mobile/app/(app)/form-questionnaire/[questionId].tsx b/mobile/app/(app)/form-questionnaire/[questionId].tsx index dee9f4c36..fabb89c27 100644 --- a/mobile/app/(app)/form-questionnaire/[questionId].tsx +++ b/mobile/app/(app)/form-questionnaire/[questionId].tsx @@ -26,10 +26,7 @@ import { useFormSubmissionMutation } from "../../../services/mutations/form-subm import OptionsSheet from "../../../components/OptionsSheet"; import AddAttachment from "../../../components/AddAttachment"; import { FileMetadata, useCamera } from "../../../hooks/useCamera"; -import { - UploadAttachmentProgress, - useUploadAttachmentMutation, -} from "../../../services/mutations/attachments/add-attachment.mutation"; +import { useUploadAttachmentMutation } from "../../../services/mutations/attachments/add-attachment.mutation"; import QuestionAttachments from "../../../components/QuestionAttachments"; import QuestionNotes from "../../../components/QuestionNotes"; import AddNoteSheetContent from "../../../components/AddNoteSheetContent"; @@ -46,8 +43,6 @@ import { MULTIPART_FILE_UPLOAD_SIZE } from "../../../common/constants"; import * as DocumentPicker from "expo-document-picker"; import { KeyboardAwareScrollView } from "react-native-keyboard-aware-scroll-view"; import { AttachmentsKeys } from "../../../services/queries/attachments.query"; -import { useAttachmentUploadProgressState } from "../../../services/store/attachment-upload-state/attachment-upload-selector"; -import { AttachmentProgressStatusEnum } from "../../../services/store/attachment-upload-state/attachment-upload-slice"; type SearchParamType = { questionId: string; @@ -60,8 +55,6 @@ const FormQuestionnaire = () => { const { questionId, formId, language } = useLocalSearchParams(); const queryClient = useQueryClient(); - - if (!questionId || !formId || !language) { return Incorrect page params; } @@ -71,7 +64,7 @@ const FormQuestionnaire = () => { const [addingNote, setAddingNote] = useState(false); const [deletingAnswer, setDeletingAnswer] = useState(false); const [isPreparingFile, setIsPreparingFile] = useState(false); - const [currentAttachment, setCurrentAttachment] = useState(''); + const [, setCurrentAttachment] = useState(""); const { data: currentForm, @@ -321,7 +314,7 @@ const FormQuestionnaire = () => { onSettled: () => { setIsOptionsSheetOpen(false); setIsPreparingFile(false); - setCurrentAttachment(''); + setCurrentAttachment(""); }, }, ); @@ -345,7 +338,6 @@ const FormQuestionnaire = () => { multiple: false, }); - if (doc?.canceled) { setIsPreparingFile(false); return; @@ -388,7 +380,7 @@ const FormQuestionnaire = () => { onSettled: () => { setIsOptionsSheetOpen(false); setIsPreparingFile(false); - setCurrentAttachment(''); + setCurrentAttachment(""); }, }, ); @@ -677,7 +669,7 @@ const FormQuestionnaire = () => { disableDrag={addingNote} > {(isUploadingAttachments && !isPaused) || isPreparingFile ? ( - + ) : addingNote ? ( { +const MediaLoading = () => { const { t } = useTranslation("polling_station_form_wizard"); - const { progresses } = useAttachmentUploadProgressState() const message = useMemo(() => { - if (!progresses && !progresses[attachmentId]) { - return ""; - } - - switch (progresses[attachmentId]?.status) { - case AttachmentProgressStatusEnum.STARTING: - return t("attachments.upload.starting"); - case "compressing": - return `Compressing progress ${progresses[attachmentId]?.progress}%`; - case AttachmentProgressStatusEnum.INPROGRESS: - return `${t("attachments.upload.progress")} ${progresses[attachmentId]?.progress} %`; - case AttachmentProgressStatusEnum.COMPLETED: - return t("attachments.upload.completed"); - case AttachmentProgressStatusEnum.ABORTED: - return t("attachments.upload.aborted"); - default: - return ""; - } - }, [progresses]); + return t("attachments.loading"); + }, []); return ( diff --git a/mobile/app/(app)/report-issue.tsx b/mobile/app/(app)/report-issue.tsx index 54b667eb6..9046e1155 100644 --- a/mobile/app/(app)/report-issue.tsx +++ b/mobile/app/(app)/report-issue.tsx @@ -13,13 +13,17 @@ import { onlineManager, useMutationState, useQueryClient } from "@tanstack/react import { QuickReportKeys } from "../../services/queries/quick-reports.query"; import { useTranslation } from "react-i18next"; import i18n from "../../common/config/i18n"; -import { AddAttachmentQuickReportStartAPIPayload, addAttachmentQuickReportMultipartAbort, addAttachmentQuickReportMultipartComplete } from "../../services/api/quick-report/add-attachment-quick-report.api"; +import { + AddAttachmentQuickReportStartAPIPayload, + addAttachmentQuickReportMultipartAbort, + addAttachmentQuickReportMultipartComplete, +} from "../../services/api/quick-report/add-attachment-quick-report.api"; import { useUploadAttachmentQuickReportMutation } from "../../services/mutations/quick-report/add-attachment-quick-report.mutation"; import { MULTIPART_FILE_UPLOAD_SIZE } from "../../common/constants"; import { KeyboardAwareScrollView } from "react-native-keyboard-aware-scroll-view"; -import { addAttachmentMultipartAbort, addAttachmentMultipartComplete, uploadS3Chunk } from "../../services/api/add-attachment.api"; -import * as FileSystem from 'expo-file-system'; -import { Buffer } from 'buffer'; +import { uploadS3Chunk } from "../../services/api/add-attachment.api"; +import * as FileSystem from "expo-file-system"; +import { Buffer } from "buffer"; import { Keyboard } from "react-native"; import { YStack, Card, XStack, Spinner } from "tamagui"; import AddAttachment from "../../components/AddAttachment"; @@ -74,9 +78,9 @@ const ReportIssue = () => { const { t } = useTranslation("report_new_issue"); const [isLoadingAttachment, setIsLoadingAttachment] = useState(false); const [isPreparingFile, setIsPreparingFile] = useState(false); - const [uploadProgress, setUploadProgress] = useState(''); + const [uploadProgress, setUploadProgress] = useState(""); - const [attachments, setAttachments] = useState>( + const [attachments, setAttachments] = useState>( [], ); @@ -86,7 +90,8 @@ const ReportIssue = () => { isPaused: isPausedAddQuickReport, } = useAddQuickReport(); - const { mutateAsync: addAttachmentQReport, isPaused: isPausedStartAddAttachment, } = useUploadAttachmentQuickReportMutation(`Quick_Report_${activeElectionRound?.id}}`); + const { mutateAsync: addAttachmentQReport, isPaused: isPausedStartAddAttachment } = + useUploadAttachmentQuickReportMutation(`Quick_Report_${activeElectionRound?.id}}`); const addAttachmentsMutationState = useMutationState({ filters: { mutationKey: QuickReportKeys.addAttachment() }, @@ -122,11 +127,12 @@ const ReportIssue = () => { const handleCameraUpload = async (type: "library" | "cameraPhoto" | "cameraVideo") => { setIsPreparingFile(true); - setUploadProgress(t("upload.preparing")) + setUploadProgress(t("upload.preparing")); const cameraResult = await uploadCameraOrMedia(type); if (!cameraResult || !activeElectionRound) { + setUploadProgress(""); return; } @@ -140,7 +146,7 @@ const ReportIssue = () => { const handleUploadAudio = async () => { setIsPreparingFile(true); - setUploadProgress(t("upload.preparing")) + setUploadProgress(t("upload.preparing")); const doc = await DocumentPicker.getDocumentAsync({ type: "audio/*", multiple: false, @@ -163,27 +169,46 @@ const ReportIssue = () => { } }; - const handleChunkUpload = async (filePath: string, uploadUrls: Record, uploadId: string, attachmentId: string, quickReportId: string) => { + 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 } - }; - + 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, }) + 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 }) + await addAttachmentQuickReportMultipartAbort({ + id: attachmentId, + uploadId, + electionRoundId: activeElectionRound.id, + quickReportId, + }); } } finally { if (activeElectionRound?.id) { @@ -192,15 +217,13 @@ const ReportIssue = () => { }); } } - } + }; const onSubmit = async (formData: ReportIssueFormType) => { if (!visits || !activeElectionRound) { return; } - - let quickReportLocationType = QuickReportLocationType.VisitedPollingStation; let pollingStationId: string | null = formData.polling_station_id; @@ -224,20 +247,30 @@ const ReportIssue = () => { setIsLoadingAttachment(true); try { // Upload each attachment - setUploadProgress(`${t("upload.starting")}`) + 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), + 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} %`); + 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); } setUploadProgress(t("upload.completed")); @@ -456,8 +489,8 @@ const ReportIssue = () => { {(isLoadingAttachment && !isPausedStartAddAttachment) || isPreparingFile ? ( - ) : - + + ) : ( { > {t("media.menu.upload_audio")} - } + + )} @@ -521,7 +555,7 @@ const MediaLoading = ({ progress }: { progress?: string }) => { - {progress ? progress : t("attachments.loading")} + {progress || t("attachments.loading")} ); diff --git a/mobile/contexts/persist-query/PersistQueryContext.provider.tsx b/mobile/contexts/persist-query/PersistQueryContext.provider.tsx index 344a291ce..f81cb9432 100644 --- a/mobile/contexts/persist-query/PersistQueryContext.provider.tsx +++ b/mobile/contexts/persist-query/PersistQueryContext.provider.tsx @@ -7,7 +7,6 @@ import * as API from "../../services/definitions.api"; import { PersistGate } from "../../components/PersistGate"; import { AddAttachmentStartAPIPayload, - addAttachmentMultipartStart, } from "../../services/api/add-attachment.api"; import { deleteAttachment } from "../../services/api/delete-attachment.api"; import { Note } from "../../common/models/note"; diff --git a/mobile/hooks/useCamera.tsx b/mobile/hooks/useCamera.tsx index 923dfb34c..92db7b41f 100644 --- a/mobile/hooks/useCamera.tsx +++ b/mobile/hooks/useCamera.tsx @@ -2,7 +2,6 @@ import * as ImagePicker from "expo-image-picker"; import Toast from "react-native-toast-message"; import { Video, Image, getVideoMetaData, getImageMetaData } from "react-native-compressor"; import * as Sentry from "@sentry/react-native"; -import { useState } from "react"; import { useQueryClient } from "@tanstack/react-query"; import { AttachmentsKeys } from "../services/queries/attachments.query"; import { UploadAttachmentProgress } from "../services/mutations/attachments/add-attachment.mutation"; @@ -115,7 +114,7 @@ export const useCamera = () => { (progress) => { console.log("Compression Progress: ", progress); queryClient.setQueryData(AttachmentsKeys.addAttachments(), { - status: "compressing", + // status: "compressing", progress: +(progress * 100).toFixed(2), }); }, 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 282e1a130..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,5 +1,3 @@ -import axios from "axios"; -import { FileMetadata } from "../../../hooks/useCamera"; import API from "../../api"; import { QuickReportAttachmentAPIResponse } from "./get-quick-reports.api"; diff --git a/mobile/services/mutations/attachments/add-attachment.mutation.ts b/mobile/services/mutations/attachments/add-attachment.mutation.ts index 9aa8a7b5c..e7ecf766b 100644 --- a/mobile/services/mutations/attachments/add-attachment.mutation.ts +++ b/mobile/services/mutations/attachments/add-attachment.mutation.ts @@ -1,4 +1,4 @@ -import { QueryClient, useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; import { AddAttachmentStartAPIPayload, addAttachmentMultipartAbort,