diff --git a/apps/mobile/app/common/filesystem/upload.ts b/apps/mobile/app/common/filesystem/upload.ts index cc824fd9a..a306d1e5f 100644 --- a/apps/mobile/app/common/filesystem/upload.ts +++ b/apps/mobile/app/common/filesystem/upload.ts @@ -17,7 +17,7 @@ You should have received a copy of the GNU General Public License along with this program. If not, see . */ -import { isImage, RequestOptions } from "@notesnook/core"; +import { isImage, RequestOptions, hosts } from "@notesnook/core"; import { PermissionsAndroid, Platform } from "react-native"; import RNFetchBlob from "react-native-blob-util"; import { ToastManager } from "../../services/event-manager"; @@ -32,6 +32,133 @@ import { getUploadedFileSize } from "./utils"; import Upload from "@ammarahmed/react-native-upload"; +import { CloudUploader } from "react-native-nitro-cloud-uploader"; + +// Upload constants +const CHUNK_SIZE = 10 * 1024 * 1024; // 10 MB +const MINIMUM_MULTIPART_FILE_SIZE = 25 * 1024 * 1024; // 25MB + +interface InitiateMultipartResponse { + uploadId: string; + parts: string[]; + error?: string; +} + +async function initiateMultipartUpload( + filename: string, + fileSize: number, + headers: Record +): Promise { + const totalParts = Math.ceil(fileSize / CHUNK_SIZE); + + const url = `${hosts.API_HOST}/s3/multipart?name=${filename}&parts=${totalParts}&uploadId=`; + const response = await fetch(url, { headers }); + + if (!response.ok) { + throw new Error( + `Failed to initiate multipart upload: ${response.statusText}` + ); + } + + const data = await response.json(); + + if (data.error) { + throw new Error(data.error); + } + + if (!data.uploadId || !data.parts) { + throw new Error("Failed to initiate multipart upload: invalid response."); + } + + DatabaseLogger.info( + `Initiated multipart upload for ${filename} with upload ID: ${data.uploadId}` + ); + + return data; +} + +async function multipartUploadFile( + filename: string, + filePath: string, + fileSize: number, + requestOptions: RequestOptions, + cancelToken: { cancel: (reason?: string) => Promise } +): Promise { + const { headers } = requestOptions; + + try { + const uploadData = await initiateMultipartUpload( + filename, + fileSize, + headers + ); + const { uploadId, parts } = uploadData; + + DatabaseLogger.info( + `Starting upload for ${filename} with ${parts.length} parts` + ); + + cancelToken.cancel = async () => { + useAttachmentStore.getState().remove(filename); + await CloudUploader.cancelUpload(uploadId); + }; + + CloudUploader.addListener("upload-progress", (event) => { + useAttachmentStore + .getState() + .setProgress( + event.bytesUploaded || 0, + event.totalBytes || fileSize, + filename, + 0, + "upload" + ); + DatabaseLogger.info( + `File upload progress: ${filename}, ${event.bytesUploaded}/${ + event.totalBytes || fileSize + }, chunk: ${event.chunkIndex}, progress: ${event.progress}` + ); + }); + // CloudUploader handles chunking and uploading all parts internally + const result = await CloudUploader.startUpload( + filename, + filePath, + parts, + 3, // maxParallel + true // showNotification + ); + + CloudUploader.removeListener("upload-progress"); + + if (!result.success) { + throw new Error("Failed to upload multipart file"); + } + + DatabaseLogger.info( + `Multipart upload completed for ${filename} with upload ID: ${uploadId}` + ); + + const response = await fetch(`${hosts.API_HOST}/s3/multipart`, { + method: "POST", + body: JSON.stringify({ + Key: filename, + UploadId: uploadId, + PartETags: result.etags.map((etag, index) => ({ + partNumber: index + 1, + etag: etag + })) + }), + headers: { ...headers, "Content-Type": "application/json" } + }); + + return response; + } catch (error) { + DatabaseLogger.error(error, "Multipart upload failed", { filename }); + CloudUploader.removeListener("upload-progress"); + useAttachmentStore.getState().remove(filename); + throw error; + } +} export async function uploadFile( filename: string, @@ -96,72 +223,111 @@ export async function uploadFile( } } - const upload = Upload.create({ - customUploadId: filename, - path: Platform.OS === "ios" ? "file://" + fileInfo.path : fileInfo.path, - url: url, - method: "PUT", - headers: { - ...headers, - "content-type": "application/octet-stream" - }, - appGroup: IOS_APPGROUPID, - notification: { - filename: - attachmentInfo && isImage(attachmentInfo?.mimeType) - ? "image" - : attachmentInfo?.filename || "file", - enabled: true, - enableRingTone: true, - autoClear: true - } - }).onChange((event) => { - switch (event.status) { - case "running": - case "pending": - useAttachmentStore - .getState() - .setProgress( - event.uploadedBytes || 0, - event.totalBytes || fileInfo.size, - filename, - 0, - "upload" - ); - DatabaseLogger.info( - `File upload progress: ${filename}, ${event.uploadedBytes}/${ - event.totalBytes || fileInfo.size - }` - ); - break; - case "completed": - console.log("Upload completed"); - break; - } - }); - const result = await upload.start(); - cancelToken.cancel = async () => { - useAttachmentStore.getState().remove(filename); - upload.cancel(); - }; + let uploaded = false; - const status = result.responseCode || 0; - const uploaded = status >= 200 && status < 300; + // Use multipart upload for files larger than MINIMUM_MULTIPART_FILE_SIZE + if (fileInfo.size >= MINIMUM_MULTIPART_FILE_SIZE) { + DatabaseLogger.info( + `Using multipart upload for large file: ${filename} (${fileInfo.size} bytes)` + ); + const result = await multipartUploadFile( + filename, + filePath, + fileInfo.size, + requestOptions, + cancelToken + ); + const status = result.status || 0; + uploaded = status >= 200 && status < 300; + + if (!uploaded) { + const fileInfo = await RNFetchBlob.fs.stat(filePath); + throw new Error( + `${status}, name: ${fileInfo.filename}, length: ${ + fileInfo.size + }, info: ${JSON.stringify(await result.text())}` + ); + } + } else { + // Use single-part upload for smaller files + DatabaseLogger.info( + `Using single-part upload for file: ${filename} (${fileInfo.size} bytes)` + ); + const upload = Upload.create({ + customUploadId: filename, + path: Platform.OS === "ios" ? "file://" + fileInfo.path : fileInfo.path, + url: url, + method: "PUT", + headers: { + ...headers, + "content-type": "application/octet-stream" + }, + appGroup: IOS_APPGROUPID, + notification: { + filename: + attachmentInfo && isImage(attachmentInfo?.mimeType) + ? "image" + : attachmentInfo?.filename || "file", + enabled: true, + enableRingTone: true, + autoClear: true + } + }).onChange((event) => { + switch (event.status) { + case "running": + case "pending": + useAttachmentStore + .getState() + .setProgress( + event.uploadedBytes || 0, + event.totalBytes || fileInfo.size, + filename, + 0, + "upload" + ); + DatabaseLogger.info( + `File upload progress: ${filename}, ${event.uploadedBytes}/${ + event.totalBytes || fileInfo.size + }` + ); + break; + case "completed": + DatabaseLogger.info("Upload completed"); + break; + } + }); + const result = await upload.start(); + cancelToken.cancel = async () => { + useAttachmentStore.getState().remove(filename); + upload.cancel(); + }; + + const status = result.responseCode || 0; + uploaded = status >= 200 && status < 300; + + if (!uploaded) { + const fileInfo = await RNFetchBlob.fs.stat(filePath); + throw new Error( + `${status}, name: ${fileInfo.filename}, length: ${ + fileInfo.size + }, info: ${JSON.stringify(result.error)}` + ); + } + } useAttachmentStore.getState().remove(filename); - if (!uploaded) { - const fileInfo = await RNFetchBlob.fs.stat(filePath); - throw new Error( - `${status}, name: ${fileInfo.filename}, length: ${ - fileInfo.size - }, info: ${JSON.stringify(result.error)}` + if (uploaded) { + attachmentInfo = await db.attachments.attachment(filename); + if (!attachmentInfo) return false; + await checkUpload( + filename, + requestOptions.chunkSize, + attachmentInfo.size ); + DatabaseLogger.info(`File upload status: ${filename}, success`); } - attachmentInfo = await db.attachments.attachment(filename); - if (!attachmentInfo) return false; - await checkUpload(filename, requestOptions.chunkSize, attachmentInfo.size); - DatabaseLogger.info(`File upload status: ${filename}, ${status}`); + return uploaded; } catch (e) { useAttachmentStore.getState().remove(filename); diff --git a/apps/mobile/ios/Podfile.lock b/apps/mobile/ios/Podfile.lock index 9c8f11a5e..0fa6a3544 100644 --- a/apps/mobile/ios/Podfile.lock +++ b/apps/mobile/ios/Podfile.lock @@ -45,6 +45,65 @@ PODS: - MMKV (1.3.14): - MMKVCore (~> 1.3.14) - MMKVCore (1.3.14) + - NitroCloudUploader (1.0.9): + - boost + - DoubleConversion + - fast_float + - fmt + - glog + - hermes-engine + - NitroModules + - RCT-Folly + - RCT-Folly/Fabric + - RCTRequired + - RCTTypeSafety + - React-callinvoker + - React-Core + - React-debug + - React-Fabric + - React-featureflags + - React-graphics + - React-ImageManager + - React-jsi + - React-NativeModulesApple + - React-RCTFabric + - React-renderercss + - React-rendererdebug + - React-utils + - ReactCodegen + - ReactCommon/turbomodule/bridging + - ReactCommon/turbomodule/core + - SocketRocket + - Yoga + - NitroModules (0.32.0): + - boost + - DoubleConversion + - fast_float + - fmt + - glog + - hermes-engine + - RCT-Folly + - RCT-Folly/Fabric + - RCTRequired + - RCTTypeSafety + - React-callinvoker + - React-Core + - React-debug + - React-Fabric + - React-featureflags + - React-graphics + - React-ImageManager + - React-jsi + - React-NativeModulesApple + - React-RCTFabric + - React-renderercss + - React-rendererdebug + - React-utils + - ReactCodegen + - ReactCommon/turbomodule/bridging + - ReactCommon/turbomodule/core + - SocketRocket + - Yoga - pop (1.0.12) - RCT-Folly (2024.11.18.00): - boost @@ -2216,7 +2275,7 @@ PODS: - ReactCommon/turbomodule/core - SocketRocket - Yoga - - react-native-upload (6.29.0): + - react-native-upload (6.31.0): - React - react-native-view-shot (4.0.3): - boost @@ -3404,6 +3463,8 @@ DEPENDENCIES: - glog (from `../node_modules/react-native/third-party-podspecs/glog.podspec`) - hermes-engine (from `../node_modules/react-native/sdks/hermes-engine/hermes-engine.podspec`) - MMKV + - NitroCloudUploader (from `../node_modules/react-native-nitro-cloud-uploader`) + - NitroModules (from `../node_modules/react-native-nitro-modules`) - RCT-Folly (from `../node_modules/react-native/third-party-podspecs/RCT-Folly.podspec`) - RCTDeprecation (from `../node_modules/react-native/ReactApple/Libraries/RCTFoundation/RCTDeprecation`) - RCTRequired (from `../node_modules/react-native/Libraries/Required`) @@ -3560,6 +3621,10 @@ EXTERNAL SOURCES: hermes-engine: :podspec: "../node_modules/react-native/sdks/hermes-engine/hermes-engine.podspec" :tag: hermes-2025-09-01-RNv0.82.0-265ef62ff3eb7289d17e366664ac0da82303e101 + NitroCloudUploader: + :path: "../node_modules/react-native-nitro-cloud-uploader" + NitroModules: + :path: "../node_modules/react-native-nitro-modules" RCT-Folly: :podspec: "../node_modules/react-native/third-party-podspecs/RCT-Folly.podspec" RCTDeprecation: @@ -3819,6 +3884,8 @@ SPEC CHECKSUMS: JWTDecode: 2eed97c2fa46ccaf3049a787004eedf0be474a87 MMKV: 7b5df6a8bf785c6705cc490c541b9d8a957c4a64 MMKVCore: 3f40b896e9ab522452df9df3ce983471aa2449ba + NitroCloudUploader: 62489381ed6b472d87c8c6b62629136ebd2342d1 + NitroModules: f964d2f3f5b392d0c0303737085cc30e375dc43f pop: d582054913807fd11fd50bfe6a539d91c7e1a55a RCT-Folly: 846fda9475e61ec7bcbf8a3fe81edfcaeb090669 RCTDeprecation: f17e2ebc07876ca9ab8eb6e4b0a4e4647497ae3a @@ -3879,7 +3946,7 @@ SPEC CHECKSUMS: react-native-share-extension: fdc6aaab51591a2d445df239c446aaa3a99658ec react-native-sodium: 066f76e46c9be13e9260521e3fa994937c4cdab4 react-native-theme-switch-animation: 449d6db7a760f55740505e7403ae8061debc9a7e - react-native-upload: 1459a4be625dda39e4364d9e7d859477a3000f2a + react-native-upload: 6285965d20879bcfcf7953f05eb785ba03a60e40 react-native-view-shot: 6c008e58f4720de58370848201c5d4a082c6d4ca react-native-webview: 654f794a7686b47491cf43aa67f7f428bea00eed React-NativeModulesApple: 46690a0fe94ec28fc6fc686ec797b911d251ded0 diff --git a/apps/mobile/package-lock.json b/apps/mobile/package-lock.json index eb9092287..8399abc60 100644 --- a/apps/mobile/package-lock.json +++ b/apps/mobile/package-lock.json @@ -16,7 +16,7 @@ "@ammarahmed/react-native-fingerprint-scanner": "^5.0.1", "@ammarahmed/react-native-share-extension": "^2.9.5", "@ammarahmed/react-native-sodium": "^1.6.8", - "@ammarahmed/react-native-upload": "^6.29.0", + "@ammarahmed/react-native-upload": "^6.31.0", "@azure/core-asynciterator-polyfill": "^1.0.2", "@bam.tech/react-native-image-resizer": "3.0.11", "@callstack/repack": "~5.2.1", @@ -100,6 +100,8 @@ "react-native-mmkv-storage": "^12.0.1", "react-native-modal-datetime-picker": "14.0.0", "react-native-navigation-bar-color": "2.0.2", + "react-native-nitro-cloud-uploader": "^1.0.9", + "react-native-nitro-modules": "^0.32.0", "react-native-notification-sounds": "0.5.5", "react-native-orientation-locker": "^1.7.0", "react-native-pdf": "^7.0.3", @@ -465,20 +467,22 @@ }, "../../servers/themes": { "name": "@notesnook/themes-server", - "version": "1.0.0", + "version": "1.0.1", "license": "ISC", "dependencies": { + "@notesnook/theme": "file:../../packages/theme", "@orama/orama": "^1.0.8", + "@sagi.io/workers-kv": "^0.0.15", "@trpc/server": "10.45.2", "async-mutex": "0.5.0", "cors": "^2.8.5", + "react": "18.3.1", "util": "^0.12.5", "zod": "3.23.8" }, "devDependencies": { - "@notesnook/theme": "file:../../packages/theme", "@types/cors": "^2.8.13", - "react": "18.3.1", + "esbuild": "0.21.5", "vite-node": "2.1.8" } }, @@ -531,9 +535,9 @@ "license": "ISC" }, "node_modules/@ammarahmed/react-native-upload": { - "version": "6.29.0", - "resolved": "https://registry.npmjs.org/@ammarahmed/react-native-upload/-/react-native-upload-6.29.0.tgz", - "integrity": "sha512-k2FJAVR4Nohx4VhfgHuXzLXQpgEnco/PAv9R080X7oY3O12DTgY0ruDvOFwvQGFW0PDIHlXfLfUiMq2kZc/k1A==", + "version": "6.31.0", + "resolved": "https://registry.npmjs.org/@ammarahmed/react-native-upload/-/react-native-upload-6.31.0.tgz", + "integrity": "sha512-GwMDd2IND3nuS4l13pQPP8zA/GJoxQKz0pxCGUoWj65fbPuZPnuUA2Rpf4jZzLlgY62vdjI/TqmX+rPiwwCceQ==", "license": "BSD-3-Clause", "peerDependencies": { "react": "*", @@ -17714,6 +17718,30 @@ "integrity": "sha512-ZmpLWRocyme1au11e5ZuecMS/UCi57nlzgnioi03Q6ERMbeUOqqbWgNBaNB7SsCeqBV6fZPjo3+A64zEIpzw4w==", "license": "MIT" }, + "node_modules/react-native-nitro-cloud-uploader": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/react-native-nitro-cloud-uploader/-/react-native-nitro-cloud-uploader-1.0.9.tgz", + "integrity": "sha512-J96JC/yp0DWeyS512zQFYbw2KGiengWs0IAGGoV6NCAystluBJlVO4WqgiCiy/nd3bFidy/+uteC+P5h28j0vw==", + "license": "MIT", + "workspaces": [ + "example" + ], + "peerDependencies": { + "react": "*", + "react-native": "*", + "react-native-nitro-modules": "*" + } + }, + "node_modules/react-native-nitro-modules": { + "version": "0.32.0", + "resolved": "https://registry.npmjs.org/react-native-nitro-modules/-/react-native-nitro-modules-0.32.0.tgz", + "integrity": "sha512-nLDsmi/H9/cLcYxArYxcTDL641JiWnAH3u2LmsFETgyqSEHvukswDTdmX6AHBRIAwXu/iUDppZyjsaRpTG6WMg==", + "license": "MIT", + "peerDependencies": { + "react": "*", + "react-native": "*" + } + }, "node_modules/react-native-notification-sounds": { "version": "0.5.5", "resolved": "https://registry.npmjs.org/react-native-notification-sounds/-/react-native-notification-sounds-0.5.5.tgz", diff --git a/apps/mobile/package.json b/apps/mobile/package.json index 66f671ca3..348345901 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -32,7 +32,7 @@ "@ammarahmed/react-native-fingerprint-scanner": "^5.0.1", "@ammarahmed/react-native-share-extension": "^2.9.5", "@ammarahmed/react-native-sodium": "^1.6.8", - "@ammarahmed/react-native-upload": "^6.29.0", + "@ammarahmed/react-native-upload": "^6.31.0", "@azure/core-asynciterator-polyfill": "^1.0.2", "@bam.tech/react-native-image-resizer": "3.0.11", "@callstack/repack": "~5.2.1", @@ -116,6 +116,8 @@ "react-native-mmkv-storage": "^12.0.1", "react-native-modal-datetime-picker": "14.0.0", "react-native-navigation-bar-color": "2.0.2", + "react-native-nitro-cloud-uploader": "^1.0.9", + "react-native-nitro-modules": "^0.32.0", "react-native-notification-sounds": "0.5.5", "react-native-orientation-locker": "^1.7.0", "react-native-pdf": "^7.0.3", diff --git a/apps/mobile/patches/react-native-nitro-cloud-uploader+1.0.9.patch b/apps/mobile/patches/react-native-nitro-cloud-uploader+1.0.9.patch new file mode 100644 index 000000000..35b5e1c13 --- /dev/null +++ b/apps/mobile/patches/react-native-nitro-cloud-uploader+1.0.9.patch @@ -0,0 +1,83 @@ +diff --git a/node_modules/react-native-nitro-cloud-uploader/android/src/main/AndroidManifest.xml b/node_modules/react-native-nitro-cloud-uploader/android/src/main/AndroidManifest.xml +index 9ea903a..e023649 100644 +--- a/node_modules/react-native-nitro-cloud-uploader/android/src/main/AndroidManifest.xml ++++ b/node_modules/react-native-nitro-cloud-uploader/android/src/main/AndroidManifest.xml +@@ -11,7 +11,7 @@ + + + +- ++ + + + +diff --git a/node_modules/react-native-nitro-cloud-uploader/android/src/main/java/com/margelo/nitro/nitroclouduploader/NitroCloudUploader.kt b/node_modules/react-native-nitro-cloud-uploader/android/src/main/java/com/margelo/nitro/nitroclouduploader/NitroCloudUploader.kt +index f93d4dc..f95a537 100644 +--- a/node_modules/react-native-nitro-cloud-uploader/android/src/main/java/com/margelo/nitro/nitroclouduploader/NitroCloudUploader.kt ++++ b/node_modules/react-native-nitro-cloud-uploader/android/src/main/java/com/margelo/nitro/nitroclouduploader/NitroCloudUploader.kt +@@ -388,7 +388,7 @@ class NitroCloudUploader( + ) + + if (shouldNotify) { +- showNotification(uploadId, 100, "Upload complete!", isComplete = true) ++// showNotification(uploadId, 100, "Upload complete!", isComplete = true) + } + + // ✅ Stop foreground service on success +@@ -414,7 +414,7 @@ class NitroCloudUploader( + println("❌ Upload failed: ${e.message}") + + if (shouldNotify) { +- showNotification(uploadId, -1, "Upload failed", isComplete = true) ++// showNotification(uploadId, -1, "Upload failed", isComplete = true) + } + + // ✅ Stop foreground service on failure +@@ -458,7 +458,7 @@ class NitroCloudUploader( + ) + + if (shouldNotify) { +- showNotification(uploadId, 0, "Starting upload...") ++// showNotification(uploadId, 0, "Starting upload...") + } + + } catch (e: Exception) { +@@ -672,7 +672,7 @@ class NitroCloudUploader( + + if (showNotification) { + val progressPercent = (progress * 100).toInt() +- showNotification(uploadId, progressPercent, "Uploading... ${progressPercent}%") ++// showNotification(uploadId, progressPercent, "Uploading... ${progressPercent}%") + + // ✅ Update foreground service notification + val ctx = appContext // Cache to avoid smart cast issues +diff --git a/node_modules/react-native-nitro-cloud-uploader/android/src/main/java/com/margelo/nitro/nitroclouduploader/UploadForegroundService.kt b/node_modules/react-native-nitro-cloud-uploader/android/src/main/java/com/margelo/nitro/nitroclouduploader/UploadForegroundService.kt +index 466c212..e261329 100644 +--- a/node_modules/react-native-nitro-cloud-uploader/android/src/main/java/com/margelo/nitro/nitroclouduploader/UploadForegroundService.kt ++++ b/node_modules/react-native-nitro-cloud-uploader/android/src/main/java/com/margelo/nitro/nitroclouduploader/UploadForegroundService.kt +@@ -134,7 +134,7 @@ class UploadForegroundService : Service() { + startForeground( + NOTIFICATION_ID, + notification, +- ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC ++ ServiceInfo.FOREGROUND_SERVICE_TYPE_SPECIAL_USE + ) + } else { + startForeground(NOTIFICATION_ID, notification) +@@ -165,6 +165,7 @@ class UploadForegroundService : Service() { + + isServiceStarted = false + stopForeground(STOP_FOREGROUND_REMOVE) ++ notificationManager.cancel(NOTIFICATION_ID); + stopSelf() + println("✅ Foreground service stopped") + } +@@ -201,7 +202,7 @@ class UploadForegroundService : Service() { + } + + val builder = NotificationCompat.Builder(this, NOTIFICATION_CHANNEL_ID) +- .setContentTitle("Cloud Uploader") ++ .setContentTitle("Uploading file") + .setContentText(message) + .setSmallIcon(android.R.drawable.stat_sys_upload) + .setPriority(NotificationCompat.PRIORITY_LOW)