Uploading file with corruption.
This commit is contained in:
@@ -10,6 +10,7 @@ import { createStructuredSelector } from "reselect";
|
||||
import { QUERY_ALL_ACTIVE_JOBS } from "../../graphql/jobs.queries";
|
||||
import { selectBodyshop } from "../../redux/user/user.selectors";
|
||||
//import ErrorDisplay from "../error-display/error-display.component";
|
||||
import UploadProgress from "../upload-progress/upload-progress";
|
||||
import JobListItem from "./job-list-item";
|
||||
|
||||
const mapStateToProps = createStructuredSelector({
|
||||
@@ -67,6 +68,7 @@ export function JobListComponent({ bodyshop }) {
|
||||
|
||||
return (
|
||||
<SafeAreaView style={{ flex: 1 }}>
|
||||
<UploadProgress />
|
||||
<FlatList
|
||||
refreshControl={
|
||||
<RefreshControl refreshing={loading} onRefresh={onRefresh} />
|
||||
|
||||
120
components/upload-progress/upload-progress.jsx
Normal file
120
components/upload-progress/upload-progress.jsx
Normal file
@@ -0,0 +1,120 @@
|
||||
import { StyleSheet, Text, View } from "react-native";
|
||||
import { connect } from "react-redux";
|
||||
import { createStructuredSelector } from "reselect";
|
||||
import { selectPhotos } from "../../redux/photos/photos.selectors";
|
||||
|
||||
const mapStateToProps = createStructuredSelector({
|
||||
photos: selectPhotos,
|
||||
});
|
||||
|
||||
export default connect(mapStateToProps, null)(UploadProgress);
|
||||
|
||||
export function UploadProgress({ photos }) {
|
||||
if (photos?.length === 0) return null;
|
||||
return (
|
||||
<View style={styles.modalContainer}>
|
||||
<Text>Upload Progress.</Text>
|
||||
<Text>{JSON.stringify(photos)}</Text>
|
||||
{/*
|
||||
<View style={styles.modal}>
|
||||
{Object.keys(progress.files).map((key) => (
|
||||
<View key={key} style={styles.progressItem}>
|
||||
<Text style={styles.progressText}>
|
||||
{progress.files[key].filename}
|
||||
</Text>
|
||||
<View style={styles.progressBarContainer}>
|
||||
<ProgressBar
|
||||
progress={progress.files[key].percent}
|
||||
style={styles.progress}
|
||||
color={progress.files[key].percent === 1 ? "green" : "blue"}
|
||||
/>
|
||||
<View
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<Text>{`${formatBytes(
|
||||
progress.files[key].loaded /
|
||||
(((progress.files[key].uploadEnd || new Date()) -
|
||||
progress.files[key].uploadStart) /
|
||||
1000)
|
||||
)}/sec`}</Text>
|
||||
{progress.files[key].percent === 1 && (
|
||||
<>
|
||||
<ActivityIndicator style={{ marginLeft: 12 }} />
|
||||
<Text style={{ marginLeft: 4 }}>Processing...</Text>
|
||||
</>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
))}
|
||||
<View style={styles.centeredView}>
|
||||
{progress.statusText ? (
|
||||
<>
|
||||
<ActivityIndicator style={{ marginLeft: 12 }} />
|
||||
<Text style={{ marginLeft: 4 }}>{progress.statusText}</Text>
|
||||
<Divider />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Text>{`${progress.totalFilesCompleted} of ${progress.totalFiles} uploaded.`}</Text>
|
||||
<Text>{`${formatBytes(progress.totalUploaded)} of ${formatBytes(
|
||||
progress.totalToUpload
|
||||
)} uploaded.`}</Text>
|
||||
</>
|
||||
)}
|
||||
|
||||
</View>
|
||||
</View>
|
||||
*/}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
const styles = StyleSheet.create({
|
||||
modalContainer: {
|
||||
display: "flex",
|
||||
flex: 1,
|
||||
justifyContent: "center",
|
||||
},
|
||||
modal: {
|
||||
//flex: 1,
|
||||
display: "flex",
|
||||
marginLeft: 20,
|
||||
marginRight: 20,
|
||||
backgroundColor: "white",
|
||||
borderRadius: 20,
|
||||
padding: 18,
|
||||
shadowColor: "#000",
|
||||
shadowOffset: {
|
||||
width: 0,
|
||||
height: 2,
|
||||
},
|
||||
shadowOpacity: 0.25,
|
||||
shadowRadius: 4,
|
||||
elevation: 5,
|
||||
},
|
||||
centeredView: {
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
marginTop: 22,
|
||||
},
|
||||
progressItem: {
|
||||
display: "flex",
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
marginBottom: 12,
|
||||
marginLeft: 12,
|
||||
marginRight: 12,
|
||||
},
|
||||
progressText: {
|
||||
flex: 1,
|
||||
},
|
||||
progressBarContainer: {
|
||||
flex: 3,
|
||||
marginLeft: 12,
|
||||
marginRight: 12,
|
||||
},
|
||||
});
|
||||
@@ -8,4 +8,14 @@ export const openImagePicker = (jobid) => ({
|
||||
export const mediaUploadStart = (imagePickerResult) => ({
|
||||
type: PhotosActionTypes.MEDIA_UPLOAD_START,
|
||||
payload: imagePickerResult,
|
||||
})
|
||||
})
|
||||
|
||||
export const mediaUploadProgressOne = (progressUpdate) => ({
|
||||
type: PhotosActionTypes.MEDIA_UPLOAD_PROGRESS_UPDATE_ONE,
|
||||
payload: progressUpdate,
|
||||
});
|
||||
|
||||
export const mediaUploadFailure = (error) => ({
|
||||
type: PhotosActionTypes.MEDIA_UPLOAD_FAILURE,
|
||||
payload: error,
|
||||
});
|
||||
@@ -4,6 +4,8 @@ const INITIAL_STATE = {
|
||||
photos: [],
|
||||
uploadInProgress: false,
|
||||
uploadError: null,
|
||||
jobid: null,
|
||||
progress: {}
|
||||
};
|
||||
|
||||
const photosReducer = (state = INITIAL_STATE, action) => {
|
||||
@@ -11,9 +13,22 @@ const photosReducer = (state = INITIAL_STATE, action) => {
|
||||
case PhotosActionTypes.MEDIA_UPLOAD_START:
|
||||
return {
|
||||
...state,
|
||||
photos: action.payload,
|
||||
photos: action.payload.photos,
|
||||
jobid: action.payload.jobid,
|
||||
uploadInProgress: true,
|
||||
uploadError: null,
|
||||
progress: {}
|
||||
};
|
||||
case PhotosActionTypes.MEDIA_UPLOAD_FAILURE:
|
||||
return {
|
||||
...state,
|
||||
uploadInProgress: false,
|
||||
uploadError: action.payload,
|
||||
};
|
||||
case PhotosActionTypes.MEDIA_UPLOAD_PROGRESS_UPDATE_ONE:
|
||||
return {
|
||||
...state,
|
||||
progress: { ...state.progress, [action.payload.assetId]: { ...state.progress[action.payload.assetId], ...action.payload } }
|
||||
};
|
||||
|
||||
default:
|
||||
|
||||
@@ -1,14 +1,31 @@
|
||||
import axios from "axios";
|
||||
import Constants from "expo-constants";
|
||||
import * as ImagePicker from "expo-image-picker";
|
||||
import { all, call, put, select, takeLatest } from "redux-saga/effects";
|
||||
import { END, eventChannel } from 'redux-saga';
|
||||
import { all, call, delay, fork, put, select, take, takeEvery, takeLatest } from "redux-saga/effects";
|
||||
import env from "../../env";
|
||||
import { axiosAuthInterceptorId } from "../../util/CleanAxios";
|
||||
import { fetchImageFromUri, replaceAccents } from '../../util/uploadUtils';
|
||||
import { selectBodyshop } from "../user/user.selectors";
|
||||
import { mediaUploadStart } from "./photos.actions";
|
||||
import {
|
||||
mediaUploadComplete,
|
||||
mediaUploadFailure,
|
||||
mediaUploadProgressOne,
|
||||
mediaUploadStart
|
||||
} from "./photos.actions";
|
||||
import PhotosActionTypes from "./photos.types";
|
||||
|
||||
//Required to prevent headers from getting set and rejected from Cloudinary.
|
||||
let cleanAxios = axios.create();
|
||||
cleanAxios.interceptors.request.eject(axiosAuthInterceptorId);
|
||||
|
||||
|
||||
|
||||
export function* onOpenImagePicker() {
|
||||
yield takeLatest(PhotosActionTypes.OPEN_IMAGE_PICKER, openImagePickerAction);
|
||||
}
|
||||
export function* openImagePickerAction(jobid) {
|
||||
|
||||
export function* openImagePickerAction({ payload: jobid }) {
|
||||
try {
|
||||
if (Constants.platform.ios) {
|
||||
const cameraRollStatus =
|
||||
@@ -29,37 +46,214 @@ export function* openImagePickerAction(jobid) {
|
||||
allowsMultipleSelection: true,
|
||||
});
|
||||
if (!(result.canceled)) {
|
||||
yield put(mediaUploadStart(result.assets));
|
||||
yield put(mediaUploadStart({ photos: result.assets, jobid }));
|
||||
}
|
||||
} catch (error) {
|
||||
console.log("Saga Error: open Picker", error);
|
||||
}
|
||||
}
|
||||
|
||||
export function* onMediaUploadStart() {
|
||||
yield takeLatest(PhotosActionTypes.MEDIA_UPLOAD_START, mediaUploadStartAction);
|
||||
}
|
||||
export function* mediaUploadStartAction(photos) {
|
||||
|
||||
export function* mediaUploadStartAction({ payload: { photos, jobid } }) {
|
||||
try {
|
||||
console.log("Got to the Photo Saga.", photos);
|
||||
console.log("Starting upload for", photos.length, "photos");
|
||||
|
||||
console.log("upload", photos)
|
||||
|
||||
//get bodyshop state
|
||||
const bodyshop = yield select(selectBodyshop);
|
||||
if (bodyshop.uselocalmediaserver) {
|
||||
//upload to LMS
|
||||
} else {
|
||||
//Upload to img proxy
|
||||
}
|
||||
|
||||
// Process photos in batches to avoid overwhelming the system
|
||||
const batchSize = 3; // Upload 3 photos concurrently
|
||||
const batches = [];
|
||||
|
||||
for (let i = 0; i < photos.length; i += batchSize) {
|
||||
batches.push(photos.slice(i, i + batchSize));
|
||||
}
|
||||
// Process each batch sequentially, but photos within batch concurrently
|
||||
for (const batch of batches) {
|
||||
const uploadTasks = batch.map((photo, index) =>
|
||||
fork(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);
|
||||
}
|
||||
yield put(mediaUploadComplete());
|
||||
|
||||
} catch (error) {
|
||||
console.log("Saga Error: open upload", error);
|
||||
console.log("Saga Error: upload start", error);
|
||||
yield put(mediaUploadFailure(error.message));
|
||||
}
|
||||
}
|
||||
|
||||
function* uploadSinglePhoto(photo, bodyshop, index, jobid) {
|
||||
try {
|
||||
yield put(mediaUploadProgressOne({ ...photo, status: 'starting', progress: 0 }));
|
||||
|
||||
const photoBlob = yield fetchImageFromUri(photo.uri); //has a name, and type.
|
||||
console.log("*** ~ uploadSinglePhoto ~ photoBlob.name.:", photoBlob.data.name);
|
||||
const extension = photoBlob.data?.name?.split('.').pop();
|
||||
|
||||
const key = `${bodyshop.id}/${jobid}/${replaceAccents(
|
||||
photoBlob.data.name
|
||||
).replace(/[^A-Z0-9]+/gi, "_")}-${new Date().getTime()}.${extension}`
|
||||
|
||||
if (bodyshop.uselocalmediaserver) {
|
||||
yield call(uploadToLocalMediaServer, photo, photoBlob, key, bodyshop, jobid);
|
||||
} else {
|
||||
yield call(uploadToImageProxy, photo, photoBlob, key, bodyshop, jobid);
|
||||
}
|
||||
|
||||
// yield put(mediaUploadSuccess({ photoId, photo }));
|
||||
|
||||
} catch (error) {
|
||||
console.log(`Upload failed for photo ${photo.assetId}:`, error);
|
||||
yield put(mediaUploadFailure({ ...photo, status: "error", error: error.message }));
|
||||
}
|
||||
}
|
||||
|
||||
function* uploadToLocalMediaServer(photo, key) {
|
||||
try {
|
||||
// yield put(mediaUploadProgress({ photoId, status: 'uploading', progress: 25 }));
|
||||
|
||||
// const formData = new FormData();
|
||||
// formData.append('file', {
|
||||
// uri: photo.uri,
|
||||
// type: photo.type || 'image/jpeg',
|
||||
// name: photo.fileName || `photo_${Date.now()}.jpg`,
|
||||
// });
|
||||
|
||||
// yield put(mediaUploadProgress({ photoId, status: 'uploading', progress: 50 }));
|
||||
|
||||
// const response = yield call(fetch, 'YOUR_LOCAL_MEDIA_SERVER_ENDPOINT', {
|
||||
// method: 'POST',
|
||||
// body: formData,
|
||||
// headers: {
|
||||
// 'Content-Type': 'multipart/form-data',
|
||||
// },
|
||||
// });
|
||||
|
||||
// yield put(mediaUploadProgress({ photoId, status: 'uploading', progress: 75 }));
|
||||
|
||||
// if (!response.ok) {
|
||||
// throw new Error(`Upload failed: ${response.status}`);
|
||||
// }
|
||||
|
||||
// const result = yield call([response, 'json']);
|
||||
// yield put(mediaUploadProgress({ photoId, status: 'completed', progress: 100 }));
|
||||
|
||||
// return result;
|
||||
|
||||
} catch (error) {
|
||||
throw new Error(`Local media server upload failed: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
function* uploadToImageProxy(photo, photoBlob, key, bodyshop, jobid) {
|
||||
try {
|
||||
yield put(mediaUploadProgressOne({ ...photo, status: 'uploading', }));
|
||||
//Get the signed url allowing us to PUT to S3.
|
||||
const signedURLResponse = yield call(axios.post,
|
||||
`${env.API_URL}/media/imgproxy/sign`,
|
||||
{
|
||||
filenames: [key],
|
||||
bodyshopid: bodyshop.id,
|
||||
jobid,
|
||||
}
|
||||
);
|
||||
|
||||
if (signedURLResponse.status !== 200) {
|
||||
console.log("Error Getting Signed URL", signedURLResponse.statusText);
|
||||
throw new Error(`Error getting signed URL : ${signedURLResponse.statusText}`);
|
||||
}
|
||||
const { presignedUrl: preSignedUploadUrlToS3, key: s3Key } =
|
||||
signedURLResponse.data.signedUrls[0];
|
||||
console.log("*** ~ uploadToImageProxy ~ s3Key:", s3Key);
|
||||
console.log("*** ~ uploadToImageProxy ~ presignedUrl:", preSignedUploadUrlToS3);
|
||||
|
||||
// Create an eventChannel to bridge imperative upload progress events into the saga world
|
||||
const uploadChannel = yield call(createAxiosUploadChannel, {
|
||||
url: preSignedUploadUrlToS3,
|
||||
data: photoBlob,
|
||||
method: 'put',
|
||||
headers: {
|
||||
'Content-Type': photoBlob.type,
|
||||
'Content-Length': photoBlob.size,
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
while (true) {
|
||||
const { progress, error, success } = yield take(uploadChannel);
|
||||
if (progress != null) {
|
||||
yield put(mediaUploadProgressOne({ ...photo, progress }));
|
||||
} else if (error) {
|
||||
throw error;
|
||||
} else if (success) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
uploadChannel.close();
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.log("Error uploading to image proxy", JSON.stringify(error));
|
||||
throw new Error(`Image proxy upload failed: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Helper: turn axios upload progress into an eventChannel so we can yield put from saga
|
||||
function createAxiosUploadChannel({ url, data, method = 'put', headers }) {
|
||||
return eventChannel(emitter => {
|
||||
const controller = new AbortController();
|
||||
cleanAxios({
|
||||
method,
|
||||
url,
|
||||
data,
|
||||
headers,
|
||||
signal: controller.signal,
|
||||
// NOTE: cannot yield here, so we emit events and handle them in saga loop
|
||||
onUploadProgress: (e) => {
|
||||
if (e && e.total) {
|
||||
emitter({ progress: e.loaded / e.total });
|
||||
}
|
||||
}
|
||||
}).then(() => {
|
||||
emitter({ success: true });
|
||||
emitter(END);
|
||||
}).catch(err => {
|
||||
emitter({ error: err });
|
||||
emitter(END);
|
||||
});
|
||||
|
||||
// Unsubscribe / cancellation logic
|
||||
return () => {
|
||||
controller.abort()
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// Handle cancellation of uploads
|
||||
export function* onCancelUpload() {
|
||||
yield takeEvery(PhotosActionTypes.CANCEL_UPLOAD, 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' }));
|
||||
// }
|
||||
}
|
||||
|
||||
export function* photosSagas() {
|
||||
yield all([call(onOpenImagePicker), call(onMediaUploadStart)]);
|
||||
}
|
||||
yield all([
|
||||
call(onOpenImagePicker),
|
||||
call(onMediaUploadStart),
|
||||
//call(onCancelUpload)
|
||||
]);
|
||||
}
|
||||
@@ -3,6 +3,6 @@ const PhotosActionTypes = {
|
||||
MEDIA_UPLOAD_START: "MEDIA_UPLOAD_START",
|
||||
MEDIUA_UPLOAD_SUCCESS: "MEDIA_UPLOAD_SUCCESS",
|
||||
MEDIA_UPLOAD_FAILURE: "MEDIA_UPLOAD_FAILURE",
|
||||
MEDIA_UPLOAD_PROGRESS_UPDATE: "MEDIA_UPLOAD_PROGRESS_UPDATE",
|
||||
MEDIA_UPLOAD_PROGRESS_UPDATE_ONE: "MEDIA_UPLOAD_PROGRESS_UPDATE_ONE",
|
||||
};
|
||||
export default PhotosActionTypes;
|
||||
|
||||
51
util/uploadUtils.js
Normal file
51
util/uploadUtils.js
Normal file
@@ -0,0 +1,51 @@
|
||||
export function replaceAccents(str) {
|
||||
// Verifies if the String has accents and replace them
|
||||
if (str.search(/[\xC0-\xFF]/g) > -1) {
|
||||
str = str
|
||||
.replace(/[\xC0-\xC5]/g, "A")
|
||||
.replace(/[\xC6]/g, "AE")
|
||||
.replace(/[\xC7]/g, "C")
|
||||
.replace(/[\xC8-\xCB]/g, "E")
|
||||
.replace(/[\xCC-\xCF]/g, "I")
|
||||
.replace(/[\xD0]/g, "D")
|
||||
.replace(/[\xD1]/g, "N")
|
||||
.replace(/[\xD2-\xD6\xD8]/g, "O")
|
||||
.replace(/[\xD9-\xDC]/g, "U")
|
||||
.replace(/[\xDD]/g, "Y")
|
||||
.replace(/[\xDE]/g, "P")
|
||||
.replace(/[\xE0-\xE5]/g, "a")
|
||||
.replace(/[\xE6]/g, "ae")
|
||||
.replace(/[\xE7]/g, "c")
|
||||
.replace(/[\xE8-\xEB]/g, "e")
|
||||
.replace(/[\xEC-\xEF]/g, "i")
|
||||
.replace(/[\xF1]/g, "n")
|
||||
.replace(/[\xF2-\xF6\xF8]/g, "o")
|
||||
.replace(/[\xF9-\xFC]/g, "u")
|
||||
.replace(/[\xFE]/g, "p")
|
||||
.replace(/[\xFD\xFF]/g, "y");
|
||||
}
|
||||
return str;
|
||||
}
|
||||
|
||||
export function formatBytes(a, b = 2) {
|
||||
if (0 === a || !a || isNaN(a)) return "0 Bytes";
|
||||
const c = 0 > b ? 0 : b,
|
||||
d = Math.floor(Math.log(a) / Math.log(1024));
|
||||
|
||||
const parsedFloat = parseFloat((a / Math.pow(1024, d)).toFixed(c))
|
||||
if (isNaN(parsedFloat)) {
|
||||
return "0 Bytes";
|
||||
}
|
||||
return (
|
||||
parsedFloat +
|
||||
" " +
|
||||
["Bytes", "KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB"][d]
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
export async function fetchImageFromUri(uri) {
|
||||
const response = await fetch(uri);
|
||||
const blob = await response.blob();
|
||||
return blob;
|
||||
};
|
||||
Reference in New Issue
Block a user