From 54745b6a79794642b2d9b2eac8b47019b80dddf8 Mon Sep 17 00:00:00 2001 From: Dylan Murphy Date: Mon, 24 Aug 2026 16:27:34 -0400 Subject: [PATCH 1/3] Widen the library-wide transmission cap to 4 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Android: the shared transfer semaphore goes from 1 (fully serial) to the design's cap of 4 — a hard cap, since every request passes through it. iOS: httpMaximumConnectionsPerHost = 4 on both background sessions as a per-session, connection-level backstop; request-level control for chunked uploads stays with the window. Co-Authored-By: Claude Fable 5 --- .../ai/openspace/backgroundupload/UploadTransport.kt | 9 +++++---- ios/RNBackgroundUpload.swift | 5 ++++- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/android/src/main/java/ai/openspace/backgroundupload/UploadTransport.kt b/android/src/main/java/ai/openspace/backgroundupload/UploadTransport.kt index 06718b01..cc8093ea 100644 --- a/android/src/main/java/ai/openspace/backgroundupload/UploadTransport.kt +++ b/android/src/main/java/ai/openspace/backgroundupload/UploadTransport.kt @@ -16,10 +16,11 @@ private const val REQUEST_TIMEOUT = 24L private val REQUEST_TIMEOUT_UNIT = TimeUnit.HOURS // The number of requests transmitting at one time across ALL uploads, chunked -// parts included. A semaphore controls this, not OkHttp's connection limits, -// because those limits add a delay between requests. The design's library-wide -// cap is 4. The change from 1 to 4 lands with the hardening slice, not here. -internal const val MAX_TRANSFER_CONCURRENCY = 1 +// parts included. This is the design's library-wide cap of 4. Every request, +// a simple upload or a chunked part, must pass this semaphore. Thus on +// Android the cap is hard. A semaphore controls this, not OkHttp's connection +// limits, because those limits add a delay between requests. +internal const val MAX_TRANSFER_CONCURRENCY = 4 internal val transferSemaphore = Semaphore(MAX_TRANSFER_CONCURRENCY) // Use Okhttp as it provides the most standard behaviors even though it's not coroutine friendly diff --git a/ios/RNBackgroundUpload.swift b/ios/RNBackgroundUpload.swift index ca16e166..66515fe8 100644 --- a/ios/RNBackgroundUpload.swift +++ b/ios/RNBackgroundUpload.swift @@ -179,7 +179,10 @@ public class RNBackgroundUpload: NSObject, URLSessionDataDelegate { private func makeSession(identifier: String, wifiOnly: Bool) -> URLSession { let config = URLSessionConfiguration.background(withIdentifier: identifier) config.isDiscretionary = false - config.httpMaximumConnectionsPerHost = 1 + // A per-session, connection-level backstop for the design's library-wide + // transmission cap of 4. The request-level control is the chunked window. + // This limit mostly bounds piles of simple uploads over HTTP/1.1. + config.httpMaximumConnectionsPerHost = 4 config.waitsForConnectivity = true config.allowsCellularAccess = !wifiOnly config.allowsConstrainedNetworkAccess = !wifiOnly From de115ac1426f5e2a14aea358b4750f23a0fd6059 Mon Sep 17 00:00:00 2001 From: Dylan Murphy Date: Mon, 24 Aug 2026 16:27:47 -0400 Subject: [PATCH 2/3] Example app: chunked-upload demo and removeUpload button The demo copies the 1MB test file (the library takes ownership of a chunked upload's file), chunkPlans it with a small min/max so it still splits into a few parts, authors fake parts against httpbin's /put, and starts the upload. Remove Upload releases the kept manifest and bytes. Co-Authored-By: Claude Fable 5 --- example/RNBGUExample/App.tsx | 78 +++++++++++++++++++++++++++++++++++- 1 file changed, 76 insertions(+), 2 deletions(-) diff --git a/example/RNBGUExample/App.tsx b/example/RNBGUExample/App.tsx index f656b38f..217f271d 100644 --- a/example/RNBGUExample/App.tsx +++ b/example/RNBGUExample/App.tsx @@ -19,7 +19,10 @@ import { import notifee, {AndroidImportance} from '@notifee/react-native'; import {Colors} from 'react-native/Libraries/NewAppScreen'; -import Upload, {UploadOptions} from 'react-native-background-upload'; +import Upload, { + ChunkedUploadOptions, + UploadOptions, +} from 'react-native-background-upload'; import * as RNFS from 'react-native-fs'; @@ -27,6 +30,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 CHUNKED_UPLOAD_URL = 'https://httpbin.org/put'; const NOTIFICATION_CHANNEL = 'RNBGUExample'; const App = () => { @@ -78,7 +82,7 @@ const App = () => { .then(() => setTestFileDownload('downloaded')); }, []); - const onPressUpload = async () => { + const ensureNotificationChannel = async () => { await notifee.requestPermission({alert: true, sound: true}); await notifee.createChannel({ @@ -86,6 +90,10 @@ const App = () => { name: NOTIFICATION_CHANNEL, importance: AndroidImportance.LOW, }); + }; + + const onPressUpload = async () => { + await ensureNotificationChannel(); const uploadOpts: UploadOptions = { type: 'raw', @@ -110,6 +118,53 @@ const App = () => { }); }; + const onPressChunkedUpload = async () => { + await ensureNotificationChannel(); + + // 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); + } + 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); + }); + }; + return ( <> @@ -126,6 +181,7 @@ const App = () => {