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)