/** @odoo-module **/ /** * Image compression for file uploads on Odoo. * * Problem: On iPhone, selecting 4+ photos (5-15MB each) causes the * browser tab to crash because Odoo converts each to a base64 data URL * before uploading. 7 photos = 50-100MB of strings in memory. * * Solution: Intercept at the FileUploader level, compress each image * via Canvas BEFORE the data URL conversion. A 5MB photo becomes ~300KB. * * The FileUploader.onFileChange is completely overridden (not wrapped) * to avoid any DataTransfer API issues on iPhone Safari. */ import { AttachmentUploadService } from "@mail/core/common/attachment_upload_service"; import { FileUploader } from "@web/views/fields/file_handler"; import { patch } from "@web/core/utils/patch"; import { getDataURLFromFile } from "@web/core/utils/urls"; import { checkFileSize } from "@web/core/utils/files"; const IMAGE_TYPES = new Set([ "image/jpeg", "image/png", "image/webp", "image/bmp", "image/heic", "image/heif", ]); const MAX_DIMENSION = 1280; // Conservative for mobile memory const JPEG_QUALITY = 0.80; const SKIP_THRESHOLD = 500 * 1024; // 500KB /** * Compress an image File via Canvas API. * Returns the original file if anything fails. */ function compressImageFile(file) { return new Promise((resolve) => { try { const img = new Image(); const objectUrl = URL.createObjectURL(file); const cleanup = () => { try { URL.revokeObjectURL(objectUrl); } catch(e) {} try { img.src = ""; } catch(e) {} }; const timeout = setTimeout(() => { cleanup(); resolve(file); // Timeout fallback after 10s }, 10000); img.onload = () => { try { clearTimeout(timeout); let w = img.naturalWidth || img.width; let h = img.naturalHeight || img.height; if (w > MAX_DIMENSION || h > MAX_DIMENSION) { const ratio = Math.min(MAX_DIMENSION / w, MAX_DIMENSION / h); w = Math.round(w * ratio); h = Math.round(h * ratio); } const canvas = document.createElement("canvas"); canvas.width = w; canvas.height = h; canvas.getContext("2d").drawImage(img, 0, 0, w, h); canvas.toBlob( (blob) => { cleanup(); canvas.width = 0; canvas.height = 0; if (!blob) { resolve(file); return; } const name = file.name.replace(/\.[^.]+$/, "") + ".jpg"; resolve(new File([blob], name, { type: "image/jpeg", lastModified: file.lastModified, })); }, "image/jpeg", JPEG_QUALITY ); } catch (e) { clearTimeout(timeout); cleanup(); resolve(file); } }; img.onerror = () => { clearTimeout(timeout); cleanup(); resolve(file); }; img.src = objectUrl; } catch (e) { resolve(file); } }); } /** * Override FileUploader.onFileChange to compress images before * converting to data URLs. This prevents the massive memory spike * that crashes mobile browsers. * * We re-implement onFileChange instead of wrapping it to avoid * DataTransfer API issues on iPhone Safari. */ patch(FileUploader.prototype, { async onFileChange(ev) { const rawFiles = ev.target?.files; if (!rawFiles || !rawFiles.length) { return; } // Check if any file needs compression let hasLargeImages = false; for (const f of rawFiles) { if (IMAGE_TYPES.has(f.type) && f.size > SKIP_THRESHOLD) { hasLargeImages = true; break; } } // No large images -- use standard Odoo behavior if (!hasLargeImages) { return super.onFileChange(ev); } // Process files one at a time with compression const files = [...rawFiles].filter((f) => this.validFileType(f)); const target = ev.target; for (const file of files) { let processedFile = file; // Compress large images if (IMAGE_TYPES.has(file.type) && file.size > SKIP_THRESHOLD) { try { processedFile = await compressImageFile(file); } catch (e) { processedFile = file; // fallback to original } } // Size check if (this.props.checkSize && !checkFileSize(processedFile.size, this.notification)) { continue; } this.state.isUploading = true; try { const data = await getDataURLFromFile(processedFile); if (!processedFile.size) { this.notification.add( `There was a problem while uploading: ${processedFile.name}`, { type: "danger" } ); continue; } await this.props.onUploaded({ name: processedFile.name, size: processedFile.size, type: processedFile.type, data: data.split(",")[1], objectUrl: processedFile.type === "application/pdf" ? URL.createObjectURL(processedFile) : null, }); } catch (e) { // Skip this file on error, continue with others } finally { this.state.isUploading = false; } } // Reset input so same file can be re-selected target.value = null; if (this.props.multiUpload && this.props.onUploadComplete) { this.props.onUploadComplete({}); } }, }); /** * Safety net for drag-drop and paste uploads that bypass FileUploader. */ patch(AttachmentUploadService.prototype, { async upload(thread, composer, file, options) { if (file && IMAGE_TYPES.has(file.type) && file.size > SKIP_THRESHOLD) { try { file = await compressImageFile(file); } catch (e) { // Use original file } } return super.upload(thread, composer, file, options); }, });