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' && ( - - - -