From b89c473f062cc16910f793ccf2fedd949fb42629 Mon Sep 17 00:00:00 2001 From: Dylan Murphy Date: Mon, 24 Aug 2026 15:28:48 -0400 Subject: [PATCH] Move notification text to a one-time configure() MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Notification text and identity are library-wide, not per-upload. The new configure() persists them natively (SharedPreferences) so a worker relaunched by WorkManager with no JS running builds the notification with the configured text; the library defaults apply when configure() was never called. Per-upload android options reduce to noNotification, which is behavioral — it decides foreground-service survival. configure() is a native no-op on iOS, which has no library notification. Co-Authored-By: Claude Fable 5 --- README.md | 25 ++++-- android/consumer-rules.pro | 35 ++++---- .../backgroundupload/NotificationConfig.kt | 83 +++++++++++++++++++ .../ai/openspace/backgroundupload/Upload.kt | 21 +---- .../backgroundupload/UploadWorker.kt | 29 ++++--- .../backgroundupload/UploaderModule.kt | 11 +++ .../NotificationConfigTest.kt | 53 ++++++++++++ .../openspace/backgroundupload/UploadTest.kt | 5 -- example/RNBGUExample/App.tsx | 28 ++++--- ios/RNFileUploader.mm | 6 ++ src/NativeRNFileUploader.ts | 4 + src/__tests__/index.test.ts | 28 +++++++ src/index.ts | 14 ++++ src/types.ts | 27 ++++-- 14 files changed, 295 insertions(+), 74 deletions(-) create mode 100644 android/src/main/java/ai/openspace/backgroundupload/NotificationConfig.kt create mode 100644 android/src/test/java/ai/openspace/backgroundupload/NotificationConfigTest.kt diff --git a/README.md b/README.md index de9deaa6..1b258fbc 100644 --- a/README.md +++ b/README.md @@ -63,11 +63,14 @@ const options = { // 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…' }, }; +// Optional. Call one time at app startup to set the Android notification text. +// The library keeps the text in native storage. Thus a worker relaunched with +// no JS shows the same text. If you do not call configure(), the library uses +// default text and makes its own channel. The call does nothing on iOS. +Upload.configure({ android: { notificationTitle: 'Uploading…' } }); + const uploadId = await Upload.startUpload(options); Upload.addListener('progress', ({ id, progress }) => {}); @@ -113,6 +116,15 @@ Notes: All methods are on the default export. +### `configure(options): void` +One-time setup — call at app startup. `options.android` sets the upload +notification's text and identity: +`notificationId/Title/TitleNoWifi/TitleNoInternet/Channel`. The config is +persisted natively, so a worker relaunched by WorkManager with no JS running +shows the same text. Optional: omitted fields keep the library defaults (each +call replaces the whole config). A no-op on iOS, which has no library +notification. + ### `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 @@ -130,7 +142,7 @@ of starting a duplicate. | `id` | 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`, `noNotification` (default false). Sensible defaults + auto-created channel if omitted. | +| `android` | object | Optional. `noNotification` (default false) — see Silent uploads. Notification text is set once via `configure()`, not per upload. | #### Silent uploads (Android) @@ -143,8 +155,9 @@ to defer it, or to stop it mid-flight and let WorkManager re-run it later. Keep the notification for anything that takes real time to upload; reserve `noNotification` for small payloads a restart would cost nothing. -Uploads sharing a `notificationId` share one notification, and its progress bar -reports every in-flight upload — silent ones included. +All uploads share one notification (identified by the configured +`notificationId`), and its progress bar reports every in-flight upload — silent +ones included. ### `cancelUpload(uploadId): Promise` Cancels an upload. Fires a `cancelled` event with `cancelReason: 'user'`. diff --git a/android/consumer-rules.pro b/android/consumer-rules.pro index 3da6e998..c831f56b 100644 --- a/android/consumer-rules.pro +++ b/android/consumer-rules.pro @@ -1,25 +1,28 @@ -# Shipped to consumers via consumerProguardFiles, so a minified release build of -# the host app keeps these guarantees. +# These rules go to consumers through consumerProguardFiles. Thus 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: +# Gson persists Upload, NotificationConfig, and EventJournal.Entry. Upload goes +# into WorkManager input data. NotificationConfig goes into SharedPreferences. +# Entry goes into the on-disk event journal. The library reads them back later, +# across app restarts AND across app updates. Gson finds fields by name through +# reflection. Gson also needs the generic Signature attribute to rebuild typed +# collections. Thus, if R8 renames a field or removes Signature, it corrupts the +# 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. +# * Upload.acceptStatus is a List. Without Signature, Gson decodes it as +# List. Then acceptStatus.contains(code) never matches, and a +# configured accept status (for example 409) is reported as an http error, +# not as a completed upload. +# * A journal Entry from an older build fails to parse if field names changed. +# The library then drops the Entry as malformed. This loses the terminal +# outcomes that the journal exists to keep. # -# Debug builds are unminified and round-trip symmetrically, so neither failure is -# reproducible without R8; keep these rules. +# Debug builds are not minified and round-trip correctly. Thus 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.NotificationConfig { *; } -keep class ai.openspace.backgroundupload.EventJournal$Entry { *; } diff --git a/android/src/main/java/ai/openspace/backgroundupload/NotificationConfig.kt b/android/src/main/java/ai/openspace/backgroundupload/NotificationConfig.kt new file mode 100644 index 00000000..ec947bfd --- /dev/null +++ b/android/src/main/java/ai/openspace/backgroundupload/NotificationConfig.kt @@ -0,0 +1,83 @@ +package ai.openspace.backgroundupload + +import android.content.Context +import com.facebook.react.bridge.ReadableMap +import com.google.gson.Gson + +/** + * The text and the identity of the upload progress notification. JS sets it + * one time with `configure()`. The class writes it to SharedPreferences. Thus a + * worker that WorkManager relaunches — with no JS, possibly in a process where + * React never initialized — shows the same text. Each field falls back to a + * library default. Thus uploads work when `configure()` was never called. + */ +data class NotificationConfig( + val notificationId: String, + val notificationTitle: String, + val notificationTitleNoInternet: String, + val notificationTitleNoWifi: String, + val notificationChannel: String, +) { + // The id given to NotificationManager. All uploads share the configured id. + // Thus they share one notification, and its progress bar is the total. + val systemNotificationId get() = notificationId.hashCode() + + companion object { + const val DEFAULT_NOTIFICATION_CHANNEL = "background-upload" + + val DEFAULTS = NotificationConfig( + notificationId = DEFAULT_NOTIFICATION_CHANNEL, + notificationTitle = "Uploading…", + notificationTitleNoInternet = "Waiting for connection…", + notificationTitleNoWifi = "Waiting for Wi-Fi…", + notificationChannel = DEFAULT_NOTIFICATION_CHANNEL, + ) + + private const val PREFS_NAME = "rnbgupload-config" + private const val PREFS_KEY = "notificationConfig" + private val gson = Gson() + + fun fromReadableMap(map: ReadableMap) = NotificationConfig( + notificationId = map.getString(NotificationConfig::notificationId.name) + ?: DEFAULTS.notificationId, + notificationTitle = map.getString(NotificationConfig::notificationTitle.name) + ?: DEFAULTS.notificationTitle, + notificationTitleNoInternet = map.getString(NotificationConfig::notificationTitleNoInternet.name) + ?: DEFAULTS.notificationTitleNoInternet, + notificationTitleNoWifi = map.getString(NotificationConfig::notificationTitleNoWifi.name) + ?: DEFAULTS.notificationTitleNoWifi, + notificationChannel = map.getString(NotificationConfig::notificationChannel.name) + ?: DEFAULTS.notificationChannel, + ) + + // Gson does not use the constructor. Thus a blob from a build with + // different fields, or a corrupt blob, can make a non-null field null. + // Each field falls back alone. A bad blob must give default text. It must + // never crash a worker. + @Suppress("SENSELESS_COMPARISON") + fun fromJson(json: String?): NotificationConfig { + val parsed = json?.let { + runCatching { gson.fromJson(it, NotificationConfig::class.java) }.getOrNull() + } ?: return DEFAULTS + return NotificationConfig( + notificationId = parsed.notificationId ?: DEFAULTS.notificationId, + notificationTitle = parsed.notificationTitle ?: DEFAULTS.notificationTitle, + notificationTitleNoInternet = parsed.notificationTitleNoInternet + ?: DEFAULTS.notificationTitleNoInternet, + notificationTitleNoWifi = parsed.notificationTitleNoWifi + ?: DEFAULTS.notificationTitleNoWifi, + notificationChannel = parsed.notificationChannel ?: DEFAULTS.notificationChannel, + ) + } + + fun save(context: Context, config: NotificationConfig) { + prefs(context).edit().putString(PREFS_KEY, gson.toJson(config)).apply() + } + + fun load(context: Context): NotificationConfig = + fromJson(prefs(context).getString(PREFS_KEY, null)) + + private fun prefs(context: Context) = + context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) + } +} diff --git a/android/src/main/java/ai/openspace/backgroundupload/Upload.kt b/android/src/main/java/ai/openspace/backgroundupload/Upload.kt index 6d043f7e..dc556693 100644 --- a/android/src/main/java/ai/openspace/backgroundupload/Upload.kt +++ b/android/src/main/java/ai/openspace/backgroundupload/Upload.kt @@ -17,11 +17,6 @@ data class Upload( // terminal http error. Empty by default. val acceptStatus: List, val headers: Map, - val notificationId: Int, - val notificationTitle: String, - val notificationTitleNoInternet: String, - val notificationTitleNoWifi: String, - val notificationChannel: String, /** * Suppresses the progress notification for this upload. * @@ -43,8 +38,6 @@ data class Upload( IllegalArgumentException("Missing '$optionName'") companion object { - const val DEFAULT_NOTIFICATION_CHANNEL = "background-upload" - fun fromReadableMap(map: ReadableMap) = Upload( id = map.getString(Upload::id.name) ?: UUID.randomUUID().toString(), url = map.getString(Upload::url.name) ?: throw MissingOptionException(Upload::url.name), @@ -62,18 +55,8 @@ data class Upload( } return@let map }, - // 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) - ?: "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) - ?: DEFAULT_NOTIFICATION_CHANNEL, + // The notification text and identity are not per-upload options. The + // worker reads them from the NotificationConfig that configure() saved. noNotification = if (map.hasKey(Upload::noNotification.name)) map.getBoolean(Upload::noNotification.name) else false, ) diff --git a/android/src/main/java/ai/openspace/backgroundupload/UploadWorker.kt b/android/src/main/java/ai/openspace/backgroundupload/UploadWorker.kt index 039dc7c7..e20f1e3c 100644 --- a/android/src/main/java/ai/openspace/backgroundupload/UploadWorker.kt +++ b/android/src/main/java/ai/openspace/backgroundupload/UploadWorker.kt @@ -74,6 +74,10 @@ class UploadWorker(private val context: Context, params: WorkerParameters) : } private lateinit var upload: Upload + // configure() saved this. The worker can read it when WorkManager relaunched + // the worker with no JS. It is lazy, so the SharedPreferences read occurs on + // the worker's IO dispatcher, not at construction. + private val config by lazy { NotificationConfig.load(context) } private var retries = 0 private var connectivity = Connectivity.Ok private val notificationManager = @@ -179,7 +183,7 @@ class UploadWorker(private val context: Context, params: WorkerParameters) : // worker never posted one, and `notify` would create it outside foreground mode. private fun updateNotification() { if (!upload.showsNotification) return - notificationManager.notify(upload.notificationId, buildNotification()) + notificationManager.notify(config.systemNotificationId, buildNotification()) } // An HTTP response came back. "completed" only for 2xx or a per-request @@ -303,15 +307,16 @@ 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. + // Makes sure that the channel for the foreground notification exists. It + // makes the channel only when the channel is absent. Thus a channel that the + // consumer registered, with their own name and importance, always wins. When + // configure() never set a channel, we use a default LOW-importance channel, + // and no notifee setup is necessary. private fun ensureNotificationChannel() { // minSdk is 29, so NotificationChannel (API 26) is always available. - if (notificationManager.getNotificationChannel(upload.notificationChannel) != null) return + if (notificationManager.getNotificationChannel(config.notificationChannel) != null) return val channel = NotificationChannel( - upload.notificationChannel, + config.notificationChannel, "Uploads", NotificationManager.IMPORTANCE_LOW, ) @@ -320,13 +325,13 @@ class UploadWorker(private val context: Context, params: WorkerParameters) : // builds the notification required to enable Foreground mode fun buildNotification(): Notification { - val channel = upload.notificationChannel + val channel = config.notificationChannel val progress = UploadProgress.total() val progress2Decimals = "%.2f".format(progress) val title = when (connectivity) { - Connectivity.NoWifi -> upload.notificationTitleNoWifi - Connectivity.NoInternet -> upload.notificationTitleNoInternet - Connectivity.Ok -> upload.notificationTitle + Connectivity.NoWifi -> config.notificationTitleNoWifi + Connectivity.NoInternet -> config.notificationTitleNoInternet + Connectivity.Ok -> config.notificationTitle } // Custom layout for progress notification. @@ -359,7 +364,7 @@ class UploadWorker(private val context: Context, params: WorkerParameters) : override suspend fun getForegroundInfo(): ForegroundInfo { val notification = buildNotification() - val id = upload.notificationId + val id = config.systemNotificationId // Starting Android 14, FOREGROUND_SERVICE_TYPE_DATA_SYNC is mandatory, otherwise app will crash return if (Build.VERSION.SDK_INT > Build.VERSION_CODES.TIRAMISU) ForegroundInfo(id, notification, ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC) diff --git a/android/src/main/java/ai/openspace/backgroundupload/UploaderModule.kt b/android/src/main/java/ai/openspace/backgroundupload/UploaderModule.kt index 5136bb09..8771af1e 100644 --- a/android/src/main/java/ai/openspace/backgroundupload/UploaderModule.kt +++ b/android/src/main/java/ai/openspace/backgroundupload/UploaderModule.kt @@ -158,6 +158,17 @@ class UploaderModule(context: ReactApplicationContext) : } + /** + * Saves the notification configuration (see [NotificationConfig]). Thus a + * worker that WorkManager relaunches with no JS can read it. Each call + * replaces the full configuration. An omitted field goes back to the library + * default. + */ + override fun configure(options: ReadableMap) { + NotificationConfig.save(reactApplicationContext, NotificationConfig.fromReadableMap(options)) + } + + /* * Starts a file upload. * Returns a promise with the string ID of the upload. diff --git a/android/src/test/java/ai/openspace/backgroundupload/NotificationConfigTest.kt b/android/src/test/java/ai/openspace/backgroundupload/NotificationConfigTest.kt new file mode 100644 index 00000000..d3545c78 --- /dev/null +++ b/android/src/test/java/ai/openspace/backgroundupload/NotificationConfigTest.kt @@ -0,0 +1,53 @@ +package ai.openspace.backgroundupload + +import com.google.gson.Gson +import org.junit.Assert.assertEquals +import org.junit.Test + +class NotificationConfigTest { + private val gson = Gson() + + private val configured = NotificationConfig( + notificationId = "my-id", + notificationTitle = "Backing up…", + notificationTitleNoInternet = "Offline", + notificationTitleNoWifi = "No wifi", + notificationChannel = "my-channel", + ) + + @Test + fun `no stored config yields the defaults`() { + assertEquals(NotificationConfig.DEFAULTS, NotificationConfig.fromJson(null)) + } + + @Test + fun `a configured blob survives a persistence round trip`() { + assertEquals(configured, NotificationConfig.fromJson(gson.toJson(configured))) + } + + @Test + fun `a corrupt blob degrades to the defaults`() { + assertEquals(NotificationConfig.DEFAULTS, NotificationConfig.fromJson("][")) + } + + // A blob from a build with fewer fields must not make the other fields null. + @Test + fun `fields missing from a stored blob fall back individually`() { + val config = NotificationConfig.fromJson("""{"notificationTitle":"Custom"}""") + assertEquals("Custom", config.notificationTitle) + assertEquals(NotificationConfig.DEFAULTS.notificationChannel, config.notificationChannel) + assertEquals(NotificationConfig.DEFAULTS.notificationTitleNoWifi, config.notificationTitleNoWifi) + assertEquals( + NotificationConfig.DEFAULTS.notificationTitleNoInternet, + config.notificationTitleNoInternet, + ) + } + + // This is the same derivation that v8 applied to the per-upload option. Thus + // an app that gives its old notificationId to configure() keeps the same + // system notification. + @Test + fun `the system notification id derives from the configured string`() { + assertEquals("my-id".hashCode(), configured.systemNotificationId) + } +} diff --git a/android/src/test/java/ai/openspace/backgroundupload/UploadTest.kt b/android/src/test/java/ai/openspace/backgroundupload/UploadTest.kt index 070c2bca..85eb5a06 100644 --- a/android/src/test/java/ai/openspace/backgroundupload/UploadTest.kt +++ b/android/src/test/java/ai/openspace/backgroundupload/UploadTest.kt @@ -16,11 +16,6 @@ class UploadTest { wifiOnly = false, acceptStatus = listOf(), headers = mapOf(), - notificationId = 1, - notificationTitle = "Uploading…", - notificationTitleNoInternet = "Waiting for connection…", - notificationTitleNoWifi = "Waiting for Wi-Fi…", - notificationChannel = "background-upload", noNotification = noNotification, ) diff --git a/example/RNBGUExample/App.tsx b/example/RNBGUExample/App.tsx index 90693d49..f656b38f 100644 --- a/example/RNBGUExample/App.tsx +++ b/example/RNBGUExample/App.tsx @@ -27,6 +27,7 @@ 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 NOTIFICATION_CHANNEL = 'RNBGUExample'; const App = () => { const [uploadId, setUploadId] = useState(); @@ -35,6 +36,21 @@ const App = () => { 'downloading' | 'downloaded' >(); + 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, + }, + }); + }, []); + useEffect(() => { Upload.addListener('progress', data => { setProgress(data.progress); @@ -65,21 +81,13 @@ const App = () => { const onPressUpload = async () => { await notifee.requestPermission({alert: true, sound: true}); - const channelId = 'RNBGUExample'; await notifee.createChannel({ - id: channelId, - name: channelId, + id: NOTIFICATION_CHANNEL, + name: NOTIFICATION_CHANNEL, importance: AndroidImportance.LOW, }); const uploadOpts: UploadOptions = { - android: { - notificationId: channelId, - notificationTitle: channelId, - notificationTitleNoWifi: 'No wifi', - notificationTitleNoInternet: 'No internet', - notificationChannel: channelId, - }, type: 'raw', url: UPLOAD_URL, path: TEST_FILE, diff --git a/ios/RNFileUploader.mm b/ios/RNFileUploader.mm index 6ff3ff24..8c5c7854 100644 --- a/ios/RNFileUploader.mm +++ b/ios/RNFileUploader.mm @@ -66,6 +66,12 @@ + (NSString *)moduleName #pragma mark - Exported methods +// configure() carries the Android notification configuration. iOS background +// uploads have no library-owned notification. Thus there is nothing to save. +- (void)configure:(NSDictionary *)options +{ +} + - (void)startUpload:(NSDictionary *)options resolve:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject diff --git a/src/NativeRNFileUploader.ts b/src/NativeRNFileUploader.ts index 1c7245f5..99b0df99 100644 --- a/src/NativeRNFileUploader.ts +++ b/src/NativeRNFileUploader.ts @@ -8,6 +8,10 @@ import { TurboModuleRegistry } from 'react-native'; // as UnsafeObject because codegen can't model index signatures, Partial<>, or // intersections. index.ts casts them back to the precise ./types shapes. export interface Spec extends TurboModule { + // One-time notification configuration. Android persists it, so a headless + // WorkManager relaunch (no JS) can read it. It does nothing on iOS, because + // iOS has no library notification. + configure(options: CodegenTypes.UnsafeObject): void; startUpload(options: CodegenTypes.UnsafeObject): Promise; cancelUpload(id: string): Promise; getUnacknowledgedEvents(): Promise; diff --git a/src/__tests__/index.test.ts b/src/__tests__/index.test.ts index 5e4e5b5d..53087c3f 100644 --- a/src/__tests__/index.test.ts +++ b/src/__tests__/index.test.ts @@ -6,6 +6,7 @@ jest.mock('react-native', () => { const subscription = { remove: jest.fn() }; const nativeModule = { + configure: jest.fn(), startUpload: jest.fn(async () => 'id-1'), cancelUpload: jest.fn(async () => true), getUnacknowledgedEvents: jest.fn(async () => [ @@ -59,6 +60,18 @@ describe('journal + query API', () => { }); }); +describe('configure', () => { + it('forwards the android notification config to native, flattened', () => { + Upload.configure({ + android: { notificationTitle: 'Backing up…', notificationChannel: 'ch' }, + }); + expect(native.configure).toHaveBeenCalledWith({ + notificationTitle: 'Backing up…', + notificationChannel: 'ch', + }); + }); +}); + describe('startUpload', () => { it('prefixes the file path on iOS and forwards options', async () => { await Upload.startUpload({ @@ -76,6 +89,21 @@ describe('startUpload', () => { }), ); }); + + it('forwards android.noNotification but no notification text', async () => { + await Upload.startUpload({ + url: 'https://example.com/up', + path: '/tmp/f.bin', + method: 'POST', + type: 'raw', + android: { noNotification: true }, + }); + const options = native.startUpload.mock.calls.at(-1)![0]; + expect(options.noNotification).toBe(true); + // configure() owns the notification text. startUpload never carries it. + expect(options).not.toHaveProperty('notificationTitle'); + expect(options).not.toHaveProperty('notificationId'); + }); }); describe('addListener', () => { diff --git a/src/index.ts b/src/index.ts index 2e763455..bdea0e96 100644 --- a/src/index.ts +++ b/src/index.ts @@ -6,6 +6,7 @@ import type { EventSubscription } from 'react-native'; import NativeRNFileUploader from './NativeRNFileUploader'; import { AddListener, + ConfigureOptions, JournaledEvent, UploadId, UploadOptions, @@ -16,6 +17,18 @@ export * from './types'; const fileURIPrefix = 'file://'; +/** + * One-time library configuration. Call it at app startup, before an upload + * starts. Android keeps the notification configuration in native storage. Thus + * a worker that WorkManager relaunches with no JS shows the same notification + * text. The call is optional: a field that you do not configure keeps the + * library default. Each call replaces the full configuration. The call does + * nothing on iOS, because iOS has no library notification. + */ +const configure = ({ android }: ConfigureOptions): void => { + NativeRNFileUploader.configure({ ...android }); +}; + /** * Starts uploading a file to an HTTP endpoint. See UploadOptions for the full * option set (url, path, method, headers, wifiOnly, acceptStatus, android). @@ -118,6 +131,7 @@ const android = { }; export default { + configure, startUpload, cancelUpload, addListener, diff --git a/src/types.ts b/src/types.ts index 14fe8f0d..0429edac 100644 --- a/src/types.ts +++ b/src/types.ts @@ -89,16 +89,12 @@ export type UploadOptions = { // duplicate-create conflicts are expected). Anything else non-2xx emits an // 'error' event with errorKind 'http'. acceptStatus?: number[]; - // Optional: the library supplies notification defaults and creates its own channel. + // Android options that change behavior. Notification text is not a + // per-upload option. Set it one time with configure(). android?: Partial; } & RawUploadOptions; export type AndroidOnlyUploadOptions = { - notificationId: string; - notificationTitle: string; - notificationTitleNoWifi: string; - notificationTitleNoInternet: string; - notificationChannel: string; /** * Uploads this file without a progress notification. Default false. * @@ -115,6 +111,25 @@ export type RawUploadOptions = { type: 'raw'; }; +/** + * The text and the identity of the Android upload progress notification. Set + * it one time with `configure()`. The library keeps it in native storage. Thus + * a worker that WorkManager relaunches with no JS shows the same text. A field + * that you omit keeps the library default. + */ +export type AndroidNotificationConfig = { + /** All uploads share one notification. Its progress bar is the total. */ + notificationId: string; + notificationTitle: string; + notificationTitleNoWifi: string; + notificationTitleNoInternet: string; + notificationChannel: string; +}; + +export type ConfigureOptions = { + android?: Partial; +}; + export interface AddListener { ( event: 'progress',