import axios from "axios"; import Constants from "expo-constants"; import * as ImagePicker from "expo-image-picker"; import * as MediaLibrary from "expo-media-library"; import moment from 'moment'; import { all, call, delay, put, select, takeEvery, takeLatest } from "redux-saga/effects"; import env from "../../env"; import { client } from '../../graphql/client'; import { GET_DOC_SIZE_TOTALS, INSERT_NEW_DOCUMENT } from "../../graphql/documents.queries"; import { axiosAuthInterceptorId } from "../../util/CleanAxios"; import { fetchImageFromUri, replaceAccents } from '../../util/uploadUtils'; import { selectBodyshop, selectCurrentUser } from "../user/user.selectors"; import { deleteMediaSuccess, mediaUploadCompleted, mediaUploadFailure, mediaUploadProgressBulk, mediaUploadProgressOne, mediaUploadStart, mediaUploadSuccessOne } from "./photos.actions"; import i18n from "@/translations/i18n"; import { Platform } from "react-native"; import { selectDeleteAfterUpload } from "../app/app.selectors"; 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("Sorry, we need these permissions to make this work!"); 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 })); } } 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 { console.log("Starting upload for", photos.length, "photos"); 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 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); } } console.log("All uploads completed. This shouldn't fire before the uploads are done."); 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 { 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 options = { headers: { "Content-Type": "multipart/form-data", ims_token: bodyshop.localmediatoken, }, onUploadProgress: (e) => { put(mediaUploadProgressBulk({ progress: e.loaded / e.total, loaded: e.loaded, total: e.total })); }, }; const formData = new FormData(); formData.append("jobid", jobid); for (const file of photos) { formData.append("file", { uri: file.uri, type: file.mimeType, name: file.fileName, }); } formData.append("skip_thumbnail", true); try { const imexMediaServerResponse = yield call(axios.post, `${bodyshop.localmediaserverhttp}/jobs/upload`, formData, options ); 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)); } } catch (error) { console.log("Uncaught error", 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 { uploadResult = yield new Promise((resolve, reject) => { const xhr = new XMLHttpRequest(); xhr.open("PUT", preSignedUploadUrlToS3); xhr.setRequestHeader("Content-Type", photoBlob.type); xhr.upload.onprogress = (e) => { if (e.lengthComputable) { put(mediaUploadProgressOne({ ...photo, progress: e.loaded / e.total, loaded: e.loaded })); } }; xhr.onload = () => { if (xhr.status === 200) { resolve(true); } else { reject(new Error(`Upload failed: ${xhr.statusText}`)); } }; xhr.onerror = (req, event) => { reject(new Error("Network error")); }; xhr.send(photoBlob); }); } catch (error) { console.log("Error uploading to S3", error.message, error.stack); throw error; } if (uploadResult) { //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 } : {}), }, ], }, })); console.log("Upload and record creation successful for", photo.uri); } } catch (error) { console.log("Error uploading to image proxy", JSON.stringify(error)); throw new Error(`Image proxy upload failed: ${error.message}`); } } // Handle cancellation of uploads 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' })); // } } 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 { // Handle the completion of media uploads const filesToDelete = [...photos] 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)); } } export function* photosSagas() { yield all([ call(onOpenImagePicker), call(onMediaUploadStart), call(onMediaUploadCompleted) //call(onCancelUpload) ]); }