diff --git a/.github/workflows/node.yml b/.github/workflows/node.yml index 7fd6416e..4e31494b 100644 --- a/.github/workflows/node.yml +++ b/.github/workflows/node.yml @@ -1,26 +1,42 @@ -name: Node Environment +name: CI on: [push, pull_request] jobs: - node-lint-tests: + checks: runs-on: ubuntu-latest if: "!contains(github.event.head_commit.message, '[skip ci]')" steps: - - name: checkout - uses: actions/checkout@v2 - - - name: setup node - uses: actions/setup-node@v1 - with: - node-version: 20 - - - name: install node_modules - run: | - yarn install --frozen-lockfile - yarn --cwd example/RNBGUExample install --frozen-lockfile - - - name: node lint - run: - yarn lint:ci + - name: checkout + uses: actions/checkout@v4 + + - name: setup node + uses: actions/setup-node@v4 + with: + node-version: 20 + + - name: install node_modules + run: | + yarn install --frozen-lockfile + yarn --cwd example/RNBGUExample install --frozen-lockfile + + - name: lint + run: yarn lint:ci + + - name: typecheck + run: yarn typecheck + + - name: js tests + run: yarn test + + - name: setup java + uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: 17 + + - name: android unit tests + run: | + cd example/RNBGUExample/android + ./gradlew :react-native-background-upload:testDebugUnitTest 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/CONTRIBUTING.md b/CONTRIBUTING.md index bde7798c..7dd49910 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,8 +1,5 @@ ## Issues -Along with a bug report, provide a functional example repository which -reproduces the bug you're experiencing. We've made this very easy by providing -a full-stack (React Native + Express.js) example app, which you can fork and -alter to reproduce your bug: - -[ReactNativeBackgroundUploadExample](https://github.com/Vydia/ReactNativeBackgroundUploadExample) +Along with a bug report, provide a functional reproduction using the full-stack +(React Native + Express.js) example app in this repo under `example/` — fork and +alter it to reproduce your bug. 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/android/build.gradle b/android/build.gradle index dee87982..591768ac 100755 --- a/android/build.gradle +++ b/android/build.gradle @@ -1,89 +1,55 @@ -buildscript { - ext { - kotlinVersion = '1.6.20' - buildToolsVersion = '29.0.2' - compileSdkVersion = 31 // this helps us use the latest Worker version - targetSdkVersion = 29 - minSdkVersion = 18 - } - ext.detoxKotlinVersion = ext.kotlinVersion - - repositories { - mavenCentral() - google() - } - dependencies { - classpath 'com.android.tools.build:gradle:3.5.4' - classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlinVersion" - } -} - apply plugin: 'com.android.library' apply plugin: 'kotlin-android' +// Generates NativeRNFileUploaderSpec from src/NativeRNFileUploader.ts, per the +// codegenConfig block in package.json. Required for the TurboModule. +apply plugin: 'com.facebook.react' -def DEFAULT_COMPILE_SDK_VERSION = 28 -def DEFAULT_BUILD_TOOLS_VERSION = "28.0.3" -def DEFAULT_TARGET_SDK_VERSION = 28 - +// Inherit the host app's toolchain; the fallbacks match Diana, the only consumer. def safeExtGet(prop, fallback) { rootProject.ext.has(prop) ? rootProject.ext.get(prop) : fallback } android { - buildFeatures { - /* - This is a replacement for the deprecated 'kotlin-android-extensions' library. More - information can be found here: https://developer.android.com/topic/libraries/view-binding/migration - */ - viewBinding = true - } - compileSdkVersion safeExtGet('compileSdkVersion', DEFAULT_COMPILE_SDK_VERSION) - buildToolsVersion safeExtGet('buildToolsVersion', DEFAULT_BUILD_TOOLS_VERSION) + namespace 'ai.openspace.backgroundupload' + + compileSdkVersion safeExtGet('compileSdkVersion', 36) defaultConfig { - minSdkVersion 24 - targetSdkVersion safeExtGet('targetSdkVersion', DEFAULT_TARGET_SDK_VERSION) - versionCode 1 - versionName "1.0" - ndk { - abiFilters "armeabi-v7a", "x86" - } - } - lintOptions { - abortOnError false + minSdkVersion safeExtGet('minSdkVersion', 29) + targetSdkVersion safeExtGet('targetSdkVersion', 36) + // Keeps the Gson-persisted models intact under the host app's R8. See the + // file for why a minified build breaks silently without it. + consumerProguardFiles 'consumer-rules.pro' } + compileOptions { sourceCompatibility JavaVersion.VERSION_17 targetCompatibility JavaVersion.VERSION_17 } + kotlinOptions { - jvmTarget = "17" + jvmTarget = '17' + } + + lint { + abortOnError false } } repositories { + google() mavenCentral() } -def _ext = ext - -def _kotlinVersion = _ext.has('detoxKotlinVersion') ? _ext.detoxKotlinVersion : '1.3.10' -def _kotlinStdlib = _ext.has('detoxKotlinStdlib') ? _ext.detoxKotlinStdlib : 'kotlin-stdlib-jdk8' - dependencies { - implementation "androidx.core:core-ktx:1.7.0" - - implementation 'com.facebook.react:react-native:+' - - implementation "org.jetbrains.kotlin:$_kotlinStdlib:$_kotlinVersion" - - implementation("com.squareup.okhttp3:okhttp:4.10.0") - - implementation 'com.google.code.gson:gson:2.8.9' - - implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-core:1.6.4' + // Version supplied by the React Native Gradle plugin. + implementation 'com.facebook.react:react-android' - implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.6.4' + implementation 'androidx.core:core-ktx:1.13.1' + implementation 'androidx.work:work-runtime-ktx:2.9.1' + implementation 'com.squareup.okhttp3:okhttp:4.12.0' + implementation 'com.google.code.gson:gson:2.11.0' + implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.1' - implementation "androidx.work:work-runtime-ktx:2.8.1" + testImplementation 'junit:junit:4.13.2' } diff --git a/android/consumer-rules.pro b/android/consumer-rules.pro new file mode 100644 index 00000000..3da6e998 --- /dev/null +++ b/android/consumer-rules.pro @@ -0,0 +1,25 @@ +# Shipped to consumers via consumerProguardFiles, so a minified release build of +# the host app keeps these guarantees. +# +# Upload and EventJournal.Entry are persisted with Gson — Upload into WorkManager +# input data, Entry into the on-disk event journal — and read back later, across +# app restarts AND across app updates. Gson resolves fields reflectively by name +# and needs the generic Signature attribute to reconstruct typed collections, so +# R8 renaming either one corrupts persisted state silently: +# +# * Upload.acceptStatus is a List. Without Signature, Gson deserializes it +# as List, so acceptStatus.contains(code) never matches and a +# configured accept status (e.g. 409) is reported as an http error instead of +# a completed upload. +# * A journal Entry written by an older build fails to parse if field names +# changed, and is then dropped as malformed — losing exactly the terminal +# outcomes the journal exists to preserve. +# +# Debug builds are unminified and round-trip symmetrically, so neither failure is +# reproducible without R8; keep these rules. +-keepattributes Signature +-keepattributes *Annotation* + +-keep class ai.openspace.backgroundupload.Upload { *; } +-keep class ai.openspace.backgroundupload.Upload$* { *; } +-keep class ai.openspace.backgroundupload.EventJournal$Entry { *; } diff --git a/android/src/main/AndroidManifest.xml b/android/src/main/AndroidManifest.xml index e62ef6f8..675d7a34 100644 --- a/android/src/main/AndroidManifest.xml +++ b/android/src/main/AndroidManifest.xml @@ -1,6 +1,7 @@ + + xmlns:tools="http://schemas.android.com/tools"> diff --git a/android/src/main/java/ai/openspace/backgroundupload/EventJournal.kt b/android/src/main/java/ai/openspace/backgroundupload/EventJournal.kt new file mode 100644 index 00000000..5bbc0738 --- /dev/null +++ b/android/src/main/java/ai/openspace/backgroundupload/EventJournal.kt @@ -0,0 +1,140 @@ +package ai.openspace.backgroundupload + +import android.content.Context +import com.google.gson.Gson +import java.io.File + +// Durable record of terminal upload events (completed / error / cancelled). +// Written BEFORE the event is emitted to JS; deleted only when JS acknowledges. +// One JSON file per event named .json — tmp+rename keeps each write +// self-contained so a crash mid-append can never corrupt other entries. +// +// `maxEntries` is a runaway guard: the design assumes JS drains the journal via +// ack() on every boot, but if that loop breaks (or a consumer hasn't adopted it +// yet) the directory would grow without bound. When exceeded we drop the OLDEST +// entries. Set high enough that legitimate heavy offline use won't hit it — this +// only fires in the pathological "nothing ever acks" case. +class EventJournal( + private val dir: File, + private val maxEntries: Int = MAX_ENTRIES, +) { + + data class Entry( + val eventId: String, + val uploadId: String, + val type: String, // completed | error | cancelled + val timestamp: Long, + val responseCode: Int? = null, + val responseBody: String? = null, + val responseBodyTruncated: Boolean = false, + val responseHeaders: Map? = null, + val error: String? = null, + val errorKind: String? = null, // http | network | file | unknown + val cancelReason: String? = null, // user | system + ) { + fun toWritableMap(): com.facebook.react.bridge.WritableMap = + com.facebook.react.bridge.Arguments.createMap().apply { + putString("eventId", eventId) + putString("id", uploadId) + putString("type", type) + putDouble("timestamp", timestamp.toDouble()) + responseCode?.let { putInt("responseCode", it) } + responseBody?.let { putString("responseBody", it) } + if (responseBodyTruncated) putBoolean("responseBodyTruncated", true) + responseHeaders?.let { + putMap("responseHeaders", com.facebook.react.bridge.Arguments.makeNativeMap(it)) + } + error?.let { putString("error", it) } + errorKind?.let { putString("errorKind", it) } + cancelReason?.let { putString("cancelReason", it) } + } + } + + companion object { + const val MAX_BODY_CHARS = 64 * 1024 + const val MAX_ENTRIES = 1000 + private val gson = Gson() + + // Char-count cap (not byte-accurate: splitting on a byte boundary risks + // cutting a surrogate pair; a slightly loose cap is fine as a safety limit). + // Returns the (possibly truncated) body and whether truncation occurred. + // Single source of truth so the journaled copy and the live-emitted copy match. + fun capBody(body: String?): Pair = + if (body != null && body.length > MAX_BODY_CHARS) + body.substring(0, MAX_BODY_CHARS) to true + else body to false + + @Volatile + private var instance: EventJournal? = null + + // The worker may run in a process where React never initialized, so the + // journal must be reachable from a bare Context, not the module. + fun get(context: Context): EventJournal = + instance ?: synchronized(this) { + instance + ?: EventJournal(File(context.filesDir, "rnbgupload-events")).also { instance = it } + } + } + + init { + dir.mkdirs() + } + + @Synchronized + fun append(entry: Entry) { + // Defensive cap in case a caller didn't pre-cap; idempotent when it did. + val (body, truncated) = capBody(entry.responseBody) + val bounded = + if (truncated) entry.copy(responseBody = body, responseBodyTruncated = true) else entry + // A journal write must NEVER throw into the caller. The worker calls this + // right after a successful upload; a propagated IOException (e.g. disk full) + // would be classified as a retryable error and re-run the upload, sending + // duplicate data to the server. Losing one journal entry is the lesser evil. + try { + val tmp = File(dir, "${entry.eventId}.tmp") + tmp.writeText(gson.toJson(bounded)) + tmp.renameTo(File(dir, "${entry.eventId}.json")) + } catch (t: Throwable) { + t.printStackTrace() + return + } + pruneToMax() + } + + // Keep the directory bounded. Prune by file modification time (no parsing) + // rather than the entry's own timestamp — cheaper, and close enough since a + // file's mtime is when it was journaled. Guarded: a prune failure must not + // propagate for the same reason append() must not. + private fun pruneToMax() { + try { + // Sweep orphaned .tmp files (writeText succeeded but rename failed). + dir.listFiles { f -> f.extension == "tmp" }?.forEach { it.delete() } + val files = dir.listFiles { f -> f.extension == "json" } ?: return + if (files.size <= maxEntries) return + files.sortedBy { it.lastModified() } + .take(files.size - maxEntries) + .forEach { it.delete() } + } catch (t: Throwable) { + t.printStackTrace() + } + } + + @Synchronized + @Suppress("SENSELESS_COMPARISON") // Gson can inject null into a non-null field + fun unacknowledged(): List = + (dir.listFiles { f -> f.extension == "json" } ?: emptyArray()) + .mapNotNull { f -> + runCatching { gson.fromJson(f.readText(), Entry::class.java) }.getOrNull() + } + // Gson bypasses the constructor, so a file missing a field yields null + // despite the non-null Kotlin type. Check every field JS relies on being + // present, not just eventId — an entry reaching JS with a null `type` + // would fall silently through a `switch (event.type)`. + .filter { it.eventId != null && it.uploadId != null && it.type != null } + .sortedBy { it.timestamp } + + @Synchronized + fun ack(eventIds: List) { + eventIds.forEach { File(dir, "$it.json").delete() } + } +} diff --git a/android/src/main/java/ai/openspace/backgroundupload/EventReporter.kt b/android/src/main/java/ai/openspace/backgroundupload/EventReporter.kt new file mode 100644 index 00000000..a4770c78 --- /dev/null +++ b/android/src/main/java/ai/openspace/backgroundupload/EventReporter.kt @@ -0,0 +1,39 @@ +package ai.openspace.backgroundupload + +import com.facebook.react.bridge.Arguments + +// Sends live events to JS through the module's codegen event emitters. Terminal +// outcomes are journaled before they reach here, so when JS is absent (headless +// worker, mid-reload) dropping the live event costs nothing — the consumer picks +// it up from getUnacknowledgedEvents instead. +object EventReporter { + + // Emit a terminal event from its journal entry, so the live event carries the + // exact same payload (incl. eventId) as the journaled copy — letting a consumer + // ackEvents([eventId]) right after handling a live event, and keeping iOS/Android + // event shapes identical. + fun emit(entry: EventJournal.Entry) { + val module = UploaderModule.instance ?: return + val params = entry.toWritableMap() + when (entry.type) { + "completed" -> module.emitCompletedEvent(params) + "cancelled" -> module.emitCancelledEvent(params) + else -> module.emitErrorEvent(params) + } + } + + fun progress(uploadId: String, bytesSentTotal: Long, contentLength: Long) { + val module = UploaderModule.instance ?: return + module.emitProgressEvent(Arguments.createMap().apply { + putString("id", uploadId) + // Guard against a zero-byte file (contentLength == 0) producing NaN. + val pct = if (contentLength <= 0) 0.0 else bytesSentTotal.toDouble() * 100 / contentLength + putDouble("progress", pct) // 0-100 + }) + } + + fun notification() { + val module = UploaderModule.instance ?: return + module.emitNotificationEvent(Arguments.createMap()) + } +} diff --git a/android/src/main/java/com/vydia/RNUploader/NotificationReceiver.kt b/android/src/main/java/ai/openspace/backgroundupload/NotificationReceiver.kt similarity index 94% rename from android/src/main/java/com/vydia/RNUploader/NotificationReceiver.kt rename to android/src/main/java/ai/openspace/backgroundupload/NotificationReceiver.kt index e519faaf..945d59e8 100644 --- a/android/src/main/java/com/vydia/RNUploader/NotificationReceiver.kt +++ b/android/src/main/java/ai/openspace/backgroundupload/NotificationReceiver.kt @@ -1,4 +1,4 @@ -package com.vydia.RNUploader +package ai.openspace.backgroundupload import android.content.BroadcastReceiver import android.content.Context diff --git a/android/src/main/java/com/vydia/RNUploader/Upload.kt b/android/src/main/java/ai/openspace/backgroundupload/Upload.kt similarity index 67% rename from android/src/main/java/com/vydia/RNUploader/Upload.kt rename to android/src/main/java/ai/openspace/backgroundupload/Upload.kt index db7b83d5..f314b1f9 100644 --- a/android/src/main/java/com/vydia/RNUploader/Upload.kt +++ b/android/src/main/java/ai/openspace/backgroundupload/Upload.kt @@ -1,4 +1,4 @@ -package com.vydia.RNUploader +package ai.openspace.backgroundupload import com.facebook.react.bridge.ReadableMap import java.util.UUID @@ -13,6 +13,10 @@ data class Upload( val method: String, val maxRetries: Int, val wifiOnly: Boolean, + // Non-2xx statuses to treat as a successful completion (e.g. [409] when + // duplicate-create conflicts are expected). Everything else non-2xx is a + // terminal http error. Empty by default. + val acceptStatus: List, val headers: Map, val notificationId: Int, val notificationTitle: String, @@ -24,6 +28,8 @@ data class Upload( IllegalArgumentException("Missing '$optionName'") companion object { + const val DEFAULT_NOTIFICATION_CHANNEL = "background-upload" + fun fromReadableMap(map: ReadableMap) = Upload( id = map.getString("customUploadId") ?: UUID.randomUUID().toString(), url = map.getString(Upload::url.name) ?: throw MissingOptionException(Upload::url.name), @@ -31,6 +37,9 @@ data class Upload( method = map.getString(Upload::method.name) ?: "POST", maxRetries = if (map.hasKey(Upload::maxRetries.name)) map.getInt(Upload::maxRetries.name) else 5, wifiOnly = if (map.hasKey(Upload::wifiOnly.name)) map.getBoolean(Upload::wifiOnly.name) else false, + acceptStatus = map.getArray(Upload::acceptStatus.name)?.let { arr -> + (0 until arr.size()).map { i -> arr.getInt(i) } + } ?: listOf(), headers = map.getMap(Upload::headers.name).let { headers -> if (headers == null) return@let mapOf() val map = mutableMapOf() @@ -39,16 +48,18 @@ data class Upload( } return@let map }, - notificationId = map.getString(Upload::notificationId.name)?.hashCode() - ?: throw MissingOptionException(Upload::notificationId.name), + // Notification options are optional: the library supplies sensible defaults + // and creates its own channel, so consumers don't need any notifee plumbing. + notificationId = (map.getString(Upload::notificationId.name) + ?: DEFAULT_NOTIFICATION_CHANNEL).hashCode(), notificationTitle = map.getString(Upload::notificationTitle.name) - ?: throw MissingOptionException(Upload::notificationTitle.name), + ?: "Uploading…", notificationTitleNoInternet = map.getString(Upload::notificationTitleNoInternet.name) - ?: throw MissingOptionException(Upload::notificationTitleNoInternet.name), + ?: "Waiting for connection…", notificationTitleNoWifi = map.getString(Upload::notificationTitleNoWifi.name) - ?: throw MissingOptionException(Upload::notificationTitleNoWifi.name), + ?: "Waiting for Wi-Fi…", notificationChannel = map.getString(Upload::notificationChannel.name) - ?: throw MissingOptionException(Upload::notificationChannel.name), + ?: DEFAULT_NOTIFICATION_CHANNEL, ) } } diff --git a/android/src/main/java/ai/openspace/backgroundupload/UploadOutcome.kt b/android/src/main/java/ai/openspace/backgroundupload/UploadOutcome.kt new file mode 100644 index 00000000..d044ac9e --- /dev/null +++ b/android/src/main/java/ai/openspace/backgroundupload/UploadOutcome.kt @@ -0,0 +1,25 @@ +package ai.openspace.backgroundupload + +import java.io.IOException + +// Pure classification of terminal upload outcomes. Kept free of Android/React +// types so it can be unit-tested on a plain JVM — this is the highest-consequence +// logic in the uploader (it decides success vs failure), so it's covered directly. +object UploadOutcome { + + // Whether an HTTP response counts as a successful completion. 2xx always, plus + // any per-request acceptStatus codes (axios validateStatus semantics). Anything + // else — including 4xx/5xx — is a terminal http error, not a completion. + fun isAccepted(code: Int, acceptStatus: List): Boolean = + code in 200..299 || acceptStatus.contains(code) + + // Classify a thrown error into a stable kind for the JS layer. `fileExists` + // is passed in (not read here) to keep this pure; callers should default it to + // true when the existence check itself fails, so a flaky file probe reads as a + // retryable network error rather than a terminal "file gone". + fun errorKind(error: Throwable, fileExists: Boolean): String = when { + error is IOException && !fileExists -> "file" + error is IOException -> "network" + else -> "unknown" + } +} diff --git a/android/src/main/java/com/vydia/RNUploader/UploadProgress.kt b/android/src/main/java/ai/openspace/backgroundupload/UploadProgress.kt similarity index 97% rename from android/src/main/java/com/vydia/RNUploader/UploadProgress.kt rename to android/src/main/java/ai/openspace/backgroundupload/UploadProgress.kt index 03b9455b..2363dbc1 100644 --- a/android/src/main/java/com/vydia/RNUploader/UploadProgress.kt +++ b/android/src/main/java/ai/openspace/backgroundupload/UploadProgress.kt @@ -1,4 +1,4 @@ -package com.vydia.RNUploader +package ai.openspace.backgroundupload import android.os.Handler import android.os.Looper diff --git a/android/src/main/java/com/vydia/RNUploader/UploadUtils.kt b/android/src/main/java/ai/openspace/backgroundupload/UploadUtils.kt similarity index 98% rename from android/src/main/java/com/vydia/RNUploader/UploadUtils.kt rename to android/src/main/java/ai/openspace/backgroundupload/UploadUtils.kt index e26e2ec1..44696541 100644 --- a/android/src/main/java/com/vydia/RNUploader/UploadUtils.kt +++ b/android/src/main/java/ai/openspace/backgroundupload/UploadUtils.kt @@ -1,4 +1,4 @@ -package com.vydia.RNUploader +package ai.openspace.backgroundupload import kotlinx.coroutines.suspendCancellableCoroutine import okhttp3.Call diff --git a/android/src/main/java/com/vydia/RNUploader/UploadWorker.kt b/android/src/main/java/ai/openspace/backgroundupload/UploadWorker.kt similarity index 66% rename from android/src/main/java/com/vydia/RNUploader/UploadWorker.kt rename to android/src/main/java/ai/openspace/backgroundupload/UploadWorker.kt index a3bbec7c..b3fd2a1c 100644 --- a/android/src/main/java/com/vydia/RNUploader/UploadWorker.kt +++ b/android/src/main/java/ai/openspace/backgroundupload/UploadWorker.kt @@ -1,6 +1,7 @@ -package com.vydia.RNUploader +package ai.openspace.backgroundupload import android.app.Notification +import android.app.NotificationChannel import android.app.NotificationManager import android.app.PendingIntent import android.content.Context @@ -24,6 +25,7 @@ import okhttp3.OkHttpClient import java.io.File import java.io.IOException import java.net.UnknownHostException +import java.util.UUID import java.util.concurrent.TimeUnit // All workers will start `doWork` immediately but only 1 request is active at a time. @@ -53,7 +55,18 @@ private enum class Connectivity { NoWifi, NoInternet, Ok } class UploadWorker(private val context: Context, params: WorkerParameters) : CoroutineWorker(context, params) { - enum class Input { Params } + companion object { + /** + * Key for the serialized [Upload] in the worker's input data. + * + * A string literal on purpose. This key is persisted in WorkManager's + * database, so the build that runs a job may not be the build that enqueued + * it — a key derived from a symbol name (an enum constant, a property) breaks + * the moment R8 renames it or someone refactors, and the failure looks like + * "No Params" on a job that was queued perfectly well by the previous version. + */ + const val PARAMS_KEY = "params" + } private lateinit var upload: Upload private var retries = 0 @@ -65,11 +78,14 @@ class UploadWorker(private val context: Context, params: WorkerParameters) : // Retrieve the upload. If this throws errors, error reporting won't work. // However, the only way it has errors is the implementation is incorrect, // which can be caught in development - val paramsJson = inputData.getString(Input.Params.name) ?: throw Throwable("No Params") + val paramsJson = inputData.getString(PARAMS_KEY) ?: throw Throwable("No Params") upload = Gson().fromJson(paramsJson, Upload::class.java) // 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. @@ -92,17 +108,17 @@ class UploadWorker(private val context: Context, params: WorkerParameters) : // - "delay" should be within the "try" block to account for worker cancellation, // which cancels the delay immediately and throws CancellationException. // - Linear backoff instead of exponential. One reason for this is we retry on - // invalid connections. Exponential will take too long. If the server flakes and - // returns 500s, we don't retry but consider the request successful. - // This is consistent with iOS behavior. User gets notifications for - // these server issues and can manually retry. Since 500s are currently rare, - // it's likely ok. If they're too frequent, we can consider adding exponential - // backoff for them. + // invalid connections. Exponential will take too long. + // - We only retry transport failures here (no response). Any HTTP response, + // including 4xx/5xx, is terminal at this layer: handleResponse classifies it + // (2xx/acceptStatus -> completed, else http error) and the worker returns + // without retrying. Response-code-based retry policy is the JS queue's job. + // This is consistent with iOS behavior. if (isRetried) delay(RETRY_DELAY) isRetried = true val response = upload() ?: continue - handleSuccess(response) + handleResponse(response) return@withContext Result.success() } catch (error: Throwable) { if (checkAndHandleCancellation()) throw error @@ -150,14 +166,44 @@ class UploadWorker(private val context: Context, params: WorkerParameters) : notificationManager.notify(upload.notificationId, buildNotification()) } - private fun handleSuccess(response: UploadResponse) { + // An HTTP response came back. "completed" only for 2xx or a per-request + // acceptStatus code (axios validateStatus semantics — a 400 is an error, not a + // completion); anything else is a terminal http error carrying the full + // response. Either way the request finished, so the worker does not retry. + private fun handleResponse(response: UploadResponse) { UploadProgress.complete(upload.id) - EventReporter.success(upload.id, response) + val accepted = UploadOutcome.isAccepted(response.code, upload.acceptStatus) + val (body, truncated) = EventJournal.capBody(response.body) + journalAndEmit( + EventJournal.Entry( + eventId = UUID.randomUUID().toString(), + uploadId = upload.id, + type = if (accepted) "completed" else "error", + timestamp = System.currentTimeMillis(), + responseCode = response.code, + responseBody = body, + responseBodyTruncated = truncated, + responseHeaders = response.headers, + errorKind = if (accepted) null else "http", + error = if (accepted) null else "HTTP ${response.code}", + ) + ) } private fun handleError(error: Throwable) { UploadProgress.remove(upload.id) - EventReporter.error(upload.id, error) + // Default fileExists=true so a failed existence probe reads as network, not file. + val fileExists = runCatching { File(upload.path).exists() }.getOrDefault(true) + journalAndEmit( + EventJournal.Entry( + eventId = UUID.randomUUID().toString(), + uploadId = upload.id, + type = "error", + timestamp = System.currentTimeMillis(), + error = error.message ?: "Unknown exception", + errorKind = UploadOutcome.errorKind(error, fileExists), + ) + ) } // Check if cancelled by user or new worker with same ID @@ -166,10 +212,43 @@ class UploadWorker(private val context: Context, params: WorkerParameters) : if (!isStopped) return false UploadProgress.remove(upload.id) - EventReporter.cancelled(upload.id) + + // Only a user cancel is terminal, so only a user cancel is journaled. + // + // WorkManager decides whether to reschedule BEFORE it stops the worker, and + // it ignores the Result we return. cancelUniqueWork marks the row CANCELLED + // first, so a user cancel is genuinely the end. A system stop — a + // foreground-service timeout, quota, or memory pressure — leaves the row + // RUNNING and WorkManager re-runs this same upload. Journaling a terminal + // `cancelled` there would durably tell JS the upload was dead while it was in + // fact about to be retried, so the consumer would settle the transfer and the + // retry would land as a duplicate on the server. + // + // Emitting nothing is the honest answer for a system stop: the upload is + // still in flight as far as anyone should be concerned. If WorkManager ever + // declines to reschedule, `getAllUploads()` is how a consumer notices. + if (!UserCancellations.consume(upload.id)) return true + + journalAndEmit( + EventJournal.Entry( + eventId = UUID.randomUUID().toString(), + uploadId = upload.id, + type = "cancelled", + timestamp = System.currentTimeMillis(), + cancelReason = "user", + ) + ) return true } + // Journal before emitting: the journal is the durable record (survives JS being + // dead); the live emit is best-effort. Both carry the identical payload, so a + // consumer can ack a live event by its eventId. + private fun journalAndEmit(entry: EventJournal.Entry) { + EventJournal.get(context).append(entry) + EventReporter.emit(entry) + } + /** @return whether to retry */ private fun checkRetry(error: Throwable): Boolean { var unlimitedRetry = false @@ -208,6 +287,21 @@ class UploadWorker(private val context: Context, params: WorkerParameters) : return this.connectivity == Connectivity.Ok } + // Ensures the channel used by the foreground notification exists. Only creates + // it when absent, so a channel the consumer registered themselves (with their + // own name/importance) always wins; when they pass nothing we fall back to a + // default LOW-importance channel and no notifee setup is required. + private fun ensureNotificationChannel() { + // minSdk is 29, so NotificationChannel (API 26) is always available. + if (notificationManager.getNotificationChannel(upload.notificationChannel) != null) return + val channel = NotificationChannel( + upload.notificationChannel, + "Uploads", + NotificationManager.IMPORTANCE_LOW, + ) + notificationManager.createNotificationChannel(channel) + } + // builds the notification required to enable Foreground mode fun buildNotification(): Notification { val channel = upload.notificationChannel diff --git a/android/src/main/java/ai/openspace/backgroundupload/UploaderModule.kt b/android/src/main/java/ai/openspace/backgroundupload/UploaderModule.kt new file mode 100644 index 00000000..aed7f4e3 --- /dev/null +++ b/android/src/main/java/ai/openspace/backgroundupload/UploaderModule.kt @@ -0,0 +1,267 @@ +package ai.openspace.backgroundupload + +import android.util.Log +import androidx.work.ExistingWorkPolicy +import androidx.work.OneTimeWorkRequestBuilder +import androidx.work.WorkInfo +import androidx.work.WorkManager +import androidx.work.workDataOf +import com.facebook.react.bridge.Arguments +import com.facebook.react.bridge.Promise +import com.facebook.react.bridge.ReactApplicationContext +import com.facebook.react.bridge.ReadableArray +import com.facebook.react.bridge.ReadableMap +import com.facebook.react.bridge.WritableMap +import com.google.gson.Gson +import java.util.UUID + + +/** + * TurboModule (New Architecture). [NativeRNFileUploaderSpec] is generated by + * codegen from `src/NativeRNFileUploader.ts` into this same package (see + * `codegenConfig.android.javaPackageName` in package.json), so it needs no import. + */ +class UploaderModule(context: ReactApplicationContext) : + NativeRNFileUploaderSpec(context) { + + companion object { + const val NAME = "RNFileUploader" + const val TAG = "RNFileUploader.UploaderModule" + const val WORKER_TAG = "RNFileUploader" + // WorkInfo exposes tags but not the unique-work name, so the upload id is + // also stored as a prefixed tag to recover it in getAllUploads. + const val ID_TAG_PREFIX = "RNFileUploaderId:" + + // The live module, so EventReporter can reach the codegen emitters — they are + // protected on the generated spec, so only this class may call them. Null + // whenever JS is absent (headless worker, mid-reload); terminal outcomes are + // journaled before being emitted, so a dropped live event is never lost. + // + // Volatile: written on the module-creation thread and read from the + // WorkManager worker, OkHttp callbacks and main, with no other barrier. + @Volatile + var instance: UploaderModule? = null + private set + } + + private val workManager = WorkManager.getInstance(context) + + init { + instance = this + } + + override fun invalidate() { + // A reload constructs the replacement before tearing this one down, so only + // clear the pointer when it still refers to us. + if (instance === this) instance = null + super.invalidate() + } + + override fun getName(): String = NAME + + + // MARK: - Event emission (called by EventReporter) + + fun emitProgressEvent(params: WritableMap) = safeEmit { emitOnProgress(params) } + + fun emitCompletedEvent(params: WritableMap) = safeEmit { emitOnCompleted(params) } + + fun emitErrorEvent(params: WritableMap) = safeEmit { emitOnError(params) } + + fun emitCancelledEvent(params: WritableMap) = safeEmit { emitOnCancelled(params) } + + fun emitNotificationEvent(params: WritableMap) = safeEmit { emitOnNotification(params) } + + private inline fun safeEmit(emit: () -> Unit) { + try { + emit() + } catch (exc: NullPointerException) { + // The generated spec's emitter callback is only installed when the C++ + // TurboModule is constructed, and is gone once the runtime tears down, so a + // null callback is expected in both gaps. It is ALSO null for the whole + // process on the old architecture, where this module still registers and its + // methods work but no event can ever be delivered — hence warn, not debug, + // so that case is diagnosable instead of silent. + Log.w(TAG, "live event dropped (no event emitter — New Architecture required)") + } catch (exc: Throwable) { + // Anything else is a real bridging or payload failure worth seeing. + Log.e(TAG, "failed to emit live event", exc) + } + } + + + /** + * Returns terminal events (completed/error/cancelled) that JS has not yet + * acknowledged, including ones that fired while JS was dead. Read these on + * startup, process them, then call ackEvents to remove them. + */ + override fun getUnacknowledgedEvents(promise: Promise) { + try { + val events = EventJournal.get(reactApplicationContext).unacknowledged() + val arr = Arguments.createArray() + events.forEach { arr.pushMap(it.toWritableMap()) } + promise.resolve(arr) + } catch (exc: Throwable) { + Log.e(TAG, exc.message, exc) + promise.reject(exc) + } + } + + + /** + * Removes journaled events by eventId once JS has processed them. + */ + override fun ackEvents(ids: ReadableArray, promise: Promise) { + try { + val eventIds = (0 until ids.size()).mapNotNull { ids.getString(it) } + EventJournal.get(reactApplicationContext).ack(eventIds) + promise.resolve(true) + } catch (exc: Throwable) { + Log.e(TAG, exc.message, exc) + promise.reject(exc) + } + } + + + /** + * Enumerates uploads WorkManager still knows about, as [{ id, state }]. + * WorkManager auto-prunes finished work after roughly a day, so this is for + * reconciling live/recent uploads — terminal outcomes must be read from + * getUnacknowledgedEvents, which is durable until acknowledged. + */ + override fun getAllUploads(promise: Promise) { + try { + val infos = workManager.getWorkInfosByTag(WORKER_TAG).get() + val arr = Arguments.createArray() + for (info in infos) { + val id = info.tags.firstOrNull { it.startsWith(ID_TAG_PREFIX) } + ?.removePrefix(ID_TAG_PREFIX) ?: continue + arr.pushMap(Arguments.createMap().apply { + putString("id", id) + putString( + "state", + when (info.state) { + WorkInfo.State.ENQUEUED, WorkInfo.State.BLOCKED -> "pending" + WorkInfo.State.RUNNING -> "running" + WorkInfo.State.SUCCEEDED -> "completed" + WorkInfo.State.FAILED -> "error" + WorkInfo.State.CANCELLED -> "cancelled" + }, + ) + }) + } + promise.resolve(arr) + } catch (exc: Throwable) { + Log.e(TAG, exc.message, exc) + promise.reject(exc) + } + } + + + /** + * iOS-only: there is no per-task byte counter to read on Android, where uploads + * are WorkManager jobs rather than URLSession tasks. Use getAllUploads for + * liveness and the progress event for bytes. + */ + override fun getUploadStatus(id: String, promise: Promise) { + promise.resolve(null) + } + + + /* + * Starts a file upload. + * Returns a promise with the string ID of the upload. + */ + override fun startUpload(options: ReadableMap, promise: Promise) { + try { + val id = enqueueUpload(options) + promise.resolve(id) + } catch (exc: Throwable) { + if (exc !is Upload.MissingOptionException) { + exc.printStackTrace() + Log.e(TAG, exc.message, exc) + } + promise.reject(exc) + } + } + + /** + * @return the id of the enqueued upload + */ + private fun enqueueUpload(options: ReadableMap): String { + val upload = Upload.fromReadableMap(options) + val data = Gson().toJson(upload) + + // Clear any stale user-cancel mark for this (possibly reused customUploadId) + // from a prior life, so a later system stop of this fresh upload isn't + // misreported as a user cancel. Done here (before enqueue), never in the + // worker, so a real cancel arriving as the worker starts can't be erased. + UserCancellations.consume(upload.id) + + val request = OneTimeWorkRequestBuilder() + .addTag(WORKER_TAG) + .addTag(ID_TAG_PREFIX + upload.id) + .setInputData(workDataOf(UploadWorker.PARAMS_KEY to data)) + .build() + + workManager + // Using KEEP policy to prevent it from cancelling the work if it's already running. + // Otherwise, it will emit "cancelled" and then go on to emit "progress" events, + // which is confusing and quite difficult to manage. "cancelled" should be reserved for + // when the user explicitly cancels the upload. + .beginUniqueWork(upload.id, ExistingWorkPolicy.KEEP, request) + .enqueue() + + return upload.id + } + + + /* + * Cancels file upload + * Accepts upload ID as a first argument, this upload will be cancelled + * Event "cancelled" will be fired when upload is cancelled. + */ + override fun cancelUpload(id: String, promise: Promise) { + try { + val active = workManager.getWorkInfosForUniqueWork(id).get() + .firstOrNull { !it.state.isFinished } + + if (active == null) { + // Nothing to cancel. Drop any mark so a later upload reusing this + // customUploadId can't be misreported as a user cancel. + UserCancellations.consume(id) + promise.resolve(false) + + return + } + + // Record intent BEFORE cancelling so the worker's stop handler can tell + // this apart from a system stop and report cancelReason 'user'. + UserCancellations.mark(id) + workManager.cancelUniqueWork(id) + + if (active.state == WorkInfo.State.ENQUEUED) { + // The worker never started, so it will never run its own stop handler and + // nothing else would ever report this cancellation — leaving a consumer + // awaiting this upload's outcome forever. Report it here instead, and + // consume the mark so it cannot leak. + UserCancellations.consume(id) + val entry = EventJournal.Entry( + eventId = UUID.randomUUID().toString(), + uploadId = id, + type = "cancelled", + timestamp = System.currentTimeMillis(), + cancelReason = "user", + ) + EventJournal.get(reactApplicationContext).append(entry) + EventReporter.emit(entry) + } + + promise.resolve(true) + } catch (exc: Throwable) { + exc.printStackTrace() + Log.e(TAG, exc.message, exc) + promise.reject(exc) + } + } +} diff --git a/android/src/main/java/ai/openspace/backgroundupload/UploaderReactPackage.kt b/android/src/main/java/ai/openspace/backgroundupload/UploaderReactPackage.kt new file mode 100644 index 00000000..96193a35 --- /dev/null +++ b/android/src/main/java/ai/openspace/backgroundupload/UploaderReactPackage.kt @@ -0,0 +1,29 @@ +package ai.openspace.backgroundupload + +import com.facebook.react.BaseReactPackage +import com.facebook.react.bridge.NativeModule +import com.facebook.react.bridge.ReactApplicationContext +import com.facebook.react.module.model.ReactModuleInfo +import com.facebook.react.module.model.ReactModuleInfoProvider + +class UploaderReactPackage : BaseReactPackage() { + + override fun getModule(name: String, reactContext: ReactApplicationContext): NativeModule? = + if (name == UploaderModule.NAME) UploaderModule(reactContext) else null + + override fun getReactModuleInfoProvider() = ReactModuleInfoProvider { + mapOf( + UploaderModule.NAME to ReactModuleInfo( + name = UploaderModule.NAME, + className = UploaderModule.NAME, + canOverrideExistingModule = false, + // Created on first JS access. Uploads outlive the module (WorkManager + // runs them, the journal records their outcomes), so nothing is lost by + // not constructing it at startup. + needsEagerInit = false, + isCxxModule = false, + isTurboModule = true, + ), + ) + } +} diff --git a/android/src/main/java/ai/openspace/backgroundupload/UserCancellations.kt b/android/src/main/java/ai/openspace/backgroundupload/UserCancellations.kt new file mode 100644 index 00000000..5b19cea6 --- /dev/null +++ b/android/src/main/java/ai/openspace/backgroundupload/UserCancellations.kt @@ -0,0 +1,17 @@ +package ai.openspace.backgroundupload + +// Upload ids the JS side explicitly cancelled. Consulted by the worker to +// distinguish user cancels from system kills (WorkManager 2.8.1 has no +// getStopReason). Same-process only: a user cancel always originates from live +// JS, so the set never needs to persist across process death. +object UserCancellations { + private val ids = mutableSetOf() + + @Synchronized + fun mark(id: String) { + ids.add(id) + } + + @Synchronized + fun consume(id: String): Boolean = ids.remove(id) +} diff --git a/android/src/main/java/com/vydia/RNUploader/EventReporter.kt b/android/src/main/java/com/vydia/RNUploader/EventReporter.kt deleted file mode 100644 index 3604a137..00000000 --- a/android/src/main/java/com/vydia/RNUploader/EventReporter.kt +++ /dev/null @@ -1,54 +0,0 @@ -package com.vydia.RNUploader - -import android.util.Log -import com.facebook.react.bridge.Arguments -import com.facebook.react.bridge.WritableMap -import com.facebook.react.modules.core.DeviceEventManagerModule.RCTDeviceEventEmitter - -// Sends events to React Native -object EventReporter { - - private const val TAG = "UploadReceiver" - fun cancelled(uploadId: String) = - sendEvent("cancelled", Arguments.createMap().apply { - putString("id", uploadId) - }) - - fun error(uploadId: String, exception: Throwable) = - sendEvent("error", Arguments.createMap().apply { - putString("id", uploadId) - putString("error", exception.message ?: "Unknown exception") - }) - - fun success(uploadId: String, response: UploadResponse) = - sendEvent("completed", Arguments.createMap().apply { - putString("id", uploadId) - putInt("responseCode", response.code) - putString("responseBody", response.body) - putMap("responseHeaders", Arguments.makeNativeMap(response.headers)) - }) - - - fun progress(uploadId: String, bytesSentTotal: Long, contentLength: Long) = - sendEvent("progress", Arguments.createMap().apply { - putString("id", uploadId) - putDouble("progress", (bytesSentTotal.toDouble() * 100 / contentLength)) //0-100 - }) - - fun notification() = sendEvent("notification") - - /** Sends an event to the JS module */ - private fun sendEvent(eventName: String, params: WritableMap = Arguments.createMap()) { - val reactContext = UploaderModule.reactContext ?: return - - // Right after JS reloads, react instance might not be available yet - if (!reactContext.hasActiveReactInstance()) return - - try { - val jsModule = reactContext.getJSModule(RCTDeviceEventEmitter::class.java) - jsModule.emit("RNFileUploader-$eventName", params) - } catch (exc: Throwable) { - Log.e(TAG, "sendEvent() failed", exc) - } - } -} diff --git a/android/src/main/java/com/vydia/RNUploader/UploaderModule.kt b/android/src/main/java/com/vydia/RNUploader/UploaderModule.kt deleted file mode 100644 index 0d833fc3..00000000 --- a/android/src/main/java/com/vydia/RNUploader/UploaderModule.kt +++ /dev/null @@ -1,113 +0,0 @@ -package com.vydia.RNUploader - -import android.util.Log -import androidx.work.ExistingWorkPolicy -import androidx.work.OneTimeWorkRequestBuilder -import androidx.work.WorkManager -import androidx.work.workDataOf -import com.facebook.react.bridge.Promise -import com.facebook.react.bridge.ReactApplicationContext -import com.facebook.react.bridge.ReactContextBaseJavaModule -import com.facebook.react.bridge.ReactMethod -import com.facebook.react.bridge.ReadableMap -import com.google.gson.Gson - - -class UploaderModule(context: ReactApplicationContext) : - ReactContextBaseJavaModule(context) { - - companion object { - const val TAG = "RNFileUploader.UploaderModule" - const val WORKER_TAG = "RNFileUploader" - var reactContext: ReactApplicationContext? = null - private set - } - - private val workManager = WorkManager.getInstance(context) - - init { - reactContext = context - } - - - override fun getName(): String = "RNFileUploader" - - - /* - * Starts a file upload. - * Returns a promise with the string ID of the upload. - */ - @ReactMethod - fun startUpload(rawOptions: ReadableMap, promise: Promise) { - try { - val id = startUpload(rawOptions) - promise.resolve(id) - } catch (exc: Throwable) { - if (exc !is Upload.MissingOptionException) { - exc.printStackTrace() - Log.e(TAG, exc.message, exc) - } - promise.reject(exc) - } - } - - /** - * @return whether the upload was started - */ - private fun startUpload(options: ReadableMap): String { - val upload = Upload.fromReadableMap(options) - val data = Gson().toJson(upload) - - val request = OneTimeWorkRequestBuilder() - .addTag(WORKER_TAG) - .setInputData(workDataOf(UploadWorker.Input.Params.name to data)) - .build() - - workManager - // Using KEEP policy to prevent it from cancelling the work if it's already running. - // Otherwise, it will emit "cancelled" and then go on to emit "progress" events, - // which is confusing and quite difficult to manage. "cancelled" should be reserved for - // when the user explicitly cancels the upload. - .beginUniqueWork(upload.id, ExistingWorkPolicy.KEEP, request) - .enqueue() - - return upload.id - } - - - /* - * Cancels file upload - * Accepts upload ID as a first argument, this upload will be cancelled - * Event "cancelled" will be fired when upload is cancelled. - */ - @ReactMethod - fun cancelUpload(uploadId: String, promise: Promise) { - try { - workManager.cancelUniqueWork(uploadId) - promise.resolve(true) - } catch (exc: Throwable) { - exc.printStackTrace() - Log.e(TAG, exc.message, exc) - promise.reject(exc) - } - } - - - /* - * Cancels all file uploads - */ - @ReactMethod - fun stopAllUploads(promise: Promise) { - try { - workManager.cancelAllWorkByTag(WORKER_TAG) - promise.resolve(true) - } catch (exc: Throwable) { - exc.printStackTrace() - Log.e(TAG, exc.message, exc) - promise.reject(exc) - } - } - - -} - diff --git a/android/src/main/java/com/vydia/RNUploader/UploaderReactPackage.java b/android/src/main/java/com/vydia/RNUploader/UploaderReactPackage.java deleted file mode 100644 index dca70178..00000000 --- a/android/src/main/java/com/vydia/RNUploader/UploaderReactPackage.java +++ /dev/null @@ -1,35 +0,0 @@ -package com.vydia.RNUploader; - -import com.facebook.react.ReactPackage; -import com.facebook.react.bridge.JavaScriptModule; -import com.facebook.react.bridge.NativeModule; -import com.facebook.react.bridge.ReactApplicationContext; -import com.facebook.react.uimanager.ViewManager; - -import java.util.ArrayList; -import java.util.Collections; -import java.util.List; - -/** - * Created by stephen on 12/8/16. - */ -public class UploaderReactPackage implements ReactPackage { - - // Deprecated in RN 0.47, @todo remove after < 0.47 support remove - public List> createJSModules() { - return Collections.emptyList(); - } - - @Override - public List createViewManagers(ReactApplicationContext reactContext) { - return Collections.emptyList(); - } - - @Override - public List createNativeModules( - ReactApplicationContext reactContext) { - List modules = new ArrayList<>(); - modules.add(new UploaderModule(reactContext)); - return modules; - } -} diff --git a/android/src/test/java/ai/openspace/backgroundupload/EventJournalTest.kt b/android/src/test/java/ai/openspace/backgroundupload/EventJournalTest.kt new file mode 100644 index 00000000..811f1bd2 --- /dev/null +++ b/android/src/test/java/ai/openspace/backgroundupload/EventJournalTest.kt @@ -0,0 +1,104 @@ +package ai.openspace.backgroundupload + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import java.io.File + +class EventJournalTest { + @get:Rule + val tmp = TemporaryFolder() + + private fun entry(id: String, uploadId: String = "u1") = EventJournal.Entry( + eventId = id, + uploadId = uploadId, + type = "completed", + timestamp = System.currentTimeMillis(), + responseCode = 200, + responseBody = "ok", + responseHeaders = mapOf("x-a" to "b"), + ) + + @Test + fun `append then read returns the entry`() { + val journal = EventJournal(tmp.newFolder()) + journal.append(entry("e1")) + val events = journal.unacknowledged() + assertEquals(1, events.size) + assertEquals("e1", events[0].eventId) + assertEquals(200, events[0].responseCode) + assertEquals("ok", events[0].responseBody) + } + + @Test + fun `ack removes only the acked entry`() { + val journal = EventJournal(tmp.newFolder()) + journal.append(entry("e1")) + journal.append(entry("e2")) + journal.ack(listOf("e1")) + assertEquals(listOf("e2"), journal.unacknowledged().map { it.eventId }) + } + + @Test + fun `entries survive a new journal instance over the same dir`() { + val dir = tmp.newFolder() + EventJournal(dir).append(entry("e1")) + assertEquals(1, EventJournal(dir).unacknowledged().size) + } + + @Test + fun `oversized body is truncated and flagged`() { + val journal = EventJournal(tmp.newFolder()) + val big = "x".repeat(EventJournal.MAX_BODY_CHARS + 100) + journal.append(entry("e1").copy(responseBody = big)) + val read = journal.unacknowledged()[0] + assertTrue(read.responseBodyTruncated) + assertTrue(read.responseBody!!.length <= EventJournal.MAX_BODY_CHARS) + } + + @Test + fun `corrupt file is skipped, not fatal`() { + val dir = tmp.newFolder() + val journal = EventJournal(dir) + journal.append(entry("e1")) + java.io.File(dir, "garbage.json").writeText("{not json") + assertEquals(1, journal.unacknowledged().size) + } + + @Test + fun `entries are ordered by timestamp`() { + val journal = EventJournal(tmp.newFolder()) + journal.append(entry("late").copy(timestamp = 2000)) + journal.append(entry("early").copy(timestamp = 1000)) + assertEquals(listOf("early", "late"), journal.unacknowledged().map { it.eventId }) + } + + @Test + fun `append does not throw when the directory is unwritable`() { + // A regular file where a directory is expected: mkdirs() and every write fail. + val notADir = tmp.newFile() + val journal = EventJournal(notADir) + journal.append(entry("e1")) // must not throw + assertEquals(emptyList(), journal.unacknowledged().map { it.eventId }) + } + + @Test + fun `prunes the oldest entries beyond the cap`() { + val dir = tmp.newFolder() + val journal = EventJournal(dir, maxEntries = 3) + // Stamp increasing mtimes so pruning order is deterministic. Each mtime is + // set before the next append, which is when pruning reads it. + journal.append(entry("e1")); File(dir, "e1.json").setLastModified(1000) + journal.append(entry("e2")); File(dir, "e2.json").setLastModified(2000) + journal.append(entry("e3")); File(dir, "e3.json").setLastModified(3000) + journal.append(entry("e4")) // 4th write trips the cap; oldest (e1) is dropped + + val ids = journal.unacknowledged().map { it.eventId } + assertEquals(3, ids.size) + assertFalse(ids.contains("e1")) + assertTrue(ids.contains("e4")) + } +} diff --git a/android/src/test/java/ai/openspace/backgroundupload/UploadOutcomeTest.kt b/android/src/test/java/ai/openspace/backgroundupload/UploadOutcomeTest.kt new file mode 100644 index 00000000..b9375ce0 --- /dev/null +++ b/android/src/test/java/ai/openspace/backgroundupload/UploadOutcomeTest.kt @@ -0,0 +1,51 @@ +package ai.openspace.backgroundupload + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import java.io.IOException + +class UploadOutcomeTest { + + @Test + fun `2xx is accepted`() { + assertTrue(UploadOutcome.isAccepted(200, listOf())) + assertTrue(UploadOutcome.isAccepted(204, listOf())) + assertTrue(UploadOutcome.isAccepted(299, listOf())) + } + + @Test + fun `non-2xx is not accepted by default`() { + assertFalse(UploadOutcome.isAccepted(199, listOf())) + assertFalse(UploadOutcome.isAccepted(300, listOf())) + assertFalse(UploadOutcome.isAccepted(404, listOf())) + assertFalse(UploadOutcome.isAccepted(500, listOf())) + } + + @Test + fun `non-2xx listed in acceptStatus is accepted`() { + assertTrue(UploadOutcome.isAccepted(409, listOf(409))) + assertTrue(UploadOutcome.isAccepted(404, listOf(404, 409))) + } + + @Test + fun `acceptStatus does not accept unlisted codes`() { + assertFalse(UploadOutcome.isAccepted(500, listOf(409))) + } + + @Test + fun `IOException with a missing file is a file error`() { + assertEquals("file", UploadOutcome.errorKind(IOException("gone"), fileExists = false)) + } + + @Test + fun `IOException with the file present is a network error`() { + assertEquals("network", UploadOutcome.errorKind(IOException("reset"), fileExists = true)) + } + + @Test + fun `a non-IO error is unknown`() { + assertEquals("unknown", UploadOutcome.errorKind(RuntimeException("boom"), fileExists = true)) + } +} diff --git a/babel.config.js b/babel.config.js new file mode 100644 index 00000000..7bcd7fce --- /dev/null +++ b/babel.config.js @@ -0,0 +1,8 @@ +// For Jest only. Metro/RN builds use each app's own babel config; the library +// itself ships TypeScript source (no build step). +module.exports = { + presets: [ + ['@babel/preset-env', { targets: { node: 'current' } }], + '@babel/preset-typescript', + ], +}; diff --git a/docs/superpowers/plans/2026-07-22-phase0-reliable-uploads.md b/docs/superpowers/plans/2026-07-22-phase0-reliable-uploads.md new file mode 100644 index 00000000..1c9526d8 --- /dev/null +++ b/docs/superpowers/plans/2026-07-22-phase0-reliable-uploads.md @@ -0,0 +1,1841 @@ +# Phase 0: Reliable Upload Statuses Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Make upload outcomes impossible to lose and impossible to misread — a native, durable event journal with acknowledgment, a query API for live uploads, correct success/error classification (`completed` = accepted statuses only), distinguishable cancel reasons, and iOS background-relaunch handling (v8.0.0, breaking changes coordinated with Diana). + +**Architecture:** Terminal events (completed/error/cancelled) are written to a native on-disk journal *before* being emitted to JS, and deleted only when JS acknowledges them; delivery becomes at-least-once instead of at-most-once. A `getAllUploads()` query enumerates live uploads (WorkManager work on Android, session tasks on iOS) so consumers can reconcile on boot instead of guessing. Outcomes are classified natively (2xx/`acceptStatus` → completed; other HTTP → typed error with the response attached) instead of pushing success determination onto every consumer. The event API keeps its names, but semantics are corrected where the old ones were wrong — Diana adopts in one coordinated bump. + +**Tech Stack:** Kotlin (WorkManager 2.8.1, OkHttp 4.10, Gson 2.8.9 — all existing deps), Objective-C (background NSURLSession), TypeScript. Tests: JUnit 4 (plain JVM) for Android journal logic, Jest for the JS surface, on-device checklist via the example app + `example/server`. + +**RFC / context:** https://github.com/openspacelabs/diana/issues/8875. Research notes in `.claude/work/831bb775-1eba-45d6-b429-ad2951acda24/`. + +## Global Constraints + +- **Best practices and official platform documentation take precedence over existing code** (Dylan, 2026-07-23: "we don't need to be married to anything in the current library"). Breaking changes are allowed — Diana is the sole consumer and pins a git tag, so every break ships with a coordinated Diana change (listed in "After Phase 0"). Event names (`RNFileUploader-*`) and native module names are kept this phase only to limit churn; they rename at the Phase 1 package split. +- Outcome classification (breaking): `completed` fires only for 2xx or per-request `acceptStatus` codes; other HTTP responses emit `error` with `errorKind: 'http'` and the full response attached. Transport failures are `errorKind: 'network'`, missing files `'file'`. +- No new runtime dependencies. JUnit is test-only. Gson/OkHttp/WorkManager stay at current versions. +- Objective-C only on iOS (no Swift); podspec globs `ios/*.{h,m}`. +- Kotlin `jvmTarget = "17"` as configured in `android/build.gradle`. +- Version bump to **8.0.0** (breaking: completed = accepted statuses only; android options optional; `lib/` removed). Branch: `dylan/phase0-reliable-uploads`. +- Every commit must leave the example app buildable. Run `yarn lint:ci` before pushing; do not poll CI after push. +- Response bodies in the journal are capped at **64 KB** with a `responseBodyTruncated` flag. +- Journal event `type` values: `'completed' | 'error' | 'cancelled'`. Cancel `reason` values: `'user' | 'system'`. +- Platform-API claims in this plan were fact-checked against official docs — see `.claude/work/831bb775-1eba-45d6-b429-ad2951acda24/07-platform-facts.md`. Two findings shaped the design: **iOS `taskDescription` has no documented persistence guarantee across process death** (hence the `RNBGUTaskMap` sidecar in Task 5 — Apple DTS guidance is to persist task metadata externally keyed by `taskIdentifier`), and **WorkManager auto-prunes finished work after ~1 day**, so `getAllUploads()` only sees recent terminal work — the journal is the source of truth for outcomes. + +## Decision gate (read first) + +**Resolved 2026-07-22: BUILD — execute Tasks 1–11 as written.** The candidate wrapper target (`@kesha-antonov/react-native-background-downloader` v4.5.8) was verified at source level (`.claude/work/831bb775-1eba-45d6-b429-ad2951acda24/06-kesha-source-verification.md`). Its iOS upload path is sound (background NSURLSession, `uploadTaskWithRequest:fromFile:`, AppDelegate hook), but two dealbreakers: (1) **Android uploads run on a plain thread pool with `HttpURLConnection`** — no WorkManager, no foreground service, uploads die with the process, "resume" restarts from byte 0; (2) **no completion journal on either platform** — an upload that completes while the app is dead emits one fire-and-forget event, deletes its persisted config, and its outcome/response is unrecoverable (`getExistingUploadTasks()` cannot see it). Wrapping it would regress the two guarantees this library exists to provide. No device spike needed; Task 0 below is kept for the record. + +--- + +### Task 0: Build-vs-wrap decision gate + +**Files:** none (decision task). + +**Interfaces:** +- Produces: a go/no-go decision recorded at the top of this plan. "GO (build)" → execute Tasks 1–11 as written. "WRAP" → stop; the native tasks are re-planned as a wrapper (JS contract in Task 9 is unchanged). + +- [x] **Step 1: Read the source-verification report** + +Read `.claude/work/831bb775-1eba-45d6-b429-ad2951acda24/06-kesha-source-verification.md`. It answers, with code quotes: does the kesha-antonov iOS upload path use `backgroundSessionConfigurationWithIdentifier` + `uploadTaskWithRequest:fromFile:`; does Android survive process death; is there any dead-JS event persistence. + +- [x] **Step 2: Apply the decision rule** + +- If the report's verdict is **BUILD** (uploads don't truly survive termination, or no meaningful advantage over our fork): proceed with Tasks 1–11. No device spike needed. +- If **WRAP or PARTIAL**: run a half-day device spike before writing native code — upload a 500 MB file from the example app, force-quit mid-upload, verify resumption/completion delivery on relaunch, on both platforms. Only a passing spike flips this plan to wrapper mode. + +- [x] **Step 3: Record the decision** + +Decision: BUILD — 2026-07-22 — kesha-antonov Android uploads don't survive process death (plain thread pool, no WorkManager) and neither platform journals completions; wrapping would regress both core guarantees. + +--- + +### Task 1: Android `EventJournal` (durable terminal-event store) + +**Files:** +- Create: `android/src/main/java/com/vydia/RNUploader/EventJournal.kt` +- Modify: `android/build.gradle` (test dependency) +- Test: `android/src/test/java/com/vydia/RNUploader/EventJournalTest.kt` + +**Interfaces:** +- Consumes: nothing (pure Kotlin + Gson + java.io; no Android runtime classes → plain JVM tests). +- Produces: `EventJournal.get(context: Context): EventJournal`, `Entry` data class, `append(entry)`, `unacknowledged(): List`, `ack(eventIds: List)`. Constructor `EventJournal(dir: File)` is public for tests. `EventJournal.MAX_BODY_BYTES = 64 * 1024`. + +- [ ] **Step 1: Add the test dependency** + +In `android/build.gradle`, inside the existing `dependencies { }` block, add: + +```groovy + testImplementation 'junit:junit:4.13.2' +``` + +- [ ] **Step 2: Write the failing tests** + +Create `android/src/test/java/com/vydia/RNUploader/EventJournalTest.kt`: + +```kotlin +package com.vydia.RNUploader + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder + +class EventJournalTest { + @get:Rule + val tmp = TemporaryFolder() + + private fun entry(id: String, uploadId: String = "u1") = EventJournal.Entry( + eventId = id, + uploadId = uploadId, + type = "completed", + timestamp = System.currentTimeMillis(), + responseCode = 200, + responseBody = "ok", + responseHeaders = mapOf("x-a" to "b"), + ) + + @Test + fun `append then read returns the entry`() { + val journal = EventJournal(tmp.newFolder()) + journal.append(entry("e1")) + val events = journal.unacknowledged() + assertEquals(1, events.size) + assertEquals("e1", events[0].eventId) + assertEquals(200, events[0].responseCode) + assertEquals("ok", events[0].responseBody) + } + + @Test + fun `ack removes only the acked entry`() { + val journal = EventJournal(tmp.newFolder()) + journal.append(entry("e1")) + journal.append(entry("e2")) + journal.ack(listOf("e1")) + assertEquals(listOf("e2"), journal.unacknowledged().map { it.eventId }) + } + + @Test + fun `entries survive a new journal instance over the same dir`() { + val dir = tmp.newFolder() + EventJournal(dir).append(entry("e1")) + assertEquals(1, EventJournal(dir).unacknowledged().size) + } + + @Test + fun `oversized body is truncated and flagged`() { + val journal = EventJournal(tmp.newFolder()) + val big = "x".repeat(EventJournal.MAX_BODY_BYTES + 100) + journal.append(entry("e1").copy(responseBody = big)) + val read = journal.unacknowledged()[0] + assertTrue(read.responseBodyTruncated) + assertTrue(read.responseBody!!.length <= EventJournal.MAX_BODY_BYTES) + } + + @Test + fun `corrupt file is skipped, not fatal`() { + val dir = tmp.newFolder() + val journal = EventJournal(dir) + journal.append(entry("e1")) + java.io.File(dir, "garbage.json").writeText("{not json") + assertEquals(1, journal.unacknowledged().size) + } + + @Test + fun `entries are ordered by timestamp`() { + val journal = EventJournal(tmp.newFolder()) + journal.append(entry("late").copy(timestamp = 2000)) + journal.append(entry("early").copy(timestamp = 1000)) + assertEquals(listOf("early", "late"), journal.unacknowledged().map { it.eventId }) + } +} +``` + +- [ ] **Step 3: Run tests to verify they fail** + +Run: `cd example/RNBGUExample/android && ./gradlew :react-native-background-upload:testDebugUnitTest --tests '*EventJournalTest*'` +Expected: FAIL — `Unresolved reference: EventJournal` (compilation error). +(The example app's `settings.gradle` maps `:react-native-background-upload` to the repo's `android/` dir, so this runs against live source. Run `yarn --cwd example/RNBGUExample install` first if `node_modules` is missing.) + +> **Review addendum (2026-07-23):** during review we hardened the journal beyond the +> original block below. The committed `EventJournal.kt` is authoritative; deltas from the +> code shown here: +> 1. Constructor takes `maxEntries: Int = MAX_ENTRIES` (`MAX_ENTRIES = 1000`); `append` +> calls `pruneToMax()` which drops the oldest `.json` files (by `lastModified`) beyond +> the cap — a runaway guard for a broken/unadopted ack loop. +> 2. `append` wraps its write in try/catch and returns on failure instead of throwing — +> a journal-write failure (e.g. disk full) must never propagate into the worker, where +> it would be misclassified as a retryable error and re-run a completed upload. +> 3. `Entry` includes a `toWritableMap()` member (originally listed under Task 2) and an +> `errorKind` field. +> Two extra tests cover the cap and the non-throwing guarantee (8 tests total). + +- [ ] **Step 4: Implement `EventJournal`** + +Create `android/src/main/java/com/vydia/RNUploader/EventJournal.kt`: + +```kotlin +package com.vydia.RNUploader + +import android.content.Context +import com.google.gson.Gson +import java.io.File + +// Durable record of terminal upload events (completed / error / cancelled). +// Written BEFORE the event is emitted to JS; deleted only when JS acknowledges. +// One JSON file per event named .json — atomic-ish via tmp+rename. +class EventJournal(private val dir: File) { + + data class Entry( + val eventId: String, + val uploadId: String, + val type: String, // completed | error | cancelled + val timestamp: Long, + val responseCode: Int? = null, + val responseBody: String? = null, + val responseBodyTruncated: Boolean = false, + val responseHeaders: Map? = null, + val error: String? = null, + val errorKind: String? = null, // http | network | file | unknown + val cancelReason: String? = null, // user | system + ) + + companion object { + const val MAX_BODY_BYTES = 64 * 1024 + private val gson = Gson() + + @Volatile + private var instance: EventJournal? = null + + // Worker may run in a process where React never initialized, so the + // journal must be reachable from a bare Context, not the module. + fun get(context: Context): EventJournal = + instance ?: synchronized(this) { + instance + ?: EventJournal(File(context.filesDir, "rnbgupload-events")).also { instance = it } + } + } + + init { + dir.mkdirs() + } + + @Synchronized + fun append(entry: Entry) { + val body = entry.responseBody + val bounded = + if (body != null && body.length > MAX_BODY_BYTES) + entry.copy(responseBody = body.substring(0, MAX_BODY_BYTES), responseBodyTruncated = true) + else entry + val tmp = File(dir, "${entry.eventId}.tmp") + tmp.writeText(gson.toJson(bounded)) + tmp.renameTo(File(dir, "${entry.eventId}.json")) + } + + @Synchronized + fun unacknowledged(): List = + (dir.listFiles { f -> f.extension == "json" } ?: emptyArray()) + .mapNotNull { f -> + runCatching { gson.fromJson(f.readText(), Entry::class.java) }.getOrNull() + } + .filter { it.eventId != null } // corrupt-but-parseable guard + .sortedBy { it.timestamp } + + @Synchronized + fun ack(eventIds: List) { + eventIds.forEach { File(dir, "$it.json").delete() } + } +} +``` + +Note: the cap counts UTF-16 chars, not bytes — acceptable for a safety cap; do not "fix" it to byte-accurate splitting (risks cutting surrogate pairs). +Note: Gson instantiates the data class via `Unsafe`, so a parsed file missing `eventId` yields a null field despite the non-null type — hence the explicit `it.eventId != null` filter. + +- [ ] **Step 5: Run tests to verify they pass** + +Run: `cd example/RNBGUExample/android && ./gradlew :react-native-background-upload:testDebugUnitTest --tests '*EventJournalTest*'` +Expected: `BUILD SUCCESSFUL`, 6 tests passing. + +- [ ] **Step 6: Commit** + +```bash +git add android/src/main/java/com/vydia/RNUploader/EventJournal.kt \ + android/src/test/java/com/vydia/RNUploader/EventJournalTest.kt \ + android/build.gradle +git commit -m "feat(android): durable terminal-event journal with ack" +``` + +--- + +### Task 2: Journal wiring in `UploadWorker` + module `getUnacknowledgedEvents`/`ackEvents` + +**Files:** +- Modify: `android/src/main/java/com/vydia/RNUploader/UploadWorker.kt:153-171` +- Modify: `android/src/main/java/com/vydia/RNUploader/UploaderModule.kt` +- Modify: `android/src/main/java/com/vydia/RNUploader/EventReporter.kt` +- Modify: `android/src/main/java/com/vydia/RNUploader/Upload.kt` (acceptStatus option) + +**Interfaces:** +- Consumes: `EventJournal` from Task 1. +- Produces: React methods `getUnacknowledgedEvents(promise)` and `ackEvents(eventIds: ReadableArray, promise)`; `Upload.acceptStatus: List` option; outcome classification (`completed` = 2xx/acceptStatus, otherwise `error` with `errorKind`); `EventReporter.cancelled(uploadId, reason: String)`, `EventReporter.error(uploadId, error, kind)`, `EventReporter.httpError(uploadId, response)`; journal entries written before every terminal emit. Task 3 depends on the `reason` parameter. + +- [ ] **Step 1: Journal terminal outcomes in the worker** + +First, add the `acceptStatus` option to `Upload.kt` — a field on the data class plus its parsing in `fromReadableMap`: + +```kotlin + val acceptStatus: List, // add to the data class fields +``` + +```kotlin + // add to fromReadableMap + acceptStatus = map.getArray("acceptStatus")?.let { arr -> + (0 until arr.size()).map { i -> arr.getInt(i) } + } ?: listOf(), +``` + +Then in `UploadWorker.kt`, rename the `handleSuccess(response)` call inside `doWork` (line ~105) to `handleResponse(response)`, and replace `handleSuccess`, `handleError`, and `checkAndHandleCancellation` (lines 153–171) with: + +```kotlin + // HTTP response received. "completed" only for 2xx or per-request acceptStatus + // codes (axios validateStatus semantics — a 400 is an error, not a completion); + // anything else is a terminal error carrying the full response. + private fun handleResponse(response: UploadResponse) { + UploadProgress.complete(upload.id) + val accepted = response.code in 200..299 || upload.acceptStatus.contains(response.code) + EventJournal.get(context).append( + EventJournal.Entry( + eventId = java.util.UUID.randomUUID().toString(), + uploadId = upload.id, + type = if (accepted) "completed" else "error", + timestamp = System.currentTimeMillis(), + responseCode = response.code, + responseBody = response.body, + responseHeaders = response.headers, + errorKind = if (accepted) null else "http", + error = if (accepted) null else "HTTP ${response.code}", + ) + ) + if (accepted) EventReporter.success(upload.id, response) + else EventReporter.httpError(upload.id, response) + } + + private fun handleError(error: Throwable) { + UploadProgress.remove(upload.id) + val kind = when { + error is IOException && + runCatching { !File(upload.path).exists() }.getOrDefault(false) -> "file" + error is IOException -> "network" + else -> "unknown" + } + EventJournal.get(context).append( + EventJournal.Entry( + eventId = java.util.UUID.randomUUID().toString(), + uploadId = upload.id, + type = "error", + timestamp = System.currentTimeMillis(), + error = error.message ?: "Unknown exception", + errorKind = kind, + ) + ) + EventReporter.error(upload.id, error, kind) + } + + // Check if cancelled by user or new worker with same ID + // Worker won't rerun, perform teardown + private fun checkAndHandleCancellation(): Boolean { + if (!isStopped) return false + + val reason = if (UserCancellations.consume(upload.id)) "user" else "system" + UploadProgress.remove(upload.id) + EventJournal.get(context).append( + EventJournal.Entry( + eventId = java.util.UUID.randomUUID().toString(), + uploadId = upload.id, + type = "cancelled", + timestamp = System.currentTimeMillis(), + cancelReason = reason, + ) + ) + EventReporter.cancelled(upload.id, reason) + return true + } +``` + +(`UserCancellations` is created in Task 3; to keep this task compiling on its own, create the stub now — Task 3 fills in the caller.) + +Create `android/src/main/java/com/vydia/RNUploader/UserCancellations.kt`: + +```kotlin +package com.vydia.RNUploader + +// Upload ids the JS side explicitly cancelled. Consulted by the worker to +// distinguish user cancels from system kills. Same-process only: a user +// cancel always originates from live JS, so the set never needs to persist. +object UserCancellations { + private val ids = mutableSetOf() + + @Synchronized + fun mark(id: String) { + ids.add(id) + } + + @Synchronized + fun consume(id: String): Boolean = ids.remove(id) +} +``` + +- [ ] **Step 2: Add `reason` to the cancelled event** + +In `EventReporter.kt`, replace the `cancelled` and `error` functions and add `httpError`: + +```kotlin + fun cancelled(uploadId: String, reason: String) = + sendEvent("cancelled", Arguments.createMap().apply { + putString("id", uploadId) + putString("cancelReason", reason) + }) + + fun error(uploadId: String, exception: Throwable, kind: String) = + sendEvent("error", Arguments.createMap().apply { + putString("id", uploadId) + putString("error", exception.message ?: "Unknown exception") + putString("errorKind", kind) + }) + + fun httpError(uploadId: String, response: UploadResponse) = + sendEvent("error", Arguments.createMap().apply { + putString("id", uploadId) + putString("error", "HTTP ${response.code}") + putString("errorKind", "http") + putInt("responseCode", response.code) + putString("responseBody", response.body) + putMap("responseHeaders", Arguments.makeNativeMap(response.headers)) + }) +``` + +- [ ] **Step 3: Expose journal methods on the module** + +In `UploaderModule.kt`, add imports `com.facebook.react.bridge.Arguments`, `com.facebook.react.bridge.ReadableArray`, `com.facebook.react.bridge.WritableMap`, then add: + +```kotlin + @ReactMethod + fun getUnacknowledgedEvents(promise: Promise) { + try { + val events = EventJournal.get(reactApplicationContext).unacknowledged() + val arr = Arguments.createArray() + events.forEach { arr.pushMap(it.toWritableMap()) } + promise.resolve(arr) + } catch (exc: Throwable) { + Log.e(TAG, exc.message, exc) + promise.reject(exc) + } + } + + @ReactMethod + fun ackEvents(eventIds: ReadableArray, promise: Promise) { + try { + val ids = (0 until eventIds.size()).mapNotNull { eventIds.getString(it) } + EventJournal.get(reactApplicationContext).ack(ids) + promise.resolve(true) + } catch (exc: Throwable) { + Log.e(TAG, exc.message, exc) + promise.reject(exc) + } + } +``` + +And add the map conversion as a **member function of the `Entry` data class** in `EventJournal.kt` (a member — not an extension declared inside `EventJournal`, which would not resolve from `UploaderModule`'s scope): + +```kotlin + data class Entry( + /* …fields exactly as in Task 1… */ + ) { + fun toWritableMap(): com.facebook.react.bridge.WritableMap = + com.facebook.react.bridge.Arguments.createMap().apply { + putString("eventId", eventId) + putString("id", uploadId) + putString("type", type) + putDouble("timestamp", timestamp.toDouble()) + responseCode?.let { putInt("responseCode", it) } + responseBody?.let { putString("responseBody", it) } + putBoolean("responseBodyTruncated", responseBodyTruncated) + responseHeaders?.let { + putMap("responseHeaders", com.facebook.react.bridge.Arguments.makeNativeMap(it)) + } + error?.let { putString("error", it) } + errorKind?.let { putString("errorKind", it) } + cancelReason?.let { putString("cancelReason", it) } + } + } +``` + +This keeps the JUnit tests green: the JVM resolves the React classes lazily at first invocation, and the tests never call `toWritableMap`. + +- [ ] **Step 4: Build to verify compilation** + +Run: `cd example/RNBGUExample/android && ./gradlew :react-native-background-upload:assembleDebug` +Expected: `BUILD SUCCESSFUL`. + +- [ ] **Step 5: Re-run journal tests** + +Run: `cd example/RNBGUExample/android && ./gradlew :react-native-background-upload:testDebugUnitTest` +Expected: PASS (6 tests). + +- [ ] **Step 6: Commit** + +```bash +git add android/src/main/java/com/vydia/RNUploader/ +git commit -m "feat(android): journal terminal events; expose get/ack; cancel reason" +``` + +--- + +### Task 3: Android cancel intent, notification defaults + auto channel, remove `stopAllUploads` + +**Files:** +- Modify: `android/src/main/java/com/vydia/RNUploader/UploaderModule.kt:83-109` +- Modify: `android/src/main/java/com/vydia/RNUploader/Upload.kt` (notification fields get defaults) +- Modify: `android/src/main/java/com/vydia/RNUploader/UploadWorker.kt` (ensure channel) + +**Interfaces:** +- Consumes: `UserCancellations.mark(id)` from Task 2. +- Produces: `cancelUpload` marks user intent before cancelling; `stopAllUploads` deleted (never exposed in JS, no consumers — YAGNI); all five `notification*` options become optional with defaults; the library creates its notification channel itself (breaking: consumers no longer need notifee/channel plumbing — Task 9 makes the `android` block optional in TS). + +- [ ] **Step 1: Mark user cancels and delete dead method** + +In `UploaderModule.kt`, replace `cancelUpload` and delete `stopAllUploads` entirely: + +```kotlin + /* + * Cancels file upload + * Accepts upload ID as a first argument, this upload will be cancelled + * Event "cancelled" will be fired when upload is cancelled. + */ + @ReactMethod + fun cancelUpload(uploadId: String, promise: Promise) { + try { + UserCancellations.mark(uploadId) + workManager.cancelUniqueWork(uploadId) + promise.resolve(true) + } catch (exc: Throwable) { + exc.printStackTrace() + Log.e(TAG, exc.message, exc) + promise.reject(exc) + } + } +``` + +- [ ] **Step 2: Notification defaults + library-owned channel** + +In `Upload.kt`'s `fromReadableMap`, replace the five throwing `notification*` parses with defaults (`url` and `path` keep `MissingOptionException`): + +```kotlin + notificationId = (map.getString(Upload::notificationId.name) + ?: "react-native-background-upload").hashCode(), + notificationTitle = map.getString(Upload::notificationTitle.name) + ?: "Uploading…", + notificationTitleNoInternet = map.getString(Upload::notificationTitleNoInternet.name) + ?: "Waiting for connection…", + notificationTitleNoWifi = map.getString(Upload::notificationTitleNoWifi.name) + ?: "Waiting for Wi-Fi…", + notificationChannel = map.getString(Upload::notificationChannel.name) + ?: "background-upload", +``` + +In `UploadWorker.kt`, add (imports `android.app.NotificationChannel`) and call it in `doWork` immediately before `setForeground(getForegroundInfo())`: + +```kotlin + private fun ensureNotificationChannel() { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return + // Idempotent per official docs: creating an existing channel is a no-op. + val channel = NotificationChannel( + upload.notificationChannel, + "Uploads", + NotificationManager.IMPORTANCE_LOW + ) + notificationManager.createNotificationChannel(channel) + } +``` + +Consumers that want a custom channel name/importance can still create the channel themselves first (creation elsewhere wins — same id is a no-op here) and pass `notificationChannel`. + +- [ ] **Step 3: Build** + +Run: `cd example/RNBGUExample/android && ./gradlew :react-native-background-upload:assembleDebug` +Expected: `BUILD SUCCESSFUL`. + +- [ ] **Step 4: Commit** + +```bash +git add android/src/main/java/com/vydia/RNUploader/ +git commit -m "feat(android): user-cancel intent; notification defaults + auto channel; drop stopAllUploads" +``` + +--- + +### Task 4: Android `getAllUploads()` + +**Files:** +- Modify: `android/src/main/java/com/vydia/RNUploader/UploaderModule.kt` (startUpload + new method) + +**Interfaces:** +- Consumes: WorkManager `getWorkInfosByTag` (verified: returns work in all states; finished records are pruned eventually, so the journal — not this call — is the source of truth for terminal outcomes). +- Produces: React method `getAllUploads(promise)` resolving `[{ id: string, state: 'pending'|'running'|'completed'|'error'|'cancelled' }]`; work requests tagged `"RNFileUploaderId:"`. + +- [ ] **Step 1: Tag work with the upload id** + +In `UploaderModule.kt` companion object add: + +```kotlin + const val ID_TAG_PREFIX = "RNFileUploaderId:" +``` + +In the private `startUpload`, add the id tag to the request builder: + +```kotlin + val request = OneTimeWorkRequestBuilder() + .addTag(WORKER_TAG) + .addTag(ID_TAG_PREFIX + upload.id) + .setInputData(workDataOf(UploadWorker.Input.Params.name to data)) + .build() +``` + +- [ ] **Step 2: Add the query method** + +Add to `UploaderModule.kt` (import `androidx.work.WorkInfo`): + +```kotlin + /** + * Enumerates uploads WorkManager still knows about. Finished work is + * auto-pruned by WorkManager after roughly ONE DAY — terminal outcomes must + * be read from getUnacknowledgedEvents(), not from this method. + */ + @ReactMethod + fun getAllUploads(promise: Promise) { + try { + val infos = workManager.getWorkInfosByTag(WORKER_TAG).get() + val arr = Arguments.createArray() + for (info in infos) { + val id = info.tags.firstOrNull { it.startsWith(ID_TAG_PREFIX) } + ?.removePrefix(ID_TAG_PREFIX) ?: continue + val map = Arguments.createMap() + map.putString("id", id) + map.putString( + "state", + when (info.state) { + WorkInfo.State.ENQUEUED, WorkInfo.State.BLOCKED -> "pending" + WorkInfo.State.RUNNING -> "running" + WorkInfo.State.SUCCEEDED -> "completed" + WorkInfo.State.FAILED -> "error" + WorkInfo.State.CANCELLED -> "cancelled" + } + ) + arr.pushMap(map) + } + promise.resolve(arr) + } catch (exc: Throwable) { + Log.e(TAG, exc.message, exc) + promise.reject(exc) + } + } +``` + +(`.get()` blocks the NativeModules thread briefly; the query is local-database-only. Acceptable — matches the existing module's synchronous style.) + +- [ ] **Step 3: Build** + +Run: `cd example/RNBGUExample/android && ./gradlew :react-native-background-upload:assembleDebug` +Expected: `BUILD SUCCESSFUL`. + +- [ ] **Step 4: Commit** + +```bash +git add android/src/main/java/com/vydia/RNUploader/UploaderModule.kt +git commit -m "feat(android): getAllUploads via id-tagged WorkManager query" +``` + +--- + +> **PIVOT (2026-07-23): iOS is now a Swift REWRITE, not edits to the Obj-C.** +> Decided with Dylan: the three Obj-C files (<500 lines) are old and hard to read, Diana +> already builds Swift under static linkage and runs new arch, and Dylan will own this. So +> Tasks 5–8 below are superseded — the Obj-C code blocks remain only as a behavior reference +> for what the Swift must reproduce. The rewrite: +> - `RNFileUploader.swift` — `@objc(VydiaRNFileUploader)` class : `RCTEventEmitter`, the two +> background `URLSession`s, the delegate, all exported methods, classification, throttle, +> bg-relaunch handler. `RNFileUploader.m` — ~30-line `RCT_EXTERN_MODULE`/`RCT_EXTERN_METHOD` +> shim (unavoidable on the bridge). `EventJournal.swift` + `TaskMap.swift` — `Codable`, +> with the cap / backup-exclusion / non-throwing-write mitigations. Delete the old +> `VydiaRNFileUploader.m`, `Helper.{h,m}`. +> - Keep the JS contract identical: module name `VydiaRNFileUploader`, events `RNFileUploader-*`. +> - Target iOS **15.1** (Diana's floor); podspec bumped from 9.0, `swift_version` + `React-Core` +> dependency + `DEFINES_MODULE` added; example app deployment target bumped to 15.1. +> - **Correctness invariant:** the background `NSURLSession` delegate API is load-bearing and +> has no modern replacement — port it verbatim (session ids, `discretionary=NO`, +> `HTTPMaximumConnectionsPerHost=1`, `waitsForConnectivity`, `uploadTask(with:fromFile:)`). +> Do NOT use `async` `URLSession.upload` (no background support). Journal stays synchronous +> (serial queue), NOT an actor, to preserve "journal before emit" ordering. +> - Verification: `xcodebuild` per checkpoint + on-device Task 11 (no plain-unit-test path). +> Started with an isolated Swift interop probe (`RNBGUProbe.{swift,m}`) to prove the pod/Swift +> setup compiles and registers before the real port; probe is deleted afterward. + +### Task 5: iOS `RNBGUEventJournal` + journal wiring + +**Files:** +- Create: `ios/RNBGUEventJournal.h` +- Create: `ios/RNBGUEventJournal.m` +- Create: `ios/RNBGUTaskMap.h` +- Create: `ios/RNBGUTaskMap.m` +- Modify: `ios/VydiaRNFileUploader.m:369-399` (didCompleteWithError) and `startUpload` + +**Interfaces:** +- Consumes: nothing new. +- Produces: `+[RNBGUEventJournal append:]` (takes `NSDictionary` containing `eventId`), `+[RNBGUEventJournal unacknowledged]` → `NSArray`, `+[RNBGUEventJournal ack:]`; `RNBGUTaskMap` persisted `sessionId:taskIdentifier → {id, acceptStatus}` sidecar (`setMeta:forKey:`, `metaForKey:`, `removeKey:`); uploader helpers `-taskMapKeyFor:task:`, `-uploadIdFor:task:`, `-acceptStatusFor:task:`; `didCompleteWithError` classifies (2xx/acceptStatus → completed, other HTTP → error with `errorKind: http`, transport → `errorKind: network`) and journals before emitting; completed events gain `eventId` + `responseHeaders`; cancelled events gain `cancelReason`. Tasks 6 and 8 use the helpers; Task 6 exports the journal to JS. + +**Why the task map exists:** Apple documents `taskDescription` only as an uninterpreted app string — there is NO guarantee it survives process death, and DTS guidance is to persist task metadata externally keyed by `taskIdentifier` (which is stable for a background session's tasks across relaunch, unique per session). `taskDescription` stays the primary id; the map is the durable fallback so a relaunch can never orphan an event under an unknown id. + +- [ ] **Step 1: Create the journal class** + +`ios/RNBGUEventJournal.h`: + +```objc +#import + +// Durable record of terminal upload events. Written before emitting to JS, +// deleted only on explicit acknowledgment from JS. One JSON file per event. +// Bounded by kMaxEntries (drops oldest) as a runaway guard; excluded from +// device backups. Mirrors the Android EventJournal contract. +@interface RNBGUEventJournal : NSObject ++ (void)append:(NSDictionary *)event; // event MUST contain @"eventId" ++ (NSArray *)unacknowledged; ++ (void)ack:(NSArray *)eventIds; +@end +``` + +`ios/RNBGUEventJournal.m`: + +```objc +#import "RNBGUEventJournal.h" + +static NSString *const kJournalDirName = @"RNFileUploaderEvents"; +// Runaway guard, mirrors Android's EventJournal.MAX_ENTRIES. Only fires if JS +// never drains the journal via ack; drops the oldest entries when exceeded. +static const NSUInteger kMaxEntries = 1000; + +@implementation RNBGUEventJournal + ++ (dispatch_queue_t)queue { + static dispatch_queue_t q; + static dispatch_once_t once; + dispatch_once(&once, ^{ + q = dispatch_queue_create("com.vydia.rnbgupload.journal", DISPATCH_QUEUE_SERIAL); + }); + return q; +} + ++ (NSURL *)dirURL { + NSURL *appSupport = [[NSFileManager.defaultManager URLsForDirectory:NSApplicationSupportDirectory + inDomains:NSUserDomainMask] firstObject]; + NSURL *dir = [appSupport URLByAppendingPathComponent:kJournalDirName isDirectory:YES]; + [NSFileManager.defaultManager createDirectoryAtURL:dir + withIntermediateDirectories:YES + attributes:nil + error:nil]; + // Application Support is backed up to iCloud/iTunes by default; the journal + // is transient device-local state, so exclude it from backups. + NSURL *mutableDir = dir; + [mutableDir setResourceValue:@YES forKey:NSURLIsExcludedFromBackupKey error:nil]; + return dir; +} + ++ (void)append:(NSDictionary *)event { + NSString *eventId = event[@"eventId"]; + if (!eventId) return; + dispatch_sync([self queue], ^{ + NSError *err = nil; + NSData *data = [NSJSONSerialization dataWithJSONObject:event options:0 error:&err]; + if (!data) return; + // writeToURL returns BOOL (never throws), so a failed journal write can + // never propagate into the upload delegate and re-trigger the upload. + NSURL *file = [[self dirURL] URLByAppendingPathComponent:[eventId stringByAppendingString:@".json"]]; + [data writeToURL:file atomically:YES]; + [self pruneToMax]; + }); +} + +// Caller already holds the serial queue. Prune oldest by content-modification +// date beyond kMaxEntries. ++ (void)pruneToMax { + NSArray *keys = @[NSURLContentModificationDateKey]; + NSArray *files = [NSFileManager.defaultManager contentsOfDirectoryAtURL:[self dirURL] + includingPropertiesForKeys:keys + options:0 + error:nil]; + NSMutableArray *jsonFiles = [NSMutableArray array]; + for (NSURL *f in files) { + if ([f.pathExtension isEqualToString:@"json"]) [jsonFiles addObject:f]; + } + if (jsonFiles.count <= kMaxEntries) return; + [jsonFiles sortUsingComparator:^NSComparisonResult(NSURL *a, NSURL *b) { + NSDate *da = nil, *db = nil; + [a getResourceValue:&da forKey:NSURLContentModificationDateKey error:nil]; + [b getResourceValue:&db forKey:NSURLContentModificationDateKey error:nil]; + return [(da ?: NSDate.distantPast) compare:(db ?: NSDate.distantPast)]; + }]; + NSUInteger toRemove = jsonFiles.count - kMaxEntries; + for (NSUInteger i = 0; i < toRemove; i++) { + [NSFileManager.defaultManager removeItemAtURL:jsonFiles[i] error:nil]; + } +} + ++ (NSArray *)unacknowledged { + __block NSMutableArray *events = [NSMutableArray array]; + dispatch_sync([self queue], ^{ + NSArray *files = [NSFileManager.defaultManager contentsOfDirectoryAtURL:[self dirURL] + includingPropertiesForKeys:nil + options:0 + error:nil]; + for (NSURL *file in files) { + if (![file.pathExtension isEqualToString:@"json"]) continue; + NSData *data = [NSData dataWithContentsOfURL:file]; + if (!data) continue; + id parsed = [NSJSONSerialization JSONObjectWithData:data options:0 error:nil]; + if ([parsed isKindOfClass:[NSDictionary class]] && parsed[@"eventId"]) { + [events addObject:parsed]; + } + } + [events sortUsingComparator:^NSComparisonResult(NSDictionary *a, NSDictionary *b) { + return [(a[@"timestamp"] ?: @0) compare:(b[@"timestamp"] ?: @0)]; + }]; + }); + return events; +} + ++ (void)ack:(NSArray *)eventIds { + dispatch_sync([self queue], ^{ + for (NSString *eventId in eventIds) { + NSURL *file = [[self dirURL] URLByAppendingPathComponent:[eventId stringByAppendingString:@".json"]]; + [NSFileManager.defaultManager removeItemAtURL:file error:nil]; + } + }); +} + +@end +``` + +- [ ] **Step 2: Create the persisted task map** + +`ios/RNBGUTaskMap.h`: + +```objc +#import + +// Durable sessionId:taskIdentifier -> uploadId mapping. taskDescription has no +// documented persistence guarantee across process death; this map does. +@interface RNBGUTaskMap : NSObject ++ (void)setMeta:(NSDictionary *)meta forKey:(NSString *)key; // meta: {id, acceptStatus} ++ (NSDictionary * _Nullable)metaForKey:(NSString *)key; ++ (void)removeKey:(NSString *)key; +@end +``` + +`ios/RNBGUTaskMap.m`: + +```objc +#import "RNBGUTaskMap.h" + +static NSString *const kTaskMapFile = @"RNFileUploaderTaskMap.json"; + +@implementation RNBGUTaskMap + ++ (dispatch_queue_t)queue { + static dispatch_queue_t q; + static dispatch_once_t once; + dispatch_once(&once, ^{ + q = dispatch_queue_create("com.vydia.rnbgupload.taskmap", DISPATCH_QUEUE_SERIAL); + }); + return q; +} + ++ (NSURL *)fileURL { + NSURL *appSupport = [[NSFileManager.defaultManager URLsForDirectory:NSApplicationSupportDirectory + inDomains:NSUserDomainMask] firstObject]; + [NSFileManager.defaultManager createDirectoryAtURL:appSupport + withIntermediateDirectories:YES + attributes:nil + error:nil]; + return [appSupport URLByAppendingPathComponent:kTaskMapFile]; +} + +// Callers hold the serial queue via dispatch_sync; these two are queue-private. ++ (NSMutableDictionary *)readMap { + NSData *data = [NSData dataWithContentsOfURL:[self fileURL]]; + if (!data) return [NSMutableDictionary dictionary]; + id parsed = [NSJSONSerialization JSONObjectWithData:data options:0 error:nil]; + return [parsed isKindOfClass:[NSDictionary class]] + ? [parsed mutableCopy] + : [NSMutableDictionary dictionary]; +} + ++ (void)writeMap:(NSDictionary *)map { + NSData *data = [NSJSONSerialization dataWithJSONObject:map options:0 error:nil]; + if (data) [data writeToURL:[self fileURL] atomically:YES]; +} + ++ (void)setMeta:(NSDictionary *)meta forKey:(NSString *)key { + if (!meta || !key) return; + dispatch_sync([self queue], ^{ + NSMutableDictionary *map = [self readMap]; + map[key] = meta; + [self writeMap:map]; + }); +} + ++ (NSDictionary *)metaForKey:(NSString *)key { + __block NSDictionary *result = nil; + dispatch_sync([self queue], ^{ + id value = [self readMap][key]; + if ([value isKindOfClass:[NSDictionary class]]) result = value; + }); + return result; +} + ++ (void)removeKey:(NSString *)key { + dispatch_sync([self queue], ^{ + NSMutableDictionary *map = [self readMap]; + [map removeObjectForKey:key]; + [self writeMap:map]; + }); +} + +@end +``` + +Add the uploader helpers to `VydiaRNFileUploader.m` (with `#import "RNBGUTaskMap.h"`): + +```objc +- (NSString *)taskMapKeyFor:(NSURLSession *)session task:(NSURLSessionTask *)task { + return [NSString stringWithFormat:@"%@:%lu", + session.configuration.identifier ?: @"", + (unsigned long)task.taskIdentifier]; +} + +// taskDescription is the primary id; the persisted map is the fallback for +// tasks observed after a relaunch where taskDescription may not have survived. +- (NSString *)uploadIdFor:(NSURLSession *)session task:(NSURLSessionTask *)task { + return task.taskDescription + ?: [RNBGUTaskMap metaForKey:[self taskMapKeyFor:session task:task]][@"id"] + ?: @"unknown"; +} + +- (NSArray *)acceptStatusFor:(NSURLSession *)session task:(NSURLSessionTask *)task { + NSArray *accept = [RNBGUTaskMap metaForKey:[self taskMapKeyFor:session task:task]][@"acceptStatus"]; + return [accept isKindOfClass:[NSArray class]] ? accept : @[]; +} +``` + +And record the mapping in `startUpload`, immediately after the taskDescription assignment: + +```objc + [RNBGUTaskMap setMeta:@{ + @"id": uploadTask.taskDescription, + @"acceptStatus": options[@"acceptStatus"] ?: @[], + } forKey:[self taskMapKeyFor:session task:uploadTask]]; +``` + +(The map persists `acceptStatus` alongside the id so classification still works for tasks that complete after a relaunch, when the original `startUpload` options are gone.) + +- [ ] **Step 3: Journal terminal events in `didCompleteWithError`** + +In `VydiaRNFileUploader.m`, add `#import "RNBGUEventJournal.h"` and a static set near the other statics: + +```objc +static NSMutableSet *_userCancelledIds = nil; +``` + +Initialize it in `init` alongside `_responsesData`: + +```objc + _userCancelledIds = [NSMutableSet set]; +``` + +Replace the entire `didCompleteWithError` delegate method (lines 369–399) with: + +```objc +- (void)URLSession:(NSURLSession *)session + task:(NSURLSessionTask *)task +didCompleteWithError:(NSError *)error { + NSString *uploadId = [self uploadIdFor:session task:task]; + NSMutableDictionary *data = [NSMutableDictionary dictionaryWithObjectsAndKeys:uploadId, @"id", nil]; + NSURLSessionDataTask *uploadTask = (NSURLSessionDataTask *)task; + NSHTTPURLResponse *response = (NSHTTPURLResponse *)uploadTask.response; + if (response != nil) { + [data setObject:[NSNumber numberWithInteger:response.statusCode] forKey:@"responseCode"]; + NSMutableDictionary *headers = [NSMutableDictionary dictionary]; + [response.allHeaderFields enumerateKeysAndObjectsUsingBlock:^(id key, id val, BOOL *stop) { + headers[[key description]] = [val description]; + }]; + [data setObject:headers forKey:@"responseHeaders"]; + } + //Add data that was collected earlier by the didReceiveData method + NSMutableData *responseData = _responsesData[@(task.taskIdentifier)]; + if (responseData) { + [_responsesData removeObjectForKey:@(task.taskIdentifier)]; + NSString *body = [[NSString alloc] initWithData:responseData encoding:NSUTF8StringEncoding] ?: @""; + // Journal cap: 64KB, flag truncation + static const NSUInteger kMaxBodyChars = 64 * 1024; + if (body.length > kMaxBodyChars) { + [data setObject:[body substringToIndex:kMaxBodyChars] forKey:@"responseBody"]; + [data setObject:@YES forKey:@"responseBodyTruncated"]; + } else { + [data setObject:body forKey:@"responseBody"]; + } + } else { + [data setObject:[NSNull null] forKey:@"responseBody"]; + } + + [data setObject:[[NSUUID UUID] UUIDString] forKey:@"eventId"]; + [data setObject:@([[NSDate date] timeIntervalSince1970] * 1000) forKey:@"timestamp"]; + + NSString *eventName; + if (error == nil) { + // "completed" only for 2xx or per-request acceptStatus codes (axios + // validateStatus semantics); other HTTP responses are terminal errors. + NSInteger status = response ? response.statusCode : 0; + NSArray *accept = [self acceptStatusFor:session task:task]; + if ((status >= 200 && status < 300) || [accept containsObject:@(status)]) { + eventName = @"completed"; + } else { + eventName = @"error"; + [data setObject:@"http" forKey:@"errorKind"]; + [data setObject:[NSString stringWithFormat:@"HTTP %ld", (long)status] forKey:@"error"]; + } + } else { + [data setObject:error.localizedDescription forKey:@"error"]; + if (error.code == NSURLErrorCancelled) { + eventName = @"cancelled"; + BOOL userCancelled; + @synchronized (_userCancelledIds) { + userCancelled = [_userCancelledIds containsObject:uploadId]; + [_userCancelledIds removeObject:uploadId]; + } + [data setObject:(userCancelled ? @"user" : @"system") forKey:@"cancelReason"]; + } else { + eventName = @"error"; + [data setObject:@"network" forKey:@"errorKind"]; + } + } + + // Journal BEFORE emitting: the emit is best-effort (JS may be dead), + // the journal entry is the durable record until JS acks it. + NSMutableDictionary *journalEntry = [data mutableCopy]; + [journalEntry setObject:eventName forKey:@"type"]; + if (journalEntry[@"responseBody"] == [NSNull null]) [journalEntry removeObjectForKey:@"responseBody"]; + [RNBGUEventJournal append:journalEntry]; + [RNBGUTaskMap removeKey:[self taskMapKeyFor:session task:task]]; + + [self _sendEventWithName:[@"RNFileUploader-" stringByAppendingString:eventName] body:data]; +} +``` + +- [ ] **Step 4: Build the example app** + +Run: `cd example/RNBGUExample && yarn install && (cd ios && pod install) && yarn ios` (builds and launches on a simulator; alternatively build `ios/RNBGUExample.xcworkspace` in Xcode). +Expected: build succeeds. New files are picked up by the podspec glob `ios/*.{h,m}` — no pbxproj edits needed for consumers; `pod install` regenerates the pod target. + +- [ ] **Step 5: Commit** + +```bash +git add ios/RNBGUEventJournal.h ios/RNBGUEventJournal.m ios/RNBGUTaskMap.h ios/RNBGUTaskMap.m ios/VydiaRNFileUploader.m +git commit -m "feat(ios): journal terminal events; persisted task map; response headers; cancel reason" +``` + +--- + +### Task 6: iOS `getUnacknowledgedEvents` / `ackEvents` / `getAllUploads` exports + +**Files:** +- Modify: `ios/VydiaRNFileUploader.m` (new RCT_EXPORT_METHODs; extend `cancelUpload`) + +**Interfaces:** +- Consumes: `RNBGUEventJournal` (Task 5), `_userCancelledIds` (Task 5). +- Produces: `getUnacknowledgedEvents()`, `ackEvents(ids)`, `getAllUploads()` — same JS names as Android (Task 2/4). `getAllUploads` returns live session tasks only: `[{ id, state: 'running'|'pending', bytesSent, totalBytes }]`. + +- [ ] **Step 1: Add the export methods** + +Add to `VydiaRNFileUploader.m`: + +```objc +RCT_EXPORT_METHOD(getUnacknowledgedEvents:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject) { + resolve([RNBGUEventJournal unacknowledged]); +} + +RCT_EXPORT_METHOD(ackEvents:(NSArray *)eventIds resolve:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject) { + [RNBGUEventJournal ack:eventIds]; + resolve(@YES); +} + +/* + * Enumerates upload tasks the OS sessions still know about (running/suspended). + * Terminal outcomes are NOT visible here — read getUnacknowledgedEvents for those. + */ +RCT_EXPORT_METHOD(getAllUploads:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject) { + NSMutableArray *sessions = [NSMutableArray array]; + if (_urlSession) [sessions addObject:_urlSession]; + if (_wifiOnlyUrlSession) [sessions addObject:_wifiOnlyUrlSession]; + + NSMutableArray *result = [NSMutableArray array]; + dispatch_group_t group = dispatch_group_create(); + + for (NSURLSession *session in sessions) { + dispatch_group_enter(group); + [session getTasksWithCompletionHandler:^(NSArray *dataTasks, NSArray *uploadTasks, NSArray *downloadTasks) { + for (NSURLSessionTask *task in uploadTasks) { + NSString *taskUploadId = [self uploadIdFor:session task:task]; + if ([taskUploadId isEqualToString:@"unknown"]) continue; + NSString *state = task.state == NSURLSessionTaskStateRunning ? @"running" : @"pending"; + @synchronized (result) { + [result addObject:@{ + @"id": taskUploadId, + @"state": state, + @"bytesSent": @(task.countOfBytesSent), + @"totalBytes": @(task.countOfBytesExpectedToSend), + }]; + } + } + dispatch_group_leave(group); + }]; + } + + dispatch_group_notify(group, dispatch_get_main_queue(), ^{ + resolve(result); + }); +} +``` + +- [ ] **Step 2: Track user cancels in `cancelUpload` and fix its task matching** + +At the top of the existing `cancelUpload` method body (before the sessions loop), add: + +```objc + @synchronized (_userCancelledIds) { + [_userCancelledIds addObject:cancelUploadId]; + } +``` + +In the same method, the matching line inside the tasks loop compares `taskDescription` directly — a task observed after a relaunch may have lost it. Replace: + +```objc + if ([uploadTask.taskDescription isEqualToString:cancelUploadId]){ +``` + +with: + +```objc + if ([[self uploadIdFor:session task:uploadTask] isEqualToString:cancelUploadId]){ +``` + +- [ ] **Step 3: Build** + +Same command as Task 5 Step 3. Expected: build succeeds. + +- [ ] **Step 4: Commit** + +```bash +git add ios/VydiaRNFileUploader.m +git commit -m "feat(ios): expose journal get/ack and getAllUploads; track user cancels" +``` + +--- + +### Task 7: iOS background-relaunch completion handler + +**Files:** +- Create: `ios/VydiaRNFileUploader.h` +- Modify: `ios/VydiaRNFileUploader.m` (class extension removal + delegate method + static handler store) +- Modify: `README.md` (AppDelegate instructions — full text in Task 10) + +**Interfaces:** +- Consumes: nothing new. +- Produces: `+[VydiaRNFileUploader setBackgroundSessionCompletionHandler:forIdentifier:]` for AppDelegates; `URLSessionDidFinishEventsForBackgroundURLSession:` implemented. Session identifiers in play: `ReactNativeBackgroundUpload`, `ReactNativeBackgroundUpload_WifiOnly`. + +- [ ] **Step 1: Create the public header** + +`ios/VydiaRNFileUploader.h`: + +```objc +#import +#import +#import + +@interface VydiaRNFileUploader : RCTEventEmitter + +/** + * Call from AppDelegate's application:handleEventsForBackgroundURLSession:completionHandler: + * so iOS can relaunch the app for uploads that finish while it is terminated. + * The handler is invoked after all pending session events have been delivered + * (and journaled), on the main queue. + */ ++ (void)setBackgroundSessionCompletionHandler:(void (^)(void))handler + forIdentifier:(NSString *)identifier; + +@end +``` + +In `VydiaRNFileUploader.m`, delete the inline `@interface VydiaRNFileUploader : RCTEventEmitter …` block (lines 8–9) and replace with `#import "VydiaRNFileUploader.h"`. + +- [ ] **Step 2: Store and fire the handler** + +In `VydiaRNFileUploader.m`, add near the other statics: + +```objc +static NSMutableDictionary *_bgCompletionHandlers = nil; +``` + +Add the class method and delegate method: + +```objc ++ (void)setBackgroundSessionCompletionHandler:(void (^)(void))handler + forIdentifier:(NSString *)identifier { + @synchronized (self) { + if (!_bgCompletionHandlers) _bgCompletionHandlers = [NSMutableDictionary dictionary]; + _bgCompletionHandlers[identifier] = [handler copy]; + } +} + +- (void)URLSessionDidFinishEventsForBackgroundURLSession:(NSURLSession *)session { + NSString *identifier = session.configuration.identifier; + if (!identifier) return; + void (^handler)(void); + @synchronized ([self class]) { + handler = _bgCompletionHandlers[identifier]; + [_bgCompletionHandlers removeObjectForKey:identifier]; + } + if (handler) { + dispatch_async(dispatch_get_main_queue(), handler); + } +} +``` + +Known limitation to note in the README (Task 10): the sessions are recreated inside the module's `init`, which runs when the React bridge starts. RN apps initialize the bridge during a background relaunch, so events are delivered and journaled; if a host app defers bridge startup in background, events wait in `nsurlsessiond` until the next real launch and are journaled then. Nothing is lost either way — the journal is the contract. + +- [ ] **Step 3: Build** + +Same build as Task 5 Step 3. Expected: build succeeds. + +- [ ] **Step 4: Commit** + +```bash +git add ios/VydiaRNFileUploader.h ios/VydiaRNFileUploader.m +git commit -m "feat(ios): background-relaunch completion handler support" +``` + +--- + +### Task 8: iOS progress throttle, UUID ids, dead-code removal + +**Files:** +- Modify: `ios/VydiaRNFileUploader.m` + +**Interfaces:** +- Consumes: nothing new. +- Produces: progress events throttled to 500ms per task (parity with Android); default upload ids are UUIDs; `startUpload` loses the multipart/PHAsset/appGroup branches (never functional for background use; TS types already forbid them). + +- [ ] **Step 1: Throttle progress events** + +Add near the other statics: + +```objc +static NSMutableDictionary *_lastProgressAt = nil; +``` + +Initialize in `init`: `_lastProgressAt = [NSMutableDictionary dictionary];` + +Replace the body of `didSendBodyData` (lines 401–418) with: + +```objc + float progress = -1; + if (totalBytesExpectedToSend > 0) { + progress = 100.0 * (float)totalBytesSent / (float)totalBytesExpectedToSend; + } + + // Throttle to 500ms per task (parity with Android). Always deliver 100%. + NSString *taskId = [self uploadIdFor:session task:task]; + NSTimeInterval now = [[NSDate date] timeIntervalSince1970]; + @synchronized (_lastProgressAt) { + NSNumber *last = _lastProgressAt[taskId]; + if (progress < 100 && last && now - last.doubleValue < 0.5) return; + _lastProgressAt[taskId] = @(now); + } + + NSDictionary *data = @{ @"id": taskId, @"progress": [NSNumber numberWithFloat:progress] }; + [self _sendEventWithName:@"RNFileUploader-progress" body:data]; +``` + +Also remove the task's entry in `didCompleteWithError` — add one line right after the `data` dictionary is created: + +```objc + @synchronized (_lastProgressAt) { [_lastProgressAt removeObjectForKey:uploadId]; } +``` + +(`uploadId` is the resolved id local added to `didCompleteWithError` in Task 5.) + +- [ ] **Step 2: UUID default ids** + +In `startUpload`, delete the `static int uploadId` counter usage (lines 124–128 and 201) and replace the taskDescription assignment: + +```objc + uploadTask.taskDescription = customUploadId ?: [[NSUUID UUID] UUIDString]; +``` + +Delete the `static int uploadId = 0;` declaration (line 16). + +- [ ] **Step 3: Delete dead branches** + +In `startUpload`: +- Delete the `assets-library` copy block (lines 160–174) and the `copyAssetToFile:` method (lines 81–109) and the `#import `. +- Delete the multipart branch: the `if ([uploadType isEqualToString:@"multipart"]) { … } else { … }` collapses to the raw path only; reject non-raw types explicitly: + +```objc + if (uploadType != nil && ![uploadType isEqualToString:@"raw"]) { + return reject(@"RN Uploader", @"Only type: 'raw' is supported", nil); + } + if (parameters.count > 0) { + return reject(@"RN Uploader", @"'parameters' requires multipart, which is not supported", nil); + } + NSURLSessionDataTask *uploadTask = (NSURLSessionDataTask *)[session uploadTaskWithRequest:request + fromFile:[NSURL URLWithString:fileURI]]; +``` + +- Delete `createBodyWithBoundary:…` (lines 281–319), `guessMIMETypeFromFileName:` (lines 67–79), the `#import `, and the `appGroup` handling (lines 136, 179–181 — it was a no-op: NSURLSession copies its configuration at creation, so mutating it later never had any effect). +- Delete the now-unused local variables (`fieldName`, `appGroup`, `parameters` stays only for the rejection check above). + +- [ ] **Step 4: Build and smoke-test** + +Build as in Task 5 Step 3, then run the example app on a simulator, press Upload against `example/server` (start it with `yarn --cwd example/server install && yarn --cwd example/server start`; it listens on port **3000**. Set `UPLOAD_URL` in `example/RNBGUExample/App.tsx` to `http://localhost:3000/upload`). +Expected: progress fires at ~2/sec max; completed event includes `eventId`, `responseHeaders`. + +- [ ] **Step 5: Commit** + +```bash +git add ios/VydiaRNFileUploader.m +git commit -m "feat(ios): throttle progress, UUID ids, remove dead multipart/PHAsset/appGroup code" +``` + +--- + +### Task 9: JS API surface + types + Jest harness + +**Files:** +- Modify: `src/index.ts`, `src/types.ts` +- Create: `jest.config.js`, `babel.config.js`, `src/__tests__/index.test.ts` +- Modify: `package.json` (test script + devDeps) + +**Interfaces:** +- Consumes: native methods from Tasks 2/4/6 (`getUnacknowledgedEvents`, `ackEvents`, `getAllUploads`). +- Produces (public API, all additive): + +```ts +export interface JournaledEvent { + eventId: string; + id: string; // upload id + type: 'completed' | 'error' | 'cancelled'; + timestamp: number; + responseCode?: number; + responseBody?: string; + responseBodyTruncated?: boolean; + responseHeaders?: Record; + error?: string; + errorKind?: 'http' | 'network' | 'file' | 'unknown'; + cancelReason?: 'user' | 'system'; +} +export interface UploadSnapshot { + id: string; + state: 'pending' | 'running' | 'completed' | 'error' | 'cancelled'; + bytesSent?: number; // iOS only + totalBytes?: number; // iOS only +} +getUnacknowledgedEvents(): Promise +ackEvents(eventIds: string[]): Promise +getAllUploads(): Promise +// CompletedData gains: responseHeaders?: Record; eventId?: string +// cancelled EventData gains: cancelReason?: 'user' | 'system' +// ErrorData gains: errorKind + responseCode/responseBody/responseHeaders (http errors) +// UploadOptions gains: acceptStatus?: number[]; android becomes optional +``` + +- [ ] **Step 1: Add Jest tooling** + +In `package.json` devDependencies add `"jest": "^29.7.0"`, `"babel-jest": "^29.7.0"`, `"@babel/preset-typescript": "^7.24.0"` and a script `"test": "jest"`. + +Create `babel.config.js` (root; the example app has its own config and Metro reads from the example dir, so this only affects Jest): + +```js +module.exports = { + presets: [['@babel/preset-env', {targets: {node: 'current'}}], '@babel/preset-typescript'], +}; +``` + +Create `jest.config.js`: + +```js +module.exports = { + testEnvironment: 'node', + testMatch: ['**/__tests__/**/*.test.ts'], +}; +``` + +Run: `yarn install` + +- [ ] **Step 2: Write the failing test** + +Create `src/__tests__/index.test.ts`: + +```ts +// Jest only allows out-of-scope variables in mock factories when their names +// start with "mock" — do not rename these. +const mockStartUpload = jest.fn(async () => 'id-1'); +const mockGetUnacknowledgedEvents = jest.fn(async () => [ + {eventId: 'e1', id: 'u1', type: 'completed', timestamp: 1, responseCode: 200}, +]); +const mockAckEvents = jest.fn(async () => true); +const mockGetAllUploads = jest.fn(async () => [{id: 'u1', state: 'running'}]); + +jest.mock('react-native', () => ({ + Platform: {OS: 'ios'}, + DeviceEventEmitter: {addListener: jest.fn()}, + NativeModules: { + VydiaRNFileUploader: { + addListener: jest.fn(), + startUpload: mockStartUpload, + getUnacknowledgedEvents: mockGetUnacknowledgedEvents, + ackEvents: mockAckEvents, + getAllUploads: mockGetAllUploads, + }, + }, +})); + +import Upload from '../index'; + +describe('journal + query API', () => { + it('exposes getUnacknowledgedEvents', async () => { + const events = await Upload.getUnacknowledgedEvents(); + expect(events[0].eventId).toBe('e1'); + }); + + it('exposes ackEvents', async () => { + await Upload.ackEvents(['e1']); + expect(mockAckEvents).toHaveBeenCalledWith(['e1']); + }); + + it('exposes getAllUploads', async () => { + const uploads = await Upload.getAllUploads(); + expect(uploads[0].state).toBe('running'); + }); + + it('still prefixes file paths in startUpload', async () => { + await Upload.startUpload({ + url: 'https://x', + path: '/tmp/f.bin', + method: 'POST', + type: 'raw', + android: { + notificationId: 'n', + notificationTitle: 't', + notificationTitleNoWifi: 'w', + notificationTitleNoInternet: 'i', + notificationChannel: 'c', + }, + }); + expect(mockStartUpload).toHaveBeenCalledWith( + expect.objectContaining({path: 'file:///tmp/f.bin'}), + ); + }); +}); +``` + +- [ ] **Step 3: Run to verify failure** + +Run: `yarn test` +Expected: FAIL — `Upload.getUnacknowledgedEvents is not a function`. + +- [ ] **Step 4: Implement the JS surface** + +In `src/types.ts` add (and extend `CompletedData`/the cancelled overload): + +```ts +export interface JournaledEvent { + eventId: string; + id: string; + type: 'completed' | 'error' | 'cancelled'; + timestamp: number; + responseCode?: number; + responseBody?: string; + responseBodyTruncated?: boolean; + responseHeaders?: Record; + error?: string; + errorKind?: 'http' | 'network' | 'file' | 'unknown'; + cancelReason?: 'user' | 'system'; +} + +export interface UploadSnapshot { + id: string; + state: 'pending' | 'running' | 'completed' | 'error' | 'cancelled'; + bytesSent?: number; + totalBytes?: number; +} + +export interface CancelledData extends EventData { + cancelReason?: 'user' | 'system'; +} +``` + +Replace `ErrorData` with (HTTP errors now arrive here, carrying the full response): + +```ts +export interface ErrorData extends EventData { + error: string; + errorKind?: 'http' | 'network' | 'file' | 'unknown'; + responseCode?: number; + responseBody?: string; + responseHeaders?: Record; +} +``` + +In `UploadOptions`, add `acceptStatus` and make the `android` block optional (all its fields optional too — native now supplies defaults and creates the channel): + +```ts + /** + * Non-2xx HTTP statuses to treat as successful completion, e.g. [409] when + * retries make duplicate-create conflicts expected. Everything else non-2xx + * emits an 'error' event with errorKind 'http' and the response attached. + */ + acceptStatus?: number[]; + android?: Partial; +``` + +Change `CompletedData` to: + +```ts +export interface CompletedData extends EventData { + eventId?: string; + responseCode: number; + responseBody: string; + responseHeaders?: Record; +} +``` + +Change the `cancelled` overload in `AddListener` to use `CancelledData`. + +In `src/index.ts` add: + +```ts +/** + * Terminal events (completed/error/cancelled) are journaled natively before + * being emitted, so they survive the app being killed. Read them on startup, + * process them, then acknowledge — unacked events are re-delivered here forever. + */ +const getUnacknowledgedEvents = (): Promise => + NativeModule.getUnacknowledgedEvents(); + +const ackEvents = (eventIds: string[]): Promise => + NativeModule.ackEvents(eventIds); + +/** + * Uploads the OS still knows about. On iOS this is live session tasks only; + * terminal outcomes come from getUnacknowledgedEvents, not from here. + */ +const getAllUploads = (): Promise => NativeModule.getAllUploads(); +``` + +Import the new types, and replace the default export with: + +```ts +export default { + startUpload, + cancelUpload, + addListener, + getUnacknowledgedEvents, + ackEvents, + getAllUploads, + ios, + android, +}; +``` + +- [ ] **Step 5: Run tests + typecheck** + +Run: `yarn test && yarn tsc --noEmit` +Expected: 4 tests PASS; typecheck clean. (The `typecheck` script replaces `build` in Task 10.) + +- [ ] **Step 6: Commit** + +```bash +git add src/ jest.config.js babel.config.js package.json yarn.lock +git commit -m "feat(js): journal get/ack, getAllUploads, typed errors, acceptStatus" +``` + +--- + +### Task 10: Packaging, CI, README, CHANGELOG + +**Files:** +- Modify: `package.json`, `.github/workflows/node.yml`, `README.md`, `CHANGELOG.md` + +**Interfaces:** +- Consumes: everything prior. +- Produces: v7.6.0; CI runs lint + jest + android unit tests + a `lib/` drift check. + +- [ ] **Step 1: Version + prepack safety** + +In `package.json`: set `"version": "8.0.0"`. Delete the committed `lib/` directory (`git rm -r lib`) — it's a hand-regenerated build artifact that has already drifted once. Metro consumes TS source directly (`"main": "src/index"`), and TypeScript reads types from source too: set `"typings": "src/index.ts"` and drop the `tsc-alias` build in favor of `"typecheck": "tsc --noEmit"` (nothing consumes emitted JS anymore). Diana's one deep import (`react-native-background-upload/lib/types` in `fileTransfers/models.ts`) moves to the root import on adoption. + +- [ ] **Step 2: CI** + +Replace the `node-lint-tests` job steps in `.github/workflows/node.yml` after "install node_modules" with: + +```yaml + - name: node lint + run: yarn lint:ci + + - name: js tests + run: yarn test + + - name: typecheck + run: yarn typecheck + + - name: setup java + uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: 17 + + - name: android unit tests + run: | + cd example/RNBGUExample/android + ./gradlew :react-native-background-upload:testDebugUnitTest +``` + +- [ ] **Step 3: README** + +Make these edits (keep the rest): +- Intro: replace "on Android it uses CoroutineWorker and Ktor" with "on Android it uses WorkManager (CoroutineWorker) and OkHttp". +- Delete the "Multipart Uploads — COMING SOON" section, the `useUtf8Charset` row, the `field`/`parameters`/`appGroup` rows, and the stale `notification` object table. Replace the notification table with: + +```markdown +### `android` options (required) + +| Name | Type | Required | Description | +| --- | --- | --- | --- | +| `notificationChannel` | string | Yes | Existing notification channel id (create it with notifee or similar before uploading) | +| `notificationId` | string | Yes | Stable id for the progress notification | +| `notificationTitle` | string | Yes | Title while uploading | +| `notificationTitleNoWifi` | string | Yes | Title while waiting for wifi (`wifiOnly: true`) | +| `notificationTitleNoInternet` | string | Yes | Title while waiting for connectivity | +| `maxRetries` | number | No (default 5) | Retry budget for non-network errors | +``` +- Add an **"iOS Setup (AppDelegate)"** section: + +````markdown +### iOS: background completion handler (required) + +Add to your `AppDelegate.mm` so uploads that finish while the app is terminated +relaunch it and get journaled: + +```objc +#import + +- (void)application:(UIApplication *)application +handleEventsForBackgroundURLSession:(NSString *)identifier + completionHandler:(void (^)(void))completionHandler { + [VydiaRNFileUploader setBackgroundSessionCompletionHandler:completionHandler + forIdentifier:identifier]; +} +``` +```` + +- Add a **"Reliable delivery (v7.6+)"** section: + +````markdown +## Reliable delivery (v7.6+) + +Terminal events (`completed` / `error` / `cancelled`) are journaled natively +*before* being emitted, so they survive app death, JS reloads, and background +relaunches. Events stay in the journal until you acknowledge them. + +On every app start: + +```js +const events = await Upload.getUnacknowledgedEvents(); +for (const e of events) { + // e: { eventId, id, type, timestamp, responseCode?, responseBody?, responseHeaders?, error?, cancelReason? } + handleOutcome(e); +} +await Upload.ackEvents(events.map((e) => e.eventId)); + +// Then reconcile anything still in flight: +const live = await Upload.getAllUploads(); +``` + +Notes: +- **Breaking in v8:** `completed` fires only for 2xx (plus any per-request `acceptStatus` + codes). Other HTTP responses emit `error` with `errorKind: 'http'` and the full + response (`responseCode`, `responseBody`, `responseHeaders`) attached. +- `errorKind` distinguishes HTTP errors, transport failures (`'network'`), and missing + files (`'file'`) — retry transport failures, don't retry client errors. +- `cancelReason` distinguishes user cancels (`'user'`) from system kills (`'system'`). +- Duplicate journal entries per upload id are possible if the process dies at + exactly the wrong moment (Android re-runs the worker); dedupe by `id`, keep latest. +- Android: `getAllUploads()` sees finished work for only ~1 day (WorkManager + auto-pruning) — read outcomes from the journal, use `getAllUploads()` for live state. +- iOS: in rare cases the OS fails to deliver the response body for a background + upload (known platform quirk) — `responseCode` is always authoritative; + treat `responseBody` as best-effort. +```` + +- [ ] **Step 4: CHANGELOG** + +Prepend to `CHANGELOG.md`: + +```markdown +## 8.0.0 + +Breaking: +- `completed` now fires only for 2xx responses (plus per-request `acceptStatus` codes, + e.g. `acceptStatus: [409]`). Other HTTP responses emit `error` with + `errorKind: 'http'` and the full response attached. +- `error` events are typed: `errorKind: 'http' | 'network' | 'file' | 'unknown'`. +- Removed committed `lib/` output; typings now point at `src/index.ts` + (deep imports of `lib/*` break — import from the package root). +- iOS: removed non-functional multipart, PHAsset (`assets-library://`), and `appGroup` + code paths. Android: removed unexposed `stopAllUploads`. + +Added: +- Native durable 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'`. +- iOS: `handleEventsForBackgroundURLSession` support (see README AppDelegate setup), + `responseHeaders` on completed events, progress throttled to 500ms, UUID default ids, + persisted task map so ids survive relaunch. +- Android: the `android` options block is now optional — sensible notification defaults, + and the library creates its notification channel itself. +``` + +- [ ] **Step 5: Lint, commit, tag** + +```bash +yarn lint:ci && yarn test +git rm -r lib +git add package.json .github/workflows/node.yml README.md CHANGELOG.md +git commit -m "chore: v8.0.0 — docs, CI (jest + android tests + typecheck), drop committed lib/" +git tag v8.0.0 # after PR merge, not on the branch +``` + +--- + +### Task 11: On-device E2E verification (manual checklist) + +**Files:** +- Modify: `example/RNBGUExample/App.tsx` (debug buttons) + +**Interfaces:** +- Consumes: the full new API. +- Produces: a verified release. **Do not mark this plan complete without this task — the whole point is behavior when the app dies, which only devices can prove.** + +- [ ] **Step 1: Add journal debug buttons to the example app** + +In `example/RNBGUExample/App.tsx`, next to the existing Cancel button, add: + +```tsx +