mobile: add support for multipart uploads

Files >100MB fail currently using simple file upload methods.
This commit is contained in:
Ammar Ahmed
2026-01-06 13:08:53 +05:00
parent 0aea1deea0
commit bbaa6c76b4
5 changed files with 417 additions and 71 deletions

View File

@@ -17,7 +17,7 @@ You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
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<string, string>
): Promise<InitiateMultipartResponse> {
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<void> }
): Promise<Response> {
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);

View File

@@ -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

View File

@@ -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",

View File

@@ -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",

View File

@@ -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 @@
<!-- Required for background uploads -->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
- <uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
+ <uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" />
<!-- Keep CPU awake during uploads -->
<uses-permission android:name="android.permission.WAKE_LOCK" />
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)