Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
16 changes: 15 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<boolean>`
Cancels an upload. Fires a `cancelled` event with `cancelReason: 'user'`.
Expand Down
18 changes: 18 additions & 0 deletions android/src/main/java/ai/openspace/backgroundupload/Upload.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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'")

Expand Down Expand Up @@ -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,
)
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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())
}

Expand Down Expand Up @@ -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
}

Expand Down
49 changes: 49 additions & 0 deletions android/src/test/java/ai/openspace/backgroundupload/UploadTest.kt
Original file line number Diff line number Diff line change
@@ -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)
}
}
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
10 changes: 10 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down
Loading