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 = {