From c7b2ed4c3187703584276b05dbc4f29a0957e4c3 Mon Sep 17 00:00:00 2001 From: Elliott Kember Date: Wed, 12 Aug 2026 17:35:04 +1200 Subject: [PATCH] Add android.noNotification for silent uploads MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A consumer whose queue carries both user-visible media and housekeeping payloads had no way to keep the housekeeping ones out of the notification shade: every upload posts a progress notification, because posting one is how the worker enters foreground mode. noNotification skips the channel registration, the foreground promotion and the notify calls. Such an upload runs as an ordinary background worker, so the OS may defer or stop it — a path the worker already handles by letting WorkManager re-run it. Phrased as an opt-out because WorkManager persists the upload as JSON: a job enqueued by 8.0.0 and replayed by 8.1.0 has no such key, and an absent Gson Boolean is false, so it keeps its notification. Co-Authored-By: Claude Opus 5 (1M context) --- CHANGELOG.md | 13 +++++ README.md | 16 +++++- .../ai/openspace/backgroundupload/Upload.kt | 18 +++++++ .../backgroundupload/UploadWorker.kt | 29 +++++++---- .../openspace/backgroundupload/UploadTest.kt | 49 +++++++++++++++++++ package.json | 2 +- src/types.ts | 10 ++++ 7 files changed, 126 insertions(+), 11 deletions(-) create mode 100644 android/src/test/java/ai/openspace/backgroundupload/UploadTest.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index aeaa0152..2aaf6abc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,16 @@ +## 8.1.0 + +Added `android.noNotification`, which uploads a file without posting a progress +notification. An app that uploads housekeeping payloads alongside user-visible +ones can now keep the notification shade for the ones a user asked to watch. + +The notification doubles as the upload worker's foreground-service notification, +so a silent upload runs as an ordinary background worker: the OS may defer it, or +stop it mid-flight for WorkManager to re-run. It suits small payloads a restart +costs nothing; media files should keep their notification. Default is `false`, so +existing uploads are unaffected, including jobs enqueued by 8.0.0 and re-run +after the upgrade. + ## 8.0.0 Reliability release. Terminal outcomes are now durable and accurately typed, the diff --git a/README.md b/README.md index 9d2285d4..24d87446 100644 --- a/README.md +++ b/README.md @@ -128,7 +128,21 @@ Starts an upload; resolves to its id. Rejects only on a bad option (missing/inva | `customUploadId` | string | Defaults to a generated UUID. | | `wifiOnly` | boolean | Wait for wifi before/while uploading. | | `acceptStatus` | number[] | Non-2xx statuses to treat as success. | -| `android` | object | Optional. `notificationId/Title/TitleNoWifi/TitleNoInternet/Channel`, `maxRetries` (default 5). Sensible defaults + auto-created channel if omitted. | +| `android` | object | Optional. `notificationId/Title/TitleNoWifi/TitleNoInternet/Channel`, `maxRetries` (default 5), `noNotification` (default false). Sensible defaults + auto-created channel if omitted. | + +#### Silent uploads (Android) + +`android: { noNotification: true }` uploads a file without posting a progress +notification, so the shade only shows the uploads a user actually asked to watch. + +That notification is also the worker's foreground-service notification, so a +silent upload runs as an ordinary background worker instead. The OS is then free +to defer it, or to stop it mid-flight and let WorkManager re-run it later. Keep +the notification for anything that takes real time to upload; reserve +`noNotification` for small payloads a restart would cost nothing. + +Uploads sharing a `notificationId` share one notification, and its progress bar +reports every in-flight upload — silent ones included. ### `cancelUpload(uploadId): Promise` Cancels an upload. Fires a `cancelled` event with `cancelReason: 'user'`. diff --git a/android/src/main/java/ai/openspace/backgroundupload/Upload.kt b/android/src/main/java/ai/openspace/backgroundupload/Upload.kt index f314b1f9..d4d753a5 100644 --- a/android/src/main/java/ai/openspace/backgroundupload/Upload.kt +++ b/android/src/main/java/ai/openspace/backgroundupload/Upload.kt @@ -23,7 +23,23 @@ data class Upload( val notificationTitleNoInternet: String, val notificationTitleNoWifi: String, val notificationChannel: String, + /** + * Suppresses the progress notification for this upload. + * + * The notification is not decoration: posting one is what lets the worker run + * in foreground mode, which is how a long-running worker survives Doze and + * memory pressure. A suppressed upload is an ordinary background worker, so + * the OS may defer it or stop it mid-flight for WorkManager to re-run later. + * Suppress only payloads small enough that a restart costs nothing. + * + * An opt-out rather than an opt-in so that absence means "notify": this model + * is serialized into WorkManager's database, and a job enqueued by a build + * that predates the option can be replayed by a build that has it. + */ + val noNotification: Boolean, ) { + val showsNotification get() = !noNotification + class MissingOptionException(optionName: String) : IllegalArgumentException("Missing '$optionName'") @@ -60,6 +76,8 @@ data class Upload( ?: "Waiting for Wi-Fi…", notificationChannel = map.getString(Upload::notificationChannel.name) ?: DEFAULT_NOTIFICATION_CHANNEL, + noNotification = if (map.hasKey(Upload::noNotification.name)) + map.getBoolean(Upload::noNotification.name) else false, ) } } diff --git a/android/src/main/java/ai/openspace/backgroundupload/UploadWorker.kt b/android/src/main/java/ai/openspace/backgroundupload/UploadWorker.kt index b3fd2a1c..9ad6380b 100644 --- a/android/src/main/java/ai/openspace/backgroundupload/UploadWorker.kt +++ b/android/src/main/java/ai/openspace/backgroundupload/UploadWorker.kt @@ -83,14 +83,18 @@ class UploadWorker(private val context: Context, params: WorkerParameters) : // initialization, errors thrown here won't be retried try { - // The foreground notification needs a channel to exist first, or posting - // it silently fails and setForeground can crash on newer Android. - ensureNotificationChannel() - // `setForeground` is recommended for long-running workers. - // Foreground mode helps prioritize the worker, reducing the risk - // of it being killed during low memory or Doze/App Standby situations. - // ⚠️ This should be called in the foreground - setForeground(getForegroundInfo()) + // An upload that suppresses its notification cannot enter foreground mode, + // since the notification is the foreground service's own notification. + if (upload.showsNotification) { + // The foreground notification needs a channel to exist first, or posting + // it silently fails and setForeground can crash on newer Android. + ensureNotificationChannel() + // `setForeground` is recommended for long-running workers. + // Foreground mode helps prioritize the worker, reducing the risk + // of it being killed during low memory or Doze/App Standby situations. + // ⚠️ This should be called in the foreground + setForeground(getForegroundInfo()) + } } catch (error: Throwable) { if (!checkAndHandleCancellation()) handleError(error) throw error @@ -163,6 +167,13 @@ class UploadWorker(private val context: Context, params: WorkerParameters) : private fun handleProgress(bytesSentTotal: Long, fileSize: Long) { UploadProgress.set(upload.id, bytesSentTotal) EventReporter.progress(upload.id, bytesSentTotal, fileSize) + updateNotification() + } + + // Redraws the progress notification. A no-op for a suppressed upload — the + // worker never posted one, and `notify` would create it outside foreground mode. + private fun updateNotification() { + if (!upload.showsNotification) return notificationManager.notify(upload.notificationId, buildNotification()) } @@ -283,7 +294,7 @@ class UploadWorker(private val context: Context, params: WorkerParameters) : private fun validateAndReportConnectivity(): Boolean { this.connectivity = validateConnectivity(context, upload.wifiOnly) // alert connectivity mode - notificationManager.notify(upload.notificationId, buildNotification()) + updateNotification() return this.connectivity == Connectivity.Ok } diff --git a/android/src/test/java/ai/openspace/backgroundupload/UploadTest.kt b/android/src/test/java/ai/openspace/backgroundupload/UploadTest.kt new file mode 100644 index 00000000..de856578 --- /dev/null +++ b/android/src/test/java/ai/openspace/backgroundupload/UploadTest.kt @@ -0,0 +1,49 @@ +package ai.openspace.backgroundupload + +import com.google.gson.Gson +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class UploadTest { + private val gson = Gson() + + private fun upload(noNotification: Boolean) = Upload( + id = "u1", + url = "https://example.com/upload", + path = "/tmp/file", + method = "POST", + maxRetries = 5, + wifiOnly = false, + acceptStatus = listOf(), + headers = mapOf(), + notificationId = 1, + notificationTitle = "Uploading…", + notificationTitleNoInternet = "Waiting for connection…", + notificationTitleNoWifi = "Waiting for Wi-Fi…", + notificationChannel = "background-upload", + noNotification = noNotification, + ) + + @Test + fun `an upload notifies unless it opts out`() { + assertTrue(upload(noNotification = false).showsNotification) + assertFalse(upload(noNotification = true).showsNotification) + } + + @Test + fun `the opt-out survives a serialization round trip`() { + val json = gson.toJson(upload(noNotification = true)) + assertFalse(gson.fromJson(json, Upload::class.java).showsNotification) + } + + // WorkManager stores this model as JSON, so an upload can be enqueued by one + // build and run by the next. A job from a build without the option must keep + // its notification rather than silently losing foreground mode. + @Test + fun `a job enqueued without the option still notifies`() { + val json = gson.toJsonTree(upload(noNotification = true)).asJsonObject + json.remove(Upload::noNotification.name) + assertTrue(gson.fromJson(json, Upload::class.java).showsNotification) + } +} diff --git a/package.json b/package.json index 2a886fef..0637a80b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "react-native-background-upload", - "version": "8.0.0", + "version": "8.1.0", "description": "Cross platform http post file uploader with android and iOS background support", "main": "src/index", "typings": "src/index.ts", diff --git a/src/types.ts b/src/types.ts index a5f15b66..6bf31eb2 100644 --- a/src/types.ts +++ b/src/types.ts @@ -103,6 +103,16 @@ export type AndroidOnlyUploadOptions = { // Only retry IO and other unknown issues. // Network failure does not count towards retries maxRetries?: number; + /** + * Uploads this file without a progress notification. Default false. + * + * The notification is what puts the upload's worker in foreground mode, which + * is how it survives Doze and memory pressure, so a silent upload is easier + * for the OS to defer or stop and re-run. Reserve it for payloads small enough + * that a restart costs nothing, and keep it off for anything a user would + * expect to see progress for. + */ + noNotification?: boolean; }; export type RawUploadOptions = {