From ab8703a524ea0326e923b6035dd48248b309dbb6 Mon Sep 17 00:00:00 2001 From: Patrick Fic Date: Fri, 31 Oct 2025 08:18:32 -0700 Subject: [PATCH] Major improvements to upload progress. --- app.json | 11 +- components/settings/settings.jsx | 39 ++++++ .../upload-progress/upload-progress.jsx | 24 +++- redux/photos/photos.actions.js | 14 +++ redux/photos/photos.reducer.js | 39 +++++- redux/photos/photos.sagas.js | 116 +++++++++++------- redux/photos/photos.types.js | 3 + 7 files changed, 185 insertions(+), 61 deletions(-) diff --git a/app.json b/app.json index 1a75de3..3a6b221 100644 --- a/app.json +++ b/app.json @@ -6,7 +6,7 @@ "scheme": "imex-mobile-scheme", "userInterfaceStyle": "automatic", "extra": { - "expover": "1", + "expover": "8", "eas": { "projectId": "ffe01f3a-d507-4698-82cd-da1f1cad450b" } @@ -14,10 +14,7 @@ "runtimeVersion": "appVersion", "orientation": "default", "icon": "./assets/ImEXlogo192noa.png", - "platforms": [ - "ios", - "android" - ], + "platforms": ["ios", "android"], "ios": { "supportsTablet": true, "bundleIdentifier": "com.imex.imexmobile", @@ -51,9 +48,7 @@ "fallbackToCacheTimeout": 0, "url": "https://u.expo.dev/ffe01f3a-d507-4698-82cd-da1f1cad450b" }, - "assetBundlePatterns": [ - "**/*" - ], + "assetBundlePatterns": ["**/*"], "web": { "favicon": "./assets/ImEXlogo192noa.png", "config": { diff --git a/components/settings/settings.jsx b/components/settings/settings.jsx index 7ff0264..ccbba77 100644 --- a/components/settings/settings.jsx +++ b/components/settings/settings.jsx @@ -7,6 +7,7 @@ import AsyncStorage from "@react-native-async-storage/async-storage"; import * as Application from "expo-application"; import Constants from "expo-constants"; import * as Notifications from "expo-notifications"; +import * as Updates from "expo-updates"; import { useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; import { Alert, ScrollView, StyleSheet, View } from "react-native"; @@ -182,6 +183,44 @@ function Tab({ bodyshop, currentUser, signOutStart }) { number: `${Constants.expoConfig.version}(${Application.nativeBuildVersion} - ${Constants.expoConfig.extra.expover})`, })} + ); diff --git a/components/upload-progress/upload-progress.jsx b/components/upload-progress/upload-progress.jsx index 9cfc589..0b19beb 100644 --- a/components/upload-progress/upload-progress.jsx +++ b/components/upload-progress/upload-progress.jsx @@ -1,10 +1,17 @@ import { useTheme } from "@/hooks"; -import { clearUploadError } from "@/redux/photos/photos.actions"; +import { cancelUploads, clearUploadError } from "@/redux/photos/photos.actions"; import { formatBytes } from "@/util/uploadUtils"; import { useMemo } from "react"; import { useTranslation } from "react-i18next"; import { ScrollView, StyleSheet, View } from "react-native"; -import { Divider, Modal, Portal, ProgressBar, Text } from "react-native-paper"; +import { + Button, + Divider, + Modal, + Portal, + ProgressBar, + Text, +} from "react-native-paper"; import { connect } from "react-redux"; import { createStructuredSelector } from "reselect"; import { @@ -20,6 +27,7 @@ const mapStateToProps = createStructuredSelector({ }); const mapDispatchToProps = (dispatch) => ({ clearError: () => dispatch(clearUploadError()), + cancelUploads: () => dispatch(cancelUploads()), }); export default connect(mapStateToProps, mapDispatchToProps)(UploadProgress); @@ -29,6 +37,7 @@ export function UploadProgress({ photoUploadProgress, uploadError, clearError, + cancelUploads, }) { const { t } = useTranslation(); const theme = useTheme(); @@ -42,6 +51,10 @@ export function UploadProgress({ return completed / total; }, [photoUploadProgress]); + const handleCancelUploads = () => { + cancelUploads(); + }; + return ( @@ -95,6 +108,9 @@ export function UploadProgress({ ))} + diff --git a/redux/photos/photos.actions.js b/redux/photos/photos.actions.js index 0995245..9d71e37 100644 --- a/redux/photos/photos.actions.js +++ b/redux/photos/photos.actions.js @@ -53,3 +53,17 @@ export const clearUploadError = () => ({ type: PhotosActionTypes.CLEAR_UPLOAD_ERROR, }); + +export const addUploadCancelTask = (payload) => ({ + type: PhotosActionTypes.ADD_UPLOAD_CANCEL_TASK, + payload, +}); + +export const removeUploadCancelTask = (payload) => ({ + type: PhotosActionTypes.REMOVE_UPLOAD_CANCEL_TASK, + payload, +}); + +export const cancelUploads = () => ({ + type: PhotosActionTypes.CANCEL_UPLOADS, +}); \ No newline at end of file diff --git a/redux/photos/photos.reducer.js b/redux/photos/photos.reducer.js index 7f434d2..52b91e3 100644 --- a/redux/photos/photos.reducer.js +++ b/redux/photos/photos.reducer.js @@ -5,7 +5,9 @@ const INITIAL_STATE = { uploadInProgress: true, uploadError: null, jobid: null, - progress: {} + progress: {}, + cancelTasks: {}, + cancelTriggered: false, }; const photosReducer = (state = INITIAL_STATE, action) => { @@ -17,7 +19,9 @@ const photosReducer = (state = INITIAL_STATE, action) => { jobid: action.payload.jobid, uploadInProgress: true, uploadError: null, - progress: action.payload.progress || {} + progress: action.payload.progress || {}, + cancelTasks: {}, + cancelTriggered: false, }; case PhotosActionTypes.MEDIA_UPLOAD_FAILURE: return { @@ -31,9 +35,12 @@ const photosReducer = (state = INITIAL_STATE, action) => { progress: { ...state.progress, [action.payload.assetId]: { ...state.progress[action.payload.assetId], ...action.payload } } }; case PhotosActionTypes.MEDIA_UPLOAD_SUCCESS_ONE: + const { [action.payload.assetId]: _, ...remainingTasks } = state.cancelTasks; + return { ...state, - progress: { ...state.progress, [action.payload.assetId]: { ...state.progress[action.payload.assetId], progress: 100, status: 'completed', endTime: new Date() } } + progress: { ...state.progress, [action.payload.assetId]: { ...state.progress[action.payload.assetId], progress: 100, status: 'completed', endTime: new Date() } }, + cancelTasks: remainingTasks }; case PhotosActionTypes.MEDIA_UPLOAD_PROGRESS_UPDATE_BULK: return { @@ -46,13 +53,35 @@ const photosReducer = (state = INITIAL_STATE, action) => { uploadInProgress: false, uploadError: null, photos: [], - progress: {} + progress: {}, + cancelTasks: {} }; case PhotosActionTypes.CLEAR_UPLOAD_ERROR: return { ...state, - photos: [], progress: {}, + photos: [], + progress: {}, uploadError: null, + cancelTasks: {}, + }; + case PhotosActionTypes.ADD_UPLOAD_CANCEL_TASK: + return { + ...state, + cancelTasks: { + ...state.cancelTasks, + [action.payload.assetId]: action.payload.cancelTask, + }, + }; + case PhotosActionTypes.REMOVE_UPLOAD_CANCEL_TASK: + const { [action.payload.assetId]: _2, ...remainingTasks2 } = state.cancelTasks; //2 added for scoped variable conflict. + return { + ...state, + cancelTasks: remainingTasks2, + }; + case PhotosActionTypes.CANCEL_UPLOADS: + return { + ...state, + cancelTriggered: true, }; default: return state; diff --git a/redux/photos/photos.sagas.js b/redux/photos/photos.sagas.js index ae8c9de..1f45643 100644 --- a/redux/photos/photos.sagas.js +++ b/redux/photos/photos.sagas.js @@ -1,5 +1,6 @@ import axios from "axios"; import Constants from "expo-constants"; +import * as FileSystem from "expo-file-system/legacy"; import * as ImagePicker from "expo-image-picker"; import * as MediaLibrary from "expo-media-library"; import _ from 'lodash'; @@ -15,6 +16,7 @@ import { selectDeleteAfterUpload } from "../app/app.selectors"; import { store } from "../store"; import { selectBodyshop, selectCurrentUser } from "../user/user.selectors"; import { + addUploadCancelTask, deleteMediaSuccess, mediaUploadCompleted, mediaUploadFailure, @@ -84,7 +86,7 @@ export function* openImagePickerAction({ payload: jobid }) { yield put(mediaUploadStart({ photos: result.assets, jobid, progress: _.keyBy(result.assets, 'assetId') })); } } catch (error) { - // console.log("Saga Error: open Picker", error); + console.log("Saga Error: open Picker", error); } } @@ -119,13 +121,17 @@ export function* mediaUploadStartAction({ payload: { photos, jobid } }) { } // Process each batch sequentially, but photos within batch concurrently for (const batch of batches) { - const uploadTasks = batch.map((photo, index) => - call(uploadSinglePhoto, photo, bodyshop, index, jobid) + const isCancelTriggered = yield select((state) => state.photos.cancelTriggered ); - // Wait for current batch to complete before starting next batch - yield all(uploadTasks); - // Small delay between batches to prevent overwhelming the server - yield delay(100); + if (!isCancelTriggered) { + const uploadTasks = batch.map((photo, index) => + call(uploadSinglePhoto, photo, bodyshop, index, jobid) + ); + // Wait for current batch to complete before starting next batch + yield all(uploadTasks); + // Small delay between batches to prevent overwhelming the server + yield delay(100); + } } } @@ -260,39 +266,53 @@ function* uploadToImageProxy(photo, photoBlob, extension, key, bodyshop, jobid) let uploadResult try { - uploadResult = yield new Promise((resolve, reject) => { - console.log("Starting XHR") - const xhr = new XMLHttpRequest(); - xhr.upload.onprogress = (e) => { - console.log("Upload Progress:", e.loaded, e.total); - store.dispatch({ ...photo, progress: e.loaded / e.total, loaded: e.loaded }); - put(mediaUploadProgressOne({ ...photo, progress: e.loaded / e.total, loaded: e.loaded })); + // const s3PutResponse = yield call(cleanAxios.put, + // preSignedUploadUrlToS3, + // photoBlob, + // { + // headers: { + // "Content-Type": photoBlob.type + // }, + // onUploadProgress: (e) => { + // const progress = e.loaded / e.total; + // console.log("Event Progress", e) + // put(mediaUploadProgressOne({ ...photo, progress, loaded: e.loaded })); + // }, + // } + // ); + + const task = FileSystem.createUploadTask( + preSignedUploadUrlToS3, + photo.uri, + { + //fieldName: FIELD_NAME_OF_THE_FILE_IN_REQUEST, + httpMethod: "PUT", + uploadType: FileSystem.FileSystemUploadType.BINARY_CONTENT, + mimeType: photoBlob.type, + headers: {}, + //parameters: {...OTHER PARAMS IN REQUEST}, + }, + (progressData) => { + const sent = progressData.totalBytesSent; + const total = progressData.totalBytesExpectedToSend; + const progress = sent / total; + console.log(progress, sent) + store.dispatch(mediaUploadProgressOne({ ...photo, progress, loaded: sent })); + // onUpload(Number(progress.toFixed(2)) * 100); + }, + ); + + yield put(addUploadCancelTask({ assetId: photo.assetId, cancelTask: task.cancelAsync })); + uploadResult = yield task.uploadAsync(); - }; - xhr.open("PUT", preSignedUploadUrlToS3); - xhr.setRequestHeader("Content-Type", photoBlob.type); - xhr.onload = () => { - if (xhr.status === 200) { - console.log("XHR Done. Resolve promise.") - resolve(true); - } else { - reject(new Error(`Upload failed: ${xhr.statusText}`)); - } - }; - xhr.onerror = (req, event) => { - reject(new Error("Network error")); - }; - console.log("Sending XHR") - xhr.send(photoBlob); - }); } catch (error) { console.log("Error uploading to S3", error.message, error.stack); throw error; } - if (uploadResult) { + if (uploadResult.status === 200) { //Create doc record. const uploaded_by = yield select(selectCurrentUser); @@ -328,26 +348,34 @@ function* uploadToImageProxy(photo, photoBlob, extension, key, bodyshop, jobid) }, })); console.log("Upload and record creation successful for", photo.uri); + } else { + console.log("Error uploading to Cloud", uploadResult); + throw new Error(`Cloud upload failed: ${uploadResult}`); } } catch (error) { - console.log("Error uploading to image proxy", JSON.stringify(error)); - throw new Error(`Image proxy upload failed: ${error.message}`); + console.log("Error uploading to Cloud", JSON.stringify(error)); + throw new Error(`Cloud upload failed: ${error.message}`); } } // Handle cancellation of uploads function* onCancelUpload() { - yield takeEvery(PhotosActionTypes.CANCEL_UPLOAD, cancelUploadAction); + yield takeEvery(PhotosActionTypes.CANCEL_UPLOADS, cancelUploadAction); } -function* cancelUploadAction({ payload: photoId }) { - // const task = uploadTasks.get(photoId); - // if (task) { - // yield cancel(task); - // uploadTasks.delete(photoId); - // yield put(mediaUploadFailure({ photoId, error: 'Upload cancelled' })); - // } +function* cancelUploadAction() { + const cancelTasks = yield select((state) => state.photos.cancelTasks); + try { + const tasksToCancel = Object.values(cancelTasks); + for (const cancelTask of tasksToCancel) { + console.log("*** ~ cancelUploadAction ~ cancelTask:", cancelTask); + cancelTask(); + } + yield put({ type: PhotosActionTypes.CLEAR_UPLOAD_ERROR }); + } catch (error) { + console.log("Error cancelling upload", error); + } } function* onMediaUploadCompleted() { @@ -414,7 +442,7 @@ export function* photosSagas() { call(onOpenImagePicker), call(onMediaUploadStart), call(onMediaUploadCompleted), - call(onMediaUploadFailure) - //call(onCancelUpload) + call(onMediaUploadFailure), + call(onCancelUpload) ]); } \ No newline at end of file diff --git a/redux/photos/photos.types.js b/redux/photos/photos.types.js index 6355af5..1b936ed 100644 --- a/redux/photos/photos.types.js +++ b/redux/photos/photos.types.js @@ -10,5 +10,8 @@ const PhotosActionTypes = { DELETE_MEDIA_SUCCESS: "DELETE_MEDIA_SUCCESS", DELETE_MEDIA_FAILURE: "DELETE_MEDIA_FAILURE", CLEAR_UPLOAD_ERROR: "CLEAR_UPLOAD_ERROR", + ADD_UPLOAD_CANCEL_TASK: "ADD_UPLOAD_CANCEL_TASK", + REMOVE_UPLOAD_CANCEL_TASK: "REMOVE_UPLOAD_CANCEL_TASK", + CANCEL_UPLOADS: "CANCEL_UPLOADS", }; export default PhotosActionTypes;