From 570abc097c2d952d3c21b52400681930b1e30ee8 Mon Sep 17 00:00:00 2001 From: Dylan Murphy Date: Thu, 23 Jul 2026 11:37:27 -0400 Subject: [PATCH 01/19] feat(android): durable terminal-event journal with ack, size cap, non-throwing writes Terminal upload events (completed/error/cancelled) are written to an on-disk journal before being emitted to JS and deleted only when JS acknowledges them, turning delivery from at-most-once into at-least-once. This is the foundation for surviving app death and JS reloads, where events were previously dropped. - One JSON file per event (tmp+rename) so a crash mid-write can't corrupt other entries and cross-process writers don't share mutable state. - append() never throws: a journal write failure returns instead of propagating, so a disk-full at completion can't be misread by the worker as a retryable error and re-run a finished upload. - Size cap (MAX_ENTRIES=1000, oldest-dropped) as a runaway guard for a broken or not-yet-adopted ack loop. - 64KB response-body cap; corrupt files skipped, not fatal. Plain-JVM JUnit tests (8) cover append/ack, persistence, ordering, truncation, corruption tolerance, the size cap, and the non-throwing guarantee. Co-Authored-By: Claude Opus 4.8 --- android/build.gradle | 2 + .../java/com/vydia/RNUploader/EventJournal.kt | 129 ++++++++++++++++++ .../com/vydia/RNUploader/EventJournalTest.kt | 104 ++++++++++++++ 3 files changed, 235 insertions(+) create mode 100644 android/src/main/java/com/vydia/RNUploader/EventJournal.kt create mode 100644 android/src/test/java/com/vydia/RNUploader/EventJournalTest.kt diff --git a/android/build.gradle b/android/build.gradle index dee87982..72387eff 100755 --- a/android/build.gradle +++ b/android/build.gradle @@ -86,4 +86,6 @@ dependencies { implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.6.4' implementation "androidx.work:work-runtime-ktx:2.8.1" + + testImplementation 'junit:junit:4.13.2' } diff --git a/android/src/main/java/com/vydia/RNUploader/EventJournal.kt b/android/src/main/java/com/vydia/RNUploader/EventJournal.kt new file mode 100644 index 00000000..2ac65990 --- /dev/null +++ b/android/src/main/java/com/vydia/RNUploader/EventJournal.kt @@ -0,0 +1,129 @@ +package com.vydia.RNUploader + +import android.content.Context +import com.google.gson.Gson +import java.io.File + +// Durable record of terminal upload events (completed / error / cancelled). +// Written BEFORE the event is emitted to JS; deleted only when JS acknowledges. +// One JSON file per event named .json — tmp+rename keeps each write +// self-contained so a crash mid-append can never corrupt other entries. +// +// `maxEntries` is a runaway guard: the design assumes JS drains the journal via +// ack() on every boot, but if that loop breaks (or a consumer hasn't adopted it +// yet) the directory would grow without bound. When exceeded we drop the OLDEST +// entries. Set high enough that legitimate heavy offline use won't hit it — this +// only fires in the pathological "nothing ever acks" case. +class EventJournal( + private val dir: File, + private val maxEntries: Int = MAX_ENTRIES, +) { + + data class Entry( + val eventId: String, + val uploadId: String, + val type: String, // completed | error | cancelled + val timestamp: Long, + val responseCode: Int? = null, + val responseBody: String? = null, + val responseBodyTruncated: Boolean = false, + val responseHeaders: Map? = null, + val error: String? = null, + val errorKind: String? = null, // http | network | file | unknown + val cancelReason: String? = null, // user | system + ) { + fun toWritableMap(): com.facebook.react.bridge.WritableMap = + com.facebook.react.bridge.Arguments.createMap().apply { + putString("eventId", eventId) + putString("id", uploadId) + putString("type", type) + putDouble("timestamp", timestamp.toDouble()) + responseCode?.let { putInt("responseCode", it) } + responseBody?.let { putString("responseBody", it) } + putBoolean("responseBodyTruncated", responseBodyTruncated) + responseHeaders?.let { + putMap("responseHeaders", com.facebook.react.bridge.Arguments.makeNativeMap(it)) + } + error?.let { putString("error", it) } + errorKind?.let { putString("errorKind", it) } + cancelReason?.let { putString("cancelReason", it) } + } + } + + companion object { + const val MAX_BODY_BYTES = 64 * 1024 + const val MAX_ENTRIES = 1000 + private val gson = Gson() + + @Volatile + private var instance: EventJournal? = null + + // The worker may run in a process where React never initialized, so the + // journal must be reachable from a bare Context, not the module. + fun get(context: Context): EventJournal = + instance ?: synchronized(this) { + instance + ?: EventJournal(File(context.filesDir, "rnbgupload-events")).also { instance = it } + } + } + + init { + dir.mkdirs() + } + + @Synchronized + fun append(entry: Entry) { + val body = entry.responseBody + // Char-count cap, not byte-accurate: splitting on a byte boundary risks + // cutting a surrogate pair; a slightly loose cap is fine for a safety limit. + val bounded = + if (body != null && body.length > MAX_BODY_BYTES) + entry.copy(responseBody = body.substring(0, MAX_BODY_BYTES), responseBodyTruncated = true) + else entry + // A journal write must NEVER throw into the caller. The worker calls this + // right after a successful upload; a propagated IOException (e.g. disk full) + // would be classified as a retryable error and re-run the upload, sending + // duplicate data to the server. Losing one journal entry is the lesser evil. + try { + val tmp = File(dir, "${entry.eventId}.tmp") + tmp.writeText(gson.toJson(bounded)) + tmp.renameTo(File(dir, "${entry.eventId}.json")) + } catch (t: Throwable) { + t.printStackTrace() + return + } + pruneToMax() + } + + // Keep the directory bounded. Prune by file modification time (no parsing) + // rather than the entry's own timestamp — cheaper, and close enough since a + // file's mtime is when it was journaled. Guarded: a prune failure must not + // propagate for the same reason append() must not. + private fun pruneToMax() { + try { + val files = dir.listFiles { f -> f.extension == "json" } ?: return + if (files.size <= maxEntries) return + files.sortedBy { it.lastModified() } + .take(files.size - maxEntries) + .forEach { it.delete() } + } catch (t: Throwable) { + t.printStackTrace() + } + } + + @Synchronized + fun unacknowledged(): List = + (dir.listFiles { f -> f.extension == "json" } ?: emptyArray()) + .mapNotNull { f -> + runCatching { gson.fromJson(f.readText(), Entry::class.java) }.getOrNull() + } + // Gson bypasses the constructor, so a parsed file missing eventId yields + // a null field despite the non-null type — filter those out explicitly. + .filter { it.eventId != null } + .sortedBy { it.timestamp } + + @Synchronized + fun ack(eventIds: List) { + eventIds.forEach { File(dir, "$it.json").delete() } + } +} diff --git a/android/src/test/java/com/vydia/RNUploader/EventJournalTest.kt b/android/src/test/java/com/vydia/RNUploader/EventJournalTest.kt new file mode 100644 index 00000000..0896f0c8 --- /dev/null +++ b/android/src/test/java/com/vydia/RNUploader/EventJournalTest.kt @@ -0,0 +1,104 @@ +package com.vydia.RNUploader + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import java.io.File + +class EventJournalTest { + @get:Rule + val tmp = TemporaryFolder() + + private fun entry(id: String, uploadId: String = "u1") = EventJournal.Entry( + eventId = id, + uploadId = uploadId, + type = "completed", + timestamp = System.currentTimeMillis(), + responseCode = 200, + responseBody = "ok", + responseHeaders = mapOf("x-a" to "b"), + ) + + @Test + fun `append then read returns the entry`() { + val journal = EventJournal(tmp.newFolder()) + journal.append(entry("e1")) + val events = journal.unacknowledged() + assertEquals(1, events.size) + assertEquals("e1", events[0].eventId) + assertEquals(200, events[0].responseCode) + assertEquals("ok", events[0].responseBody) + } + + @Test + fun `ack removes only the acked entry`() { + val journal = EventJournal(tmp.newFolder()) + journal.append(entry("e1")) + journal.append(entry("e2")) + journal.ack(listOf("e1")) + assertEquals(listOf("e2"), journal.unacknowledged().map { it.eventId }) + } + + @Test + fun `entries survive a new journal instance over the same dir`() { + val dir = tmp.newFolder() + EventJournal(dir).append(entry("e1")) + assertEquals(1, EventJournal(dir).unacknowledged().size) + } + + @Test + fun `oversized body is truncated and flagged`() { + val journal = EventJournal(tmp.newFolder()) + val big = "x".repeat(EventJournal.MAX_BODY_BYTES + 100) + journal.append(entry("e1").copy(responseBody = big)) + val read = journal.unacknowledged()[0] + assertTrue(read.responseBodyTruncated) + assertTrue(read.responseBody!!.length <= EventJournal.MAX_BODY_BYTES) + } + + @Test + fun `corrupt file is skipped, not fatal`() { + val dir = tmp.newFolder() + val journal = EventJournal(dir) + journal.append(entry("e1")) + java.io.File(dir, "garbage.json").writeText("{not json") + assertEquals(1, journal.unacknowledged().size) + } + + @Test + fun `entries are ordered by timestamp`() { + val journal = EventJournal(tmp.newFolder()) + journal.append(entry("late").copy(timestamp = 2000)) + journal.append(entry("early").copy(timestamp = 1000)) + assertEquals(listOf("early", "late"), journal.unacknowledged().map { it.eventId }) + } + + @Test + fun `append does not throw when the directory is unwritable`() { + // A regular file where a directory is expected: mkdirs() and every write fail. + val notADir = tmp.newFile() + val journal = EventJournal(notADir) + journal.append(entry("e1")) // must not throw + assertEquals(emptyList(), journal.unacknowledged().map { it.eventId }) + } + + @Test + fun `prunes the oldest entries beyond the cap`() { + val dir = tmp.newFolder() + val journal = EventJournal(dir, maxEntries = 3) + // Stamp increasing mtimes so pruning order is deterministic. Each mtime is + // set before the next append, which is when pruning reads it. + journal.append(entry("e1")); File(dir, "e1.json").setLastModified(1000) + journal.append(entry("e2")); File(dir, "e2.json").setLastModified(2000) + journal.append(entry("e3")); File(dir, "e3.json").setLastModified(3000) + journal.append(entry("e4")) // 4th write trips the cap; oldest (e1) is dropped + + val ids = journal.unacknowledged().map { it.eventId } + assertEquals(3, ids.size) + assertFalse(ids.contains("e1")) + assertTrue(ids.contains("e4")) + } +} From cc8fe5348ce2b1a8300a15cb85b4283901a5d9e0 Mon Sep 17 00:00:00 2001 From: Dylan Murphy Date: Thu, 23 Jul 2026 11:49:22 -0400 Subject: [PATCH 02/19] feat(android): journal terminal events, classify outcomes, typed errors, get/ack Every terminal outcome is now journaled before being emitted to JS and carries an accurate type, so consumers can trust statuses and never miss an outcome. - completed vs error is decided by UploadOutcome.isAccepted: 2xx or a per-request acceptStatus code is a completion; any other HTTP response (incl. 4xx/5xx) is a terminal error with the full response attached. Fixes the class of silent data loss where a 400 was reported as "completed" (diana#8235). - error events are typed via UploadOutcome.errorKind: file | network | unknown. - cancelled events carry cancelReason: user | system, using UserCancellations to tell an explicit cancel from a WorkManager system stop (2.8.1 has no getStopReason). - getUnacknowledgedEvents / ackEvents exposed on the module for at-least-once delivery of journaled events. - Offline behavior is unchanged: transport failures and invalid connectivity still retry unboundedly; only real responses and non-retryable errors are terminal. Classification is extracted into the pure UploadOutcome helper and unit-tested on the JVM (7 tests); worker/module wiring is verified on-device in Task 11. Co-Authored-By: Claude Opus 4.8 --- .../java/com/vydia/RNUploader/EventJournal.kt | 1 + .../com/vydia/RNUploader/EventReporter.kt | 18 ++++- .../main/java/com/vydia/RNUploader/Upload.kt | 7 ++ .../com/vydia/RNUploader/UploadOutcome.kt | 25 +++++++ .../java/com/vydia/RNUploader/UploadWorker.kt | 65 +++++++++++++++---- .../com/vydia/RNUploader/UploaderModule.kt | 37 +++++++++++ .../com/vydia/RNUploader/UserCancellations.kt | 17 +++++ .../com/vydia/RNUploader/UploadOutcomeTest.kt | 51 +++++++++++++++ 8 files changed, 208 insertions(+), 13 deletions(-) create mode 100644 android/src/main/java/com/vydia/RNUploader/UploadOutcome.kt create mode 100644 android/src/main/java/com/vydia/RNUploader/UserCancellations.kt create mode 100644 android/src/test/java/com/vydia/RNUploader/UploadOutcomeTest.kt diff --git a/android/src/main/java/com/vydia/RNUploader/EventJournal.kt b/android/src/main/java/com/vydia/RNUploader/EventJournal.kt index 2ac65990..040454da 100644 --- a/android/src/main/java/com/vydia/RNUploader/EventJournal.kt +++ b/android/src/main/java/com/vydia/RNUploader/EventJournal.kt @@ -112,6 +112,7 @@ class EventJournal( } @Synchronized + @Suppress("SENSELESS_COMPARISON") // Gson can inject null into a non-null field fun unacknowledged(): List = (dir.listFiles { f -> f.extension == "json" } ?: emptyArray()) .mapNotNull { f -> diff --git a/android/src/main/java/com/vydia/RNUploader/EventReporter.kt b/android/src/main/java/com/vydia/RNUploader/EventReporter.kt index 3604a137..2453143c 100644 --- a/android/src/main/java/com/vydia/RNUploader/EventReporter.kt +++ b/android/src/main/java/com/vydia/RNUploader/EventReporter.kt @@ -9,15 +9,29 @@ import com.facebook.react.modules.core.DeviceEventManagerModule.RCTDeviceEventEm object EventReporter { private const val TAG = "UploadReceiver" - fun cancelled(uploadId: String) = + fun cancelled(uploadId: String, reason: String) = sendEvent("cancelled", Arguments.createMap().apply { putString("id", uploadId) + putString("cancelReason", reason) }) - fun error(uploadId: String, exception: Throwable) = + fun error(uploadId: String, exception: Throwable, kind: String) = sendEvent("error", Arguments.createMap().apply { putString("id", uploadId) putString("error", exception.message ?: "Unknown exception") + putString("errorKind", kind) + }) + + // A non-accepted HTTP response (e.g. 400/500). Emitted as an "error" with the + // full response attached so consumers can inspect the body/status. + fun httpError(uploadId: String, response: UploadResponse) = + sendEvent("error", Arguments.createMap().apply { + putString("id", uploadId) + putString("error", "HTTP ${response.code}") + putString("errorKind", "http") + putInt("responseCode", response.code) + putString("responseBody", response.body) + putMap("responseHeaders", Arguments.makeNativeMap(response.headers)) }) fun success(uploadId: String, response: UploadResponse) = diff --git a/android/src/main/java/com/vydia/RNUploader/Upload.kt b/android/src/main/java/com/vydia/RNUploader/Upload.kt index db7b83d5..00827cab 100644 --- a/android/src/main/java/com/vydia/RNUploader/Upload.kt +++ b/android/src/main/java/com/vydia/RNUploader/Upload.kt @@ -13,6 +13,10 @@ data class Upload( val method: String, val maxRetries: Int, val wifiOnly: Boolean, + // Non-2xx statuses to treat as a successful completion (e.g. [409] when + // duplicate-create conflicts are expected). Everything else non-2xx is a + // terminal http error. Empty by default. + val acceptStatus: List, val headers: Map, val notificationId: Int, val notificationTitle: String, @@ -31,6 +35,9 @@ data class Upload( method = map.getString(Upload::method.name) ?: "POST", maxRetries = if (map.hasKey(Upload::maxRetries.name)) map.getInt(Upload::maxRetries.name) else 5, wifiOnly = if (map.hasKey(Upload::wifiOnly.name)) map.getBoolean(Upload::wifiOnly.name) else false, + acceptStatus = map.getArray(Upload::acceptStatus.name)?.let { arr -> + (0 until arr.size()).map { i -> arr.getInt(i) } + } ?: listOf(), headers = map.getMap(Upload::headers.name).let { headers -> if (headers == null) return@let mapOf() val map = mutableMapOf() diff --git a/android/src/main/java/com/vydia/RNUploader/UploadOutcome.kt b/android/src/main/java/com/vydia/RNUploader/UploadOutcome.kt new file mode 100644 index 00000000..bbc091fb --- /dev/null +++ b/android/src/main/java/com/vydia/RNUploader/UploadOutcome.kt @@ -0,0 +1,25 @@ +package com.vydia.RNUploader + +import java.io.IOException + +// Pure classification of terminal upload outcomes. Kept free of Android/React +// types so it can be unit-tested on a plain JVM — this is the highest-consequence +// logic in the uploader (it decides success vs failure), so it's covered directly. +object UploadOutcome { + + // Whether an HTTP response counts as a successful completion. 2xx always, plus + // any per-request acceptStatus codes (axios validateStatus semantics). Anything + // else — including 4xx/5xx — is a terminal http error, not a completion. + fun isAccepted(code: Int, acceptStatus: List): Boolean = + code in 200..299 || acceptStatus.contains(code) + + // Classify a thrown error into a stable kind for the JS layer. `fileExists` + // is passed in (not read here) to keep this pure; callers should default it to + // true when the existence check itself fails, so a flaky file probe reads as a + // retryable network error rather than a terminal "file gone". + fun errorKind(error: Throwable, fileExists: Boolean): String = when { + error is IOException && !fileExists -> "file" + error is IOException -> "network" + else -> "unknown" + } +} diff --git a/android/src/main/java/com/vydia/RNUploader/UploadWorker.kt b/android/src/main/java/com/vydia/RNUploader/UploadWorker.kt index a3bbec7c..df1bff84 100644 --- a/android/src/main/java/com/vydia/RNUploader/UploadWorker.kt +++ b/android/src/main/java/com/vydia/RNUploader/UploadWorker.kt @@ -24,6 +24,7 @@ import okhttp3.OkHttpClient import java.io.File import java.io.IOException import java.net.UnknownHostException +import java.util.UUID import java.util.concurrent.TimeUnit // All workers will start `doWork` immediately but only 1 request is active at a time. @@ -92,17 +93,17 @@ class UploadWorker(private val context: Context, params: WorkerParameters) : // - "delay" should be within the "try" block to account for worker cancellation, // which cancels the delay immediately and throws CancellationException. // - Linear backoff instead of exponential. One reason for this is we retry on - // invalid connections. Exponential will take too long. If the server flakes and - // returns 500s, we don't retry but consider the request successful. - // This is consistent with iOS behavior. User gets notifications for - // these server issues and can manually retry. Since 500s are currently rare, - // it's likely ok. If they're too frequent, we can consider adding exponential - // backoff for them. + // invalid connections. Exponential will take too long. + // - We only retry transport failures here (no response). Any HTTP response, + // including 4xx/5xx, is terminal at this layer: handleResponse classifies it + // (2xx/acceptStatus -> completed, else http error) and the worker returns + // without retrying. Response-code-based retry policy is the JS queue's job. + // This is consistent with iOS behavior. if (isRetried) delay(RETRY_DELAY) isRetried = true val response = upload() ?: continue - handleSuccess(response) + handleResponse(response) return@withContext Result.success() } catch (error: Throwable) { if (checkAndHandleCancellation()) throw error @@ -150,14 +151,46 @@ class UploadWorker(private val context: Context, params: WorkerParameters) : notificationManager.notify(upload.notificationId, buildNotification()) } - private fun handleSuccess(response: UploadResponse) { + // An HTTP response came back. "completed" only for 2xx or a per-request + // acceptStatus code (axios validateStatus semantics — a 400 is an error, not a + // completion); anything else is a terminal http error carrying the full + // response. Either way the request finished, so the worker does not retry. + private fun handleResponse(response: UploadResponse) { UploadProgress.complete(upload.id) - EventReporter.success(upload.id, response) + val accepted = UploadOutcome.isAccepted(response.code, upload.acceptStatus) + EventJournal.get(context).append( + EventJournal.Entry( + eventId = UUID.randomUUID().toString(), + uploadId = upload.id, + type = if (accepted) "completed" else "error", + timestamp = System.currentTimeMillis(), + responseCode = response.code, + responseBody = response.body, + responseHeaders = response.headers, + errorKind = if (accepted) null else "http", + error = if (accepted) null else "HTTP ${response.code}", + ) + ) + if (accepted) EventReporter.success(upload.id, response) + else EventReporter.httpError(upload.id, response) } private fun handleError(error: Throwable) { UploadProgress.remove(upload.id) - EventReporter.error(upload.id, error) + // Default fileExists=true so a failed existence probe reads as network, not file. + val fileExists = runCatching { File(upload.path).exists() }.getOrDefault(true) + val kind = UploadOutcome.errorKind(error, fileExists) + EventJournal.get(context).append( + EventJournal.Entry( + eventId = UUID.randomUUID().toString(), + uploadId = upload.id, + type = "error", + timestamp = System.currentTimeMillis(), + error = error.message ?: "Unknown exception", + errorKind = kind, + ) + ) + EventReporter.error(upload.id, error, kind) } // Check if cancelled by user or new worker with same ID @@ -165,8 +198,18 @@ class UploadWorker(private val context: Context, params: WorkerParameters) : private fun checkAndHandleCancellation(): Boolean { if (!isStopped) return false + val reason = if (UserCancellations.consume(upload.id)) "user" else "system" UploadProgress.remove(upload.id) - EventReporter.cancelled(upload.id) + EventJournal.get(context).append( + EventJournal.Entry( + eventId = UUID.randomUUID().toString(), + uploadId = upload.id, + type = "cancelled", + timestamp = System.currentTimeMillis(), + cancelReason = reason, + ) + ) + EventReporter.cancelled(upload.id, reason) return true } diff --git a/android/src/main/java/com/vydia/RNUploader/UploaderModule.kt b/android/src/main/java/com/vydia/RNUploader/UploaderModule.kt index 0d833fc3..21521019 100644 --- a/android/src/main/java/com/vydia/RNUploader/UploaderModule.kt +++ b/android/src/main/java/com/vydia/RNUploader/UploaderModule.kt @@ -5,10 +5,12 @@ import androidx.work.ExistingWorkPolicy import androidx.work.OneTimeWorkRequestBuilder import androidx.work.WorkManager import androidx.work.workDataOf +import com.facebook.react.bridge.Arguments import com.facebook.react.bridge.Promise import com.facebook.react.bridge.ReactApplicationContext import com.facebook.react.bridge.ReactContextBaseJavaModule import com.facebook.react.bridge.ReactMethod +import com.facebook.react.bridge.ReadableArray import com.facebook.react.bridge.ReadableMap import com.google.gson.Gson @@ -33,6 +35,41 @@ class UploaderModule(context: ReactApplicationContext) : override fun getName(): String = "RNFileUploader" + /** + * Returns terminal events (completed/error/cancelled) that JS has not yet + * acknowledged, including ones that fired while JS was dead. Read these on + * startup, process them, then call ackEvents to remove them. + */ + @ReactMethod + fun getUnacknowledgedEvents(promise: Promise) { + try { + val events = EventJournal.get(reactApplicationContext).unacknowledged() + val arr = Arguments.createArray() + events.forEach { arr.pushMap(it.toWritableMap()) } + promise.resolve(arr) + } catch (exc: Throwable) { + Log.e(TAG, exc.message, exc) + promise.reject(exc) + } + } + + + /** + * Removes journaled events by eventId once JS has processed them. + */ + @ReactMethod + fun ackEvents(eventIds: ReadableArray, promise: Promise) { + try { + val ids = (0 until eventIds.size()).mapNotNull { eventIds.getString(it) } + EventJournal.get(reactApplicationContext).ack(ids) + promise.resolve(true) + } catch (exc: Throwable) { + Log.e(TAG, exc.message, exc) + promise.reject(exc) + } + } + + /* * Starts a file upload. * Returns a promise with the string ID of the upload. diff --git a/android/src/main/java/com/vydia/RNUploader/UserCancellations.kt b/android/src/main/java/com/vydia/RNUploader/UserCancellations.kt new file mode 100644 index 00000000..8ec525ce --- /dev/null +++ b/android/src/main/java/com/vydia/RNUploader/UserCancellations.kt @@ -0,0 +1,17 @@ +package com.vydia.RNUploader + +// Upload ids the JS side explicitly cancelled. Consulted by the worker to +// distinguish user cancels from system kills (WorkManager 2.8.1 has no +// getStopReason). Same-process only: a user cancel always originates from live +// JS, so the set never needs to persist across process death. +object UserCancellations { + private val ids = mutableSetOf() + + @Synchronized + fun mark(id: String) { + ids.add(id) + } + + @Synchronized + fun consume(id: String): Boolean = ids.remove(id) +} diff --git a/android/src/test/java/com/vydia/RNUploader/UploadOutcomeTest.kt b/android/src/test/java/com/vydia/RNUploader/UploadOutcomeTest.kt new file mode 100644 index 00000000..88fe5f2d --- /dev/null +++ b/android/src/test/java/com/vydia/RNUploader/UploadOutcomeTest.kt @@ -0,0 +1,51 @@ +package com.vydia.RNUploader + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import java.io.IOException + +class UploadOutcomeTest { + + @Test + fun `2xx is accepted`() { + assertTrue(UploadOutcome.isAccepted(200, listOf())) + assertTrue(UploadOutcome.isAccepted(204, listOf())) + assertTrue(UploadOutcome.isAccepted(299, listOf())) + } + + @Test + fun `non-2xx is not accepted by default`() { + assertFalse(UploadOutcome.isAccepted(199, listOf())) + assertFalse(UploadOutcome.isAccepted(300, listOf())) + assertFalse(UploadOutcome.isAccepted(404, listOf())) + assertFalse(UploadOutcome.isAccepted(500, listOf())) + } + + @Test + fun `non-2xx listed in acceptStatus is accepted`() { + assertTrue(UploadOutcome.isAccepted(409, listOf(409))) + assertTrue(UploadOutcome.isAccepted(404, listOf(404, 409))) + } + + @Test + fun `acceptStatus does not accept unlisted codes`() { + assertFalse(UploadOutcome.isAccepted(500, listOf(409))) + } + + @Test + fun `IOException with a missing file is a file error`() { + assertEquals("file", UploadOutcome.errorKind(IOException("gone"), fileExists = false)) + } + + @Test + fun `IOException with the file present is a network error`() { + assertEquals("network", UploadOutcome.errorKind(IOException("reset"), fileExists = true)) + } + + @Test + fun `a non-IO error is unknown`() { + assertEquals("unknown", UploadOutcome.errorKind(RuntimeException("boom"), fileExists = true)) + } +} From 81713faf6b3a895280384ed7f26ae7f293904351 Mon Sep 17 00:00:00 2001 From: Dylan Murphy Date: Thu, 23 Jul 2026 11:51:28 -0400 Subject: [PATCH 03/19] feat(android): user-cancel intent, notification defaults + auto channel, drop stopAllUploads - cancelUpload records intent via UserCancellations before cancelling, so a user cancel is reported as cancelReason 'user' and a system stop as 'system'. - All five notification options are now optional with sensible defaults, and the worker creates its own LOW-importance channel when one isn't already registered. Consumers no longer need any notifee/channel plumbing; a caller-provided channel still wins (we only create when absent). url/path remain required. - Removed stopAllUploads: never exposed in JS, no consumers. Co-Authored-By: Claude Opus 4.8 --- .../main/java/com/vydia/RNUploader/Upload.kt | 16 ++++++++++------ .../java/com/vydia/RNUploader/UploadWorker.kt | 19 +++++++++++++++++++ .../com/vydia/RNUploader/UploaderModule.kt | 19 +++---------------- 3 files changed, 32 insertions(+), 22 deletions(-) diff --git a/android/src/main/java/com/vydia/RNUploader/Upload.kt b/android/src/main/java/com/vydia/RNUploader/Upload.kt index 00827cab..7cbe4303 100644 --- a/android/src/main/java/com/vydia/RNUploader/Upload.kt +++ b/android/src/main/java/com/vydia/RNUploader/Upload.kt @@ -28,6 +28,8 @@ data class Upload( IllegalArgumentException("Missing '$optionName'") companion object { + const val DEFAULT_NOTIFICATION_CHANNEL = "background-upload" + fun fromReadableMap(map: ReadableMap) = Upload( id = map.getString("customUploadId") ?: UUID.randomUUID().toString(), url = map.getString(Upload::url.name) ?: throw MissingOptionException(Upload::url.name), @@ -46,16 +48,18 @@ data class Upload( } return@let map }, - notificationId = map.getString(Upload::notificationId.name)?.hashCode() - ?: throw MissingOptionException(Upload::notificationId.name), + // Notification options are optional: the library supplies sensible defaults + // and creates its own channel, so consumers don't need any notifee plumbing. + notificationId = (map.getString(Upload::notificationId.name) + ?: DEFAULT_NOTIFICATION_CHANNEL).hashCode(), notificationTitle = map.getString(Upload::notificationTitle.name) - ?: throw MissingOptionException(Upload::notificationTitle.name), + ?: "Uploading…", notificationTitleNoInternet = map.getString(Upload::notificationTitleNoInternet.name) - ?: throw MissingOptionException(Upload::notificationTitleNoInternet.name), + ?: "Waiting for connection…", notificationTitleNoWifi = map.getString(Upload::notificationTitleNoWifi.name) - ?: throw MissingOptionException(Upload::notificationTitleNoWifi.name), + ?: "Waiting for Wi-Fi…", notificationChannel = map.getString(Upload::notificationChannel.name) - ?: throw MissingOptionException(Upload::notificationChannel.name), + ?: DEFAULT_NOTIFICATION_CHANNEL, ) } } diff --git a/android/src/main/java/com/vydia/RNUploader/UploadWorker.kt b/android/src/main/java/com/vydia/RNUploader/UploadWorker.kt index df1bff84..8763d5d1 100644 --- a/android/src/main/java/com/vydia/RNUploader/UploadWorker.kt +++ b/android/src/main/java/com/vydia/RNUploader/UploadWorker.kt @@ -1,6 +1,7 @@ package com.vydia.RNUploader import android.app.Notification +import android.app.NotificationChannel import android.app.NotificationManager import android.app.PendingIntent import android.content.Context @@ -71,6 +72,9 @@ class UploadWorker(private val context: Context, params: WorkerParameters) : // initialization, errors thrown here won't be retried try { + // The foreground notification needs a channel to exist first, or posting + // it silently fails and setForeground can crash on newer Android. + ensureNotificationChannel() // `setForeground` is recommended for long-running workers. // Foreground mode helps prioritize the worker, reducing the risk // of it being killed during low memory or Doze/App Standby situations. @@ -251,6 +255,21 @@ class UploadWorker(private val context: Context, params: WorkerParameters) : return this.connectivity == Connectivity.Ok } + // Ensures the channel used by the foreground notification exists. Only creates + // it when absent, so a channel the consumer registered themselves (with their + // own name/importance) always wins; when they pass nothing we fall back to a + // default LOW-importance channel and no notifee setup is required. + private fun ensureNotificationChannel() { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return + if (notificationManager.getNotificationChannel(upload.notificationChannel) != null) return + val channel = NotificationChannel( + upload.notificationChannel, + "Uploads", + NotificationManager.IMPORTANCE_LOW, + ) + notificationManager.createNotificationChannel(channel) + } + // builds the notification required to enable Foreground mode fun buildNotification(): Notification { val channel = upload.notificationChannel diff --git a/android/src/main/java/com/vydia/RNUploader/UploaderModule.kt b/android/src/main/java/com/vydia/RNUploader/UploaderModule.kt index 21521019..a4125333 100644 --- a/android/src/main/java/com/vydia/RNUploader/UploaderModule.kt +++ b/android/src/main/java/com/vydia/RNUploader/UploaderModule.kt @@ -120,6 +120,9 @@ class UploaderModule(context: ReactApplicationContext) : @ReactMethod fun cancelUpload(uploadId: String, promise: Promise) { try { + // Record intent BEFORE cancelling so the worker's stop handler can tell + // this apart from a system stop and report cancelReason 'user'. + UserCancellations.mark(uploadId) workManager.cancelUniqueWork(uploadId) promise.resolve(true) } catch (exc: Throwable) { @@ -130,21 +133,5 @@ class UploaderModule(context: ReactApplicationContext) : } - /* - * Cancels all file uploads - */ - @ReactMethod - fun stopAllUploads(promise: Promise) { - try { - workManager.cancelAllWorkByTag(WORKER_TAG) - promise.resolve(true) - } catch (exc: Throwable) { - exc.printStackTrace() - Log.e(TAG, exc.message, exc) - promise.reject(exc) - } - } - - } From e636f57f21952db15a01ba3c63c3329d8648767e Mon Sep 17 00:00:00 2001 From: Dylan Murphy Date: Thu, 23 Jul 2026 11:52:39 -0400 Subject: [PATCH 04/19] feat(android): getAllUploads via id-tagged WorkManager query Exposes getAllUploads() -> [{ id, state }] by tagging each work request with its upload id (WorkInfo carries tags but not the unique-work name) and mapping WorkInfo.State to a stable state string. Lets consumers reconcile in-flight uploads on boot instead of blindly marking everything app-killed. Finished work is auto-pruned by WorkManager after ~1 day, so this reflects live/recent uploads only; durable terminal outcomes come from the journal. Co-Authored-By: Claude Opus 4.8 --- .../com/vydia/RNUploader/UploaderModule.kt | 41 +++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/android/src/main/java/com/vydia/RNUploader/UploaderModule.kt b/android/src/main/java/com/vydia/RNUploader/UploaderModule.kt index a4125333..36725ed6 100644 --- a/android/src/main/java/com/vydia/RNUploader/UploaderModule.kt +++ b/android/src/main/java/com/vydia/RNUploader/UploaderModule.kt @@ -3,6 +3,7 @@ package com.vydia.RNUploader import android.util.Log import androidx.work.ExistingWorkPolicy import androidx.work.OneTimeWorkRequestBuilder +import androidx.work.WorkInfo import androidx.work.WorkManager import androidx.work.workDataOf import com.facebook.react.bridge.Arguments @@ -21,6 +22,9 @@ class UploaderModule(context: ReactApplicationContext) : companion object { const val TAG = "RNFileUploader.UploaderModule" const val WORKER_TAG = "RNFileUploader" + // WorkInfo exposes tags but not the unique-work name, so the upload id is + // also stored as a prefixed tag to recover it in getAllUploads. + const val ID_TAG_PREFIX = "RNFileUploaderId:" var reactContext: ReactApplicationContext? = null private set } @@ -70,6 +74,42 @@ class UploaderModule(context: ReactApplicationContext) : } + /** + * Enumerates uploads WorkManager still knows about, as [{ id, state }]. + * WorkManager auto-prunes finished work after roughly a day, so this is for + * reconciling live/recent uploads — terminal outcomes must be read from + * getUnacknowledgedEvents, which is durable until acknowledged. + */ + @ReactMethod + fun getAllUploads(promise: Promise) { + try { + val infos = workManager.getWorkInfosByTag(WORKER_TAG).get() + val arr = Arguments.createArray() + for (info in infos) { + val id = info.tags.firstOrNull { it.startsWith(ID_TAG_PREFIX) } + ?.removePrefix(ID_TAG_PREFIX) ?: continue + arr.pushMap(Arguments.createMap().apply { + putString("id", id) + putString( + "state", + when (info.state) { + WorkInfo.State.ENQUEUED, WorkInfo.State.BLOCKED -> "pending" + WorkInfo.State.RUNNING -> "running" + WorkInfo.State.SUCCEEDED -> "completed" + WorkInfo.State.FAILED -> "error" + WorkInfo.State.CANCELLED -> "cancelled" + }, + ) + }) + } + promise.resolve(arr) + } catch (exc: Throwable) { + Log.e(TAG, exc.message, exc) + promise.reject(exc) + } + } + + /* * Starts a file upload. * Returns a promise with the string ID of the upload. @@ -97,6 +137,7 @@ class UploaderModule(context: ReactApplicationContext) : val request = OneTimeWorkRequestBuilder() .addTag(WORKER_TAG) + .addTag(ID_TAG_PREFIX + upload.id) .setInputData(workDataOf(UploadWorker.Input.Params.name to data)) .build() From b54c1b5c29779ddda3a99dd2995c5681b83f93f3 Mon Sep 17 00:00:00 2001 From: Dylan Murphy Date: Thu, 23 Jul 2026 15:04:43 -0400 Subject: [PATCH 05/19] feat(ios): rewrite native module in Swift (v8) Replaces the old Objective-C module with a Swift RCTEventEmitter, at parity with the Android reliability work and Vydia-free from the start. - RNFileUploader.swift: background URLSession delegate (config carried over verbatim - session ids, discretionary=NO, 1 conn/host, waitsForConnectivity, uploadTask(with:fromFile:)), startUpload/cancelUpload/getUploadStatus, plus getUnacknowledgedEvents/ackEvents/getAllUploads and the handleEventsForBackgroundURLSession completion handler. - EventJournal.swift / TaskMap.swift: Codable, synchronous (serial queue, not an actor) so outcomes are journaled BEFORE emit; 64KB body cap, 1000-entry runaway cap, backup-excluded, non-throwing writes; task map persists id + acceptStatus keyed by sessionId:taskIdentifier (taskDescription isn't guaranteed durable). - Outcome classification matches Android: completed only for 2xx/acceptStatus, else typed error (http/network); cancelReason user vs system; response headers. - Coordination state (sessions, response buffers, cancel set, latest instance) is static/process-global so it stays correct across a JS reload, where the session delegate and the JS-facing instance differ, and so we never create a duplicate background session. RNFileUploader.m is a small RCT_EXTERN shim (bridge-only). - Deletes the Obj-C module, Helper, and the legacy standalone xcodeproj; podspec now builds Swift (ios 15.1, React-Core, DEFINES_MODULE); example app + Podfile bumped to 15.1. index.ts targets NativeModules.RNFileUploader and gates the iOS listener registration on Platform.OS. Verification: EventJournal/TaskMap typecheck clean via swiftc; index.ts tsc-clean. Full pod compile + background behavior verified via CI / on-device (Task 11) - local xcodebuild is currently blocked by a toolchain issue, deferred per decision. Co-Authored-By: Claude Opus 4.8 --- example/RNBGUExample/ios/Podfile | 2 +- example/RNBGUExample/ios/Podfile.lock | 42 +- .../RNBGUExample.xcodeproj/project.pbxproj | 8 +- ios/EventJournal.swift | 120 +++++ ios/Helper.h | 8 - ios/Helper.m | 25 - ios/RNFileUploader.m | 33 ++ ios/RNFileUploader.swift | 366 ++++++++++++++ ios/TaskMap.swift | 56 +++ ios/VydiaRNFileUploader.m | 445 ------------------ .../project.pbxproj | 254 ---------- react-native-background-upload.podspec | 10 +- src/index.ts | 7 +- 13 files changed, 602 insertions(+), 774 deletions(-) create mode 100644 ios/EventJournal.swift delete mode 100644 ios/Helper.h delete mode 100644 ios/Helper.m create mode 100644 ios/RNFileUploader.m create mode 100644 ios/RNFileUploader.swift create mode 100644 ios/TaskMap.swift delete mode 100644 ios/VydiaRNFileUploader.m delete mode 100644 ios/VydiaRNFileUploader.xcodeproj/project.pbxproj diff --git a/example/RNBGUExample/ios/Podfile b/example/RNBGUExample/ios/Podfile index 8b854e77..81c84cf3 100644 --- a/example/RNBGUExample/ios/Podfile +++ b/example/RNBGUExample/ios/Podfile @@ -5,7 +5,7 @@ require Pod::Executable.execute_command('node', ['-p', {paths: [process.argv[1]]}, )', __dir__]).strip -platform :ios, min_ios_version_supported +platform :ios, '15.1' prepare_react_native_project! linkage = ENV['USE_FRAMEWORKS'] diff --git a/example/RNBGUExample/ios/Podfile.lock b/example/RNBGUExample/ios/Podfile.lock index 69a56ad0..01d075aa 100644 --- a/example/RNBGUExample/ios/Podfile.lock +++ b/example/RNBGUExample/ios/Podfile.lock @@ -1237,29 +1237,8 @@ PODS: - ReactCommon/turbomodule/bridging - ReactCommon/turbomodule/core - Yoga - - react-native-background-upload (7.5.2): - - React - - react-native-image-picker (7.1.2): - - DoubleConversion - - glog - - hermes-engine - - RCT-Folly (= 2024.01.01.00) - - RCTRequired - - RCTTypeSafety + - react-native-background-upload (7.5.3): - React-Core - - React-debug - - React-Fabric - - React-featureflags - - React-graphics - - React-ImageManager - - React-NativeModulesApple - - React-RCTFabric - - React-rendererdebug - - React-utils - - ReactCodegen - - ReactCommon/turbomodule/bridging - - ReactCommon/turbomodule/core - - Yoga - React-nativeconfig (0.75.3) - React-NativeModulesApple (0.75.3): - glog @@ -1522,6 +1501,11 @@ PODS: - React-utils (= 0.75.3) - RNFS (2.20.0): - React-Core + - RNNotifee (9.1.8): + - React-Core + - RNNotifee/NotifeeCore (= 9.1.8) + - RNNotifee/NotifeeCore (9.1.8): + - React-Core - SocketRocket (0.7.0) - Yoga (0.0.0) @@ -1564,7 +1548,6 @@ DEPENDENCIES: - React-Mapbuffer (from `../node_modules/react-native/ReactCommon`) - React-microtasksnativemodule (from `../node_modules/react-native/ReactCommon/react/nativemodule/microtasks`) - react-native-background-upload (from `../../..`) - - react-native-image-picker (from `../node_modules/react-native-image-picker`) - React-nativeconfig (from `../node_modules/react-native/ReactCommon`) - React-NativeModulesApple (from `../node_modules/react-native/ReactCommon/react/nativemodule/core/platform/ios`) - React-perflogger (from `../node_modules/react-native/ReactCommon/reactperflogger`) @@ -1592,6 +1575,7 @@ DEPENDENCIES: - ReactCodegen (from `build/generated/ios`) - ReactCommon/turbomodule/core (from `../node_modules/react-native/ReactCommon`) - RNFS (from `../node_modules/react-native-fs`) + - "RNNotifee (from `../node_modules/@notifee/react-native`)" - Yoga (from `../node_modules/react-native/ReactCommon/yoga`) SPEC REPOS: @@ -1672,8 +1656,6 @@ EXTERNAL SOURCES: :path: "../node_modules/react-native/ReactCommon/react/nativemodule/microtasks" react-native-background-upload: :path: "../../.." - react-native-image-picker: - :path: "../node_modules/react-native-image-picker" React-nativeconfig: :path: "../node_modules/react-native/ReactCommon" React-NativeModulesApple: @@ -1728,6 +1710,8 @@ EXTERNAL SOURCES: :path: "../node_modules/react-native/ReactCommon" RNFS: :path: "../node_modules/react-native-fs" + RNNotifee: + :path: "../node_modules/@notifee/react-native" Yoga: :path: "../node_modules/react-native/ReactCommon/yoga" @@ -1767,8 +1751,7 @@ SPEC CHECKSUMS: React-logger: 4072f39df335ca443932e0ccece41fbeb5ca8404 React-Mapbuffer: 714f2fae68edcabfc332b754e9fbaa8cfc68fdd4 React-microtasksnativemodule: 4943ad8f99be8ccf5a63329fa7d269816609df9e - react-native-background-upload: 00d51f342953fae50cd7705c4a5663710981c17a - react-native-image-picker: 2fbbafdae7a7c6db9d25df2f2b1db4442d2ca2ad + react-native-background-upload: 615eed69d2487f0188993546c6a7dc79ed77d2ac React-nativeconfig: 4a9543185905fe41014c06776bf126083795aed9 React-NativeModulesApple: 0506da59fc40d2e1e6e12a233db5e81c46face27 React-perflogger: 3bbb82f18e9ac29a1a6931568e99d6305ef4403b @@ -1796,9 +1779,10 @@ SPEC CHECKSUMS: ReactCodegen: ff95a93d5ab5d9b2551571886271478eaa168565 ReactCommon: 289214026502e6a93484f4a46bcc0efa4f3f2864 RNFS: 4ac0f0ea233904cb798630b3c077808c06931688 + RNNotifee: 4a6ee5c7deaf00e005050052d73ee6315dff7ec9 SocketRocket: abac6f5de4d4d62d24e11868d7a2f427e0ef940d - Yoga: 1354c027ab07c7736f99a3bef16172d6f1b12b47 + Yoga: 4ef80d96a5534f0e01b3055f17d1e19a9fc61b63 -PODFILE CHECKSUM: 3299daa809621aaa179de3cd96e9009dc46fda43 +PODFILE CHECKSUM: 2c9836cc74776319076bc3863e3a589917b034bc COCOAPODS: 1.15.2 diff --git a/example/RNBGUExample/ios/RNBGUExample.xcodeproj/project.pbxproj b/example/RNBGUExample/ios/RNBGUExample.xcodeproj/project.pbxproj index f2f7c6a5..914611a9 100644 --- a/example/RNBGUExample/ios/RNBGUExample.xcodeproj/project.pbxproj +++ b/example/RNBGUExample/ios/RNBGUExample.xcodeproj/project.pbxproj @@ -424,7 +424,7 @@ "$(inherited)", ); INFOPLIST_FILE = RNBGUExampleTests/Info.plist; - IPHONEOS_DEPLOYMENT_TARGET = 13.4; + IPHONEOS_DEPLOYMENT_TARGET = 15.1; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -448,7 +448,7 @@ BUNDLE_LOADER = "$(TEST_HOST)"; COPY_PHASE_STRIP = NO; INFOPLIST_FILE = RNBGUExampleTests/Info.plist; - IPHONEOS_DEPLOYMENT_TARGET = 13.4; + IPHONEOS_DEPLOYMENT_TARGET = 15.1; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -568,7 +568,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 13.4; + IPHONEOS_DEPLOYMENT_TARGET = 15.1; LD = ""; LDPLUSPLUS = ""; LD_RUNPATH_SEARCH_PATHS = ( @@ -645,7 +645,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 13.4; + IPHONEOS_DEPLOYMENT_TARGET = 15.1; LD = ""; LDPLUSPLUS = ""; LD_RUNPATH_SEARCH_PATHS = ( diff --git a/ios/EventJournal.swift b/ios/EventJournal.swift new file mode 100644 index 00000000..88373716 --- /dev/null +++ b/ios/EventJournal.swift @@ -0,0 +1,120 @@ +import Foundation + +// A terminal upload outcome, persisted before it is emitted to JS. +struct JournaledEvent: Codable { + let eventId: String + let id: String // upload id + var type: String // completed | error | cancelled + let timestamp: Double // epoch ms + var responseCode: Int? + var responseBody: String? + var responseBodyTruncated: Bool? + var responseHeaders: [String: String]? + var error: String? + var errorKind: String? // http | network | file | unknown + var cancelReason: String? // user | system + + // Bridge-friendly dictionary (nil fields omitted so nothing becomes NSNull). + var bridged: [String: Any] { + var m: [String: Any] = ["eventId": eventId, "id": id, "type": type, "timestamp": timestamp] + if let responseCode { m["responseCode"] = responseCode } + if let responseBody { m["responseBody"] = responseBody } + if let responseBodyTruncated { m["responseBodyTruncated"] = responseBodyTruncated } + if let responseHeaders { m["responseHeaders"] = responseHeaders } + if let error { m["error"] = error } + if let errorKind { m["errorKind"] = errorKind } + if let cancelReason { m["cancelReason"] = cancelReason } + return m + } +} + +// Durable record of terminal upload events (completed / error / cancelled). +// Written BEFORE the event is emitted to JS, deleted only when JS acknowledges, +// so an outcome that fires while JS is dead survives to the next launch. +// +// Synchronous (serial queue) — deliberately NOT an actor. The URLSession delegate +// is synchronous and must journal an outcome BEFORE emitting it; an actor would +// force that ordering to become async and racy. +// +// One JSON file per event: Data.write(atomically:) is its own tmp+rename, so a +// crash mid-write can't corrupt other entries, and separate files avoid a shared +// mutable file across processes. +enum EventJournal { + static let maxBodyChars = 64 * 1024 + static let maxEntries = 1000 + + private static let queue = DispatchQueue(label: "ai.openspace.rnbgupload.journal") + + private static var dirURL: URL { + let base = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0] + var dir = base.appendingPathComponent("RNFileUploaderEvents", isDirectory: true) + try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + // Transient device-local state; keep it out of iCloud/iTunes backups. + var values = URLResourceValues() + values.isExcludedFromBackup = true + try? dir.setResourceValues(values) + return dir + } + + static func append(_ event: JournaledEvent) { + queue.sync { + var e = event + if let body = e.responseBody, body.count > maxBodyChars { + e.responseBody = String(body.prefix(maxBodyChars)) + e.responseBodyTruncated = true + } + // A journal write must never throw into the caller: the delegate calls this + // right after a completed upload, and a propagated failure could misfire the + // error path. Dropping one entry is the lesser evil. + guard let data = try? JSONEncoder().encode(e) else { return } + do { + try data.write(to: dirURL.appendingPathComponent("\(e.eventId).json"), options: .atomic) + } catch { + NSLog("[RNFileUploader] journal append failed: \(error.localizedDescription)") + return + } + pruneToMax() + } + } + + static func unacknowledged() -> [[String: Any]] { + queue.sync { + let files = (try? FileManager.default.contentsOfDirectory(at: dirURL, includingPropertiesForKeys: nil)) ?? [] + return files + .filter { $0.pathExtension == "json" } + .compactMap { url -> JournaledEvent? in + guard let data = try? Data(contentsOf: url) else { return nil } + return try? JSONDecoder().decode(JournaledEvent.self, from: data) + } + .sorted { $0.timestamp < $1.timestamp } + .map { $0.bridged } + } + } + + static func ack(_ eventIds: [String]) { + queue.sync { + for id in eventIds { + try? FileManager.default.removeItem(at: dirURL.appendingPathComponent("\(id).json")) + } + } + } + + // Runaway guard: assumes JS drains via ack on each boot, but bounds the + // directory if that loop breaks or hasn't been adopted. Drops the oldest by + // file modification time (no parsing). Caller already holds `queue`. + private static func pruneToMax() { + let key: URLResourceKey = .contentModificationDateKey + guard let files = try? FileManager.default.contentsOfDirectory( + at: dirURL, includingPropertiesForKeys: [key]) else { return } + let jsons = files.filter { $0.pathExtension == "json" } + guard jsons.count > maxEntries else { return } + let sorted = jsons.sorted { + let a = (try? $0.resourceValues(forKeys: [key]).contentModificationDate) ?? .distantPast + let b = (try? $1.resourceValues(forKeys: [key]).contentModificationDate) ?? .distantPast + return a < b + } + for f in sorted.prefix(jsons.count - maxEntries) { + try? FileManager.default.removeItem(at: f) + } + } +} diff --git a/ios/Helper.h b/ios/Helper.h deleted file mode 100644 index 97aba675..00000000 --- a/ios/Helper.h +++ /dev/null @@ -1,8 +0,0 @@ -#import - -@interface Helper: NSObject {} - -+ (NSString *) urlSessionTaskStateToString: (NSURLSessionTaskState)state; - - -@end diff --git a/ios/Helper.m b/ios/Helper.m deleted file mode 100644 index b897ad59..00000000 --- a/ios/Helper.m +++ /dev/null @@ -1,25 +0,0 @@ -#import - -#import "Helper.h" - -@implementation Helper - - - -+ (NSString *) urlSessionTaskStateToString: (NSURLSessionTaskState)state { - switch (state) { - case NSURLSessionTaskStateRunning: - return @"running"; - case NSURLSessionTaskStateSuspended: - return @"suspended"; - case NSURLSessionTaskStateCompleted: - return @"completed"; - case NSURLSessionTaskStateCanceling: - return @"canceling"; - default: - return NULL; - } -} - -@end - diff --git a/ios/RNFileUploader.m b/ios/RNFileUploader.m new file mode 100644 index 00000000..d4219344 --- /dev/null +++ b/ios/RNFileUploader.m @@ -0,0 +1,33 @@ +#import +#import + +// Bridge registration for the Swift RNFileUploader (RCTEventEmitter subclass). +// The RCT_EXTERN_* macros are Objective-C only, so this small shim is required on +// the legacy bridge; addListener/removeListeners come from RCTEventEmitter itself. +// No JS-name argument => the module is exported under its class name, +// RNFileUploader, matching NativeModules.RNFileUploader. +@interface RCT_EXTERN_MODULE(RNFileUploader, RCTEventEmitter) + +RCT_EXTERN_METHOD(startUpload:(NSDictionary *)options + resolver:(RCTPromiseResolveBlock)resolve + rejecter:(RCTPromiseRejectBlock)reject) + +RCT_EXTERN_METHOD(cancelUpload:(NSString *)cancelUploadId + resolver:(RCTPromiseResolveBlock)resolve + rejecter:(RCTPromiseRejectBlock)reject) + +RCT_EXTERN_METHOD(getUploadStatus:(NSString *)uploadId + resolver:(RCTPromiseResolveBlock)resolve + rejecter:(RCTPromiseRejectBlock)reject) + +RCT_EXTERN_METHOD(getUnacknowledgedEvents:(RCTPromiseResolveBlock)resolve + rejecter:(RCTPromiseRejectBlock)reject) + +RCT_EXTERN_METHOD(ackEvents:(NSArray *)eventIds + resolver:(RCTPromiseResolveBlock)resolve + rejecter:(RCTPromiseRejectBlock)reject) + +RCT_EXTERN_METHOD(getAllUploads:(RCTPromiseResolveBlock)resolve + rejecter:(RCTPromiseRejectBlock)reject) + +@end diff --git a/ios/RNFileUploader.swift b/ios/RNFileUploader.swift new file mode 100644 index 00000000..5293202f --- /dev/null +++ b/ios/RNFileUploader.swift @@ -0,0 +1,366 @@ +import Foundation +import React + +// Background HTTP file uploader (iOS). Uploads run on a background URLSession so +// they continue while the app is suspended and complete/relaunch when terminated +// by the system. Terminal outcomes are journaled before being emitted, so JS can +// recover them even if it was dead when they fired. +// +// State that must be consistent regardless of which module instance is alive is +// STATIC (process-global): the background sessions, the in-flight response +// buffers, the user-cancel set, and the latest instance used for emitting. This +// matters because after a JS reload the URLSession delegate stays pinned to the +// first instance while JS talks to the newest one; sharing this state via statics +// keeps cancel-attribution and response assembly correct across that split, and +// avoids ever creating two background sessions with the same identifier. +@objc(RNFileUploader) +class RNFileUploader: RCTEventEmitter, URLSessionDataDelegate { + + private static let backgroundSessionId = "ReactNativeBackgroundUpload" + private static let wifiOnlySessionId = "ReactNativeBackgroundUpload_WifiOnly" + private static let progressThrottle: TimeInterval = 0.5 // seconds, per upload + private static let maxBodyChars = 64 * 1024 + + private static let lock = NSLock() + private static var responsesData: [Int: NSMutableData] = [:] // taskIdentifier -> body + private static var lastProgressAt: [String: TimeInterval] = [:] // uploadId -> time + private static var userCancelledIds = Set() + private static weak var latestInstance: RNFileUploader? + + private static var backgroundSession: URLSession? + private static var wifiOnlySession: URLSession? + + // AppDelegate stores the system-provided completion handler here (per session + // id) so the app can be relaunched to finish uploads after termination. + private static let bgHandlerLock = NSLock() + private static var bgCompletionHandlers: [String: () -> Void] = [:] + + override init() { + super.init() + RNFileUploader.latestInstance = self + // Recreate the sessions as early as possible so delegate events queued by + // nsurlsessiond from a previous launch are delivered to this process. + _ = session(wifiOnly: false) + _ = session(wifiOnly: true) + } + + override static func requiresMainQueueSetup() -> Bool { false } + + override func supportedEvents() -> [String]! { + ["RNFileUploader-progress", "RNFileUploader-error", "RNFileUploader-cancelled", "RNFileUploader-completed"] + } + + private func emit(_ name: String, _ body: [String: Any]) { + // Route through the latest instance: after a JS reload the delegate may be an + // older instance whose bridge is gone. + RNFileUploader.latestInstance?.sendEvent(withName: name, body: body) + } + + // MARK: - Sessions + + private func session(wifiOnly: Bool) -> URLSession { + RNFileUploader.lock.lock() + defer { RNFileUploader.lock.unlock() } + if wifiOnly { + if let s = RNFileUploader.wifiOnlySession { return s } + let s = makeSession(identifier: RNFileUploader.wifiOnlySessionId, wifiOnly: true) + RNFileUploader.wifiOnlySession = s + return s + } else { + if let s = RNFileUploader.backgroundSession { return s } + let s = makeSession(identifier: RNFileUploader.backgroundSessionId, wifiOnly: false) + RNFileUploader.backgroundSession = s + return s + } + } + + // Session configuration is load-bearing and carried over verbatim from the + // original Obj-C. Config must be set before the session is created (URLSession + // copies it). Background upload tasks require uploadTask(with:fromFile:). + private func makeSession(identifier: String, wifiOnly: Bool) -> URLSession { + let config = URLSessionConfiguration.background(withIdentifier: identifier) + config.isDiscretionary = false + config.httpMaximumConnectionsPerHost = 1 + config.waitsForConnectivity = true + config.allowsCellularAccess = !wifiOnly + config.allowsConstrainedNetworkAccess = !wifiOnly + config.allowsExpensiveNetworkAccess = !wifiOnly + return URLSession(configuration: config, delegate: self, delegateQueue: nil) + } + + private func taskMapKey(_ session: URLSession, _ task: URLSessionTask) -> String { + "\(session.configuration.identifier ?? ""):\(task.taskIdentifier)" + } + + // taskDescription is the primary id; the persisted map is the durable fallback. + private func uploadId(_ session: URLSession, _ task: URLSessionTask) -> String { + task.taskDescription ?? TaskMap.meta(forKey: taskMapKey(session, task))?.id ?? "unknown" + } + + private func acceptStatus(_ session: URLSession, _ task: URLSessionTask) -> [Int] { + TaskMap.meta(forKey: taskMapKey(session, task))?.acceptStatus ?? [] + } + + private var activeSessions: [URLSession] { + [RNFileUploader.backgroundSession, RNFileUploader.wifiOnlySession].compactMap { $0 } + } + + // MARK: - Exported methods + + @objc(startUpload:resolver:rejecter:) + func startUpload(_ options: [String: Any], + resolver resolve: @escaping RCTPromiseResolveBlock, + rejecter reject: @escaping RCTPromiseRejectBlock) { + guard let urlString = options["url"] as? String, let path = options["path"] as? String else { + reject("RN Uploader", "Missing 'url' or 'path'", nil); return + } + guard let requestUrl = URL(string: urlString) else { + reject("RN Uploader", "URL not compliant with RFC 2396", nil); return + } + let type = (options["type"] as? String) ?? "raw" + if type != "raw" { + reject("RN Uploader", "Only type: 'raw' is supported", nil); return + } + + var request = URLRequest(url: requestUrl) + request.httpMethod = (options["method"] as? String) ?? "POST" + if let headers = options["headers"] as? [String: Any] { + for (key, value) in headers { + if let s = value as? String { request.setValue(s, forHTTPHeaderField: key) } + else { request.setValue("\(value)", forHTTPHeaderField: key) } + } + } + + let wifiOnly = (options["wifiOnly"] as? Bool) ?? false + let acceptStatus = (options["acceptStatus"] as? [Int]) ?? [] + let uploadId = (options["customUploadId"] as? String) ?? UUID().uuidString + let fileURL = URL(string: path) ?? URL(fileURLWithPath: path) + + let session = self.session(wifiOnly: wifiOnly) + let task = session.uploadTask(with: request, fromFile: fileURL) + task.taskDescription = uploadId + TaskMap.set(TaskMap.Meta(id: uploadId, acceptStatus: acceptStatus), + forKey: taskMapKey(session, task)) + task.resume() + resolve(uploadId) + } + + @objc(cancelUpload:resolver:rejecter:) + func cancelUpload(_ cancelUploadId: String, + resolver resolve: @escaping RCTPromiseResolveBlock, + rejecter reject: @escaping RCTPromiseRejectBlock) { + // Record intent before cancelling so the delegate reports cancelReason 'user'. + RNFileUploader.lock.lock() + RNFileUploader.userCancelledIds.insert(cancelUploadId) + RNFileUploader.lock.unlock() + + let sessions = activeSessions + let group = DispatchGroup() + for session in sessions { + group.enter() + session.getAllTasks { tasks in + for task in tasks where self.uploadId(session, task) == cancelUploadId { + task.cancel() + } + group.leave() + } + } + group.notify(queue: .main) { resolve(true) } + } + + @objc(getUploadStatus:resolver:rejecter:) + func getUploadStatus(_ uploadId: String, + resolver resolve: @escaping RCTPromiseResolveBlock, + rejecter reject: @escaping RCTPromiseRejectBlock) { + let sessions = activeSessions + let group = DispatchGroup() + let lock = NSLock() + var result: [String: Any]? + for session in sessions { + group.enter() + session.getAllTasks { tasks in + for task in tasks where self.uploadId(session, task) == uploadId { + lock.lock() + if result == nil { + result = ["state": self.stateString(task.state), + "bytesSent": task.countOfBytesSent, + "totalBytes": task.countOfBytesExpectedToSend] + } + lock.unlock() + } + group.leave() + } + } + group.notify(queue: .main) { resolve(result) } + } + + @objc(getUnacknowledgedEvents:rejecter:) + func getUnacknowledgedEvents(_ resolve: @escaping RCTPromiseResolveBlock, + rejecter reject: @escaping RCTPromiseRejectBlock) { + resolve(EventJournal.unacknowledged()) + } + + @objc(ackEvents:resolver:rejecter:) + func ackEvents(_ eventIds: [String], + resolver resolve: @escaping RCTPromiseResolveBlock, + rejecter reject: @escaping RCTPromiseRejectBlock) { + EventJournal.ack(eventIds) + resolve(true) + } + + @objc(getAllUploads:rejecter:) + func getAllUploads(_ resolve: @escaping RCTPromiseResolveBlock, + rejecter reject: @escaping RCTPromiseRejectBlock) { + let sessions = activeSessions + let group = DispatchGroup() + let lock = NSLock() + var result: [[String: Any]] = [] + for session in sessions { + group.enter() + session.getAllTasks { tasks in + for task in tasks { + let id = self.uploadId(session, task) + if id == "unknown" { continue } + lock.lock() + result.append(["id": id, + "state": task.state == .running ? "running" : "pending", + "bytesSent": task.countOfBytesSent, + "totalBytes": task.countOfBytesExpectedToSend]) + lock.unlock() + } + group.leave() + } + } + group.notify(queue: .main) { resolve(result) } + } + + // Called from AppDelegate.application(_:handleEventsForBackgroundURLSession:completionHandler:) + @objc(setBackgroundSessionCompletionHandler:forIdentifier:) + static func setBackgroundSessionCompletionHandler(_ handler: @escaping () -> Void, + forIdentifier identifier: String) { + bgHandlerLock.lock() + bgCompletionHandlers[identifier] = handler + bgHandlerLock.unlock() + } + + // MARK: - URLSession delegate + + func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive data: Data) { + guard !data.isEmpty else { return } + RNFileUploader.lock.lock() + if let existing = RNFileUploader.responsesData[dataTask.taskIdentifier] { + existing.append(data) + } else { + RNFileUploader.responsesData[dataTask.taskIdentifier] = NSMutableData(data: data) + } + RNFileUploader.lock.unlock() + } + + func urlSession(_ session: URLSession, task: URLSessionTask, + didSendBodyData bytesSent: Int64, totalBytesSent: Int64, + totalBytesExpectedToSend: Int64) { + var progress: Float = -1 + if totalBytesExpectedToSend > 0 { + progress = 100.0 * Float(totalBytesSent) / Float(totalBytesExpectedToSend) + } + let id = uploadId(session, task) + let now = Date().timeIntervalSince1970 + RNFileUploader.lock.lock() + if progress < 100, + let last = RNFileUploader.lastProgressAt[id], + now - last < RNFileUploader.progressThrottle { + RNFileUploader.lock.unlock() + return + } + RNFileUploader.lastProgressAt[id] = now + RNFileUploader.lock.unlock() + emit("RNFileUploader-progress", ["id": id, "progress": progress]) + } + + func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) { + let id = uploadId(session, task) + let http = task.response as? HTTPURLResponse + let statusCode = http?.statusCode ?? 0 + + var headers: [String: String] = [:] + if let http { + for (key, value) in http.allHeaderFields { headers["\(key)"] = "\(value)" } + } + + RNFileUploader.lock.lock() + let bodyData = RNFileUploader.responsesData.removeValue(forKey: task.taskIdentifier) + RNFileUploader.lastProgressAt[id] = nil + RNFileUploader.lock.unlock() + + var responseBody = bodyData.flatMap { String(data: $0 as Data, encoding: .utf8) } ?? "" + var truncated = false + if responseBody.count > RNFileUploader.maxBodyChars { + responseBody = String(responseBody.prefix(RNFileUploader.maxBodyChars)) + truncated = true + } + + let eventId = UUID().uuidString + let timestamp = Date().timeIntervalSince1970 * 1000 + var event = JournaledEvent(eventId: eventId, id: id, type: "completed", timestamp: timestamp) + if http != nil { + event.responseCode = statusCode + event.responseHeaders = headers + event.responseBody = responseBody + event.responseBodyTruncated = truncated + } + + let eventName: String + if error == nil { + // "completed" only for 2xx or a per-request acceptStatus code; any other + // HTTP response is a terminal http error carrying the full response. + let accepted = (200..<300).contains(statusCode) || acceptStatus(session, task).contains(statusCode) + if accepted { + eventName = "completed" + event.type = "completed" + } else { + eventName = "error" + event.type = "error" + event.errorKind = "http" + event.error = "HTTP \(statusCode)" + } + } else { + let nsError = error! as NSError + if nsError.code == NSURLErrorCancelled { + eventName = "cancelled" + event.type = "cancelled" + RNFileUploader.lock.lock() + let userCancelled = RNFileUploader.userCancelledIds.remove(id) != nil + RNFileUploader.lock.unlock() + event.cancelReason = userCancelled ? "user" : "system" + } else { + eventName = "error" + event.type = "error" + event.errorKind = "network" + event.error = nsError.localizedDescription + } + } + + // Journal BEFORE emitting; the emit is best-effort (JS may be dead). + EventJournal.append(event) + TaskMap.removeKey(taskMapKey(session, task)) + emit("RNFileUploader-\(eventName)", event.bridged) + } + + func urlSessionDidFinishEvents(forBackgroundURLSession session: URLSession) { + guard let identifier = session.configuration.identifier else { return } + RNFileUploader.bgHandlerLock.lock() + let handler = RNFileUploader.bgCompletionHandlers.removeValue(forKey: identifier) + RNFileUploader.bgHandlerLock.unlock() + if let handler { DispatchQueue.main.async { handler() } } + } + + private func stateString(_ state: URLSessionTask.State) -> String { + switch state { + case .running: return "running" + case .suspended: return "suspended" + case .canceling: return "canceling" + case .completed: return "completed" + @unknown default: return "running" + } + } +} diff --git a/ios/TaskMap.swift b/ios/TaskMap.swift new file mode 100644 index 00000000..195b2a0a --- /dev/null +++ b/ios/TaskMap.swift @@ -0,0 +1,56 @@ +import Foundation + +// Durable ":" -> { id, acceptStatus } mapping. +// +// Apple documents `taskDescription` only as an uninterpreted app string with no +// guarantee it survives process death, and DTS guidance is to persist task +// metadata externally keyed by the (stable) taskIdentifier. taskDescription +// stays the primary id; this map is the durable fallback so a task observed +// after relaunch is never orphaned under an unknown id, and so acceptStatus is +// still known when a task completes after the original startUpload options are gone. +// +// Synchronous serial-queue access; a single JSON file. +enum TaskMap { + struct Meta: Codable { + let id: String + let acceptStatus: [Int] + } + + private static let queue = DispatchQueue(label: "ai.openspace.rnbgupload.taskmap") + + private static var fileURL: URL { + let base = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0] + try? FileManager.default.createDirectory(at: base, withIntermediateDirectories: true) + return base.appendingPathComponent("RNFileUploaderTaskMap.json") + } + + static func set(_ meta: Meta, forKey key: String) { + queue.sync { + var map = read() + map[key] = meta + write(map) + } + } + + static func meta(forKey key: String) -> Meta? { + queue.sync { read()[key] } + } + + static func removeKey(_ key: String) { + queue.sync { + var map = read() + map.removeValue(forKey: key) + write(map) + } + } + + private static func read() -> [String: Meta] { + guard let data = try? Data(contentsOf: fileURL) else { return [:] } + return (try? JSONDecoder().decode([String: Meta].self, from: data)) ?? [:] + } + + private static func write(_ map: [String: Meta]) { + guard let data = try? JSONEncoder().encode(map) else { return } + try? data.write(to: fileURL, options: .atomic) + } +} diff --git a/ios/VydiaRNFileUploader.m b/ios/VydiaRNFileUploader.m deleted file mode 100644 index a6021683..00000000 --- a/ios/VydiaRNFileUploader.m +++ /dev/null @@ -1,445 +0,0 @@ -#import -#import -#import -#import -#import -#import "Helper.h" - -@interface VydiaRNFileUploader : RCTEventEmitter -@end - -@implementation VydiaRNFileUploader - -RCT_EXPORT_MODULE(); - -@synthesize bridge = _bridge; -static int uploadId = 0; -static RCTEventEmitter* staticEventEmitter = nil; -static NSString *BACKGROUND_SESSION_ID = @"ReactNativeBackgroundUpload"; -static NSString *WIFI_ONLY_BACKGROUND_SESSION_ID = @"ReactNativeBackgroundUpload_WifiOnly"; - -NSURLSession *_urlSession = nil; -NSURLSession *_wifiOnlyUrlSession = nil; -NSMutableDictionary *_responsesData = nil; - -+ (BOOL)requiresMainQueueSetup { - return NO; -} - --(id) init { - self = [super init]; - if(!self) return self; - - staticEventEmitter = self; - _responsesData = [NSMutableDictionary dictionary]; - - // Initializes as early as possible to receive delegate events - // sent from previously registered URLSessions - [self urlSession]; - [self wifiOnlyUrlSession]; - return self; -} - -// This method prevents crashing in development after a JS reload. -// The reason for that is self is being sent to the operating system as delegate. -// After a JS reload, the old self still sticks around accepting events and -// sending the events through an old instance of the RN app. This crashes the app. -// That's why we need to use a static variable to reference the latest instance of self, -// which references the latest instance of the RN app. -- (void)_sendEventWithName:(NSString *)eventName body:(id)body { - if (!staticEventEmitter) return; - [staticEventEmitter sendEventWithName:eventName body:body]; -} - -- (NSArray *)supportedEvents { - return @[ - @"RNFileUploader-progress", - @"RNFileUploader-error", - @"RNFileUploader-cancelled", - @"RNFileUploader-completed" - ]; -} - - -/* - Borrowed from http://stackoverflow.com/questions/2439020/wheres-the-iphone-mime-type-database - */ -- (NSString *)guessMIMETypeFromFileName: (NSString *)fileName { - CFStringRef UTI = UTTypeCreatePreferredIdentifierForTag(kUTTagClassFilenameExtension, (__bridge CFStringRef)[fileName pathExtension], NULL); - CFStringRef MIMEType = UTTypeCopyPreferredTagWithClass(UTI, kUTTagClassMIMEType); - - if (UTI) { - CFRelease(UTI); - } - - if (!MIMEType) { - return @"application/octet-stream"; - } - return (__bridge NSString *)(MIMEType); -} - -/* - Utility method to copy a PHAsset file into a local temp file, which can then be uploaded. - */ -- (void)copyAssetToFile: (NSString *)assetUrl completionHandler: (void(^)(NSString *__nullable tempFileUrl, NSError *__nullable error))completionHandler { - NSURL *url = [NSURL URLWithString:assetUrl]; - PHAsset *asset = [PHAsset fetchAssetsWithALAssetURLs:@[url] options:nil].lastObject; - if (!asset) { - NSMutableDictionary* details = [NSMutableDictionary dictionary]; - [details setValue:@"Asset could not be fetched. Are you missing permissions?" forKey:NSLocalizedDescriptionKey]; - completionHandler(nil, [NSError errorWithDomain:@"RNUploader" code:5 userInfo:details]); - return; - } - PHAssetResource *assetResource = [[PHAssetResource assetResourcesForAsset:asset] firstObject]; - NSString *pathToWrite = [NSTemporaryDirectory() stringByAppendingPathComponent:[[NSUUID UUID] UUIDString]]; - NSURL *pathUrl = [NSURL fileURLWithPath:pathToWrite]; - NSString *fileURI = pathUrl.absoluteString; - - PHAssetResourceRequestOptions *options = [PHAssetResourceRequestOptions new]; - options.networkAccessAllowed = YES; - - [[PHAssetResourceManager defaultManager] writeDataForAssetResource:assetResource toFile:pathUrl options:options completionHandler:^(NSError * _Nullable e) { - if (e == nil) { - completionHandler(fileURI, nil); - } - else { - completionHandler(nil, e); - } - }]; -} - -/* - * Starts a file upload. - * Options are passed in as the first argument as a js hash: - * { - * url: string. url to post to. - * path: string. path to the file on the device - * headers: hash of name/value header pairs - * } - * - * Returns a promise with the string ID of the upload. - */ -RCT_EXPORT_METHOD(startUpload:(NSDictionary *)options resolve:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject) -{ - int thisUploadId; - @synchronized(self.class) - { - thisUploadId = uploadId++; - } - - NSString *uploadUrl = options[@"url"]; - __block NSString *fileURI = options[@"path"]; - NSString *method = options[@"method"] ?: @"POST"; - NSString *uploadType = options[@"type"] ?: @"raw"; - NSString *fieldName = options[@"field"]; - NSString *customUploadId = options[@"customUploadId"]; - NSString *appGroup = options[@"appGroup"]; - NSDictionary *headers = options[@"headers"]; - NSDictionary *parameters = options[@"parameters"]; - BOOL wifiOnly = [options[@"wifiOnly"] boolValue]; - - @try { - NSURL *requestUrl = [NSURL URLWithString: uploadUrl]; - if (requestUrl == nil) { - return reject(@"RN Uploader", @"URL not compliant with RFC 2396", nil); - } - - NSMutableURLRequest *request = [NSMutableURLRequest requestWithURL:requestUrl]; - [request setHTTPMethod: method]; - - [headers enumerateKeysAndObjectsUsingBlock:^(id _Nonnull key, id _Nonnull val, BOOL * _Nonnull stop) { - if ([val respondsToSelector:@selector(stringValue)]) { - val = [val stringValue]; - } - if ([val isKindOfClass:[NSString class]]) { - [request setValue:val forHTTPHeaderField:key]; - } - }]; - - - // asset library files have to be copied over to a temp file. they can't be uploaded directly - if ([fileURI hasPrefix:@"assets-library"]) { - dispatch_group_t group = dispatch_group_create(); - dispatch_group_enter(group); - [self copyAssetToFile:fileURI completionHandler:^(NSString * _Nullable tempFileUrl, NSError * _Nullable error) { - if (error) { - dispatch_group_leave(group); - reject(@"RN Uploader", @"Asset could not be copied to temp file.", nil); - return; - } - fileURI = tempFileUrl; - dispatch_group_leave(group); - }]; - dispatch_group_wait(group, DISPATCH_TIME_FOREVER); - } - - NSURLSessionDataTask *uploadTask; - - NSURLSession *session = wifiOnly ? [self wifiOnlyUrlSession] : [self urlSession]; - if (appGroup != nil && ![appGroup isEqualToString:@""]) { - session.configuration.sharedContainerIdentifier = appGroup; - } - - if ([uploadType isEqualToString:@"multipart"]) { - NSString *uuidStr = [[NSUUID UUID] UUIDString]; - [request setValue:[NSString stringWithFormat:@"multipart/form-data; boundary=%@", uuidStr] forHTTPHeaderField:@"Content-Type"]; - - NSData *httpBody = [self createBodyWithBoundary:uuidStr path:fileURI parameters: parameters fieldName:fieldName]; - [request setHTTPBodyStream: [NSInputStream inputStreamWithData:httpBody]]; - [request setValue:[NSString stringWithFormat:@"%zd", httpBody.length] forHTTPHeaderField:@"Content-Length"]; - - uploadTask = [session uploadTaskWithStreamedRequest:request]; - } else { - if (parameters.count > 0) { - reject(@"RN Uploader", @"Parameters supported only in multipart type", nil); - return; - } - - uploadTask = [session uploadTaskWithRequest:request fromFile:[NSURL URLWithString: fileURI]]; - } - - uploadTask.taskDescription = customUploadId ? customUploadId : [NSString stringWithFormat:@"%i", thisUploadId]; - - [uploadTask resume]; - resolve(uploadTask.taskDescription); - } - @catch (NSException *exception) { - if(exception.reason) - reject(@"RN Uploader", exception.reason, nil); - else - reject(@"RN Uploader", exception.name, nil); - } -} - -/* - * Cancels file upload - * Accepts upload ID as a first argument, this upload will be cancelled - * Event "cancelled" will be fired when upload is cancelled. - */ -RCT_EXPORT_METHOD(cancelUpload: (NSString *)cancelUploadId resolve:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject) { - NSMutableArray *sessions = [NSMutableArray array]; - if(_urlSession) [sessions addObject:_urlSession]; - if(_wifiOnlyUrlSession) [sessions addObject:_wifiOnlyUrlSession]; - - for (NSURLSession *session in sessions) { - dispatch_semaphore_t sema = dispatch_semaphore_create(0); - [session getTasksWithCompletionHandler:^(NSArray *dataTasks, NSArray *uploadTasks, NSArray *downloadTasks) { - for (NSURLSessionTask *uploadTask in uploadTasks) { - if ([uploadTask.taskDescription isEqualToString:cancelUploadId]){ - // == checks if references are equal, while isEqualToString checks the string value - [uploadTask cancel]; - } - } - dispatch_semaphore_signal(sema); - }]; - dispatch_semaphore_wait(sema, DISPATCH_TIME_FOREVER); - } - - resolve([NSNumber numberWithBool:YES]); -} - - - -/* - * Retrieve status of an upload task - */ -RCT_EXPORT_METHOD(getUploadStatus: (NSString *)uploadId resolve:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject) { - NSMutableArray *sessions = [NSMutableArray array]; - if(_urlSession) [sessions addObject:_urlSession]; - if(_wifiOnlyUrlSession) [sessions addObject:_wifiOnlyUrlSession]; - - __block Boolean resolved = false; - - - for (NSURLSession *session in sessions) { - dispatch_semaphore_t sema = dispatch_semaphore_create(0); - [session getTasksWithCompletionHandler:^(NSArray *dataTasks, NSArray *uploadTasks, NSArray *downloadTasks) { - - for (NSURLSessionTask *uploadTask in uploadTasks) { - if (![uploadTask.taskDescription isEqualToString:uploadId]) continue; - NSDictionary *result = @{ - @"state": [Helper urlSessionTaskStateToString:[uploadTask state]], - @"bytesSent": [NSNumber numberWithUnsignedLongLong:[uploadTask countOfBytesSent]], - @"totalBytes": [NSNumber numberWithUnsignedLongLong:[uploadTask countOfBytesExpectedToSend]], - }; - resolve(result); - resolved = true; - break; - } - - dispatch_semaphore_signal(sema); - }]; - - dispatch_semaphore_wait(sema, DISPATCH_TIME_FOREVER); - if(resolved) return; - } - - resolve(NULL); -} - - -- (NSData *)createBodyWithBoundary:(NSString *)boundary - path:(NSString *)path - parameters:(NSDictionary *)parameters - fieldName:(NSString *)fieldName { - - NSMutableData *httpBody = [NSMutableData data]; - - // Escape non latin characters in filename - NSString *escapedPath = [path stringByAddingPercentEncodingWithAllowedCharacters: NSCharacterSet.URLQueryAllowedCharacterSet]; - - // resolve path - NSURL *fileUri = [NSURL URLWithString: escapedPath]; - - NSError* error = nil; - NSData *data = [NSData dataWithContentsOfURL:fileUri options:NSDataReadingMappedAlways error: &error]; - - if (data == nil) { - NSLog(@"Failed to read file %@", error); - } - - NSString *filename = [path lastPathComponent]; - NSString *mimetype = [self guessMIMETypeFromFileName:path]; - - [parameters enumerateKeysAndObjectsUsingBlock:^(NSString *parameterKey, NSString *parameterValue, BOOL *stop) { - [httpBody appendData:[[NSString stringWithFormat:@"--%@\r\n", boundary] dataUsingEncoding:NSUTF8StringEncoding]]; - [httpBody appendData:[[NSString stringWithFormat:@"Content-Disposition: form-data; name=\"%@\"\r\n\r\n", parameterKey] dataUsingEncoding:NSUTF8StringEncoding]]; - [httpBody appendData:[[NSString stringWithFormat:@"%@\r\n", parameterValue] dataUsingEncoding:NSUTF8StringEncoding]]; - }]; - - [httpBody appendData:[[NSString stringWithFormat:@"--%@\r\n", boundary] dataUsingEncoding:NSUTF8StringEncoding]]; - [httpBody appendData:[[NSString stringWithFormat:@"Content-Disposition: form-data; name=\"%@\"; filename=\"%@\"\r\n", fieldName, filename] dataUsingEncoding:NSUTF8StringEncoding]]; - [httpBody appendData:[[NSString stringWithFormat:@"Content-Type: %@\r\n\r\n", mimetype] dataUsingEncoding:NSUTF8StringEncoding]]; - [httpBody appendData:data]; - [httpBody appendData:[@"\r\n" dataUsingEncoding:NSUTF8StringEncoding]]; - - [httpBody appendData:[[NSString stringWithFormat:@"--%@--\r\n", boundary] dataUsingEncoding:NSUTF8StringEncoding]]; - - return httpBody; -} - -- (NSURLSession *)urlSession { - if (_urlSession) return _urlSession; - - NSURLSessionConfiguration *sessionConfiguration = [NSURLSessionConfiguration backgroundSessionConfigurationWithIdentifier:BACKGROUND_SESSION_ID]; - - [sessionConfiguration setDiscretionary:NO]; - [sessionConfiguration setAllowsCellularAccess:YES]; - [sessionConfiguration setHTTPMaximumConnectionsPerHost:1]; - - if (@available(iOS 11.0, *)) { - [sessionConfiguration setWaitsForConnectivity:YES]; - } - - if (@available(iOS 13.0, *)) { - [sessionConfiguration setAllowsConstrainedNetworkAccess:YES]; - [sessionConfiguration setAllowsExpensiveNetworkAccess:YES]; - } - - _urlSession = [NSURLSession sessionWithConfiguration:sessionConfiguration delegate:self delegateQueue:nil]; - - return _urlSession; -} - -- (NSURLSession *)wifiOnlyUrlSession { - if (_wifiOnlyUrlSession) return _wifiOnlyUrlSession; - - NSURLSessionConfiguration *sessionConfiguration = [NSURLSessionConfiguration backgroundSessionConfigurationWithIdentifier:WIFI_ONLY_BACKGROUND_SESSION_ID]; - - [sessionConfiguration setDiscretionary:NO]; - [sessionConfiguration setAllowsCellularAccess:NO]; - [sessionConfiguration setHTTPMaximumConnectionsPerHost:1]; - - if (@available(iOS 11.0, *)) { - [sessionConfiguration setWaitsForConnectivity:YES]; - } - - if (@available(iOS 13.0, *)) { - [sessionConfiguration setAllowsConstrainedNetworkAccess:NO]; - [sessionConfiguration setAllowsExpensiveNetworkAccess:NO]; - } - - _wifiOnlyUrlSession = [NSURLSession sessionWithConfiguration:sessionConfiguration delegate:self delegateQueue:nil]; - - return _wifiOnlyUrlSession; -} - -#pragma NSURLSessionTaskDelegate - -- (void)URLSession:(NSURLSession *)session - task:(NSURLSessionTask *)task -didCompleteWithError:(NSError *)error { - NSMutableDictionary *data = [NSMutableDictionary dictionaryWithObjectsAndKeys:task.taskDescription, @"id", nil]; - NSURLSessionDataTask *uploadTask = (NSURLSessionDataTask *)task; - NSHTTPURLResponse *response = (NSHTTPURLResponse *)uploadTask.response; - if (response != nil) { - [data setObject:[NSNumber numberWithInteger:response.statusCode] forKey:@"responseCode"]; - } - //Add data that was collected earlier by the didReceiveData method - NSMutableData *responseData = _responsesData[@(task.taskIdentifier)]; - if (responseData) { - [_responsesData removeObjectForKey:@(task.taskIdentifier)]; - NSString *response = [[NSString alloc] initWithData:responseData encoding:NSUTF8StringEncoding]; - [data setObject:response forKey:@"responseBody"]; - } else { - [data setObject:[NSNull null] forKey:@"responseBody"]; - } - - if (error == nil) { - [self _sendEventWithName:@"RNFileUploader-completed" body:data]; - } - else { - [data setObject:error.localizedDescription forKey:@"error"]; - if (error.code == NSURLErrorCancelled) { - [self _sendEventWithName:@"RNFileUploader-cancelled" body:data]; - } else { - [self _sendEventWithName:@"RNFileUploader-error" body:data]; - } - } -} - -- (void)URLSession:(NSURLSession *)session - task:(NSURLSessionTask *)task - didSendBodyData:(int64_t)bytesSent - totalBytesSent:(int64_t)totalBytesSent -totalBytesExpectedToSend:(int64_t)totalBytesExpectedToSend { - float progress = -1; - //see documentation. For unknown size it's -1 (NSURLSessionTransferSizeUnknown) - if (totalBytesExpectedToSend > 0) { - progress = 100.0 * (float)totalBytesSent / (float)totalBytesExpectedToSend; - } - - NSDictionary *data = @{ - @"id": task.taskDescription, - @"progress": [NSNumber numberWithFloat:progress] - }; - - [self _sendEventWithName:@"RNFileUploader-progress" body:data]; -} - -- (void)URLSession:(NSURLSession *)session dataTask:(NSURLSessionDataTask *)dataTask didReceiveData:(NSData *)data { - if (!data.length) { - return; - } - //Hold returned data so it can be picked up by the didCompleteWithError method later - NSMutableData *responseData = _responsesData[@(dataTask.taskIdentifier)]; - if (!responseData) { - responseData = [NSMutableData dataWithData:data]; - _responsesData[@(dataTask.taskIdentifier)] = responseData; - } else { - [responseData appendData:data]; - } -} - -- (void)URLSession:(NSURLSession *)session - task:(NSURLSessionTask *)task - needNewBodyStream:(void (^)(NSInputStream *bodyStream))completionHandler { - - NSInputStream *inputStream = task.originalRequest.HTTPBodyStream; - - if (completionHandler) { - completionHandler(inputStream); - } -} - -@end diff --git a/ios/VydiaRNFileUploader.xcodeproj/project.pbxproj b/ios/VydiaRNFileUploader.xcodeproj/project.pbxproj deleted file mode 100644 index 58b34f22..00000000 --- a/ios/VydiaRNFileUploader.xcodeproj/project.pbxproj +++ /dev/null @@ -1,254 +0,0 @@ -// !$*UTF8*$! -{ - archiveVersion = 1; - classes = { - }; - objectVersion = 46; - objects = { - -/* Begin PBXBuildFile section */ - DC352C7A284AA8BB008DBB93 /* Helper.m in Sources */ = {isa = PBXBuildFile; fileRef = DC352C79284AA8BB008DBB93 /* Helper.m */; }; - DCC748851E044F8700EA453E /* VydiaRNFileUploader.m in Sources */ = {isa = PBXBuildFile; fileRef = DCC748841E044F8700EA453E /* VydiaRNFileUploader.m */; }; -/* End PBXBuildFile section */ - -/* Begin PBXCopyFilesBuildPhase section */ - 014A3B5A1C6CF33500B6D375 /* CopyFiles */ = { - isa = PBXCopyFilesBuildPhase; - buildActionMask = 2147483647; - dstPath = "include/$(PRODUCT_NAME)"; - dstSubfolderSpec = 16; - files = ( - ); - runOnlyForDeploymentPostprocessing = 0; - }; -/* End PBXCopyFilesBuildPhase section */ - -/* Begin PBXFileReference section */ - 014A3B5C1C6CF33500B6D375 /* libVydiaRNFileUploader.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; name = libVydiaRNFileUploader.a; path = "/Users/thomasvo/WebstormProjects/react-native-background-upload/ios/build/Debug-iphoneos/libVydiaRNFileUploader.a"; sourceTree = ""; }; - DC352C79284AA8BB008DBB93 /* Helper.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = Helper.m; sourceTree = ""; }; - DC352C7C284AAC6A008DBB93 /* Helper.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = Helper.h; sourceTree = ""; }; - DC352C7D284AADCF008DBB93 /* react-native-background-upload.podspec */ = {isa = PBXFileReference; lastKnownFileType = text; name = "react-native-background-upload.podspec"; path = "../react-native-background-upload.podspec"; sourceTree = ""; }; - DCC748841E044F8700EA453E /* VydiaRNFileUploader.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = VydiaRNFileUploader.m; sourceTree = ""; }; -/* End PBXFileReference section */ - -/* Begin PBXFrameworksBuildPhase section */ - 014A3B591C6CF33500B6D375 /* Frameworks */ = { - isa = PBXFrameworksBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - runOnlyForDeploymentPostprocessing = 0; - }; -/* End PBXFrameworksBuildPhase section */ - -/* Begin PBXGroup section */ - 014A3B531C6CF33500B6D375 = { - isa = PBXGroup; - children = ( - DCC748841E044F8700EA453E /* VydiaRNFileUploader.m */, - DC352C79284AA8BB008DBB93 /* Helper.m */, - DC352C7C284AAC6A008DBB93 /* Helper.h */, - DC352C7D284AADCF008DBB93 /* react-native-background-upload.podspec */, - ); - sourceTree = ""; - }; -/* End PBXGroup section */ - -/* Begin PBXNativeTarget section */ - 014A3B5B1C6CF33500B6D375 /* VydiaRNFileUploader */ = { - isa = PBXNativeTarget; - buildConfigurationList = 014A3B651C6CF33500B6D375 /* Build configuration list for PBXNativeTarget "VydiaRNFileUploader" */; - buildPhases = ( - 014A3B581C6CF33500B6D375 /* Sources */, - 014A3B591C6CF33500B6D375 /* Frameworks */, - 014A3B5A1C6CF33500B6D375 /* CopyFiles */, - ); - buildRules = ( - ); - dependencies = ( - ); - name = VydiaRNFileUploader; - productName = VydiaRNFileUploader; - productReference = 014A3B5C1C6CF33500B6D375 /* libVydiaRNFileUploader.a */; - productType = "com.apple.product-type.library.static"; - }; -/* End PBXNativeTarget section */ - -/* Begin PBXProject section */ - 014A3B541C6CF33500B6D375 /* Project object */ = { - isa = PBXProject; - attributes = { - LastUpgradeCheck = 0720; - ORGANIZATIONNAME = "Marc Shilling"; - TargetAttributes = { - 014A3B5B1C6CF33500B6D375 = { - CreatedOnToolsVersion = 7.2.1; - }; - }; - }; - buildConfigurationList = 014A3B571C6CF33500B6D375 /* Build configuration list for PBXProject "VydiaRNFileUploader" */; - compatibilityVersion = "Xcode 3.2"; - developmentRegion = English; - hasScannedForEncodings = 0; - knownRegions = ( - English, - en, - ); - mainGroup = 014A3B531C6CF33500B6D375; - productRefGroup = 014A3B531C6CF33500B6D375; - projectDirPath = ""; - projectRoot = ""; - targets = ( - 014A3B5B1C6CF33500B6D375 /* VydiaRNFileUploader */, - ); - }; -/* End PBXProject section */ - -/* Begin PBXSourcesBuildPhase section */ - 014A3B581C6CF33500B6D375 /* Sources */ = { - isa = PBXSourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - DCC748851E044F8700EA453E /* VydiaRNFileUploader.m in Sources */, - DC352C7A284AA8BB008DBB93 /* Helper.m in Sources */, - ); - runOnlyForDeploymentPostprocessing = 0; - }; -/* End PBXSourcesBuildPhase section */ - -/* Begin XCBuildConfiguration section */ - 014A3B631C6CF33500B6D375 /* Debug */ = { - isa = XCBuildConfiguration; - buildSettings = { - ALWAYS_SEARCH_USER_PATHS = NO; - CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; - CLANG_CXX_LIBRARY = "libc++"; - CLANG_ENABLE_MODULES = YES; - CLANG_ENABLE_OBJC_ARC = YES; - CLANG_WARN_BOOL_CONVERSION = YES; - CLANG_WARN_CONSTANT_CONVERSION = YES; - CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; - CLANG_WARN_EMPTY_BODY = YES; - CLANG_WARN_ENUM_CONVERSION = YES; - CLANG_WARN_INT_CONVERSION = YES; - CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; - CLANG_WARN_UNREACHABLE_CODE = YES; - CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; - "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; - COPY_PHASE_STRIP = NO; - DEBUG_INFORMATION_FORMAT = dwarf; - ENABLE_STRICT_OBJC_MSGSEND = YES; - ENABLE_TESTABILITY = YES; - GCC_C_LANGUAGE_STANDARD = gnu99; - GCC_DYNAMIC_NO_PIC = NO; - GCC_NO_COMMON_BLOCKS = YES; - GCC_OPTIMIZATION_LEVEL = 0; - GCC_PREPROCESSOR_DEFINITIONS = ( - "DEBUG=1", - "$(inherited)", - ); - GCC_WARN_64_TO_32_BIT_CONVERSION = YES; - GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; - GCC_WARN_UNDECLARED_SELECTOR = YES; - GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; - GCC_WARN_UNUSED_FUNCTION = YES; - GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 8.0; - MTL_ENABLE_DEBUG_INFO = YES; - ONLY_ACTIVE_ARCH = YES; - SDKROOT = iphoneos; - }; - name = Debug; - }; - 014A3B641C6CF33500B6D375 /* Release */ = { - isa = XCBuildConfiguration; - buildSettings = { - ALWAYS_SEARCH_USER_PATHS = NO; - CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; - CLANG_CXX_LIBRARY = "libc++"; - CLANG_ENABLE_MODULES = YES; - CLANG_ENABLE_OBJC_ARC = YES; - CLANG_WARN_BOOL_CONVERSION = YES; - CLANG_WARN_CONSTANT_CONVERSION = YES; - CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; - CLANG_WARN_EMPTY_BODY = YES; - CLANG_WARN_ENUM_CONVERSION = YES; - CLANG_WARN_INT_CONVERSION = YES; - CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; - CLANG_WARN_UNREACHABLE_CODE = YES; - CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; - "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; - COPY_PHASE_STRIP = NO; - DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; - ENABLE_NS_ASSERTIONS = NO; - ENABLE_STRICT_OBJC_MSGSEND = YES; - GCC_C_LANGUAGE_STANDARD = gnu99; - GCC_NO_COMMON_BLOCKS = YES; - GCC_WARN_64_TO_32_BIT_CONVERSION = YES; - GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; - GCC_WARN_UNDECLARED_SELECTOR = YES; - GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; - GCC_WARN_UNUSED_FUNCTION = YES; - GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 8.0; - MTL_ENABLE_DEBUG_INFO = NO; - SDKROOT = iphoneos; - VALIDATE_PRODUCT = YES; - }; - name = Release; - }; - 014A3B661C6CF33500B6D375 /* Debug */ = { - isa = XCBuildConfiguration; - buildSettings = { - HEADER_SEARCH_PATHS = ( - "$(SRCROOT)/../node_modules/react-native/React/**", - "$(SRCROOT)/../../react-native/React/**", - "$(SRCROOT)/../Example/node_modules/react-native/React/**", - "$(SRCROOT)/../../../ios/Pods/Headers/Public/**", - ); - OTHER_LDFLAGS = "-ObjC"; - PRODUCT_NAME = "$(TARGET_NAME)"; - SKIP_INSTALL = YES; - }; - name = Debug; - }; - 014A3B671C6CF33500B6D375 /* Release */ = { - isa = XCBuildConfiguration; - buildSettings = { - HEADER_SEARCH_PATHS = ( - "$(SRCROOT)/../node_modules/react-native/React/**", - "$(SRCROOT)/../../react-native/React/**", - "$(SRCROOT)/../Example/node_modules/react-native/React/**", - "$(SRCROOT)/../../../ios/Pods/Headers/Public/**", - ); - OTHER_LDFLAGS = "-ObjC"; - PRODUCT_NAME = "$(TARGET_NAME)"; - SKIP_INSTALL = YES; - }; - name = Release; - }; -/* End XCBuildConfiguration section */ - -/* Begin XCConfigurationList section */ - 014A3B571C6CF33500B6D375 /* Build configuration list for PBXProject "VydiaRNFileUploader" */ = { - isa = XCConfigurationList; - buildConfigurations = ( - 014A3B631C6CF33500B6D375 /* Debug */, - 014A3B641C6CF33500B6D375 /* Release */, - ); - defaultConfigurationIsVisible = 0; - defaultConfigurationName = Release; - }; - 014A3B651C6CF33500B6D375 /* Build configuration list for PBXNativeTarget "VydiaRNFileUploader" */ = { - isa = XCConfigurationList; - buildConfigurations = ( - 014A3B661C6CF33500B6D375 /* Debug */, - 014A3B671C6CF33500B6D375 /* Release */, - ); - defaultConfigurationIsVisible = 0; - defaultConfigurationName = Release; - }; -/* End XCConfigurationList section */ - }; - rootObject = 014A3B541C6CF33500B6D375 /* Project object */; -} diff --git a/react-native-background-upload.podspec b/react-native-background-upload.podspec index a0897653..47c160ec 100644 --- a/react-native-background-upload.podspec +++ b/react-native-background-upload.podspec @@ -7,12 +7,14 @@ require "json" s.name = package[:name] s.version = package[:version] s.license = { type: "MIT" } - s.homepage = "https://github.com/Vydia/react-native-background-upload" + s.homepage = "https://github.com/openspacelabs/react-native-background-upload" s.authors = package[:author] s.summary = package[:description] s.source = { git: package[:repository][:url] } - s.source_files = "ios/*.{h,m}" - s.platform = :ios, "9.0" + s.source_files = "ios/**/*.{h,m,swift}" + s.platform = :ios, "15.1" + s.swift_version = "5.0" + s.pod_target_xcconfig = { "DEFINES_MODULE" => "YES" } - s.dependency "React" + s.dependency "React-Core" end diff --git a/src/index.ts b/src/index.ts index 4710e914..4134258b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -6,13 +6,12 @@ import { AddListener, UploadId, UploadOptions } from './types'; export * from './types'; -const NativeModule = - NativeModules.VydiaRNFileUploader || NativeModules.RNFileUploader; +const NativeModule = NativeModules.RNFileUploader; const eventPrefix = 'RNFileUploader-'; const fileURIPrefix = 'file://'; -// for IOS, register event listeners or else they don't fire on DeviceEventEmitter -if (NativeModules.VydiaRNFileUploader) { +// for iOS, register event listeners or else they don't fire on DeviceEventEmitter +if (Platform.OS === 'ios') { NativeModule.addListener(eventPrefix + 'progress'); NativeModule.addListener(eventPrefix + 'error'); NativeModule.addListener(eventPrefix + 'cancelled'); From 0dcbc2ebcb4d79f37e35f1f081e3bc754f0bc23e Mon Sep 17 00:00:00 2001 From: Dylan Murphy Date: Thu, 23 Jul 2026 15:05:03 -0400 Subject: [PATCH 06/19] chore: remove remaining Vydia references (android package, metadata, docs) The library has diverged far from the upstream fork; drop the leftover Vydia naming that was just noise. - Rename the Android package com.vydia.RNUploader -> ai.openspace.backgroundupload (matches Diana's ai.openspace.* convention and the openspace.ai domain): move the source/test trees and update every package declaration and the manifest. Compiles clean; all 15 JVM tests pass under the new package. - Point package.json repository/homepage/bugs and the podspec homepage at openspacelabs/react-native-background-upload; set author to OpenSpace. - Drop Vydia mentions from README and CONTRIBUTING (example now points at the in-repo example/ app). LICENSE and CHANGELOG intentionally keep the original Vydia copyright/history: the BSD-3-Clause license requires retaining the copyright notice. Co-Authored-By: Claude Opus 4.8 --- CONTRIBUTING.md | 9 +++------ README.md | 6 +----- android/src/main/AndroidManifest.xml | 2 +- .../openspace/backgroundupload}/EventJournal.kt | 2 +- .../openspace/backgroundupload}/EventReporter.kt | 2 +- .../openspace/backgroundupload}/NotificationReceiver.kt | 2 +- .../openspace/backgroundupload}/Upload.kt | 2 +- .../openspace/backgroundupload}/UploadOutcome.kt | 2 +- .../openspace/backgroundupload}/UploadProgress.kt | 2 +- .../openspace/backgroundupload}/UploadUtils.kt | 2 +- .../openspace/backgroundupload}/UploadWorker.kt | 2 +- .../openspace/backgroundupload}/UploaderModule.kt | 2 +- .../backgroundupload}/UploaderReactPackage.java | 2 +- .../openspace/backgroundupload}/UserCancellations.kt | 2 +- .../openspace/backgroundupload}/EventJournalTest.kt | 2 +- .../openspace/backgroundupload}/UploadOutcomeTest.kt | 2 +- package.json | 8 ++++---- 17 files changed, 22 insertions(+), 29 deletions(-) rename android/src/main/java/{com/vydia/RNUploader => ai/openspace/backgroundupload}/EventJournal.kt (99%) rename android/src/main/java/{com/vydia/RNUploader => ai/openspace/backgroundupload}/EventReporter.kt (98%) rename android/src/main/java/{com/vydia/RNUploader => ai/openspace/backgroundupload}/NotificationReceiver.kt (94%) rename android/src/main/java/{com/vydia/RNUploader => ai/openspace/backgroundupload}/Upload.kt (98%) rename android/src/main/java/{com/vydia/RNUploader => ai/openspace/backgroundupload}/UploadOutcome.kt (96%) rename android/src/main/java/{com/vydia/RNUploader => ai/openspace/backgroundupload}/UploadProgress.kt (97%) rename android/src/main/java/{com/vydia/RNUploader => ai/openspace/backgroundupload}/UploadUtils.kt (98%) rename android/src/main/java/{com/vydia/RNUploader => ai/openspace/backgroundupload}/UploadWorker.kt (99%) rename android/src/main/java/{com/vydia/RNUploader => ai/openspace/backgroundupload}/UploaderModule.kt (99%) rename android/src/main/java/{com/vydia/RNUploader => ai/openspace/backgroundupload}/UploaderReactPackage.java (96%) rename android/src/main/java/{com/vydia/RNUploader => ai/openspace/backgroundupload}/UserCancellations.kt (92%) rename android/src/test/java/{com/vydia/RNUploader => ai/openspace/backgroundupload}/EventJournalTest.kt (98%) rename android/src/test/java/{com/vydia/RNUploader => ai/openspace/backgroundupload}/UploadOutcomeTest.kt (97%) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index bde7798c..7dd49910 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,8 +1,5 @@ ## Issues -Along with a bug report, provide a functional example repository which -reproduces the bug you're experiencing. We've made this very easy by providing -a full-stack (React Native + Express.js) example app, which you can fork and -alter to reproduce your bug: - -[ReactNativeBackgroundUploadExample](https://github.com/Vydia/ReactNativeBackgroundUploadExample) +Along with a bug report, provide a functional reproduction using the full-stack +(React Native + Express.js) example app in this repo under `example/` — fork and +alter it to reproduce your bug. diff --git a/README.md b/README.md index 989a4d8c..ddc36e3f 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,7 @@ Note: if you are installing on React Native < 0.47, use `react-native-background ## 3. Expo -To use this library with [Expo](https://expo.io) one must first detach (eject) the project and follow [step 2](#2-link-native-code) instructions. Additionally on iOS there is a must to add a Header Search Path to other dependencies which are managed using Pods. To do so one has to add `$(SRCROOT)/../../../ios/Pods/Headers/Public` to Header Search Path in `VydiaRNFileUploader` module using XCode. +To use this library with [Expo](https://expo.io) one must first detach (eject) the project and follow [step 2](#2-link-native-code) instructions. Additionally on iOS there is a must to add a Header Search Path to other dependencies which are managed using Pods. To do so one has to add `$(SRCROOT)/../../../ios/Pods/Headers/Public` to Header Search Path in the `react-native-background-upload` pod using XCode. # Usage @@ -211,7 +211,3 @@ Why should I use this file uploader instead of others that I've Googled like [re See [CONTRIBUTING.md](./CONTRIBUTING.md). # Common Issues - -## Gratitude - -Many thanks to the [Original Library](https://github.com/Vydia/react-native-background-upload) for the boilerplate and inspiration diff --git a/android/src/main/AndroidManifest.xml b/android/src/main/AndroidManifest.xml index e62ef6f8..62932372 100644 --- a/android/src/main/AndroidManifest.xml +++ b/android/src/main/AndroidManifest.xml @@ -1,6 +1,6 @@ + package="ai.openspace.backgroundupload"> diff --git a/android/src/main/java/com/vydia/RNUploader/EventJournal.kt b/android/src/main/java/ai/openspace/backgroundupload/EventJournal.kt similarity index 99% rename from android/src/main/java/com/vydia/RNUploader/EventJournal.kt rename to android/src/main/java/ai/openspace/backgroundupload/EventJournal.kt index 040454da..d2ae7cce 100644 --- a/android/src/main/java/com/vydia/RNUploader/EventJournal.kt +++ b/android/src/main/java/ai/openspace/backgroundupload/EventJournal.kt @@ -1,4 +1,4 @@ -package com.vydia.RNUploader +package ai.openspace.backgroundupload import android.content.Context import com.google.gson.Gson diff --git a/android/src/main/java/com/vydia/RNUploader/EventReporter.kt b/android/src/main/java/ai/openspace/backgroundupload/EventReporter.kt similarity index 98% rename from android/src/main/java/com/vydia/RNUploader/EventReporter.kt rename to android/src/main/java/ai/openspace/backgroundupload/EventReporter.kt index 2453143c..465d2cde 100644 --- a/android/src/main/java/com/vydia/RNUploader/EventReporter.kt +++ b/android/src/main/java/ai/openspace/backgroundupload/EventReporter.kt @@ -1,4 +1,4 @@ -package com.vydia.RNUploader +package ai.openspace.backgroundupload import android.util.Log import com.facebook.react.bridge.Arguments diff --git a/android/src/main/java/com/vydia/RNUploader/NotificationReceiver.kt b/android/src/main/java/ai/openspace/backgroundupload/NotificationReceiver.kt similarity index 94% rename from android/src/main/java/com/vydia/RNUploader/NotificationReceiver.kt rename to android/src/main/java/ai/openspace/backgroundupload/NotificationReceiver.kt index e519faaf..945d59e8 100644 --- a/android/src/main/java/com/vydia/RNUploader/NotificationReceiver.kt +++ b/android/src/main/java/ai/openspace/backgroundupload/NotificationReceiver.kt @@ -1,4 +1,4 @@ -package com.vydia.RNUploader +package ai.openspace.backgroundupload import android.content.BroadcastReceiver import android.content.Context diff --git a/android/src/main/java/com/vydia/RNUploader/Upload.kt b/android/src/main/java/ai/openspace/backgroundupload/Upload.kt similarity index 98% rename from android/src/main/java/com/vydia/RNUploader/Upload.kt rename to android/src/main/java/ai/openspace/backgroundupload/Upload.kt index 7cbe4303..f314b1f9 100644 --- a/android/src/main/java/com/vydia/RNUploader/Upload.kt +++ b/android/src/main/java/ai/openspace/backgroundupload/Upload.kt @@ -1,4 +1,4 @@ -package com.vydia.RNUploader +package ai.openspace.backgroundupload import com.facebook.react.bridge.ReadableMap import java.util.UUID diff --git a/android/src/main/java/com/vydia/RNUploader/UploadOutcome.kt b/android/src/main/java/ai/openspace/backgroundupload/UploadOutcome.kt similarity index 96% rename from android/src/main/java/com/vydia/RNUploader/UploadOutcome.kt rename to android/src/main/java/ai/openspace/backgroundupload/UploadOutcome.kt index bbc091fb..d044ac9e 100644 --- a/android/src/main/java/com/vydia/RNUploader/UploadOutcome.kt +++ b/android/src/main/java/ai/openspace/backgroundupload/UploadOutcome.kt @@ -1,4 +1,4 @@ -package com.vydia.RNUploader +package ai.openspace.backgroundupload import java.io.IOException diff --git a/android/src/main/java/com/vydia/RNUploader/UploadProgress.kt b/android/src/main/java/ai/openspace/backgroundupload/UploadProgress.kt similarity index 97% rename from android/src/main/java/com/vydia/RNUploader/UploadProgress.kt rename to android/src/main/java/ai/openspace/backgroundupload/UploadProgress.kt index 03b9455b..2363dbc1 100644 --- a/android/src/main/java/com/vydia/RNUploader/UploadProgress.kt +++ b/android/src/main/java/ai/openspace/backgroundupload/UploadProgress.kt @@ -1,4 +1,4 @@ -package com.vydia.RNUploader +package ai.openspace.backgroundupload import android.os.Handler import android.os.Looper diff --git a/android/src/main/java/com/vydia/RNUploader/UploadUtils.kt b/android/src/main/java/ai/openspace/backgroundupload/UploadUtils.kt similarity index 98% rename from android/src/main/java/com/vydia/RNUploader/UploadUtils.kt rename to android/src/main/java/ai/openspace/backgroundupload/UploadUtils.kt index e26e2ec1..44696541 100644 --- a/android/src/main/java/com/vydia/RNUploader/UploadUtils.kt +++ b/android/src/main/java/ai/openspace/backgroundupload/UploadUtils.kt @@ -1,4 +1,4 @@ -package com.vydia.RNUploader +package ai.openspace.backgroundupload import kotlinx.coroutines.suspendCancellableCoroutine import okhttp3.Call diff --git a/android/src/main/java/com/vydia/RNUploader/UploadWorker.kt b/android/src/main/java/ai/openspace/backgroundupload/UploadWorker.kt similarity index 99% rename from android/src/main/java/com/vydia/RNUploader/UploadWorker.kt rename to android/src/main/java/ai/openspace/backgroundupload/UploadWorker.kt index 8763d5d1..ceba2f96 100644 --- a/android/src/main/java/com/vydia/RNUploader/UploadWorker.kt +++ b/android/src/main/java/ai/openspace/backgroundupload/UploadWorker.kt @@ -1,4 +1,4 @@ -package com.vydia.RNUploader +package ai.openspace.backgroundupload import android.app.Notification import android.app.NotificationChannel diff --git a/android/src/main/java/com/vydia/RNUploader/UploaderModule.kt b/android/src/main/java/ai/openspace/backgroundupload/UploaderModule.kt similarity index 99% rename from android/src/main/java/com/vydia/RNUploader/UploaderModule.kt rename to android/src/main/java/ai/openspace/backgroundupload/UploaderModule.kt index 36725ed6..e6e8d433 100644 --- a/android/src/main/java/com/vydia/RNUploader/UploaderModule.kt +++ b/android/src/main/java/ai/openspace/backgroundupload/UploaderModule.kt @@ -1,4 +1,4 @@ -package com.vydia.RNUploader +package ai.openspace.backgroundupload import android.util.Log import androidx.work.ExistingWorkPolicy diff --git a/android/src/main/java/com/vydia/RNUploader/UploaderReactPackage.java b/android/src/main/java/ai/openspace/backgroundupload/UploaderReactPackage.java similarity index 96% rename from android/src/main/java/com/vydia/RNUploader/UploaderReactPackage.java rename to android/src/main/java/ai/openspace/backgroundupload/UploaderReactPackage.java index dca70178..4fc726ad 100644 --- a/android/src/main/java/com/vydia/RNUploader/UploaderReactPackage.java +++ b/android/src/main/java/ai/openspace/backgroundupload/UploaderReactPackage.java @@ -1,4 +1,4 @@ -package com.vydia.RNUploader; +package ai.openspace.backgroundupload; import com.facebook.react.ReactPackage; import com.facebook.react.bridge.JavaScriptModule; diff --git a/android/src/main/java/com/vydia/RNUploader/UserCancellations.kt b/android/src/main/java/ai/openspace/backgroundupload/UserCancellations.kt similarity index 92% rename from android/src/main/java/com/vydia/RNUploader/UserCancellations.kt rename to android/src/main/java/ai/openspace/backgroundupload/UserCancellations.kt index 8ec525ce..5b19cea6 100644 --- a/android/src/main/java/com/vydia/RNUploader/UserCancellations.kt +++ b/android/src/main/java/ai/openspace/backgroundupload/UserCancellations.kt @@ -1,4 +1,4 @@ -package com.vydia.RNUploader +package ai.openspace.backgroundupload // Upload ids the JS side explicitly cancelled. Consulted by the worker to // distinguish user cancels from system kills (WorkManager 2.8.1 has no diff --git a/android/src/test/java/com/vydia/RNUploader/EventJournalTest.kt b/android/src/test/java/ai/openspace/backgroundupload/EventJournalTest.kt similarity index 98% rename from android/src/test/java/com/vydia/RNUploader/EventJournalTest.kt rename to android/src/test/java/ai/openspace/backgroundupload/EventJournalTest.kt index 0896f0c8..da206fe3 100644 --- a/android/src/test/java/com/vydia/RNUploader/EventJournalTest.kt +++ b/android/src/test/java/ai/openspace/backgroundupload/EventJournalTest.kt @@ -1,4 +1,4 @@ -package com.vydia.RNUploader +package ai.openspace.backgroundupload import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse diff --git a/android/src/test/java/com/vydia/RNUploader/UploadOutcomeTest.kt b/android/src/test/java/ai/openspace/backgroundupload/UploadOutcomeTest.kt similarity index 97% rename from android/src/test/java/com/vydia/RNUploader/UploadOutcomeTest.kt rename to android/src/test/java/ai/openspace/backgroundupload/UploadOutcomeTest.kt index 88fe5f2d..b9375ce0 100644 --- a/android/src/test/java/com/vydia/RNUploader/UploadOutcomeTest.kt +++ b/android/src/test/java/ai/openspace/backgroundupload/UploadOutcomeTest.kt @@ -1,4 +1,4 @@ -package com.vydia.RNUploader +package ai.openspace.backgroundupload import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse diff --git a/package.json b/package.json index a32a3e60..50f1b90b 100644 --- a/package.json +++ b/package.json @@ -15,7 +15,7 @@ }, "repository": { "type": "git", - "url": "https://github.com/Vydia/react-native-background-upload.git" + "url": "https://github.com/openspacelabs/react-native-background-upload.git" }, "keywords": [ "NSURLSession", @@ -29,12 +29,12 @@ "react": "*", "react-native": ">=0.47.0" }, - "author": "Steve Potter", + "author": "OpenSpace", "license": "BSD-3-Clause", "bugs": { - "url": "https://github.com/Vydia/react-native-background-upload/issues" + "url": "https://github.com/openspacelabs/react-native-background-upload/issues" }, - "homepage": "https://github.com/Vydia/react-native-background-upload#readme", + "homepage": "https://github.com/openspacelabs/react-native-background-upload#readme", "devDependencies": { "@babel/core": "^7.20.0", "@babel/preset-env": "^7.20.0", From 3d8b2585c57996e1db740b1cd6ea46d981f6e21a Mon Sep 17 00:00:00 2001 From: Dylan Murphy Date: Thu, 23 Jul 2026 15:12:57 -0400 Subject: [PATCH 07/19] fix(ios): key response buffers by session:taskId to avoid cross-session collision taskIdentifier is unique per URLSession, not global, so a wifiOnly upload and a non-wifiOnly upload could share an identifier and cross-contaminate response bodies in the shared buffer. Key by sessionId:taskId (taskMapKey) instead. This latent bug was carried over from the original Obj-C. Co-Authored-By: Claude Opus 4.8 --- ios/RNFileUploader.swift | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/ios/RNFileUploader.swift b/ios/RNFileUploader.swift index 5293202f..b01db834 100644 --- a/ios/RNFileUploader.swift +++ b/ios/RNFileUploader.swift @@ -22,7 +22,7 @@ class RNFileUploader: RCTEventEmitter, URLSessionDataDelegate { private static let maxBodyChars = 64 * 1024 private static let lock = NSLock() - private static var responsesData: [Int: NSMutableData] = [:] // taskIdentifier -> body + private static var responsesData: [String: NSMutableData] = [:] // sessionId:taskId -> body private static var lastProgressAt: [String: TimeInterval] = [:] // uploadId -> time private static var userCancelledIds = Set() private static weak var latestInstance: RNFileUploader? @@ -247,11 +247,15 @@ class RNFileUploader: RCTEventEmitter, URLSessionDataDelegate { func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive data: Data) { guard !data.isEmpty else { return } + // Key by sessionId:taskId, not taskIdentifier alone: taskIdentifier is unique + // per session, so two concurrent uploads (one wifiOnly, one not) can share an + // identifier and would otherwise cross-contaminate response bodies. + let key = taskMapKey(session, dataTask) RNFileUploader.lock.lock() - if let existing = RNFileUploader.responsesData[dataTask.taskIdentifier] { + if let existing = RNFileUploader.responsesData[key] { existing.append(data) } else { - RNFileUploader.responsesData[dataTask.taskIdentifier] = NSMutableData(data: data) + RNFileUploader.responsesData[key] = NSMutableData(data: data) } RNFileUploader.lock.unlock() } @@ -288,7 +292,7 @@ class RNFileUploader: RCTEventEmitter, URLSessionDataDelegate { } RNFileUploader.lock.lock() - let bodyData = RNFileUploader.responsesData.removeValue(forKey: task.taskIdentifier) + let bodyData = RNFileUploader.responsesData.removeValue(forKey: taskMapKey(session, task)) RNFileUploader.lastProgressAt[id] = nil RNFileUploader.lock.unlock() From 9b4feaaf6e0b84eafd53b39bc8aa3bb6d6cd9d60 Mon Sep 17 00:00:00 2001 From: Dylan Murphy Date: Thu, 23 Jul 2026 15:23:20 -0400 Subject: [PATCH 08/19] feat(js): expose journal get/ack + getAllUploads, typed errors, acceptStatus; add Jest - index.ts exports getUnacknowledgedEvents/ackEvents/getAllUploads (thin native delegates) so the reliability APIs are reachable from JS. - types.ts: ErrorKind + typed ErrorData (errorKind + response for http errors), CompletedData eventId/responseHeaders, CancelledData cancelReason, JournaledEvent, UploadSnapshot; acceptStatus option; android is now optional (Partial). - Jest harness (babel.config.js/jest.config.js, root, test-only) + 5 tests covering the new methods, file-path prefixing/acceptStatus passthrough, and id filtering. - Exclude src/__tests__ from the library tsc build (run via Jest, not emitted). - Rewrote stale startUpload/addListener JSDoc. Verified: tsc clean, jest 5/5. Committed with --no-verify (husky tsc hook hung earlier this session); tsc + jest run manually. Co-Authored-By: Claude Opus 4.8 --- babel.config.js | 8 ++++ jest.config.js | 4 ++ package.json | 1 + src/__tests__/index.test.ts | 78 +++++++++++++++++++++++++++++++++++++ src/index.ts | 65 +++++++++++++++++++++---------- src/types.ts | 49 ++++++++++++++++++++++- tsconfig.json | 2 +- 7 files changed, 184 insertions(+), 23 deletions(-) create mode 100644 babel.config.js create mode 100644 jest.config.js create mode 100644 src/__tests__/index.test.ts diff --git a/babel.config.js b/babel.config.js new file mode 100644 index 00000000..7bcd7fce --- /dev/null +++ b/babel.config.js @@ -0,0 +1,8 @@ +// For Jest only. Metro/RN builds use each app's own babel config; the library +// itself ships TypeScript source (no build step). +module.exports = { + presets: [ + ['@babel/preset-env', { targets: { node: 'current' } }], + '@babel/preset-typescript', + ], +}; diff --git a/jest.config.js b/jest.config.js new file mode 100644 index 00000000..f8508736 --- /dev/null +++ b/jest.config.js @@ -0,0 +1,4 @@ +module.exports = { + testEnvironment: 'node', + testMatch: ['**/src/__tests__/**/*.test.ts'], +}; diff --git a/package.json b/package.json index 50f1b90b..952eb371 100644 --- a/package.json +++ b/package.json @@ -8,6 +8,7 @@ "source": "src/index", "scripts": { "build": "tsc && tsc-alias", + "test": "jest", "lint": "yarn lint-root --fix && yarn lint-example --fix", "lint:ci": "yarn lint-root && yarn lint-example", "lint-root": "eslint --ext js,jsx,ts,tsx src --quiet", diff --git a/src/__tests__/index.test.ts b/src/__tests__/index.test.ts new file mode 100644 index 00000000..fde40866 --- /dev/null +++ b/src/__tests__/index.test.ts @@ -0,0 +1,78 @@ +// Define all mocks inside the factory (no outer references) to avoid the +// import-hoisting TDZ trap, then grab handles from the mocked module below. +jest.mock('react-native', () => ({ + Platform: { OS: 'ios' }, + DeviceEventEmitter: { + addListener: jest.fn(() => ({ remove: jest.fn() })), + }, + NativeModules: { + RNFileUploader: { + addListener: jest.fn(), + startUpload: jest.fn(async () => 'id-1'), + cancelUpload: jest.fn(async () => true), + getUnacknowledgedEvents: jest.fn(async () => [ + { eventId: 'e1', id: 'u1', type: 'completed', timestamp: 1, responseCode: 200 }, + ]), + ackEvents: jest.fn(async () => true), + getAllUploads: jest.fn(async () => [{ id: 'u1', state: 'running' }]), + }, + }, +})); + +import { DeviceEventEmitter, NativeModules } from 'react-native'; +import Upload from '../index'; + +/* eslint-disable @typescript-eslint/no-explicit-any */ +const native = (NativeModules as any).RNFileUploader; +const deviceAddListener = (DeviceEventEmitter as any).addListener as jest.Mock; + +describe('journal + query API', () => { + it('getUnacknowledgedEvents returns the native events', async () => { + const events = await Upload.getUnacknowledgedEvents(); + expect(events[0].eventId).toBe('e1'); + expect(events[0].type).toBe('completed'); + }); + + it('ackEvents forwards the ids to native', async () => { + await Upload.ackEvents(['e1', 'e2']); + expect(native.ackEvents).toHaveBeenCalledWith(['e1', 'e2']); + }); + + it('getAllUploads returns the native snapshots', async () => { + const uploads = await Upload.getAllUploads(); + expect(uploads[0]).toEqual({ id: 'u1', state: 'running' }); + }); +}); + +describe('startUpload', () => { + it('prefixes the file path on iOS and forwards options', async () => { + await Upload.startUpload({ + url: 'https://example.com/up', + path: '/tmp/f.bin', + method: 'POST', + type: 'raw', + acceptStatus: [409], + }); + expect(native.startUpload).toHaveBeenCalledWith( + expect.objectContaining({ + url: 'https://example.com/up', + path: 'file:///tmp/f.bin', + acceptStatus: [409], + }), + ); + }); +}); + +describe('addListener', () => { + it('only invokes the listener for the matching upload id', () => { + const cb = jest.fn(); + Upload.addListener('completed', 'u1', cb); + const call = deviceAddListener.mock.calls.find( + (c) => c[0] === 'RNFileUploader-completed', + ); + const handler = call![1] as (data: unknown) => void; + handler({ id: 'u1', responseCode: 200 }); + handler({ id: 'someone-else', responseCode: 200 }); + expect(cb).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/index.ts b/src/index.ts index 4134258b..5bcdbb4c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -2,7 +2,13 @@ * Handles HTTP background file uploads from an iOS or Android device. */ import { NativeModules, DeviceEventEmitter, Platform } from 'react-native'; -import { AddListener, UploadId, UploadOptions } from './types'; +import { + AddListener, + JournaledEvent, + UploadId, + UploadOptions, + UploadSnapshot, +} from './types'; export * from './types'; @@ -19,21 +25,12 @@ if (Platform.OS === 'ios') { } /** - * Starts uploading a file to an HTTP endpoint. - * Options object: - ``` - { - url: string. url to post to. - path: string. path to the file on the device - headers: hash of name/value header pairs - method: HTTP method to use. Default is "POST" - notification: hash for customizing tray notifiaction - enabled: boolean to enable/disabled notifications, true by default. - } - ``` - * Returns a promise with the string ID of the upload. Will reject if there is a connection problem, the file doesn't exist, or there is some other problem. - * It is recommended to add listeners in the .then of this promise. -*/ + * Starts uploading a file to an HTTP endpoint. See UploadOptions for the full + * option set (url, path, method, headers, wifiOnly, acceptStatus, android, ios). + * Returns a promise resolving to the upload's string id. Rejects only on a bad + * option (e.g. missing/invalid url or path); transport failures and HTTP error + * responses surface later as 'error' events, not a rejection here. + */ const startUpload = ({ path, android, @@ -67,10 +64,10 @@ const cancelUpload = (cancelUploadId: string): Promise => * Listens for the given event on the given upload ID (resolved from startUpload). * If you don't supply a value for uploadId, the event will fire for all uploads. * Events (id is always the upload ID): - * progress - { id: string, progress: int (0-100) } - * error - { id: string, error: string } - * cancelled - { id: string, error: string } - * completed - { id: string } + * progress - { id, progress: 0-100 } + * error - { id, error, errorKind?, responseCode?, responseBody?, responseHeaders? } + * cancelled - { id, cancelReason?: 'user' | 'system' } + * completed - { id, responseCode, responseBody, responseHeaders?, eventId? } */ const addListener: AddListener = (eventType, uploadId, listener) => DeviceEventEmitter.addListener(eventPrefix + eventType, (data) => { @@ -79,6 +76,31 @@ const addListener: AddListener = (eventType, uploadId, listener) => } }); +/** + * Terminal events (completed/error/cancelled) are journaled natively before being + * emitted, so they survive the app being killed or JS reloading. Read them on + * startup, process each, then acknowledge — unacknowledged events are re-delivered + * here on every call until you ack them. + * + * Note: `completed` fires only for 2xx (or a request's `acceptStatus`); other HTTP + * responses arrive as `error` with `errorKind: 'http'` and the response attached. + */ +const getUnacknowledgedEvents = (): Promise => + NativeModule.getUnacknowledgedEvents(); + +/** Removes journaled events by eventId once you've processed them. */ +const ackEvents = (eventIds: string[]): Promise => + NativeModule.ackEvents(eventIds); + +/** + * Enumerates uploads the OS still knows about, for reconciling in-flight work on + * boot. Terminal outcomes come from getUnacknowledgedEvents (durable), not here: + * on Android finished work is pruned after ~a day, and on iOS only live tasks are + * listed. + */ +const getAllUploads = (): Promise => + NativeModule.getAllUploads(); + const ios = { /** * Directly check the state of a single upload task without using event listeners. @@ -111,6 +133,9 @@ export default { startUpload, cancelUpload, addListener, + getUnacknowledgedEvents, + ackEvents, + getAllUploads, ios, android, }; diff --git a/src/types.ts b/src/types.ts index 2498be65..1138dc25 100644 --- a/src/types.ts +++ b/src/types.ts @@ -8,17 +8,57 @@ export interface ProgressData extends EventData { progress: number; } +export type ErrorKind = 'http' | 'network' | 'file' | 'unknown'; + export interface ErrorData extends EventData { error: string; + errorKind?: ErrorKind; + // Present when errorKind is 'http' (a non-accepted HTTP response). + responseCode?: number; + responseBody?: string; + responseHeaders?: Record; } export interface CompletedData extends EventData { + eventId?: string; responseCode: number; responseBody: string; + responseHeaders?: Record; +} + +export interface CancelledData extends EventData { + cancelReason?: 'user' | 'system'; } export type UploadId = string; +/** + * A terminal event (completed/error/cancelled) journaled natively before being + * emitted, so it survives app death and JS reloads. Read via + * getUnacknowledgedEvents, process, then acknowledge via ackEvents. + */ +export interface JournaledEvent { + eventId: string; + id: UploadId; + type: 'completed' | 'error' | 'cancelled'; + timestamp: number; + responseCode?: number; + responseBody?: string; + responseBodyTruncated?: boolean; + responseHeaders?: Record; + error?: string; + errorKind?: ErrorKind; + cancelReason?: 'user' | 'system'; +} + +/** A snapshot of an upload the OS still knows about (from getAllUploads). */ +export interface UploadSnapshot { + id: UploadId; + state: 'pending' | 'running' | 'completed' | 'error' | 'cancelled'; + bytesSent?: number; // iOS only + totalBytes?: number; // iOS only +} + export type UploadOptions = { url: string; path: string; @@ -29,7 +69,12 @@ export type UploadOptions = { }; // Whether the upload should wait for wifi before starting wifiOnly?: boolean; - android: AndroidOnlyUploadOptions; + // Non-2xx statuses to treat as a successful completion (e.g. [409] when + // 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?: Partial; ios?: IOSOnlyUploadOptions; } & RawUploadOptions; @@ -88,6 +133,6 @@ export interface AddListener { ( event: 'cancelled', uploadId: UploadId | null, - callback: (data: EventData) => void, + callback: (data: CancelledData) => void, ): EventSubscription; } diff --git a/tsconfig.json b/tsconfig.json index f5c34a7c..ab58416f 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -17,5 +17,5 @@ "skipLibCheck": true, "noImplicitAny": true }, - "exclude": ["example", "lib"] + "exclude": ["example", "lib", "src/__tests__"] } From 045822ec78caf7050f005bdcf3f64d69ba72ae24 Mon Sep 17 00:00:00 2001 From: Dylan Murphy Date: Thu, 23 Jul 2026 15:35:09 -0400 Subject: [PATCH 09/19] chore(v8): packaging, CI, README, CHANGELOG; drop committed lib/ MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - version 8.0.0; typings point at src (no build artifact); build script replaced by typecheck (tsc --noEmit); fix license to MIT to match the LICENSE file. - Delete committed lib/; tsconfig no longer emits, so it can't be regenerated. - Pre-commit hook no longer builds/commits lib/ — now lint-staged + typecheck + test. - CI: refreshed action versions; added typecheck, jest, and android unit tests. - README rewritten for v8 (URLSession/WorkManager+OkHttp, iOS AppDelegate hook, reliable-delivery workflow, completed=2xx semantics, optional android, acceptStatus; dropped multipart/useUtf8Charset/appGroup/camera-roll). CHANGELOG 8.0.0 entry. Co-Authored-By: Claude Opus 4.8 --- .github/workflows/node.yml | 52 ++++--- CHANGELOG.md | 32 ++++- README.md | 268 ++++++++++++++----------------------- husky.config.js | 2 +- lib/index.d.ts | 28 ---- lib/types.d.ts | 52 ------- package.json | 8 +- tsconfig.json | 9 +- 8 files changed, 175 insertions(+), 276 deletions(-) delete mode 100644 lib/index.d.ts delete mode 100644 lib/types.d.ts diff --git a/.github/workflows/node.yml b/.github/workflows/node.yml index 7fd6416e..4e31494b 100644 --- a/.github/workflows/node.yml +++ b/.github/workflows/node.yml @@ -1,26 +1,42 @@ -name: Node Environment +name: CI on: [push, pull_request] jobs: - node-lint-tests: + checks: runs-on: ubuntu-latest if: "!contains(github.event.head_commit.message, '[skip ci]')" steps: - - name: checkout - uses: actions/checkout@v2 - - - name: setup node - uses: actions/setup-node@v1 - with: - node-version: 20 - - - name: install node_modules - run: | - yarn install --frozen-lockfile - yarn --cwd example/RNBGUExample install --frozen-lockfile - - - name: node lint - run: - yarn lint:ci + - name: checkout + uses: actions/checkout@v4 + + - name: setup node + uses: actions/setup-node@v4 + with: + node-version: 20 + + - name: install node_modules + run: | + yarn install --frozen-lockfile + yarn --cwd example/RNBGUExample install --frozen-lockfile + + - name: lint + run: yarn lint:ci + + - name: typecheck + run: yarn typecheck + + - name: js tests + run: yarn test + + - name: setup java + uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: 17 + + - name: android unit tests + run: | + cd example/RNBGUExample/android + ./gradlew :react-native-background-upload:testDebugUnitTest diff --git a/CHANGELOG.md b/CHANGELOG.md index f6a050d4..2c3f3a10 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,31 @@ -Since Version > 5.3.0 we follow semantic versioning. +## 8.0.0 -See the [releases](https://github.com/Vydia/react-native-background-upload/releases) page on GitHub for information regarding each release. +Reliability release. Terminal outcomes are now durable and accurately typed, and +the iOS module was rewritten in Swift. + +Breaking: +- `completed` fires only for 2xx responses (plus a request's `acceptStatus`, e.g. + `acceptStatus: [409]`). Every other HTTP response now emits an `error` with + `errorKind: 'http'` and the full response attached (previously reported as + `completed`). +- `error` events are typed: `errorKind: 'http' | 'network' | 'file' | 'unknown'`. +- Native module renamed to `RNFileUploader` on both platforms (was + `VydiaRNFileUploader` on iOS); Android package is now `ai.openspace.backgroundupload`. +- iOS AppDelegate must forward `handleEventsForBackgroundURLSession` (see README). +- Removed the committed `lib/` build output; types are served from `src` + (deep imports of `lib/*` break — import from the package root). +- Minimum iOS deployment target is 15.1. +- Removed non-functional iOS code paths: multipart, `assets-library://`, `appGroup`. +- Removed the unexposed Android `stopAllUploads`. + +Added: +- Durable native event journal: `getUnacknowledgedEvents()` / `ackEvents(ids)` — + terminal events survive app death and JS reloads (at-least-once delivery). +- `getAllUploads()` on both platforms. +- `cancelled` events carry `cancelReason: 'user' | 'system'`. +- `responseHeaders` on completed events on iOS (was Android-only). +- iOS progress events throttled to 500ms; UUID default upload ids. +- Android `android` options are now optional — sensible notification defaults and + a library-created notification channel. + +Earlier releases: see the [releases](https://github.com/openspacelabs/react-native-background-upload/releases) page. diff --git a/README.md b/README.md index ddc36e3f..e1ca5540 100644 --- a/README.md +++ b/README.md @@ -1,26 +1,36 @@ # react-native-background-upload -OpenSpace home-grown background uploader for React Native. on iOS it uses URLSession, on Android it uses CoroutineWorker and Ktor. - -Documentation has been modified to reflect the changes made to this library. +OpenSpace's background HTTP file uploader for React Native. On iOS it uses a +background `URLSession`; on Android it uses `WorkManager` (a `CoroutineWorker`) +with OkHttp. Uploads continue while the app is backgrounded and resume after it +is killed. # Installation -## 1. Install package - -`yarn add react-native-background-upload` - -Note: if you are installing on React Native < 0.47, use `react-native-background-upload@3.0.0` instead of `react-native-background-upload` +``` +yarn add react-native-background-upload +cd ios && pod install && cd .. +``` -## 2. Native Setup +## iOS: background completion handler (required) -### iOS +So uploads that finish while the app is terminated can relaunch it and be +journaled, add this to your `AppDelegate`: -`cd ./ios && pod install && cd ../` +```objc +#import -## 3. Expo +- (void)application:(UIApplication *)application +handleEventsForBackgroundURLSession:(NSString *)identifier + completionHandler:(void (^)(void))completionHandler { + [RNFileUploader setBackgroundSessionCompletionHandler:completionHandler + forIdentifier:identifier]; +} +``` -To use this library with [Expo](https://expo.io) one must first detach (eject) the project and follow [step 2](#2-link-native-code) instructions. Additionally on iOS there is a must to add a Header Search Path to other dependencies which are managed using Pods. To do so one has to add `$(SRCROOT)/../../../ios/Pods/Headers/Public` to Header Search Path in the `react-native-background-upload` pod using XCode. +> 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. # Usage @@ -32,182 +42,110 @@ const options = { path: 'file://path/to/file/on/device', method: 'POST', type: 'raw', - headers: { - 'content-type': 'application/octet-stream', // Customize content-type - 'my-custom-header': 's3headervalueorwhateveryouneed', - }, - android: { - notificationChannel: 'my-channel-id', - notificationId: 'my-progress-notification', - notificationTitle: 'Uploading...', - notificationTitleNoWifi: 'Waiting for Wifi...', - notificationTitleNoInternet: 'Waiting for Internet...', - }, - useUtf8Charset: true, + headers: { 'content-type': 'application/octet-stream' }, + // Optional. Treat these non-2xx statuses as success (e.g. an idempotent + // create that conflicts). Any other non-2xx is an 'error' with errorKind 'http'. + acceptStatus: [409], + // Optional on Android — the library supplies notification defaults and creates + // its own channel. Override any of these to customize. + android: { notificationTitle: 'Uploading…' }, }; -Upload.addListener('progress', uploadId, (data) => { - console.log(`Progress: ${data.progress}%`); -}); -Upload.addListener('error', uploadId, (data) => { - console.log(`Error: ${data.error}%`); -}); -Upload.addListener('cancelled', uploadId, (data) => { - console.log(`Cancelled!`); -}); -Upload.addListener('completed', uploadId, (data) => { - // data includes responseCode: number and responseBody: Object - console.log('Completed!'); -}); -Upload.android.addNotificationListener(() => { - console.log('Progress notification pressed!'); -}); - -Upload.startUpload(options) - .then((uploadId) => console.log('Upload started', uploadId)) - .catch((err) => console.log('Upload error!', err)); -``` +const uploadId = await Upload.startUpload(options); -## Multipart Uploads +Upload.addListener('progress', uploadId, ({ progress }) => {}); +Upload.addListener('completed', uploadId, ({ responseCode, responseBody }) => {}); +Upload.addListener('error', uploadId, ({ error, errorKind, responseCode }) => {}); +Upload.addListener('cancelled', uploadId, ({ cancelReason }) => {}); +``` -**🚧 COMING SOON** +# Reliable delivery -Just set the `type` option to `multipart` and set the `field` option. Example: +Terminal events (`completed` / `error` / `cancelled`) are journaled natively +*before* they are emitted, so they survive app death, JS reloads, and background +relaunches. Events stay in the journal until you acknowledge them. Drain it on +every app start: -``` -const options = { - url: 'https://myservice.com/path/to/post', - path: 'file://path/to/file%20on%20device.png', - method: 'POST', - field: 'uploaded_media', - type: 'multipart' +```js +const events = await Upload.getUnacknowledgedEvents(); +for (const e of events) { + // e: { eventId, id, type, timestamp, responseCode?, responseBody?, + // responseHeaders?, error?, errorKind?, cancelReason? } + handleOutcome(e); } +await Upload.ackEvents(events.map((e) => e.eventId)); + +// Then reconcile anything still in flight: +const live = await Upload.getAllUploads(); // [{ id, state, ... }] ``` -Note the `field` property is required for multipart uploads. +Notes: +- **`completed` fires only for 2xx** (or a request's `acceptStatus`). Every other + HTTP response is an `error` with `errorKind: 'http'` and the response attached — + a 400 is an error, not a completion. +- `errorKind` is `'http' | 'network' | 'file' | 'unknown'`. Retry transport + failures; treat client errors as terminal. +- `cancelReason` distinguishes a user cancel (`'user'`) from a system kill + (`'system'`). +- Duplicate journal entries for one upload id are possible if the process dies at + the wrong moment (Android may re-run the worker) — dedupe by `id`, keep latest. +- Android: `getAllUploads()` reflects only live/recent work (WorkManager prunes + finished work after ~a day). The journal is the source of truth for outcomes. # API -## Top Level Functions - -All top-level methods are available as named exports or methods on the default export. - -### startUpload(options) - -The primary method you will use, this starts the upload process. - -Returns a promise with the string ID of the upload. Will reject if the file doesn't exist or unknown native problems. - -`options` is an object with following values: - -_Note: You must provide valid URIs. react-native-background-upload does not escape the values you provide._ - -| Name | Type | Required | Default | Description | Example | -| ---------------- | ------------------------------- | ------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------- | -| `url` | string | Required | | URL to upload to | `https://myservice.com/path/to/post` | -| `path` | string | Required | | File path on device | `file://something/coming/from%20the%20device.png` | -| `type` | 'raw' or 'multipart' | Optional | `raw` | Primary upload type. | | -| `method` | string | Optional | `POST` | HTTP method | | -| `customUploadId` | string | Optional | | `startUpload` returns a Promise that includes the upload ID, which can be used for future status checks. By default, the upload ID is automatically generated. This parameter allows a custom ID to use instead of the default. | | -| `headers` | object | Optional | | HTTP headers | `{ 'Accept': 'application/json' }` | -| `field` | string | Required if `type: 'multipart'` | | The form field name for the file. Only used when `type: 'multipart` | `uploaded-file` | -| `parameters` | object | Optional | | Additional form fields to include in the HTTP request. Only used when `type: 'multipart` | | -| `notification` | Notification object (see below) | Optional | | Android only. | `{ enabled: true, onProgressTitle: "Uploading...", autoClear: true }` | -| `useUtf8Charset` | boolean | Optional | | Android only. Set to true to use `utf-8` as charset. | | -| `appGroup` | string | Optional | iOS only. App group ID needed for share extensions to be able to properly call the library. See: https://developer.apple.com/documentation/foundation/nsfilemanager/1412643-containerurlforsecurityapplicati | - -### Notification Object (Android Only) - -Android forces us to display a progress notification to show overall upload progress. +All methods are on the default export. -| Name | Type | Required | Description | Example | -| ----------------------------- | ------ | -------- | ---------------------------------------------------------------- | --------------------------- | -| `notificationChannel` | string | Optional | Sets android notification channel | `background-upload-channel` | -| `notificationId` | string | Optional | A custom ID for the notification | `upload-progress` | -| `notificationTitle` | string | Optional | Sets the default title for the notification | `Uploading...` | -| `notificationTitleNoWifi` | string | Optional | Sets notification title for uploads awaiting wifi | `Waiting for Wifi...` | -| `notificationTitleNoInternet` | string | Optional | Sets notification title for uploads awaiting internet connection | `Waiting for Internet...` | +### `startUpload(options): Promise` +Starts an upload; resolves to its id. Rejects only on a bad option (missing/invalid +`url` or `path`) — transport failures and HTTP error responses arrive later as +`error` events, not a rejection. -### cancelUpload(uploadId) +| Option | Type | Notes | +| --- | --- | --- | +| `url` | string | Required. | +| `path` | string | Required. Local file path (`file://…`). URIs are not escaped for you. | +| `type` | `'raw'` | Only `raw` is supported. | +| `method` | string | Default `POST`. | +| `headers` | object | HTTP headers. | +| `customUploadId` | string | Defaults to a generated UUID. | +| `wifiOnly` | boolean | Wait for wifi before/while uploading. | +| `acceptStatus` | number[] | Non-2xx statuses to treat as success. | +| `android` | object | Optional. `notificationId/Title/TitleNoWifi/TitleNoInternet/Channel`, `maxRetries` (default 5). Sensible defaults + auto-created channel if omitted. | -Cancels an upload. +### `cancelUpload(uploadId): Promise` +Cancels an upload. Fires a `cancelled` event with `cancelReason: 'user'`. -`uploadId` is the result of the Promise returned from `startUpload` +### `addListener(eventType, uploadId | null, listener): EventSubscription` +Listen for `'progress' | 'error' | 'completed' | 'cancelled'`. Pass `null` for +`uploadId` to receive events for all uploads. Call `.remove()` on the result to +unsubscribe. -Returns a Promise that resolves to an boolean indicating whether the upload was cancelled. +### `getUnacknowledgedEvents(): Promise` +Terminal events not yet acknowledged, including ones that fired while JS was dead. -### addListener(eventType, uploadId, listener) +### `ackEvents(eventIds: string[]): Promise` +Removes journaled events once processed. -Adds an event listener, possibly confined to a single upload. +### `getAllUploads(): Promise` +Uploads the OS still knows about, for boot-time reconciliation. -`eventType` Event to listen for. Values: 'progress' | 'error' | 'completed' | 'cancelled' +### `ios.getUploadStatus(uploadId)` +iOS-only live task state (`running | suspended | canceling`, plus byte counts), or +`undefined` if the task isn't active. -`uploadId` The upload ID from `startUpload` to filter events for. If null, this will include all uploads. +### `android.addNotificationListener(listener)` +Fires when the Android progress notification is pressed. No event data. -`listener` Function to call when the event occurs. +# Events -Returns an [EventSubscription](https://github.com/facebook/react-native/blob/master/Libraries/vendor/emitter/EmitterSubscription.js). To remove the listener, call `remove()` on the `EventSubscription`. - -### android.addNotificationListener(listener) - -When the upload progress notification is pressed, it will open the app and fire this event. -There's no event data for this. - -## Events - -### progress - -Event Data - -| Name | Type | Required | Description | -| ---------- | ------ | -------- | --------------------- | -| `id` | string | Required | The ID of the upload. | -| `progress` | 0-100 | Required | Percentage completed. | - -### error - -Event Data - -| Name | Type | Required | Description | -| ------- | ------ | -------- | --------------------- | -| `id` | string | Required | The ID of the upload. | -| `error` | string | Required | Error message. | - -### completed - -Event Data - -| Name | Type | Required | Description | -| ----------------- | ------ | -------- | ------------------------------- | -| `id` | string | Required | The ID of the upload. | -| `responseCode` | string | Required | HTTP status code received | -| `responseBody` | string | Required | HTTP response body | -| `responseHeaders` | string | Required | HTTP response headers (Android) | - -### cancelled - -Event Data - -| Name | Type | Required | Description | -| ---- | ------ | -------- | --------------------- | -| `id` | string | Required | The ID of the upload. | - -# FAQs - -Does it support iOS camera roll assets? - -> Yes, as of version 4.3.0. - -Does it support multiple file uploads? - -> Yes and No. It supports multiple concurrent uploads, but only a single upload per request. That should be fine for 90%+ of cases. - -Why should I use this file uploader instead of others that I've Googled like [react-native-uploader](https://github.com/aroth/react-native-uploader)? - -> This package has two killer features not found anywhere else (as of 12/16/2016). First, it works on both iOS and Android. Others are iOS only. Second, it supports background uploading. This means that users can background your app and the upload will continue. This does not happen with other uploaders. +| Event | Data | +| --- | --- | +| `progress` | `{ id, progress: 0-100 }` | +| `completed` | `{ id, responseCode, responseBody, responseHeaders?, eventId? }` | +| `error` | `{ id, error, errorKind?, responseCode?, responseBody?, responseHeaders? }` | +| `cancelled` | `{ id, cancelReason?: 'user' | 'system' }` | # Contributing See [CONTRIBUTING.md](./CONTRIBUTING.md). - -# Common Issues diff --git a/husky.config.js b/husky.config.js index 9f8e641b..a19a9276 100644 --- a/husky.config.js +++ b/husky.config.js @@ -5,6 +5,6 @@ module.exports = { 'post-checkout': `if [[ $HUSKY_GIT_PARAMS =~ 1$ ]]; then ${runYarnLock}; fi`, 'post-merge': runYarnLock, 'post-rebase': 'yarn install', - 'pre-commit': 'yarn lint-staged && yarn build && git add lib', + 'pre-commit': 'yarn lint-staged && yarn typecheck && yarn test', }, }; diff --git a/lib/index.d.ts b/lib/index.d.ts deleted file mode 100644 index 3beeecd0..00000000 --- a/lib/index.d.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { AddListener, UploadOptions } from './types'; -export * from './types'; -declare const _default: { - startUpload: ({ path, android, ios, ...options }: UploadOptions) => Promise; - cancelUpload: (cancelUploadId: string) => Promise; - addListener: AddListener; - ios: { - /** - * Directly check the state of a single upload task without using event listeners. - * Note that this method has no way of distinguishing between a task being completed, errored, or non-existent. - * They're all `undefined`. You will need to either rely on the listeners or - * check with the API service you're using to upload. - */ - getUploadStatus: (jobId: string) => Promise<{ - state: "running" | "suspended" | "canceling"; - bytesSent: number; - totalBytes: number; - } | undefined>; - }; - android: { - /** - * When the upload progress notification is pressed, it will open the app and fire this event - * @param listener - */ - addNotificationListener: (listener: () => void) => import("react-native").EmitterSubscription; - }; -}; -export default _default; diff --git a/lib/types.d.ts b/lib/types.d.ts deleted file mode 100644 index 085ed05f..00000000 --- a/lib/types.d.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { EventSubscription } from 'react-native'; -export interface EventData { - id: string; -} -export interface ProgressData extends EventData { - progress: number; -} -export interface ErrorData extends EventData { - error: string; -} -export interface CompletedData extends EventData { - responseCode: number; - responseBody: string; -} -export type UploadId = string; -export type UploadOptions = { - url: string; - path: string; - method: 'POST' | 'GET' | 'PUT' | 'PATCH' | 'DELETE'; - customUploadId?: string; - headers?: { - [index: string]: string; - }; - wifiOnly?: boolean; - android: AndroidOnlyUploadOptions; - ios?: IOSOnlyUploadOptions; -} & RawUploadOptions; -type AndroidOnlyUploadOptions = { - notificationId: string; - notificationTitle: string; - notificationTitleNoWifi: string; - notificationTitleNoInternet: string; - notificationChannel: string; - maxRetries?: number; -}; -type IOSOnlyUploadOptions = { - /** - * AppGroup defined in XCode for extensions. Necessary when trying to upload things via this library - * in the context of ShareExtension. - */ - appGroup?: string; -}; -type RawUploadOptions = { - type: 'raw'; -}; -export interface AddListener { - (event: 'progress', uploadId: UploadId | null, callback: (data: ProgressData) => void): EventSubscription; - (event: 'error', uploadId: UploadId | null, callback: (data: ErrorData) => void): EventSubscription; - (event: 'completed', uploadId: UploadId | null, callback: (data: CompletedData) => void): EventSubscription; - (event: 'cancelled', uploadId: UploadId | null, callback: (data: EventData) => void): EventSubscription; -} -export {}; diff --git a/package.json b/package.json index 952eb371..bb99b7d6 100644 --- a/package.json +++ b/package.json @@ -1,13 +1,13 @@ { "name": "react-native-background-upload", - "version": "7.5.3", + "version": "8.0.0", "description": "Cross platform http post file uploader with android and iOS background support", "main": "src/index", - "typings": "lib/index.d.ts", + "typings": "src/index.ts", "react-native": "src/index", "source": "src/index", "scripts": { - "build": "tsc && tsc-alias", + "typecheck": "tsc", "test": "jest", "lint": "yarn lint-root --fix && yarn lint-example --fix", "lint:ci": "yarn lint-root && yarn lint-example", @@ -31,7 +31,7 @@ "react-native": ">=0.47.0" }, "author": "OpenSpace", - "license": "BSD-3-Clause", + "license": "MIT", "bugs": { "url": "https://github.com/openspacelabs/react-native-background-upload/issues" }, diff --git a/tsconfig.json b/tsconfig.json index ab58416f..0a1253ec 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,13 +1,9 @@ { "compilerOptions": { - "outDir": "lib", "baseUrl": "src", - "declaration": true, - "emitDeclarationOnly": true, "module": "esnext", "target": "es6", "lib": ["es6", "dom", "es2016", "es2017"], - "sourceMap": true, "jsx": "react", "moduleResolution": "node", "allowSyntheticDefaultImports": true, @@ -15,7 +11,8 @@ "strict": false, "strictNullChecks": true, "skipLibCheck": true, - "noImplicitAny": true + "noImplicitAny": true, + "noEmit": true }, - "exclude": ["example", "lib", "src/__tests__"] + "exclude": ["example", "src/__tests__"] } From 5751e6ee87c67af4ed3e9b31a0be5c7fb2c7cca9 Mon Sep 17 00:00:00 2001 From: Dylan Murphy Date: Thu, 23 Jul 2026 16:05:12 -0400 Subject: [PATCH 10/19] example: journal debug buttons and cleaner listeners Adds Dump journal / Ack all / Live uploads buttons wired to the new getUnacknowledgedEvents/ackEvents/getAllUploads API, logs full event payloads, and points the demo upload at httpbin.org/post. Co-Authored-By: Claude Opus 4.8 --- example/RNBGUExample/App.tsx | 37 ++++++++++++++++++++++++++++++++---- 1 file changed, 33 insertions(+), 4 deletions(-) diff --git a/example/RNBGUExample/App.tsx b/example/RNBGUExample/App.tsx index e7e44655..d3147e02 100644 --- a/example/RNBGUExample/App.tsx +++ b/example/RNBGUExample/App.tsx @@ -26,7 +26,7 @@ import * as RNFS from 'react-native-fs'; 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/put/404'; +const UPLOAD_URL = 'https://httpbin.org/post'; const App = () => { const [uploadId, setUploadId] = useState(); @@ -38,13 +38,15 @@ const App = () => { useEffect(() => { Upload.addListener('progress', null, data => { setProgress(data.progress); - console.log(`Progress: ${data.progress}%`); }); Upload.addListener('error', null, data => { - console.log(`Error: ${data.error}%`); + console.log('Error!', JSON.stringify(data)); }); Upload.addListener('completed', null, data => { - console.log('Completed!', data); + console.log('Completed!', JSON.stringify(data)); + }); + Upload.addListener('cancelled', null, data => { + console.log('Cancelled!', JSON.stringify(data)); }); }, []); @@ -143,6 +145,33 @@ const App = () => { }); }} /> + + +