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'; import moment from 'moment'; import { Alert, Platform } from "react-native"; import { all, call, delay, put, select, takeEvery, takeLatest } from "redux-saga/effects"; import env from "../../env"; import { client } from '../../graphql/client'; import { INSERT_NEW_DOCUMENT } from "../../graphql/documents.queries"; import { axiosAuthInterceptorId } from "../../util/CleanAxios"; import { fetchImageFromUri, replaceAccents } from '../../util/uploadUtils'; import { selectDeleteAfterUpload } from "../app/app.selectors"; import { store } from "../store"; import { selectBodyshop, selectCurrentUser } from "../user/user.selectors"; import { addUploadCancelTask, deleteMediaSuccess, mediaUploadCompleted, mediaUploadFailure, mediaUploadProgressBulk, mediaUploadProgressOne, mediaUploadStart, mediaUploadSuccessOne } from "./photos.actions"; import PhotosActionTypes from "./photos.types"; axios.interceptors.request.use( function (config) { config.metadata = { startTime: new Date() }; return config; }, function (error) { return Promise.reject(error); } ); axios.interceptors.response.use( function (response) { response.config.metadata.endTime = new Date(); response.duration = response.config.metadata.endTime - response.config.metadata.startTime; return response; }, function (error) { error.config.metadata.endTime = new Date(); error.duration = error.config.metadata.endTime - error.config.metadata.startTime; return Promise.reject(error); } ); //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({ payload: jobid }) { try { if (Constants.platform.ios) { const cameraRollStatus = yield ImagePicker.requestMediaLibraryPermissionsAsync(); const cameraStatus = yield ImagePicker.requestCameraPermissionsAsync(); if ( cameraRollStatus.status !== "granted" || cameraStatus.status !== "granted" ) { alert("Photo and Camera permissions have not been granted. Please open the settings app and allow these permissions to upload photos."); return; } } let result = yield ImagePicker.launchImageLibraryAsync({ mediaTypes: ["images", "videos"], aspect: [4, 3], quality: 1, allowsMultipleSelection: true, exif: true, }); if (!(result.canceled)) { yield put(mediaUploadStart({ photos: result.assets, jobid, progress: _.keyBy(result.assets, 'assetId') })); } } catch (error) { console.log("Saga Error: open Picker", error); } } export function* onMediaUploadStart() { yield takeLatest(PhotosActionTypes.MEDIA_UPLOAD_START, mediaUploadStartAction); } export function* mediaUploadStartAction({ payload: { photos, jobid } }) { try { const bodyshop = yield select(selectBodyshop); if (bodyshop.uselocalmediaserver) { yield call(uploadToLocalMediaServer, photos, bodyshop, jobid); } else { //Check to see if the job has enough space before uploading. // const hasEnoughSpace = yield call(checkJobSpace, jobid, photos, bodyshop); // if (!hasEnoughSpace) { // alert(i18n.t("mediabrowser.labels.storageexceeded")); // yield put(mediaUploadFailure(i18n.t("mediabrowser.labels.storageexceeded"))); // return; // } // 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 isCancelTriggered = yield select((state) => state.photos.cancelTriggered ); 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); } } } yield put(mediaUploadCompleted(photos)); } catch (error) { console.log("Saga Error: upload start", error, error.stack); yield put(mediaUploadFailure(error.message)); } } // function* checkJobSpace(jobid, photos, bodyshop) { // try { // //TODO - This function has not been validated and saw issues during testing. // //It was not fixed as we will not be enabling it. // const totalOfUploads = photos.reduce((acc, val) => { // //Get the size of the file based on URI. // if (val.fileSize) { // return acc + val.fileSize; // } else { // alert("Asset is missing filesize. Cannot verify job space."); // return acc // } // }, 0); // if (jobid !== "temp") { // const queryData = yield call(client.query, { // query: GET_DOC_SIZE_TOTALS, // fetchPolicy: "network-only", // variables: { // jobId: jobid, // }, // }); // if ( // bodyshop.jobsizelimit - // queryData?.data?.documents_aggregate.aggregate.sum.size <= // totalOfUploads // ) { // //No more room... abandon ship. // return false; // } // } // return true; // } // catch (error) { // console.log("Error checking job space", error, error.stack); // return false; // } // } 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. 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}` yield call(uploadToImageProxy, photo, photoBlob, extension, key, bodyshop, jobid); yield put(mediaUploadSuccessOne(photo)); } catch (error) { console.log(`Upload failed for photo ${photo.uri}:`, error); yield put(mediaUploadFailure({ ...photo, status: "error", error: error.message })); } } function* uploadToLocalMediaServer(photos, bodyshop, jobid) { try { const formData = new FormData(); formData.append("jobid", jobid || "temporary"); for (const file of photos) { formData.append("file", { uri: file.uri, type: file.mimeType, name: file.fileName, }); } formData.append("skip_thumbnail", true); try { const startTime = new Date(); const handleUploadProgress = (e) => { console.log("progress", e) store.dispatch(mediaUploadProgressBulk({ progress: e.loaded / e.total, loaded: e.loaded, total: e.total, startTime })); }; const controller = new AbortController(); yield put(addUploadCancelTask({ assetId: "Bulk", cancelTask: () => controller.abort() })); const imexMediaServerResponse = yield call(axios.post, `${bodyshop.localmediaserverhttp}/jobs/upload`, formData, { headers: { "Content-Type": "multipart/form-data", ims_token: bodyshop.localmediatoken, }, onUploadProgress: handleUploadProgress, signal: controller.signal } ); if (imexMediaServerResponse.status !== 200) { console.log("Error uploading documents:", JSON.stringify(imexMediaServerResponse, null, 2)); } else { console.log("Local media server upload complete:", imexMediaServerResponse.data); } } catch (error) { console.log("Error uploading documents:", error.message, JSON.stringify(error, null, 2)); throw error; } } catch (error) { console.log("Uncaught error", error); throw error; } } function* uploadToImageProxy(photo, photoBlob, extension, key, bodyshop, jobid) { try { yield put(mediaUploadProgressOne({ ...photo, startTime: new Date(), 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]; let uploadResult try { // 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; 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(); } catch (error) { console.log("Error uploading to S3", error.message, error.stack); throw error; } if (uploadResult.status === 200) { //Create doc record. const uploaded_by = yield select(selectCurrentUser); let pictureMoment = null; try { if (photo.exif) { const [date, time] = photo.exif?.DateTime?.split(' ') || []; const [year, month, day] = date ? date.split(':') : []; const [hours, minutes, seconds] = time ? time.split(':') : []; pictureMoment = moment(`${year}-${month}-${day}T${hours}:${minutes}:${seconds}`); } } catch (exifError) { console.log('Error parsing exif date. Unable to set created date.', exifError); } yield call(client.mutate, ({ mutation: INSERT_NEW_DOCUMENT, variables: { docInput: [ { ...(jobid ? { jobid: jobid } : {}), uploaded_by: uploaded_by.email, key: s3Key, type: photoBlob.type, extension: extension, bodyshopid: bodyshop.id, size: photoBlob.size, ...(pictureMoment && pictureMoment.isValid() ? { takenat: pictureMoment } : {}), }, ], }, })); } else { console.log("Error uploading to Cloud", uploadResult); throw new Error(`Cloud upload failed: ${uploadResult}`); } } catch (error) { 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_UPLOADS, cancelUploadAction); } 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() { yield takeLatest(PhotosActionTypes.MEDIA_UPLOAD_COMPLETED, mediaUploadCompletedAction); } function* mediaUploadCompletedAction({ payload: photos }) { //Check if this should be getting deleted const deletedAfterUpload = yield select(selectDeleteAfterUpload); if ( !deletedAfterUpload ) { //Nothing to do here. return; } try { const progress = yield select((state) => state.photos.progress); // Handle the completion of media uploads const filesToDelete = Object.keys(progress).filter((key) => progress[key].status === 'completed').map((key) => progress[key]); if (Platform.OS === "android") { //Create a new asset with the first file to delete. // console.log('Trying new delete.'); yield MediaLibrary.getPermissionsAsync(false); const album = yield call(MediaLibrary.createAlbumAsync, "ImEX Mobile Deleted", filesToDelete.pop(), false ); //Move the rest. if (filesToDelete.length > 0) { const moveResult = yield call(MediaLibrary.addAssetsToAlbumAsync, filesToDelete, album, false ); } yield call(MediaLibrary.deleteAlbumsAsync, album); } else { yield call(MediaLibrary.deleteAssetsAsync, filesToDelete.map(f => f.assetId)); } yield put(deleteMediaSuccess(photos)); } catch (error) { console.log("Saga Error: upload start", error, error.stack); yield put(mediaUploadFailure(error.message)); } } // Handle cancellation of uploads function* onMediaUploadFailure() { yield takeEvery(PhotosActionTypes.MEDIA_UPLOAD_FAILURE, mediaUploadFailureAction); } function* mediaUploadFailureAction({ payload: errorMessage }) { Alert.alert("Upload Error", `An error occurred during upload: ${errorMessage}`); } export function* photosSagas() { yield all([ call(onOpenImagePicker), call(onMediaUploadStart), call(onMediaUploadCompleted), call(onMediaUploadFailure), call(onCancelUpload) ]); }