From b678007e5e047722599850abd4b53c34d48580b4 Mon Sep 17 00:00:00 2001 From: Dylan Murphy Date: Thu, 24 Sep 2026 17:40:15 -0400 Subject: [PATCH] v10 slice 5: example app on v10, test seam, CI, docs, version 10.0.0 The example app is the device test harness. It is rewritten on the v10 API: definitions at module scope (JSON POST, multipart, chunked with chunkPlan, bodiless DELETE, no-vars GET), configure() with a headers provider, a queue list from getRequests() and the state and progress feeds, an attempt log, and controls for every step of the two device scripts: cancel, pause, resume, wifiOnly, updateHeaders with a typed value, same-id resume and replace, a short expiresAt, and a section of outcomes journaled before this launch for the kill-and-relaunch check. The local Express server answers by path segment (401, 404, 409, 503, slow, oversize) and writes chunked parts at their offsets. The stale Podfile.lock is regenerated for RN 0.84.1, the AppDelegate wires the background completion handler, and the Android manifest declares and requests POST_NOTIFICATIONS. The example README carries the device script in UI terms. Library: createUploadClient({ native }) accepts a fake TurboModule, and react-native-background-upload/src/testing exports createFakeNative(), an in-memory Spec with settle(), seeded rows, recorded calls, and ack tracking, so a consumer tests definitions and handlers against the real registry and delivery. CI gains a macOS job that runs swift test. Docs: CHANGELOG 10.0.0 covers all slices; README gets an "Upgrading from v9" section, iOS platform notes, a "Testing your definitions" section, and a corrected AppDelegate snippet (@import for .m, header search path for .mm). Version 10.0.0. Co-Authored-By: Claude Fable 5.1 --- .github/workflows/node.yml | 39 + CHANGELOG.md | 193 +- README.md | 158 +- example/RNBGUExample/App.tsx | 918 ++++++--- example/RNBGUExample/README.md | 321 +++- example/RNBGUExample/__tests__/App.test.tsx | 107 +- .../android/app/src/main/AndroidManifest.xml | 4 + example/RNBGUExample/harness/files.ts | 80 + example/RNBGUExample/harness/log.ts | 54 + example/RNBGUExample/harness/settings.ts | 45 + example/RNBGUExample/harness/uploads.ts | 327 ++++ example/RNBGUExample/ios/Podfile.lock | 1663 ++++++++++------- .../RNBGUExample.xcodeproj/project.pbxproj | 20 + .../ios/RNBGUExample/AppDelegate.mm | 32 +- .../RNBGUExample/ios/RNBGUExample/Info.plist | 5 +- example/RNBGUExample/metro.config.js | 2 +- example/server/.gitignore | 3 + example/server/package.json | 2 +- example/server/src/index.ts | 347 ++-- example/server/yarn.lock | 14 +- package.json | 6 +- src/__tests__/client.test.ts | 93 + src/__tests__/testing.test.ts | 423 +++++ src/__tests__/testingGlobal.test.ts | 46 + src/index.ts | 17 +- src/testing.ts | 581 ++++++ 26 files changed, 4342 insertions(+), 1158 deletions(-) create mode 100644 example/RNBGUExample/harness/files.ts create mode 100644 example/RNBGUExample/harness/log.ts create mode 100644 example/RNBGUExample/harness/settings.ts create mode 100644 example/RNBGUExample/harness/uploads.ts create mode 100644 src/__tests__/testing.test.ts create mode 100644 src/__tests__/testingGlobal.test.ts create mode 100644 src/testing.ts diff --git a/.github/workflows/node.yml b/.github/workflows/node.yml index 4e31494b..46803663 100644 --- a/.github/workflows/node.yml +++ b/.github/workflows/node.yml @@ -40,3 +40,42 @@ jobs: run: | cd example/RNBGUExample/android ./gradlew :react-native-background-upload:testDebugUnitTest + + # Host-side unit tests for the pure Swift half of the iOS module. + # They run on macOS with Xcode's Swift toolchain. ios/Package.swift needs + # swift-tools-version 5.9. The macos-15 image ships Xcode 16 or later + # (Swift 6 or later), which meets that. + # + # This job does not build the example app. Do that by hand before a + # release, because a simulator build is too slow for every push: + # cd example/RNBGUExample/ios && pod install + # xcodebuild -workspace RNBGUExample.xcworkspace -scheme RNBGUExample \ + # -sdk iphonesimulator -destination 'generic/platform=iOS Simulator' \ + # -configuration Debug build + ios-host-tests: + runs-on: macos-15 + timeout-minutes: 30 + if: "!contains(github.event.head_commit.message, '[skip ci]')" + + steps: + - name: checkout + uses: actions/checkout@v4 + + - name: swift version + id: swift + run: | + swift --version + echo "version=$(swift --version 2>&1 | head -n 1 | shasum | cut -c1-12)" >> "$GITHUB_OUTPUT" + + - name: cache swift build + uses: actions/cache@v4 + with: + path: ios/.build + key: swiftpm-${{ runner.os }}-${{ steps.swift.outputs.version }}-${{ hashFiles('ios/*.swift', 'ios/Tests/**/*.swift') }} + restore-keys: | + swiftpm-${{ runner.os }}-${{ steps.swift.outputs.version }}- + + - name: ios host tests + run: | + cd ios + swift test diff --git a/CHANGELOG.md b/CHANGELOG.md index 829b7da2..30e40e87 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,15 +1,12 @@ -## 10.0.0 (unreleased) +## 10.0.0 The library now owns a durable request queue. A consumer describes each request kind one time with `define()`, enqueues instances with `mutate()`, and receives every outcome through the definition's handlers, on this launch or a later one. Outcomes are journaled natively before JS hears about them and acknowledged only after the handler's promise resolves. See the README's -"Usage" and "Reliable delivery" sections. - -This release is built in slices. The JS layer, the codegen spec, and native -stubs land first; the Android and iOS queues follow. Until they land, every -queue method rejects with `E_NOT_IMPLEMENTED`. +"Usage" and "Reliable delivery" sections. "Upgrading from v9" there covers +the migration. Breaking: - **`startUpload` and `getAllUploads` are removed.** `define()` + `mutate()` @@ -29,13 +26,18 @@ Breaking: - **`progress` carries `{ id, bytesSent, totalBytes }`** instead of a percentage. - **`configure()` must be called at boot, after every `define()`.** It starts - the replay of journaled outcomes. It also takes `lifetimeMs`, `retry`, a - `headers` provider that runs at `mutate()`, `maxVarsBytes` (default 1 MB), - and `enqueueTimeoutMs` (default 10 s): `mutate()` rejects with a named - error and warns when the native write has not settled by then, so a native - bug cannot hang a caller in silence. + the replay of journaled outcomes. Its `android` notification options are + unchanged from v9. - **`ErrorKind` gains `'truncated'`.** With a `response` parser set and a body over the 1 MB cap, `onError` fires with it instead of `onSuccess`. +- **Removed exports:** `startUpload`, `cancelUpload`, `removeUpload`, + `getAllUploads`, the public `getUnacknowledgedEvents` / `ackEvents`, the + `error` / `completed` / `cancelled` event names, and the + `ProgressData`, `CompletedData`, `ErrorData`, `CancelledData`, `EventData`, + `TerminalEventData`, `JournaledEvent`, `UploadSnapshot`, `UploadOptions`, + `ChunkedUploadOptions`, `StartUploadOptions`, `AndroidOnlyUploadOptions`, + `RawUploadOptions`, and `UploadId` types. The native `startChunkedUpload` is + internal: a descriptor with `parts` routes to it. Added: - **`createUploadClient()`**: builds a client with its own definitions and @@ -43,49 +45,146 @@ Added: - **`define({ key, request, response?, onSuccess?, onError? })`**: `vars` infer from the `request` parameter, the handler data type from the `response` return. `response(raw, vars)` also receives the entry's `vars`; - a one-argument parser such as `schema.parse` still fits. A duplicate key - replaces the definition and warns in development. + a one-argument parser such as `schema.parse` still fits. Without + `response`, `onSuccess` receives the `RawResponse`. A duplicate key + replaces the definition and warns in development. A `request` that takes + no parameter gives a `mutate()` that takes no arguments. - **`mutate(vars, { id? })`**: runs `request(vars)` once, merges the configured headers under the descriptor's, validates the descriptor (at most one of - `data` / `form` / `file`, none for a bodiless DELETE; no body on a GET; - `parts` only with `file`; parts must tile the file; no field outside the - descriptor shape), defaults `expiresAt` to now + - `lifetimeMs`, and resolves when the entry is durable. `vars` is any - JSON-serializable object, so generated API request types work as they are; - `mutate()` rejects vars or `data` that do not serialize (a cycle, a function, - a BigInt) and caps `vars` at `configure().maxVarsBytes`, 1 MB by default. A - definition whose `request` takes no vars calls `mutate()` with no arguments. - It resolves after the row and every staged body copy are on disk, so the - caller may delete its source file then; native failures reject with - `E_INVALID`, `E_RUNNING`, `E_FILE_MISSING`, or `E_STORAGE`. + `data` / `form` / `file`; no body on a GET; `parts` only with `file`; parts + must tile the file from 0; no field outside the descriptor shape, with a + "did you mean" hint), defaults `expiresAt` to now + `lifetimeMs`, and + resolves when the entry is durable: after the row and every staged body + copy are on disk, so the caller may delete its source file then. `vars` is + any JSON-serializable object, so generated API request types work as they + are; `mutate()` rejects vars or `data` that do not serialize (a cycle, a + function, a BigInt) and caps `vars` at `configure().maxVarsBytes`, 1 MB by + default. `id` defaults to a UUID. +- **Same id, again.** The body is `data`, `form`, `file`, or `parts`, and a + different `url` or `method` counts as a different body. Same body: resume, + with new headers, `expiresAt`, and `vars`. Different body on an entry that + is not running: replace and reopen; it settles once more. Different body + on a running entry: reject `E_RUNNING`. Cancelled but not yet acknowledged: + a fresh generation. Completed but not yet acknowledged: the journaled + outcome is emitted again and nothing is re-sent. - **Request bodies**: JSON (`data`), multipart (`form`), whole file (`file`), - and chunked (`file` + `parts`). All under one entry shape and one id. + and chunked (`file` + `parts`). All under one entry shape and one id. A + bodiless request (a GET, a DELETE, a POST whose meaning is in the URL) sets + none. `data: null` sends the JSON body `null`. +- **`configure()` options**: `lifetimeMs` (default 14 days), `retry` + (backoff base 1 s, max 2 h, jitter 0.2; `terminalHttp.exempt` default + `[404]`), a `headers` provider that runs at `mutate()`, `maxVarsBytes` + (default 1 MB), `enqueueTimeoutMs` (default 10 s: `mutate()` rejects with + a named error and warns when the native write has not settled by then, so + a native bug cannot hang a caller in silence), and the v9 `android` + notification options. +- **Queue control**: `pause()` / `resume()` for the whole queue, `cancel(id)`, + `setWifiOnly(enabled)`, and `updateHeaders(patch)` to re-auth entries + parked on 401 or 403. Each `updateHeaders()` call bumps a header + generation, so a 401 from an attempt sent under older headers re-issues at + once instead of parking. +- **`getRequests(filter?)`**: synchronous, from native's in-memory index. + Returns every entry native has not yet forgotten, so completed and + cancelled rows appear until their ack and `error` rows until `cancel()` or + a same-id `mutate()`. `filter` is `{ key?, id? }`. +- **`RequestRow`**: `{ id, key, vars, state, bytesSent, totalBytes, attempts, + updatedAt, nextAttemptAt? }`. `state` is `queued`, `running`, + `awaiting-auth`, `paused`, `completed`, `error`, or `cancelled`. + `nextAttemptAt` (epoch ms) is set while an entry waits out a retry + backoff. `attempts` counts the current generation. +- **`Meta`** for handlers: `{ id, key, at, attempts, requestId?, deliveries }`. + `deliveries` counts deliveries that reached a JS listener: 1 on the first, + +1 per replay. A handler that keeps throwing sees it grow; the library + never gives up on its own, so the app decides a poison policy. +- **Events**: `state` (a full `RequestRow` per transition, plus + `reason: 'unhandled-key'` for an outcome whose key has no definition), + `progress` (byte-weighted across a chunked upload's parts, throttled to + 1 s in the foreground), and `attempt` (one row per HTTP attempt with + `requestId`, `httpCode`, a 4 KB response body, `responseHeaders`, and + `errorKind`). `attempt.outcome` is `completed` or `error`; pause, cancel, + and supersede emit none. `attempt` events are live-only, never journaled. - **Delivery rules**: dedupe by event id; the outcomes of one id deliver in order, one handler at a time; an outcome for an id waits for that id's in-flight `mutate()`; an outcome whose key has no definition stays unacknowledged and reaches `state` listeners with `reason: 'unhandled-key'`; - a handler that has not settled after 30 s logs a warning. No ordering is - promised between different ids. -- **`Meta.deliveries`**: counts deliveries that reached a JS listener: 1 on - the first, +1 per replay. A handler that keeps throwing sees it grow; the - library never gives up on its own, so the app decides a poison policy. -- **`RequestRow.nextAttemptAt`**: epoch ms, set while an entry waits out a - retry backoff. `getRequests()` returns every entry native has not yet - forgotten, so completed and cancelled rows appear until their ack. -- **`pause()` / `resume()`** for the whole queue, **`updateHeaders(patch)`** to - re-auth parked entries, and the **`attempt`** event with one row per HTTP - attempt. Its `outcome` is `completed` or `error`; pause, cancel, and - supersede emit none. - -Removed: -- `startUpload`, `startChunkedUpload` (native), `cancelUpload`, - `removeUpload`, `getAllUploads`, the public `getUnacknowledgedEvents` / - `ackEvents`, and the `progress` / `error` / `completed` / `cancelled` event - names, with their `ProgressData`, `CompletedData`, `ErrorData`, - `CancelledData`, `EventData`, `TerminalEventData`, `JournaledEvent`, - `UploadSnapshot`, `UploadOptions`, `ChunkedUploadOptions`, - `StartUploadOptions`, `AndroidOnlyUploadOptions`, `RawUploadOptions`, and - `UploadId` types. + a handler that has not settled after 30 s logs a warning and keeps + waiting; a malformed journal entry is dropped with a warning. No ordering + is promised between different ids. +- **Error codes** on the native promises: `E_INVALID` (input native cannot + send: a non-http(s) URL, a header name or value the platform HTTP client + rejects, a GET with a body, parts that do not tile the file), + `E_RUNNING`, `E_FILE_MISSING`, and `E_STORAGE`. `cancel()` rejects with + `E_STORAGE` and changes nothing when the journal or the store cannot be + written. `updateHeaders()` rejects with `E_INVALID` for a bad header name + or value. +- **`X-Request-Id`** on every attempt, minted per attempt. `Meta.requestId` + and `attempt.requestId` carry it. +- **`chunkPlan`** stays a module export and is also on the client. +- **Testing seam**: `createUploadClient({ native })` takes a fake native + module, and `createFakeNative()` in `src/testing` builds one that keeps + rows, journals outcomes, and acks, with `settle()`, `failNext()`, + `seedRows()`, and `seedUnacknowledged()` to script native behavior. The + real validation and delivery run on top of it. + +Native: +- **Android queue.** One durable store under `files/rnbgupload-chunked/`, + one directory per entry (`entry.json` plus the staged body), written + tmp + fsync + rename. The journal of settled outcomes moves to + `files/rnbgupload-settled/`. Queue settings (`wifiOnly`, `paused`, header + generation, retry defaults) persist next to the entries. An in-memory + index serves `getRequests()`. One WorkManager worker per id; a long + backoff waits on a separate wake job so a same-id `mutate()` or + `updateHeaders()` can run the entry at once. The backoff streak is stored + on the entry, so the wait grows toward 2 h across runs. A run stopped by + WorkManager's timeout takes one more backoff step instead of restarting + at once. The global cap of 4 concurrent requests and the window of 3 + parts per chunked upload carry over from v9. A boot sweep repairs a settle + whose entry save was lost, applies a cancel whose save was lost, and + finishes a pause or resume cut short by process death. A settle whose + journal write fails holds the record in memory and retries the write + (5 s, doubling to 10 min). Response bodies are capped at 1 MB while they + stream, counted in UTF-8 bytes and cut on a character boundary. +- **iOS queue.** The v9 chunked store is generalized in place into the queue + store. Every body is staged to a file in the entry directory, because a + background `URLSession` uploads from files only. Store, journal, and task + map writes are tmp + fsync + rename, and the directories are excluded from + backup. Retries are delayed session tasks (`earliestBeginDate`) for simple + entries and for chunked parts, so a backoff keeps running while the app is + suspended or dead. Each attempt owns its task through the task + description, so a stale completion cannot settle a newer attempt. At + relaunch, an entry whose task is gone but whose completion may still be + pending waits up to 10 s for the replay before it re-issues, so a request + the daemon finished while the app was dead is not sent twice; the + background completion handler is released as soon as that replay lands. + Two background sessions, one that allows cellular and one Wi-Fi only, each + with `httpMaximumConnectionsPerHost = 4`. A system cancel, including a + force-quit, is a transient failure: no outcome, the entry retries. A + multipart body always uses its own `Content-Type`, because a caller's has + no boundary. Response bodies are capped at 1 MB while they stream. The v9 + per-part budget of 3 HTTP rejections is gone; the retry policy decides. +- **v9 import, both platforms.** The first v10 launch imports each v9 journal + entry as a read-only settled row with key `legacy`, the v9 upload id, and + 0/0 bytes. Nothing is delivered for them: the app reads them with + `getRequests({ key: 'legacy' })` and calls `cancel(id)`. In-flight v9 work + is cancelled (Android WorkManager rows, iOS session tasks). v9 chunked + manifests and their bytes stay: a same-id `mutate()` with the same parts + resumes from the accepted parts, and one with different parts starts over + on the kept bytes. Wi-Fi only starts off. +- **Platform limits.** Android, API 31 and later: a WorkManager run started + from the background usually cannot start its foreground service and then + has JobScheduler's limit of about 10 minutes; a single body that does not + finish in that time restarts from byte 0 after a backoff, so large bodies + need `parts`. Android: the store and journal hold auth headers and staged + bodies, so the host app sets `android:allowBackup="false"` or excludes the + two directories. iOS: no global in-flight cap, and the AppDelegate + `handleEventsForBackgroundURLSession` hook is required for outcomes to be + journaled when the system relaunches the app. + +Fixed: +- iOS: a chunked part's retry backoff no longer stalls while the app is + dead. v9 ran the cooldown on an in-process timer. +- iOS: task-map keys whose completion never arrives are pruned at the end of + the relaunch grace wait and when their entry is gone. v9 kept them forever. ## 9.0.0 diff --git a/README.md b/README.md index c0189ddc..e76194b6 100644 --- a/README.md +++ b/README.md @@ -15,12 +15,12 @@ yarn add react-native-background-upload cd ios && pod install && cd .. ``` -`pod install` is required after installing — it runs codegen to generate the native +`pod install` is required after installing. It runs codegen to generate the native spec this module implements. > 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` +> (and `tsc`) but not through plain Node. If you import it from a non-Metro context, +> such as a script or Jest without a transform, add it to your `transformIgnorePatterns` > allowlist or mock it. ## iOS: background completion handler (required) @@ -29,7 +29,8 @@ So uploads that finish while the app is terminated can relaunch it and be journaled, add this to your `AppDelegate`: ```objc -#import +// AppDelegate.m (Objective-C). Diana uses this form. +@import react_native_background_upload; - (void)application:(UIApplication *)application handleEventsForBackgroundURLSession:(NSString *)identifier @@ -39,15 +40,20 @@ handleEventsForBackgroundURLSession:(NSString *)identifier } ``` -> 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. +> CocoaPods wires the module map for the app target, so `@import` works in an +> Objective-C `.m` file with the default static-library setup. If your +> AppDelegate is Objective-C++ (`.mm`), `@import` is not available. Add +> `"$(PODS_CONFIGURATION_BUILD_DIR)/react-native-background-upload/Swift Compatibility Header"` +> to the app target's `HEADER_SEARCH_PATHS`, then use +> `#import ` followed by +> `#import "react_native_background_upload-Swift.h"`. The example app's +> `AppDelegate.mm` does this. 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`. +is the TurboModule and is deliberately not reachable from plain Objective-C, because its +generated header is Objective-C++ only. So the handler lives on `RNBackgroundUpload`. # Usage @@ -206,6 +212,30 @@ accepted parts are kept across runs. iOS has no equal limit. and staged bodies. Set `android:allowBackup="false"` in the host app, or exclude those two directories in its backup rules. +### iOS platform notes + +**Bodies are files.** A background `URLSession` uploads from files only, so +the library writes every `data` and `form` body to a file in its own +directory at `mutate()`. The bytes stay there until the entry is forgotten. +The store and journal are excluded from iCloud and iTunes backups. + +**No global cap.** iOS has no hard limit on requests in flight. Each of the +two background sessions (cellular allowed, and Wi-Fi only) sets +`httpMaximumConnectionsPerHost = 4` as a per-host backstop, and a chunked +upload sends at most 3 parts at a time. + +**Force-quit.** When the user swipes the app away, iOS cancels the session's +tasks. The library treats that as a transient failure: no outcome is +produced, the entry stays `queued` or `running`, and it is sent again at the +next launch. Accepted chunked parts are kept. A suspension or a system +termination is different: the tasks keep running, and the AppDelegate hook +above lets the library journal their outcomes. + +**Relaunch.** When the app comes back and an entry's task is gone but a +completion may still be in flight from the daemon, the library waits up to +10 s for it before it sends again. This is what stops a request that finished +while the app was dead from being sent twice. + # Reliable delivery 1. **Write-ahead.** Entry, descriptor, and staged body persist before any @@ -343,8 +373,11 @@ entry past `expiresAt` settles `error` with `errorKind: 'expired'` at A live entry settles `cancelled` with reason `user` and is forgotten after its ack. A settled entry is forgotten now: row, bytes, and its unacknowledged outcomes. An unknown id resolves and does nothing. If the -journal or the store cannot be written, `cancel()` rejects with `E_STORAGE` and changes -nothing; the caller may call again. +journal or the store cannot be written, `cancel()` rejects with `E_STORAGE`. +The caller may call again. On Android, a cancel whose journal write landed but +whose entry save failed is already in effect: the work stops and the +`cancelled` outcome is journaled. Its ack, a retry, or the next boot sweep +finishes it. ### `setWifiOnly(enabled): Promise` Persisted natively. Applies to queued and future entries. @@ -355,14 +388,16 @@ resumes the entries parked on `awaiting-auth`. The patch also replaces same-named headers a part carries. This is how a fresh token reaches requests that stalled on 401. Each call bumps a header generation: a 401 or 403 from an attempt issued under an older generation re-issues at once -instead of parking. Parking emits one `state` event per entry. +instead of parking. Parking emits one `state` event per entry. A header name +or value the platform HTTP client cannot send rejects with `E_INVALID`. ### `getRequests(filter?): RequestRow[]` Synchronous, from native's in-memory index, so it works offline. Returns every entry native has not yet forgotten: `queued`, `running`, `awaiting-auth`, and `paused` entries; `completed` and `cancelled` entries until their ack; `error` entries until `cancel()` or a same-id `mutate()`; -and imported legacy rows. `filter` is `{ key?, id? }`. A row is +and rows imported from a v9 install (key `legacy`, see Upgrading from v9). +`filter` is `{ key?, id? }`. A row is `{ id, key, vars, state, bytesSent, totalBytes, attempts, updatedAt, nextAttemptAt? }`; `nextAttemptAt` (epoch ms) is set while the entry waits out a retry backoff. `vars` is typed `Json`, because a row does not know its definition. Narrow @@ -398,6 +433,103 @@ Fires when the Android progress notification is pressed. No event data. Terminal outcomes do not appear here. They go to the definition's handlers. +# Upgrading from v9 + +The CHANGELOG lists every removed v9 export with its replacement. In short: +`startUpload` becomes a `define()` plus `mutate()`; `getAllUploads` becomes +`getRequests()`; `cancelUpload` and `removeUpload` become `cancel(id)`; the +terminal event names become the definition's handlers; per-upload `wifiOnly` +becomes `setWifiOnly()`. + +What happens to work a v9 build left behind: + +- **The v9 journal becomes `legacy` rows.** On the first v10 launch, every + v9 journal entry that JS never acknowledged becomes a read-only settled row + with key `legacy`, the v9 upload id, and `0/0` bytes. No handler runs for + them. Read them with `getRequests({ key: 'legacy' })`, reconcile your own + state, then `cancel(id)` each one. The import runs before the first + `getRequests()` answers. +- **In-flight v9 work is cancelled natively.** Nothing runs unowned. +- **v9 chunked uploads resume under the same id.** The v9 manifest and bytes + stay on disk. A `mutate()` with the v9 upload id and the same parts resumes + from the accepted parts; different parts start over on the kept bytes. An + id with no `mutate()` keeps its bytes until `cancel(id)`. +- **Wi-Fi only starts off.** Call `setWifiOnly(true)` again if the app had it + on. + +Each `mutate()` on an id that already exists follows the rules in "Same id, +again" above, so a re-dispatch from persisted app state is safe. + +# Testing your definitions + +`createUploadClient({ native })` takes a fake native module, and +`createFakeNative()` builds one. The real validation, header merge, and +delivery run on top of it; the fake keeps rows, journals outcomes, and acks, +but never sends HTTP. + +Importing the package root resolves the native module at load, so a test must +mock `react-native`'s `TurboModuleRegistry` first, as below. The fake imports +no react-native code at runtime, so the mock factory can require it. + +```ts +jest.mock('react-native', () => { + const { createFakeNative } = jest.requireActual( + 'react-native-background-upload/src/testing', + ); + const fake = createFakeNative(); + return { + TurboModuleRegistry: { getEnforcing: () => fake, get: () => fake }, + }; +}); + +import { createUploadClient } from 'react-native-background-upload'; +import { createFakeNative } from 'react-native-background-upload/src/testing'; + +const native = createFakeNative(); +const uploads = createUploadClient({ native }); +const onSuccess = jest.fn(); +const ping = uploads.define({ + key: 'ping', + request: () => ({ url: 'https://api.test/ping' }), + onSuccess, +}); +uploads.configure({}); + +const { id } = await ping.mutate(); +await native.settle(id, { kind: 'completed', response: { body: '{}' } }); +expect(onSuccess).toHaveBeenCalled(); +``` + +`settle()` resolves after delivery acks the outcome, so an async handler +has finished. It rejects after 2 s (`ackTimeoutMs`) when no ack comes. A +handler that rejects is not acked, so test it with a short timeout: + +```ts +const native = createFakeNative({ ackTimeoutMs: 50 }); +// ... define, configure, and mutate as above ... +await expect(native.settle(id, outcome)).rejects.toThrow(/not acknowledged/); +``` + +If you fire an event with `native.emit.settled()` instead, delivery runs the +handler and the ack later. Flush pending promises (for example +`await new Promise(setImmediate)`) before you assert on +`native.ackedEventIds`. + +The fake does not model the same-id rules (resume, replace, `E_RUNNING`, +re-emit) or the header merge of `updateHeaders()`; script those with +`failNext()` and `seedUnacknowledged()`. + +`native.entries` holds every enqueue as `mutate()` sent it, with `vars` and +`data` parsed back from JSON. `failNext()` makes the next native call reject +with a code such as `E_STORAGE`. `seedUnacknowledged()` and `seedRows()` +model a journal and rows left by a dead session, so `configure()` replays +them. + +An app that builds its client at module load can use the fake from the mock +for every client instead. Get it with +`TurboModuleRegistry.getEnforcing('RNFileUploader')`, and call +`native.reset()` in `beforeEach`. + # Contributing See [CONTRIBUTING.md](./CONTRIBUTING.md). diff --git a/example/RNBGUExample/App.tsx b/example/RNBGUExample/App.tsx index f7dbc9f7..8ce289bf 100644 --- a/example/RNBGUExample/App.tsx +++ b/example/RNBGUExample/App.tsx @@ -1,314 +1,706 @@ /** - * Sample React Native App - * https://github.com/facebook/react-native + * The v10 device test harness. Every step of the device test script in + * README.md can be done from this screen. The request kinds, the listeners, + * and configure() are in ./harness/uploads.ts. * * @format - * @flow */ -import React, {useEffect, useState} from 'react'; +import React, {useEffect, useState, useSyncExternalStore} from 'react'; import { - SafeAreaView, - StyleSheet, + PermissionsAndroid, + Platform, + Pressable, ScrollView, - View, - Text, StatusBar, - Button, + StyleSheet, + Switch, + Text, + TextInput, + View, } from 'react-native'; -import notifee, {AndroidImportance} from '@notifee/react-native'; +import Upload, {type RequestRow} from 'react-native-background-upload'; -import Upload, { - ChunkedUploadOptions, - UploadOptions, -} from 'react-native-background-upload'; +import { + exists, + makeFile, + makePhoto, + missingPath, + remove, + type TestFile, +} from './harness/files'; +import {clearLog, clock, getLog, log, subscribeLog} from './harness/log'; +import type {LogEntry, LogKind} from './harness/log'; +import {saveSettings, settings} from './harness/settings'; +import { + byKey, + deleteThing, + getNoVars, + getWithBody, + LAUNCHED_AT, + MODES, + PART_BYTES, + postForm, + postJson, + progress, + putChunked, + putFile, + ready, + session, + type Mode, +} from './harness/uploads'; + +const runId = () => + Date.now().toString(36) + Math.random().toString(36).slice(2, 5); + +let counter = 0; + +type RejectLike = {code?: string; message?: string}; + +// Runs one button's work and logs the result or the rejection code. +const run = (label: string, work: () => Promise) => () => { + log('action', `${label}...`); + work().then( + result => + log( + 'action', + `${label}: resolved${ + result === undefined ? '' : ` ${JSON.stringify(result)}` + }`, + ), + (e: RejectLike) => + log( + 'warn', + `${label}: rejected code=${e?.code ?? '-'} ${e?.message ?? String(e)}`, + ), + ); +}; -import * as RNFS from 'react-native-fs'; +const fileNote = (f: TestFile) => + log('action', `file ${f.path.split('/').pop()} ${f.size} B md5=${f.md5}`); -const TEST_FILE = `${RNFS.DocumentDirectoryPath}/1MB.bin`; -const TEST_FILE_URL = - 'https://gist.githubusercontent.com/khaykov/a6105154becce4c0530da38e723c2330/raw/41ab415ac41c93a198f7da5b47d604956157c5c3/gistfile1.txt'; -const UPLOAD_URL = 'https://httpbin.org/post'; -const CHUNKED_UPLOAD_URL = 'https://httpbin.org/put'; -const NOTIFICATION_CHANNEL = 'RNBGUExample'; +// The path after its first segment: /ok/json/abc gives /json/abc. +const afterMode = (path: string) => { + const i = path.indexOf('/', 1); + return i < 0 ? '' : path.slice(i); +}; const App = () => { - const [uploadId, setUploadId] = useState(); - const [progress, setProgress] = useState(); - const [testFileDownload, setTestFileDownload] = useState< - 'downloading' | 'downloaded' - >(); + const [isReady, setReady] = useState(false); + const [host, setHost] = useState(settings.host); + const [mode, setMode] = useState(session.mode); + const [authDraft, setAuthDraft] = useState('Bearer good'); + const [auth, setAuth] = useState(session.auth); + const [customId, setCustomId] = useState(''); + const [expiresInS, setExpiresInS] = useState(''); + const [sizeMb, setSizeMb] = useState('30'); + const [wifiOnly, setWifiOnlyMirror] = useState(settings.wifiOnly); + const [throwInHandlers, setThrow] = useState(settings.throwInHandlers); + const [silent, setSilent] = useState(settings.silent); + const [rows, setRows] = useState([]); + const [filter, setFilter] = useState('all'); + const entries = useSyncExternalStore(subscribeLog, getLog); useEffect(() => { - // One-time notification configuration. The library keeps it in native - // storage. Thus a headless WorkManager relaunch shows the same text. The - // call does nothing on iOS. - Upload.configure({ - android: { - notificationId: NOTIFICATION_CHANNEL, - notificationTitle: NOTIFICATION_CHANNEL, - notificationTitleNoWifi: 'No wifi', - notificationTitleNoInternet: 'No internet', - notificationChannel: NOTIFICATION_CHANNEL, + ready.then( + () => { + setHost(settings.host); + setWifiOnlyMirror(settings.wifiOnly); + setThrow(settings.throwInHandlers); + setSilent(settings.silent); + setReady(true); }, - }); + e => log('warn', `boot failed: ${String(e)}`), + ); + if (Platform.OS === 'android' && Platform.Version >= 33) { + // The progress notification is also the foreground-service + // notification. Without this permission Android 13+ hides it. + PermissionsAndroid.request( + PermissionsAndroid.PERMISSIONS.POST_NOTIFICATIONS, + ).then(r => log('action', `POST_NOTIFICATIONS: ${r}`)); + } }, []); + // getRequests() is synchronous and cheap. A 1 s read also refreshes the + // backoff countdowns and drops rows that native forgot after an ack. useEffect(() => { - Upload.addListener('progress', data => { - setProgress(data.progress); - }); - Upload.addListener('error', data => { - console.log('Error!', JSON.stringify(data)); - }); - Upload.addListener('completed', data => { - console.log('Completed!', JSON.stringify(data)); - }); - Upload.addListener('cancelled', data => { - console.log('Cancelled!', JSON.stringify(data)); - }); - }, []); + if (!isReady) return; + const read = () => setRows(Upload.getRequests()); + read(); + const sub = Upload.addListener('state', read); + const timer = setInterval(read, 1000); + return () => { + sub.remove(); + clearInterval(timer); + }; + }, [isReady]); - useEffect(() => { - RNFS.exists('file://' + TEST_FILE) - .then(exists => { - if (exists) return; - - setTestFileDownload('downloading'); - return RNFS.downloadFile({fromUrl: TEST_FILE_URL, toFile: TEST_FILE}) - .promise; - }) - .then(() => setTestFileDownload('downloaded')); - }, []); + const target = (path: string) => ({ + host: settings.host, + path, + ...(Number(expiresInS) > 0 ? {expiresInS: Number(expiresInS)} : {}), + ...(settings.silent ? {silent: true} : {}), + }); + const idOption = () => (customId.trim() ? {id: customId.trim()} : undefined); + const mb = () => Math.max(1, Math.floor(Number(sizeMb) || 1)); - const ensureNotificationChannel = async () => { - await notifee.requestPermission({alert: true, sound: true}); + const enqueueJson = (m: Mode = mode, id = idOption()) => + postJson.mutate( + {...target(`/${m}/json/${runId()}`), n: ++counter, text: 'hi'}, + id, + ); - await notifee.createChannel({ - id: NOTIFICATION_CHANNEL, - name: NOTIFICATION_CHANNEL, - importance: AndroidImportance.LOW, - }); + const enqueueChunked = async (m: Mode = mode, id = idOption()) => { + const f = await makeFile(`chunked-${runId()}.bin`, mb()); + fileNote(f); + const r = await putChunked.mutate( + {...target(`/${m}/chunk/${runId()}`), file: f.path, size: f.size}, + id, + ); + log( + 'action', + `chunked ${r.id}: ${Math.ceil(f.size / PART_BYTES)} parts. Source ${ + (await exists(f.path)) ? 'still there (unexpected)' : 'moved' + }`, + ); + return r; }; - const onPressUpload = async () => { - await ensureNotificationChannel(); - - const uploadOpts: UploadOptions = { - type: 'raw', - url: UPLOAD_URL, - path: TEST_FILE, - method: 'POST', - headers: {}, - }; - - Upload.startUpload(uploadOpts) - .then(uploadId => { - console.log( - `Upload started with options: ${JSON.stringify(uploadOpts)}`, - ); - setUploadId(uploadId); - setProgress(0); - }) - .catch(function (err) { - setUploadId(undefined); - setProgress(undefined); - console.log('Upload error!', err); - }); + const actions = { + json: run('JSON POST', () => enqueueJson()), + form: run('Multipart photo', async () => { + const photo = await makePhoto(`photo-${runId()}.jpg`); + fileNote(photo); + const r = await postForm.mutate( + { + ...target(`/${mode}/form/${runId()}`), + photo: photo.path, + caption: 'test', + }, + idOption(), + ); + // The library copied the file, so the source can go now. + await remove(photo.path); + log('action', `form ${r.id}: source deleted after mutate()`); + return r; + }), + file: run('File PUT', async () => { + const f = await makeFile(`file-${runId()}.bin`, mb()); + fileNote(f); + const r = await putFile.mutate( + {...target(`/${mode}/file/${runId()}`), file: f.path}, + idOption(), + ); + await remove(f.path); + log('action', `file ${r.id}: source deleted after mutate()`); + return r; + }), + chunked: run('Chunked PUT', () => enqueueChunked()), + del: run('DELETE', () => + deleteThing.mutate(target(`/${mode}/thing/${runId()}`), idOption()), + ), + get: run('GET', () => getNoVars.mutate(null, idOption())), + threeJson: run('3 JSON at once', () => + Promise.all([1, 2, 3].map(() => enqueueJson(mode, undefined))), + ), + capTest: run('Cap test', async () => { + await fetch(`${settings.host}/stats/reset`, {method: 'POST'}); + return Promise.all([ + ...[1, 2, 3].map(() => enqueueChunked('slow', undefined)), + ...[1, 2, 3].map(() => enqueueJson('slow', undefined)), + ]); + }), + stats: run('Server stats', async () => + (await fetch(`${settings.host}/stats`)).json(), + ), + ping: run('Ping server (plain fetch)', async () => + (await fetch(`${settings.host}/`)).text(), + ), + missing: run('Missing file', () => + putFile.mutate({...target(`/${mode}/file/missing`), file: missingPath()}), + ), + getBody: run('GET with a body', () => + getWithBody.mutate(target(`/${mode}/get-body`)), + ), + pause: run('pause()', () => Upload.pause()), + resume: run('resume()', () => Upload.resume()), + updateHeaders: run(`updateHeaders(${authDraft})`, async () => { + await Upload.updateHeaders({Authorization: authDraft}); + session.auth = authDraft; + setAuth(authDraft); + }), + cancelUnknown: run("cancel('nope')", () => Upload.cancel('nope')), }; - const onPressChunkedUpload = async () => { - await ensureNotificationChannel(); + const onWifiOnly = (enabled: boolean) => + run(`setWifiOnly(${enabled})`, async () => { + await Upload.setWifiOnly(enabled); + saveSettings({wifiOnly: enabled}); + setWifiOnlyMirror(enabled); + })(); - // The library takes ownership of a chunked upload's file. It renames the - // file into its own directory. Thus we upload a copy, and the test file - // stays available. - const chunkedFile = `${RNFS.DocumentDirectoryPath}/chunked.bin`; - if (await RNFS.exists('file://' + chunkedFile)) { - await RNFS.unlink(chunkedFile); + // Same id and the stored vars: the same body. A different mode and path + // is a different body. + const again = (row: RequestRow, newBody: boolean) => { + const def = byKey[row.key]; + let vars = row.vars as {path?: string} | null; + if (newBody && vars?.path) { + vars = {...vars, path: `/${mode}${afterMode(vars.path)}-b`}; } - await RNFS.copyFile(TEST_FILE, chunkedFile); - - // A small min and max, so the 1MB test file still splits into some parts. - // Production callers use the server's real part-size limits. - const {size} = await RNFS.stat(chunkedFile); - const ranges = Upload.chunkPlan(size, {min: 128 * 1024, max: 256 * 1024}); - - const uploadOpts: ChunkedUploadOptions = { - type: 'chunked', - id: 'chunked-demo', - path: chunkedFile, - parts: ranges.map((range, i) => ({ - url: `${CHUNKED_UPLOAD_URL}?partNum=${i + 1}`, - headers: { - 'Content-Type': 'application/octet-stream', - 'Content-Range': `bytes ${range.start}-${range.end - 1}/${size}`, - }, - range, - })), - expiresAt: Date.now() + 24 * 60 * 60 * 1000, - }; - - Upload.startUpload(uploadOpts) - .then(uploadId => { - console.log( - `Chunked upload started: ${uploadId} (${ranges.length} parts)`, - ); - setUploadId(uploadId); - setProgress(0); - }) - .catch(function (err) { - setUploadId(undefined); - setProgress(undefined); - console.log('Chunked upload error!', err); - }); + run(`${newBody ? 'new body' : 'same body'} ${row.id}`, () => + def.mutate(vars as never, {id: row.id}), + )(); }; + const shown = entries.filter(e => filter === 'all' || e.kind === filter); + const journaled = entries.filter(e => e.journaled); + return ( - <> + - - - {testFileDownload === 'downloading' && ( - Downloading test file... - )} - - {testFileDownload === 'downloaded' && ( - - - -