From b273ba722d9333aa56c3665a016373c65aeaa009 Mon Sep 17 00:00:00 2001 From: Dylan Murphy Date: Mon, 27 Jul 2026 10:22:14 -0400 Subject: [PATCH] docs: document the v8 API and update the example app README covers the New Architecture requirement, the durable journal and the read/ack cycle, typed errorKind and cancelReason, acceptStatus, getAllUploads, the AppDelegate hook for background session completion, and the fact that pod install now runs codegen. Adds a note that consumers must resolve this package through Metro rather than a nested node_modules. CHANGELOG records the breaking changes. The example app is updated to the new API so it stays a working reference. Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 67 +++++++- README.md | 285 +++++++++++++++-------------------- example/RNBGUExample/App.tsx | 37 ++++- 3 files changed, 216 insertions(+), 173 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f6a050d4..aeaa0152 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,66 @@ -Since Version > 5.3.0 we follow semantic versioning. +## 8.0.0 -See the [releases](https://github.com/Vydia/react-native-background-upload/releases) page on GitHub for information regarding each release. +Reliability release. Terminal outcomes are now durable and accurately typed, the +iOS module was rewritten in Swift, and the module is a New Architecture +TurboModule. + +Breaking: +- **New Architecture only.** The module is now a codegen TurboModule on both + platforms; the legacy bridge (`RCTEventEmitter` / `RCT_EXTERN_MODULE` on iOS, + `ReactPackage` + `RCTDeviceEventEmitter` on Android) is gone, along with any + reliance on RN's legacy-interop layer. Requires React Native >= 0.84 with the + New Architecture enabled, and React >= 19. +- The iOS background-session handler moved from `RNFileUploader` to + `RNBackgroundUpload`: `[RNBackgroundUpload setBackgroundSessionCompletionHandler: + forIdentifier:]`. `RNFileUploader` is now the TurboModule and is intentionally + unreachable from plain Objective-C (its generated header is Objective-C++ only). + Update the AppDelegate snippet — see README. +- Events are delivered through the codegen event emitters rather than + `DeviceEventEmitter`, so they are no longer visible under the raw + `RNFileUploader-*` device-event names. The `Upload.addListener(...)` API is + unchanged. +- `cancelUpload` on iOS now resolves `false` when no matching in-flight upload was + found (it previously always resolved `true`). Android still always resolves `true`. +- Terminal event payloads are typed as the journal entry they actually are. The + natives emit the journaled entry itself, so `CompletedData` / `ErrorData` / + `CancelledData` now declare the `eventId`, `type` and `timestamp` they were always + sending, plus `responseBodyTruncated`. `eventId` in particular means you can + `ackEvents([eventId])` straight after handling a live event. `JournaledEvent` is + now a union discriminated on `type`. +- `CompletedData.responseCode` and `.responseBody` are optional. They were declared + required but are absent when a task completes without an HTTP response, so reading + them unguarded could throw. +- The `cancelled` payload no longer carries `error` (it used to hold the cancellation + error string). Use `cancelReason` instead. +- iOS `progress` reports `0` instead of `-1` when the total length is unknown, + matching Android and the documented 0-100 range. +- iOS `getAllUploads` reports `cancelled` and `completed` states instead of + collapsing everything non-running into `pending`. +- `completed` fires only for 2xx responses (plus a request's `acceptStatus`, e.g. + `acceptStatus: [409]`). Every other HTTP response now emits an `error` with + `errorKind: 'http'` and the full response attached (previously reported as + `completed`). +- `error` events are typed: `errorKind: 'http' | 'network' | 'file' | 'unknown'`. +- Native module renamed to `RNFileUploader` on both platforms (was + `VydiaRNFileUploader` on iOS); Android package is now `ai.openspace.backgroundupload`. +- iOS AppDelegate must forward `handleEventsForBackgroundURLSession` (see README). +- Removed the committed `lib/` build output; types are served from `src` + (deep imports of `lib/*` break — import from the package root). +- Minimum iOS deployment target is 15.1; minimum Android SDK is 29. Minimum React + Native is 0.84 (New Architecture), minimum React is 19. +- Removed non-functional iOS code paths: multipart, `assets-library://`, and the + `appGroup` option (a no-op even before this — it mutated the session config after + creation, which URLSession ignores; also removed from the TypeScript options). +- Removed the unexposed Android `stopAllUploads`. + +Added: +- Durable native event journal: `getUnacknowledgedEvents()` / `ackEvents(ids)` — + terminal events survive app death and JS reloads (at-least-once delivery). +- `getAllUploads()` on both platforms. +- `cancelled` events carry `cancelReason: 'user' | 'system'`. +- `responseHeaders` on completed events on iOS (was Android-only). +- iOS progress events throttled to 500ms; UUID default upload ids. +- Android `android` options are now optional — sensible notification defaults and + a library-created notification channel. + +Earlier releases: see the [releases](https://github.com/openspacelabs/react-native-background-upload/releases) page. diff --git a/README.md b/README.md index 989a4d8c..9d2285d4 100644 --- a/README.md +++ b/README.md @@ -1,26 +1,53 @@ # react-native-background-upload -OpenSpace home-grown background uploader for React Native. on iOS it uses URLSession, on Android it uses CoroutineWorker and Ktor. - -Documentation has been modified to reflect the changes made to this library. +OpenSpace's background HTTP file uploader for React Native. On iOS it uses a +background `URLSession`; on Android it uses `WorkManager` (a `CoroutineWorker`) +with OkHttp. Uploads continue while the app is backgrounded and resume after it +is killed. # Installation -## 1. Install package +**Requires React Native ≥ 0.84 with the New Architecture enabled, and React ≥ 19.** +This is a codegen TurboModule; it does not support the legacy bridge. + +``` +yarn add react-native-background-upload +cd ios && pod install && cd .. +``` -`yarn add react-native-background-upload` +`pod install` is required after installing — it runs codegen to generate the native +spec this module implements. -Note: if you are installing on React Native < 0.47, use `react-native-background-upload@3.0.0` instead of `react-native-background-upload` +> The package ships TypeScript source with no build step, so it resolves through Metro +> (and `tsc`) but not through plain Node. If you import it from a non-Metro context — +> a script, or Jest without a transform — add it to your `transformIgnorePatterns` +> allowlist or mock it. -## 2. Native Setup +## iOS: background completion handler (required) -### iOS +So uploads that finish while the app is terminated can relaunch it and be +journaled, add this to your `AppDelegate`: -`cd ./ios && pod install && cd ../` +```objc +#import + +- (void)application:(UIApplication *)application +handleEventsForBackgroundURLSession:(NSString *)identifier + completionHandler:(void (^)(void))completionHandler { + [RNBackgroundUpload setBackgroundSessionCompletionHandler:completionHandler + forIdentifier:identifier]; +} +``` -## 3. Expo +> The Swift header import name is the pod name with hyphens as underscores. If +> your app links pods as frameworks, use `@import react_native_background_upload;` +> instead of the `#import <...-Swift.h>` line. -To use this library with [Expo](https://expo.io) one must first detach (eject) the project and follow [step 2](#2-link-native-code) instructions. Additionally on iOS there is a must to add a Header Search Path to other dependencies which are managed using Pods. To do so one has to add `$(SRCROOT)/../../../ios/Pods/Headers/Public` to Header Search Path in `VydiaRNFileUploader` module using XCode. +This hook is load-bearing beyond just calling the completion handler: it is what +brings the library's background `URLSession` back to life in a process the system +relaunched with no JS running, so queued completions get journaled. `RNFileUploader` +is the TurboModule and is deliberately not reachable from plain Objective-C — its +generated header is Objective-C++ only — so the handler lives on `RNBackgroundUpload`. # Usage @@ -32,186 +59,110 @@ const options = { path: 'file://path/to/file/on/device', method: 'POST', type: 'raw', - headers: { - 'content-type': 'application/octet-stream', // Customize content-type - 'my-custom-header': 's3headervalueorwhateveryouneed', - }, - android: { - notificationChannel: 'my-channel-id', - notificationId: 'my-progress-notification', - notificationTitle: 'Uploading...', - notificationTitleNoWifi: 'Waiting for Wifi...', - notificationTitleNoInternet: 'Waiting for Internet...', - }, - useUtf8Charset: true, + headers: { 'content-type': 'application/octet-stream' }, + // Optional. Treat these non-2xx statuses as success (e.g. an idempotent + // create that conflicts). Any other non-2xx is an 'error' with errorKind 'http'. + acceptStatus: [409], + // Optional on Android — the library supplies notification defaults and creates + // its own channel. Override any of these to customize. + android: { notificationTitle: 'Uploading…' }, }; -Upload.addListener('progress', uploadId, (data) => { - console.log(`Progress: ${data.progress}%`); -}); -Upload.addListener('error', uploadId, (data) => { - console.log(`Error: ${data.error}%`); -}); -Upload.addListener('cancelled', uploadId, (data) => { - console.log(`Cancelled!`); -}); -Upload.addListener('completed', uploadId, (data) => { - // data includes responseCode: number and responseBody: Object - console.log('Completed!'); -}); -Upload.android.addNotificationListener(() => { - console.log('Progress notification pressed!'); -}); - -Upload.startUpload(options) - .then((uploadId) => console.log('Upload started', uploadId)) - .catch((err) => console.log('Upload error!', err)); -``` +const uploadId = await Upload.startUpload(options); -## Multipart Uploads +Upload.addListener('progress', uploadId, ({ progress }) => {}); +Upload.addListener('completed', uploadId, ({ responseCode, responseBody }) => {}); +Upload.addListener('error', uploadId, ({ error, errorKind, responseCode }) => {}); +Upload.addListener('cancelled', uploadId, ({ cancelReason }) => {}); +``` -**🚧 COMING SOON** +# Reliable delivery -Just set the `type` option to `multipart` and set the `field` option. Example: +Terminal events (`completed` / `error` / `cancelled`) are journaled natively +*before* they are emitted, so they survive app death, JS reloads, and background +relaunches. Events stay in the journal until you acknowledge them. Drain it on +every app start: -``` -const options = { - url: 'https://myservice.com/path/to/post', - path: 'file://path/to/file%20on%20device.png', - method: 'POST', - field: 'uploaded_media', - type: 'multipart' +```js +const events = await Upload.getUnacknowledgedEvents(); +for (const e of events) { + // e: { eventId, id, type, timestamp, responseCode?, responseBody?, + // responseHeaders?, error?, errorKind?, cancelReason? } + handleOutcome(e); } +await Upload.ackEvents(events.map((e) => e.eventId)); + +// Then reconcile anything still in flight: +const live = await Upload.getAllUploads(); // [{ id, state, ... }] ``` -Note the `field` property is required for multipart uploads. +Notes: +- **`completed` fires only for 2xx** (or a request's `acceptStatus`). Every other + HTTP response is an `error` with `errorKind: 'http'` and the response attached — + a 400 is an error, not a completion. +- `errorKind` is `'http' | 'network' | 'file' | 'unknown'`. Retry transport + failures; treat client errors as terminal. +- `cancelReason` distinguishes a user cancel (`'user'`) from a system kill + (`'system'`). +- Duplicate journal entries for one upload id are possible if the process dies at + the wrong moment (Android may re-run the worker) — dedupe by `id`, keep latest. +- Android: `getAllUploads()` reflects only live/recent work (WorkManager prunes + finished work after ~a day). The journal is the source of truth for outcomes. # API -## Top Level Functions - -All top-level methods are available as named exports or methods on the default export. - -### startUpload(options) - -The primary method you will use, this starts the upload process. - -Returns a promise with the string ID of the upload. Will reject if the file doesn't exist or unknown native problems. - -`options` is an object with following values: - -_Note: You must provide valid URIs. react-native-background-upload does not escape the values you provide._ - -| Name | Type | Required | Default | Description | Example | -| ---------------- | ------------------------------- | ------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------- | -| `url` | string | Required | | URL to upload to | `https://myservice.com/path/to/post` | -| `path` | string | Required | | File path on device | `file://something/coming/from%20the%20device.png` | -| `type` | 'raw' or 'multipart' | Optional | `raw` | Primary upload type. | | -| `method` | string | Optional | `POST` | HTTP method | | -| `customUploadId` | string | Optional | | `startUpload` returns a Promise that includes the upload ID, which can be used for future status checks. By default, the upload ID is automatically generated. This parameter allows a custom ID to use instead of the default. | | -| `headers` | object | Optional | | HTTP headers | `{ 'Accept': 'application/json' }` | -| `field` | string | Required if `type: 'multipart'` | | The form field name for the file. Only used when `type: 'multipart` | `uploaded-file` | -| `parameters` | object | Optional | | Additional form fields to include in the HTTP request. Only used when `type: 'multipart` | | -| `notification` | Notification object (see below) | Optional | | Android only. | `{ enabled: true, onProgressTitle: "Uploading...", autoClear: true }` | -| `useUtf8Charset` | boolean | Optional | | Android only. Set to true to use `utf-8` as charset. | | -| `appGroup` | string | Optional | iOS only. App group ID needed for share extensions to be able to properly call the library. See: https://developer.apple.com/documentation/foundation/nsfilemanager/1412643-containerurlforsecurityapplicati | - -### Notification Object (Android Only) - -Android forces us to display a progress notification to show overall upload progress. - -| Name | Type | Required | Description | Example | -| ----------------------------- | ------ | -------- | ---------------------------------------------------------------- | --------------------------- | -| `notificationChannel` | string | Optional | Sets android notification channel | `background-upload-channel` | -| `notificationId` | string | Optional | A custom ID for the notification | `upload-progress` | -| `notificationTitle` | string | Optional | Sets the default title for the notification | `Uploading...` | -| `notificationTitleNoWifi` | string | Optional | Sets notification title for uploads awaiting wifi | `Waiting for Wifi...` | -| `notificationTitleNoInternet` | string | Optional | Sets notification title for uploads awaiting internet connection | `Waiting for Internet...` | +All methods are on the default export. -### cancelUpload(uploadId) +### `startUpload(options): Promise` +Starts an upload; resolves to its id. Rejects only on a bad option (missing/invalid +`url` or `path`) — transport failures and HTTP error responses arrive later as +`error` events, not a rejection. -Cancels an upload. +| Option | Type | Notes | +| --- | --- | --- | +| `url` | string | Required. | +| `path` | string | Required. Local file path (`file://…`). URIs are not escaped for you. | +| `type` | `'raw'` | Only `raw` is supported. | +| `method` | string | Default `POST`. | +| `headers` | object | HTTP headers. | +| `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. | -`uploadId` is the result of the Promise returned from `startUpload` +### `cancelUpload(uploadId): Promise` +Cancels an upload. Fires a `cancelled` event with `cancelReason: 'user'`. -Returns a Promise that resolves to an boolean indicating whether the upload was cancelled. +### `addListener(eventType, uploadId | null, listener): EventSubscription` +Listen for `'progress' | 'error' | 'completed' | 'cancelled'`. Pass `null` for +`uploadId` to receive events for all uploads. Call `.remove()` on the result to +unsubscribe. -### addListener(eventType, uploadId, listener) +### `getUnacknowledgedEvents(): Promise` +Terminal events not yet acknowledged, including ones that fired while JS was dead. -Adds an event listener, possibly confined to a single upload. +### `ackEvents(eventIds: string[]): Promise` +Removes journaled events once processed. -`eventType` Event to listen for. Values: 'progress' | 'error' | 'completed' | 'cancelled' +### `getAllUploads(): Promise` +Uploads the OS still knows about, for boot-time reconciliation. -`uploadId` The upload ID from `startUpload` to filter events for. If null, this will include all uploads. +### `ios.getUploadStatus(uploadId)` +iOS-only live task state (`running | suspended | canceling`, plus byte counts), or +`undefined` if the task isn't active. -`listener` Function to call when the event occurs. +### `android.addNotificationListener(listener)` +Fires when the Android progress notification is pressed. No event data. -Returns an [EventSubscription](https://github.com/facebook/react-native/blob/master/Libraries/vendor/emitter/EmitterSubscription.js). To remove the listener, call `remove()` on the `EventSubscription`. +# Events -### android.addNotificationListener(listener) - -When the upload progress notification is pressed, it will open the app and fire this event. -There's no event data for this. - -## Events - -### progress - -Event Data - -| Name | Type | Required | Description | -| ---------- | ------ | -------- | --------------------- | -| `id` | string | Required | The ID of the upload. | -| `progress` | 0-100 | Required | Percentage completed. | - -### error - -Event Data - -| Name | Type | Required | Description | -| ------- | ------ | -------- | --------------------- | -| `id` | string | Required | The ID of the upload. | -| `error` | string | Required | Error message. | - -### completed - -Event Data - -| Name | Type | Required | Description | -| ----------------- | ------ | -------- | ------------------------------- | -| `id` | string | Required | The ID of the upload. | -| `responseCode` | string | Required | HTTP status code received | -| `responseBody` | string | Required | HTTP response body | -| `responseHeaders` | string | Required | HTTP response headers (Android) | - -### cancelled - -Event Data - -| Name | Type | Required | Description | -| ---- | ------ | -------- | --------------------- | -| `id` | string | Required | The ID of the upload. | - -# FAQs - -Does it support iOS camera roll assets? - -> Yes, as of version 4.3.0. - -Does it support multiple file uploads? - -> Yes and No. It supports multiple concurrent uploads, but only a single upload per request. That should be fine for 90%+ of cases. - -Why should I use this file uploader instead of others that I've Googled like [react-native-uploader](https://github.com/aroth/react-native-uploader)? - -> This package has two killer features not found anywhere else (as of 12/16/2016). First, it works on both iOS and Android. Others are iOS only. Second, it supports background uploading. This means that users can background your app and the upload will continue. This does not happen with other uploaders. +| Event | Data | +| --- | --- | +| `progress` | `{ id, progress: 0-100 }` | +| `completed` | `{ id, responseCode, responseBody, responseHeaders?, eventId? }` | +| `error` | `{ id, error, errorKind?, responseCode?, responseBody?, responseHeaders? }` | +| `cancelled` | `{ id, cancelReason?: 'user' | 'system' }` | # Contributing See [CONTRIBUTING.md](./CONTRIBUTING.md). - -# Common Issues - -## Gratitude - -Many thanks to the [Original Library](https://github.com/Vydia/react-native-background-upload) for the boilerplate and inspiration diff --git a/example/RNBGUExample/App.tsx b/example/RNBGUExample/App.tsx index e7e44655..d3147e02 100644 --- a/example/RNBGUExample/App.tsx +++ b/example/RNBGUExample/App.tsx @@ -26,7 +26,7 @@ import * as RNFS from 'react-native-fs'; const TEST_FILE = `${RNFS.DocumentDirectoryPath}/1MB.bin`; const TEST_FILE_URL = 'https://gist.githubusercontent.com/khaykov/a6105154becce4c0530da38e723c2330/raw/41ab415ac41c93a198f7da5b47d604956157c5c3/gistfile1.txt'; -const UPLOAD_URL = 'https://httpbin.org/put/404'; +const UPLOAD_URL = 'https://httpbin.org/post'; const App = () => { const [uploadId, setUploadId] = useState(); @@ -38,13 +38,15 @@ const App = () => { useEffect(() => { Upload.addListener('progress', null, data => { setProgress(data.progress); - console.log(`Progress: ${data.progress}%`); }); Upload.addListener('error', null, data => { - console.log(`Error: ${data.error}%`); + console.log('Error!', JSON.stringify(data)); }); Upload.addListener('completed', null, data => { - console.log('Completed!', data); + console.log('Completed!', JSON.stringify(data)); + }); + Upload.addListener('cancelled', null, data => { + console.log('Cancelled!', JSON.stringify(data)); }); }, []); @@ -143,6 +145,33 @@ const App = () => { }); }} /> + + +