From a15c88570c58928abbc3b448fa2ab765bfccb00c Mon Sep 17 00:00:00 2001 From: thomasvo Date: Thu, 16 Oct 2025 15:33:02 -0700 Subject: [PATCH 01/18] fix --- .../com/vydia/RNUploader/EventReporter.kt | 27 ++---- .../main/java/com/vydia/RNUploader/Upload.kt | 6 +- .../com/vydia/RNUploader/UploadProgress.kt | 84 +++++++++---------- .../java/com/vydia/RNUploader/UploadUtils.kt | 23 ++++- .../java/com/vydia/RNUploader/UploadWorker.kt | 60 ++++++------- .../com/vydia/RNUploader/UploaderModule.kt | 9 +- example/RNBGUExample/android/build.gradle | 2 +- 7 files changed, 105 insertions(+), 106 deletions(-) diff --git a/android/src/main/java/com/vydia/RNUploader/EventReporter.kt b/android/src/main/java/com/vydia/RNUploader/EventReporter.kt index 32daf999..0ded9ba0 100644 --- a/android/src/main/java/com/vydia/RNUploader/EventReporter.kt +++ b/android/src/main/java/com/vydia/RNUploader/EventReporter.kt @@ -4,10 +4,6 @@ import android.util.Log import com.facebook.react.bridge.Arguments import com.facebook.react.bridge.WritableMap import com.facebook.react.modules.core.DeviceEventManagerModule.RCTDeviceEventEmitter -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch -import okhttp3.Response // Sends events to React Native class EventReporter { @@ -24,21 +20,14 @@ class EventReporter { putString("error", exception.message ?: "Unknown exception") }) - fun success(uploadId: String, response: Response) = - CoroutineScope(Dispatchers.IO).launch { - sendEvent("completed", Arguments.createMap().apply { - putString("id", uploadId) - putInt("responseCode", response.code) - putString("responseBody", response.body?.string().let { - if (it.isNullOrBlank()) response.message else it - }) - putMap("responseHeaders", Arguments.createMap().apply { - response.headers.names().forEach { name -> - putString(name, response.headers.values(name).joinToString(", ")) - } - }) - }) - } + fun success(uploadId: String, response: UploadResponse) = + sendEvent("completed", Arguments.createMap().apply { + putString("id", uploadId) + putInt("responseCode", response.code) + putString("responseBody", response.body) + putMap("responseHeaders", Arguments.makeNativeMap(response.headers)) + }) + fun progress(uploadId: String, bytesSentTotal: Long, contentLength: Long) = sendEvent("progress", Arguments.createMap().apply { diff --git a/android/src/main/java/com/vydia/RNUploader/Upload.kt b/android/src/main/java/com/vydia/RNUploader/Upload.kt index 5afdac69..db7b83d5 100644 --- a/android/src/main/java/com/vydia/RNUploader/Upload.kt +++ b/android/src/main/java/com/vydia/RNUploader/Upload.kt @@ -1,7 +1,7 @@ package com.vydia.RNUploader import com.facebook.react.bridge.ReadableMap -import java.util.* +import java.util.UUID // Data model of a single upload // Can be created from RN's ReadableMap @@ -14,7 +14,7 @@ data class Upload( val maxRetries: Int, val wifiOnly: Boolean, val headers: Map, - val notificationId: String, + val notificationId: Int, val notificationTitle: String, val notificationTitleNoInternet: String, val notificationTitleNoWifi: String, @@ -39,7 +39,7 @@ data class Upload( } return@let map }, - notificationId = map.getString(Upload::notificationId.name) + notificationId = map.getString(Upload::notificationId.name)?.hashCode() ?: throw MissingOptionException(Upload::notificationId.name), notificationTitle = map.getString(Upload::notificationTitle.name) ?: throw MissingOptionException(Upload::notificationTitle.name), diff --git a/android/src/main/java/com/vydia/RNUploader/UploadProgress.kt b/android/src/main/java/com/vydia/RNUploader/UploadProgress.kt index ee1bd78a..7a7c9b88 100644 --- a/android/src/main/java/com/vydia/RNUploader/UploadProgress.kt +++ b/android/src/main/java/com/vydia/RNUploader/UploadProgress.kt @@ -1,64 +1,56 @@ package com.vydia.RNUploader import android.content.Context -import android.os.Handler -import android.os.Looper import androidx.work.WorkManager import com.vydia.RNUploader.UploaderModule.Companion.WORKER_TAG +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch // Stores and aggregates total progress from all workers -class UploadProgress { +object UploadProgress { + private data class Progress(var bytesUploaded: Long, val size: Long) - companion object { - private fun storage(context: Context) = - context.getSharedPreferences("RNFileUpload-Progress", Context.MODE_PRIVATE) + private val map = mutableMapOf() - @Synchronized - fun set(context: Context, uploadId: String, bytesUploaded: Long, fileSize: Long) = - storage(context).edit() - .putLong("$uploadId-uploaded", bytesUploaded) - .putLong("$uploadId-size", fileSize) - .apply() - - @Synchronized - fun remove(context: Context, uploadId: String) = - storage(context).edit() - .remove("$uploadId-uploaded") - .remove("$uploadId-size") - .apply() - - @Synchronized - fun total(context: Context): Double { - val storage = storage(context) - - val totalBytesUploaded = storage.all.keys - .filter { it.endsWith("-uploaded") } - .sumOf { storage.getLong(it, 0L) } + @Synchronized + fun add(id: String, size: Long) { + map[id] = Progress(bytesUploaded = 0L, size = size) + } - val totalFileSize = storage.all.keys - .filter { it.endsWith("-size") } - .sumOf { storage.getLong(it, 0L) } + @Synchronized + fun set(uploadId: String, bytesUploaded: Long) { + map[uploadId]?.bytesUploaded = bytesUploaded + } - if (totalFileSize == 0L) return 0.0 - return (totalBytesUploaded.toDouble() * 100 / totalFileSize) - } + @Synchronized + fun remove(uploadId: String) { + map.remove(uploadId) + } - private val handler = Handler(Looper.getMainLooper()) + @Synchronized + fun total(): Double { + val totalBytesUploaded = map.values.sumOf { it.bytesUploaded } + val totalFileSize = map.values.sumOf { it.size } + if (totalFileSize == 0L) return 0.0 + return (totalBytesUploaded.toDouble() * 100 / totalFileSize) + } - // Attempt to clear in 2 seconds. This is the simplest way to let the - // last worker reset the overall progress. - // Clearing progress ensures the notification starts at 0% next time. - fun scheduleClearing(context: Context) = - handler.postDelayed({ clearIfNeeded(context) }, 2000) + @Synchronized + private fun clearIfNeeded(context: Context) { + val workManager = WorkManager.getInstance(context) + val works = workManager.getWorkInfosByTag(WORKER_TAG).get() - @Synchronized - fun clearIfNeeded(context: Context) { - val workManager = WorkManager.getInstance(context) - val works = workManager.getWorkInfosByTag(WORKER_TAG).get() - if (works.any { !it.state.isFinished }) return + if (works.all { it.state.isFinished }) map.clear() + } - val storage = storage(context) - storage.edit().clear().apply() + init { + CoroutineScope(Dispatchers.IO).launch { + while (true) { + delay(5000L) + clearIfNeeded(UploaderModule.reactContext ?: continue) + } } } } \ No newline at end of file diff --git a/android/src/main/java/com/vydia/RNUploader/UploadUtils.kt b/android/src/main/java/com/vydia/RNUploader/UploadUtils.kt index 57da5aac..aadb415b 100644 --- a/android/src/main/java/com/vydia/RNUploader/UploadUtils.kt +++ b/android/src/main/java/com/vydia/RNUploader/UploadUtils.kt @@ -1,9 +1,14 @@ package com.vydia.RNUploader import kotlinx.coroutines.suspendCancellableCoroutine -import okhttp3.* +import okhttp3.Call +import okhttp3.Callback import okhttp3.Headers.Companion.toHeaders +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.RequestBody import okhttp3.RequestBody.Companion.asRequestBody +import okhttp3.Response import okio.Buffer import okio.BufferedSink import okio.ForwardingSink @@ -15,6 +20,11 @@ import kotlin.coroutines.resumeWithException // Throttling interval of progress reports private const val PROGRESS_INTERVAL = 500 // milliseconds +data class UploadResponse( + val code: Int, + val body: String, + val headers: Map +) // make an upload request using okhttp suspend fun okhttpUpload( @@ -23,7 +33,7 @@ suspend fun okhttpUpload( file: File, onProgress: (Long) -> Unit ) = - suspendCancellableCoroutine { continuation -> + suspendCancellableCoroutine { continuation -> val requestBody = file.asRequestBody() var lastProgressReport = 0L fun throttled(): Boolean { @@ -48,7 +58,14 @@ suspend fun okhttpUpload( continuation.resumeWithException(e) override fun onResponse(call: Call, response: Response) = - continuation.resumeWith(Result.success(response)) + response.use { + val result = UploadResponse( + response.code, + response.body?.string().takeIf { !it.isNullOrBlank() } ?: response.message, + response.headers.toMultimap().mapValues { it.value.joinToString(", ") } + ) + continuation.resumeWith(Result.success(result)) + } }) } diff --git a/android/src/main/java/com/vydia/RNUploader/UploadWorker.kt b/android/src/main/java/com/vydia/RNUploader/UploadWorker.kt index a3fc701b..cebf9edf 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.NotificationManager import android.app.PendingIntent import android.content.Context import android.content.Intent @@ -17,11 +18,9 @@ import androidx.work.WorkerParameters import com.google.gson.Gson import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay -import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Semaphore import kotlinx.coroutines.withContext import okhttp3.OkHttpClient -import okhttp3.Response import java.io.File import java.io.IOException import java.net.UnknownHostException @@ -59,6 +58,8 @@ class UploadWorker(private val context: Context, params: WorkerParameters) : private lateinit var upload: Upload private var retries = 0 private var connectivity = Connectivity.Ok + private val notificationManager = + context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager override suspend fun doWork(): Result = withContext(Dispatchers.IO) { // Retrieve the upload. If this throws errors, error reporting won't work. @@ -115,48 +116,46 @@ class UploadWorker(private val context: Context, params: WorkerParameters) : return@withContext Result.failure() } - private suspend fun upload(): Response? = withContext(Dispatchers.IO) { + private suspend fun upload(): UploadResponse? { val file = File(upload.path) val size = file.length() // Register progress asap so the total progress is accurate // This needs to happen before the semaphore wait - handleProgress(0, size) + UploadProgress.add(upload.id, size) // Don't bother to run on an invalid network - if (!validateAndReportConnectivity()) return@withContext null + if (!validateAndReportConnectivity()) return null // wait for its turn to run semaphore.acquire() try { - val response = okhttpUpload(client, upload, file) { progress -> - launch { handleProgress(progress, size) } + return okhttpUpload(client, upload, file) { progress -> + handleProgress(progress, size) } - - handleProgress(size, size) - return@withContext response - } - // don't catch, propagate error up - finally { + } catch (error: Throwable) { + // reset progress on error + UploadProgress.set(upload.id, 0L) + // pass the error to upper layer for retry decision + throw error + } finally { semaphore.release() } } - private suspend fun handleProgress(bytesSentTotal: Long, fileSize: Long) { - UploadProgress.set(context, upload.id, bytesSentTotal, fileSize) + private fun handleProgress(bytesSentTotal: Long, fileSize: Long) { + UploadProgress.set(upload.id, bytesSentTotal) EventReporter.progress(upload.id, bytesSentTotal, fileSize) - setForeground(getForegroundInfo()) + notificationManager.notify(upload.notificationId, buildNotification()) } - private fun handleSuccess(response: Response) { - UploadProgress.scheduleClearing(context) + private fun handleSuccess(response: UploadResponse) { EventReporter.success(upload.id, response) } private fun handleError(error: Throwable) { - UploadProgress.remove(context, upload.id) - UploadProgress.scheduleClearing(context) + UploadProgress.remove(upload.id) EventReporter.error(upload.id, error) } @@ -165,14 +164,13 @@ class UploadWorker(private val context: Context, params: WorkerParameters) : private fun checkAndHandleCancellation(): Boolean { if (!isStopped) return false - UploadProgress.remove(context, upload.id) - UploadProgress.scheduleClearing(context) + UploadProgress.remove(upload.id) EventReporter.cancelled(upload.id) return true } /** @return whether to retry */ - private suspend fun checkRetry(error: Throwable): Boolean { + private fun checkRetry(error: Throwable): Boolean { var unlimitedRetry = false // Error was thrown due to unmet network preferences. @@ -202,19 +200,17 @@ class UploadWorker(private val context: Context, params: WorkerParameters) : } // Checks connection and alerts connection issues - private suspend fun validateAndReportConnectivity(): Boolean { + private fun validateAndReportConnectivity(): Boolean { this.connectivity = validateConnectivity(context, upload.wifiOnly) // alert connectivity mode - setForeground(getForegroundInfo()) + notificationManager.notify(upload.notificationId, buildNotification()) return this.connectivity == Connectivity.Ok } // builds the notification required to enable Foreground mode - override suspend fun getForegroundInfo(): ForegroundInfo { - // All workers share the same notification that shows the total progress - val id = upload.notificationId.hashCode() + fun buildNotification(): Notification { val channel = upload.notificationChannel - val progress = UploadProgress.total(context) + val progress = UploadProgress.total() val progress2Decimals = "%.2f".format(progress) val title = when (connectivity) { Connectivity.NoWifi -> upload.notificationTitleNoWifi @@ -230,7 +226,7 @@ class UploadWorker(private val context: Context, params: WorkerParameters) : content.setTextViewText(R.id.notification_progress, "${progress2Decimals}%") content.setProgressBar(R.id.notification_progress_bar, 100, progress.toInt(), false) - val notification = NotificationCompat.Builder(context, channel).run { + return NotificationCompat.Builder(context, channel).run { // Starting Android 12, the notification shows up with a confusing delay of 10s. // This fixes that delay. if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) @@ -248,7 +244,11 @@ class UploadWorker(private val context: Context, params: WorkerParameters) : setContentIntent(openAppIntent(context)) build() } + } + override suspend fun getForegroundInfo(): ForegroundInfo { + val notification = buildNotification() + val id = upload.notificationId // Starting Android 14, FOREGROUND_SERVICE_TYPE_DATA_SYNC is mandatory, otherwise app will crash return if (Build.VERSION.SDK_INT > Build.VERSION_CODES.TIRAMISU) ForegroundInfo(id, notification, ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC) diff --git a/android/src/main/java/com/vydia/RNUploader/UploaderModule.kt b/android/src/main/java/com/vydia/RNUploader/UploaderModule.kt index 0b6db02b..0d833fc3 100644 --- a/android/src/main/java/com/vydia/RNUploader/UploaderModule.kt +++ b/android/src/main/java/com/vydia/RNUploader/UploaderModule.kt @@ -5,7 +5,11 @@ import androidx.work.ExistingWorkPolicy import androidx.work.OneTimeWorkRequestBuilder import androidx.work.WorkManager import androidx.work.workDataOf -import com.facebook.react.bridge.* +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.ReadableMap import com.google.gson.Gson @@ -23,9 +27,6 @@ class UploaderModule(context: ReactApplicationContext) : init { reactContext = context - // workers may be killed abruptly for whatever reasons, - // so they might not have had a chance to clear the progress data. - UploadProgress.clearIfNeeded(context) } diff --git a/example/RNBGUExample/android/build.gradle b/example/RNBGUExample/android/build.gradle index c83c9a07..ca04cb46 100644 --- a/example/RNBGUExample/android/build.gradle +++ b/example/RNBGUExample/android/build.gradle @@ -1,7 +1,7 @@ buildscript { ext { buildToolsVersion = "34.0.0" - minSdkVersion = 24 + minSdkVersion = 29 compileSdkVersion = 34 targetSdkVersion = 34 ndkVersion = "26.1.10909125" From 8d3d07415dd10e6624adb168f6d963b4d9c08f3a Mon Sep 17 00:00:00 2001 From: thomasvo Date: Thu, 16 Oct 2025 15:41:20 -0700 Subject: [PATCH 02/18] doc --- android/src/main/java/com/vydia/RNUploader/UploadUtils.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/android/src/main/java/com/vydia/RNUploader/UploadUtils.kt b/android/src/main/java/com/vydia/RNUploader/UploadUtils.kt index aadb415b..1df757b9 100644 --- a/android/src/main/java/com/vydia/RNUploader/UploadUtils.kt +++ b/android/src/main/java/com/vydia/RNUploader/UploadUtils.kt @@ -58,7 +58,7 @@ suspend fun okhttpUpload( continuation.resumeWithException(e) override fun onResponse(call: Call, response: Response) = - response.use { + response.use { // close the response asap val result = UploadResponse( response.code, response.body?.string().takeIf { !it.isNullOrBlank() } ?: response.message, From 710a9caf8415542e587b73027d950bfe125cfb70 Mon Sep 17 00:00:00 2001 From: thomasvo Date: Thu, 16 Oct 2025 15:50:45 -0700 Subject: [PATCH 03/18] fix --- .../com/vydia/RNUploader/UploadProgress.kt | 44 +++++++++---------- .../java/com/vydia/RNUploader/UploadWorker.kt | 1 + 2 files changed, 23 insertions(+), 22 deletions(-) diff --git a/android/src/main/java/com/vydia/RNUploader/UploadProgress.kt b/android/src/main/java/com/vydia/RNUploader/UploadProgress.kt index 7a7c9b88..412f7073 100644 --- a/android/src/main/java/com/vydia/RNUploader/UploadProgress.kt +++ b/android/src/main/java/com/vydia/RNUploader/UploadProgress.kt @@ -1,16 +1,15 @@ package com.vydia.RNUploader -import android.content.Context -import androidx.work.WorkManager -import com.vydia.RNUploader.UploaderModule.Companion.WORKER_TAG -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.delay -import kotlinx.coroutines.launch +import android.os.Handler +import android.os.Looper // Stores and aggregates total progress from all workers object UploadProgress { - private data class Progress(var bytesUploaded: Long, val size: Long) + private data class Progress( + var bytesUploaded: Long, + val size: Long, + var complete: Boolean = false + ) private val map = mutableMapOf() @@ -24,6 +23,19 @@ object UploadProgress { map[uploadId]?.bytesUploaded = bytesUploaded } + @Synchronized + fun complete(uploadId: String) { + map[uploadId]?.let { + it.bytesUploaded = it.size + it.complete = true + } + + // Attempt to clear in 2 seconds. This is the simplest way to let the + // last worker reset the overall progress. + // Clearing progress ensures the notification starts at 0% next time. + Handler(Looper.getMainLooper()).postDelayed({ clearIfNeeded() }, 2000) + } + @Synchronized fun remove(uploadId: String) { map.remove(uploadId) @@ -38,19 +50,7 @@ object UploadProgress { } @Synchronized - private fun clearIfNeeded(context: Context) { - val workManager = WorkManager.getInstance(context) - val works = workManager.getWorkInfosByTag(WORKER_TAG).get() - - if (works.all { it.state.isFinished }) map.clear() - } - - init { - CoroutineScope(Dispatchers.IO).launch { - while (true) { - delay(5000L) - clearIfNeeded(UploaderModule.reactContext ?: continue) - } - } + private fun clearIfNeeded() { + if (map.values.all { it.complete }) map.clear() } } \ No newline at end of file diff --git a/android/src/main/java/com/vydia/RNUploader/UploadWorker.kt b/android/src/main/java/com/vydia/RNUploader/UploadWorker.kt index cebf9edf..a3bbec7c 100644 --- a/android/src/main/java/com/vydia/RNUploader/UploadWorker.kt +++ b/android/src/main/java/com/vydia/RNUploader/UploadWorker.kt @@ -151,6 +151,7 @@ class UploadWorker(private val context: Context, params: WorkerParameters) : } private fun handleSuccess(response: UploadResponse) { + UploadProgress.complete(upload.id) EventReporter.success(upload.id, response) } From a3ac03ba0fbbb2a20c84fee5ff5b198c638bbcad Mon Sep 17 00:00:00 2001 From: thomasvo Date: Thu, 16 Oct 2025 15:51:56 -0700 Subject: [PATCH 04/18] simpler --- .../com/vydia/RNUploader/EventReporter.kt | 88 +++++++++---------- 1 file changed, 43 insertions(+), 45 deletions(-) diff --git a/android/src/main/java/com/vydia/RNUploader/EventReporter.kt b/android/src/main/java/com/vydia/RNUploader/EventReporter.kt index 0ded9ba0..3604a137 100644 --- a/android/src/main/java/com/vydia/RNUploader/EventReporter.kt +++ b/android/src/main/java/com/vydia/RNUploader/EventReporter.kt @@ -6,51 +6,49 @@ import com.facebook.react.bridge.WritableMap import com.facebook.react.modules.core.DeviceEventManagerModule.RCTDeviceEventEmitter // Sends events to React Native -class EventReporter { - companion object { - private const val TAG = "UploadReceiver" - fun cancelled(uploadId: String) = - sendEvent("cancelled", Arguments.createMap().apply { - putString("id", uploadId) - }) - - fun error(uploadId: String, exception: Throwable) = - sendEvent("error", Arguments.createMap().apply { - putString("id", uploadId) - putString("error", exception.message ?: "Unknown exception") - }) - - fun success(uploadId: String, response: UploadResponse) = - sendEvent("completed", Arguments.createMap().apply { - putString("id", uploadId) - putInt("responseCode", response.code) - putString("responseBody", response.body) - putMap("responseHeaders", Arguments.makeNativeMap(response.headers)) - }) - - - fun progress(uploadId: String, bytesSentTotal: Long, contentLength: Long) = - sendEvent("progress", Arguments.createMap().apply { - putString("id", uploadId) - putDouble("progress", (bytesSentTotal.toDouble() * 100 / contentLength)) //0-100 - }) - - fun notification() = sendEvent("notification") - - /** Sends an event to the JS module */ - private fun sendEvent(eventName: String, params: WritableMap = Arguments.createMap()) { - val reactContext = UploaderModule.reactContext ?: return - - // Right after JS reloads, react instance might not be available yet - if (!reactContext.hasActiveReactInstance()) return - - try { - val jsModule = reactContext.getJSModule(RCTDeviceEventEmitter::class.java) - jsModule.emit("RNFileUploader-$eventName", params) - } catch (exc: Throwable) { - Log.e(TAG, "sendEvent() failed", exc) - } +object EventReporter { + + private const val TAG = "UploadReceiver" + fun cancelled(uploadId: String) = + sendEvent("cancelled", Arguments.createMap().apply { + putString("id", uploadId) + }) + + fun error(uploadId: String, exception: Throwable) = + sendEvent("error", Arguments.createMap().apply { + putString("id", uploadId) + putString("error", exception.message ?: "Unknown exception") + }) + + fun success(uploadId: String, response: UploadResponse) = + sendEvent("completed", Arguments.createMap().apply { + putString("id", uploadId) + putInt("responseCode", response.code) + putString("responseBody", response.body) + putMap("responseHeaders", Arguments.makeNativeMap(response.headers)) + }) + + + fun progress(uploadId: String, bytesSentTotal: Long, contentLength: Long) = + sendEvent("progress", Arguments.createMap().apply { + putString("id", uploadId) + putDouble("progress", (bytesSentTotal.toDouble() * 100 / contentLength)) //0-100 + }) + + fun notification() = sendEvent("notification") + + /** Sends an event to the JS module */ + private fun sendEvent(eventName: String, params: WritableMap = Arguments.createMap()) { + val reactContext = UploaderModule.reactContext ?: return + + // Right after JS reloads, react instance might not be available yet + if (!reactContext.hasActiveReactInstance()) return + + try { + val jsModule = reactContext.getJSModule(RCTDeviceEventEmitter::class.java) + jsModule.emit("RNFileUploader-$eventName", params) + } catch (exc: Throwable) { + Log.e(TAG, "sendEvent() failed", exc) } } - } From e3c135ce217faef9c69bcc6f0345ad305f51509a Mon Sep 17 00:00:00 2001 From: thomasvo Date: Thu, 16 Oct 2025 16:11:48 -0700 Subject: [PATCH 05/18] fix --- .../src/main/java/com/vydia/RNUploader/UploadUtils.kt | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/android/src/main/java/com/vydia/RNUploader/UploadUtils.kt b/android/src/main/java/com/vydia/RNUploader/UploadUtils.kt index 1df757b9..38d4f8b1 100644 --- a/android/src/main/java/com/vydia/RNUploader/UploadUtils.kt +++ b/android/src/main/java/com/vydia/RNUploader/UploadUtils.kt @@ -57,15 +57,17 @@ suspend fun okhttpUpload( override fun onFailure(call: Call, e: IOException) = continuation.resumeWithException(e) - override fun onResponse(call: Call, response: Response) = - response.use { // close the response asap - val result = UploadResponse( + override fun onResponse(call: Call, response: Response) { + val result = response.use { // close the response asap + UploadResponse( response.code, response.body?.string().takeIf { !it.isNullOrBlank() } ?: response.message, response.headers.toMultimap().mapValues { it.value.joinToString(", ") } ) - continuation.resumeWith(Result.success(result)) } + + continuation.resumeWith(Result.success(result)) + } }) } From 67b69443208e95582731d5d2da75dfb3fc375bd4 Mon Sep 17 00:00:00 2001 From: thomasvo Date: Thu, 16 Oct 2025 17:33:28 -0700 Subject: [PATCH 06/18] fix --- .../RNUploader/MissingOptionException.kt | 4 ++ .../java/com/vydia/RNUploader/Notification.kt | 49 +++++++++++++++ .../main/java/com/vydia/RNUploader/Upload.kt | 18 ------ .../java/com/vydia/RNUploader/UploadWorker.kt | 40 ++++++------ .../com/vydia/RNUploader/UploaderModule.kt | 16 ++++- example/RNBGUExample/App.tsx | 62 ++++++++++--------- lib/index.d.ts | 5 +- lib/types.d.ts | 3 +- src/index.ts | 27 ++++++-- src/types.ts | 3 +- 10 files changed, 149 insertions(+), 78 deletions(-) create mode 100644 android/src/main/java/com/vydia/RNUploader/MissingOptionException.kt create mode 100644 android/src/main/java/com/vydia/RNUploader/Notification.kt diff --git a/android/src/main/java/com/vydia/RNUploader/MissingOptionException.kt b/android/src/main/java/com/vydia/RNUploader/MissingOptionException.kt new file mode 100644 index 00000000..c1ab351d --- /dev/null +++ b/android/src/main/java/com/vydia/RNUploader/MissingOptionException.kt @@ -0,0 +1,4 @@ +package com.vydia.RNUploader + +class MissingOptionException(optionName: String) : + IllegalArgumentException("Missing '$optionName'") \ No newline at end of file diff --git a/android/src/main/java/com/vydia/RNUploader/Notification.kt b/android/src/main/java/com/vydia/RNUploader/Notification.kt new file mode 100644 index 00000000..0fdabaf0 --- /dev/null +++ b/android/src/main/java/com/vydia/RNUploader/Notification.kt @@ -0,0 +1,49 @@ +package com.vydia.RNUploader + +import com.facebook.react.bridge.ReadableMap + + +object UploadNotification { + var id: Int = 0 + private set + var title: String = "Uploading files" + private set + var titleNoInternet: String = "Waiting for internet connection" + private set + var titleNoWifi: String = "Waiting for WiFi connection" + private set + var channel: String = "File Uploads" + private set + + private var activeUpload: Upload? = null + + @Synchronized + fun setActiveUpload(upload: Upload?) { + this.activeUpload = upload + } + + @Synchronized + fun getActiveUpload(): Upload? { + return this.activeUpload + } + + @Synchronized + fun releaseActiveUpload(upload: Upload) { + if (this.activeUpload?.id == upload.id) this.activeUpload = null + } + + fun update(opts: ReadableMap) { + id = opts.getString("notificationId")?.hashCode() + ?: throw MissingOptionException("notificationId") + title = opts.getString("notificationTitle") + ?: throw MissingOptionException("notificationTitle") + titleNoInternet = opts.getString("notificationTitleNoInternet") + ?: throw MissingOptionException("notificationTitleNoInternet") + titleNoWifi = opts.getString("notificationTitleNoWifi") + ?: throw MissingOptionException("notificationTitleNoWifi") + channel = opts.getString("notificationChannel") + ?: throw MissingOptionException("notificationChannel") + } +} + + diff --git a/android/src/main/java/com/vydia/RNUploader/Upload.kt b/android/src/main/java/com/vydia/RNUploader/Upload.kt index db7b83d5..9d22a82b 100644 --- a/android/src/main/java/com/vydia/RNUploader/Upload.kt +++ b/android/src/main/java/com/vydia/RNUploader/Upload.kt @@ -14,15 +14,7 @@ data class Upload( val maxRetries: Int, val wifiOnly: Boolean, val headers: Map, - val notificationId: Int, - val notificationTitle: String, - val notificationTitleNoInternet: String, - val notificationTitleNoWifi: String, - val notificationChannel: String, ) { - class MissingOptionException(optionName: String) : - IllegalArgumentException("Missing '$optionName'") - companion object { fun fromReadableMap(map: ReadableMap) = Upload( id = map.getString("customUploadId") ?: UUID.randomUUID().toString(), @@ -39,16 +31,6 @@ data class Upload( } return@let map }, - notificationId = map.getString(Upload::notificationId.name)?.hashCode() - ?: throw MissingOptionException(Upload::notificationId.name), - notificationTitle = map.getString(Upload::notificationTitle.name) - ?: throw MissingOptionException(Upload::notificationTitle.name), - notificationTitleNoInternet = map.getString(Upload::notificationTitleNoInternet.name) - ?: throw MissingOptionException(Upload::notificationTitleNoInternet.name), - notificationTitleNoWifi = map.getString(Upload::notificationTitleNoWifi.name) - ?: throw MissingOptionException(Upload::notificationTitleNoWifi.name), - notificationChannel = map.getString(Upload::notificationChannel.name) - ?: throw MissingOptionException(Upload::notificationChannel.name), ) } } diff --git a/android/src/main/java/com/vydia/RNUploader/UploadWorker.kt b/android/src/main/java/com/vydia/RNUploader/UploadWorker.kt index a3bbec7c..63b8bc38 100644 --- a/android/src/main/java/com/vydia/RNUploader/UploadWorker.kt +++ b/android/src/main/java/com/vydia/RNUploader/UploadWorker.kt @@ -57,7 +57,6 @@ class UploadWorker(private val context: Context, params: WorkerParameters) : private lateinit var upload: Upload private var retries = 0 - private var connectivity = Connectivity.Ok private val notificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager @@ -125,14 +124,19 @@ class UploadWorker(private val context: Context, params: WorkerParameters) : UploadProgress.add(upload.id, size) // Don't bother to run on an invalid network - if (!validateAndReportConnectivity()) return null + if (getConnectivity(context, upload.wifiOnly) != Connectivity.Ok) return null // wait for its turn to run semaphore.acquire() + // mark as active upload for notification + UploadNotification.setActiveUpload(upload) + notificationManager.notify(UploadNotification.id, buildNotification()) + try { return okhttpUpload(client, upload, file) { progress -> handleProgress(progress, size) + notificationManager.notify(UploadNotification.id, buildNotification()) } } catch (error: Throwable) { // reset progress on error @@ -141,13 +145,15 @@ class UploadWorker(private val context: Context, params: WorkerParameters) : throw error } finally { semaphore.release() + // by this point, another worker might have started and set itself as active upload + // so only clear if it's still this upload + UploadNotification.releaseActiveUpload(upload) } } private fun handleProgress(bytesSentTotal: Long, fileSize: Long) { UploadProgress.set(upload.id, bytesSentTotal) EventReporter.progress(upload.id, bytesSentTotal, fileSize) - notificationManager.notify(upload.notificationId, buildNotification()) } private fun handleSuccess(response: UploadResponse) { @@ -176,7 +182,7 @@ class UploadWorker(private val context: Context, params: WorkerParameters) : // Error was thrown due to unmet network preferences. // Also happens every time you switch from one network to any other - if (!validateAndReportConnectivity()) unlimitedRetry = true + if (getConnectivity(context, upload.wifiOnly) != Connectivity.Ok) unlimitedRetry = true // Due to the flaky nature of networking, sometimes the network is // valid but the URL is still inaccessible, so keep waiting until // the URL is accessible @@ -200,23 +206,19 @@ class UploadWorker(private val context: Context, params: WorkerParameters) : return retries <= upload.maxRetries } - // Checks connection and alerts connection issues - private fun validateAndReportConnectivity(): Boolean { - this.connectivity = validateConnectivity(context, upload.wifiOnly) - // alert connectivity mode - notificationManager.notify(upload.notificationId, buildNotification()) - return this.connectivity == Connectivity.Ok - } // builds the notification required to enable Foreground mode fun buildNotification(): Notification { - val channel = upload.notificationChannel + // since all workers share the same notification ID, + // get the active upload so we don't overwrite the notification when multiple uploads are running + val upload = UploadNotification.getActiveUpload() ?: this.upload + val channel = UploadNotification.channel val progress = UploadProgress.total() val progress2Decimals = "%.2f".format(progress) - val title = when (connectivity) { - Connectivity.NoWifi -> upload.notificationTitleNoWifi - Connectivity.NoInternet -> upload.notificationTitleNoInternet - Connectivity.Ok -> upload.notificationTitle + val title = when (getConnectivity(context, upload.wifiOnly)) { + Connectivity.NoWifi -> UploadNotification.titleNoWifi + Connectivity.NoInternet -> UploadNotification.titleNoInternet + Connectivity.Ok -> UploadNotification.title } // Custom layout for progress notification. @@ -249,7 +251,7 @@ class UploadWorker(private val context: Context, params: WorkerParameters) : override suspend fun getForegroundInfo(): ForegroundInfo { val notification = buildNotification() - val id = upload.notificationId + val id = UploadNotification.id // Starting Android 14, FOREGROUND_SERVICE_TYPE_DATA_SYNC is mandatory, otherwise app will crash return if (Build.VERSION.SDK_INT > Build.VERSION_CODES.TIRAMISU) ForegroundInfo(id, notification, ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC) @@ -258,9 +260,7 @@ class UploadWorker(private val context: Context, params: WorkerParameters) : } } -// This is outside and synchronized to ensure consistent status across workers -@Synchronized -private fun validateConnectivity(context: Context, wifiOnly: Boolean): Connectivity { +private fun getConnectivity(context: Context, wifiOnly: Boolean): Connectivity { val manager = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager val network = manager.activeNetwork val capabilities = manager.getNetworkCapabilities(network) diff --git a/android/src/main/java/com/vydia/RNUploader/UploaderModule.kt b/android/src/main/java/com/vydia/RNUploader/UploaderModule.kt index 0d833fc3..a0a4a5d2 100644 --- a/android/src/main/java/com/vydia/RNUploader/UploaderModule.kt +++ b/android/src/main/java/com/vydia/RNUploader/UploaderModule.kt @@ -43,7 +43,7 @@ class UploaderModule(context: ReactApplicationContext) : val id = startUpload(rawOptions) promise.resolve(id) } catch (exc: Throwable) { - if (exc !is Upload.MissingOptionException) { + if (exc !is MissingOptionException) { exc.printStackTrace() Log.e(TAG, exc.message, exc) } @@ -74,6 +74,20 @@ class UploaderModule(context: ReactApplicationContext) : return upload.id } + @ReactMethod + fun initialize(options: ReadableMap, promise: Promise) { + try { + UploadNotification.update(options) + promise.resolve(true) + } catch (exc: Throwable) { + if (exc !is MissingOptionException) { + exc.printStackTrace() + Log.e(TAG, exc.message, exc) + } + promise.reject(exc) + } + } + /* * Cancels file upload diff --git a/example/RNBGUExample/App.tsx b/example/RNBGUExample/App.tsx index e7e44655..09f88888 100644 --- a/example/RNBGUExample/App.tsx +++ b/example/RNBGUExample/App.tsx @@ -28,6 +28,16 @@ const TEST_FILE_URL = 'https://gist.githubusercontent.com/khaykov/a6105154becce4c0530da38e723c2330/raw/41ab415ac41c93a198f7da5b47d604956157c5c3/gistfile1.txt'; const UPLOAD_URL = 'https://httpbin.org/put/404'; +const channelId = 'RNBGUExample'; + +Upload.initialize({ + notificationId: channelId, + notificationTitle: channelId, + notificationTitleNoWifi: 'No wifi', + notificationTitleNoInternet: 'No internet', + notificationChannel: channelId, +}); + const App = () => { const [uploadId, setUploadId] = useState(); const [progress, setProgress] = useState(); @@ -63,41 +73,35 @@ const App = () => { const onPressUpload = async () => { await notifee.requestPermission({alert: true, sound: true}); - const channelId = 'RNBGUExample'; await notifee.createChannel({ id: channelId, name: channelId, importance: AndroidImportance.LOW, }); - const uploadOpts: UploadOptions = { - android: { - notificationId: channelId, - notificationTitle: channelId, - notificationTitleNoWifi: 'No wifi', - notificationTitleNoInternet: 'No internet', - notificationChannel: channelId, - }, - type: 'raw', - url: UPLOAD_URL, - path: TEST_FILE, - method: 'POST', - headers: {}, - }; - - Upload.startUpload(uploadOpts) - .then(uploadId => { - console.log( - `Upload started with options: ${JSON.stringify(uploadOpts)}`, - ); - setUploadId(uploadId); - setProgress(0); - }) - .catch(function (err) { - setUploadId(undefined); - setProgress(undefined); - console.log('Upload error!', err); - }); + for (let i = 0; i < 100; i++) { + const uploadOpts: UploadOptions = { + type: 'raw', + url: UPLOAD_URL, + path: TEST_FILE, + method: 'POST', + headers: {}, + }; + + Upload.startUpload(uploadOpts) + .then(uploadId => { + console.log( + `Upload started with options: ${JSON.stringify(uploadOpts)}`, + ); + setUploadId(uploadId); + setProgress(0); + }) + .catch(function (err) { + setUploadId(undefined); + setProgress(undefined); + console.log('Upload error!', err); + }); + } }; return ( diff --git a/lib/index.d.ts b/lib/index.d.ts index 3beeecd0..3338c98a 100644 --- a/lib/index.d.ts +++ b/lib/index.d.ts @@ -1,7 +1,8 @@ -import { AddListener, UploadOptions } from './types'; +import { AddListener, AndroidInitializationOptions, UploadOptions } from './types'; export * from './types'; declare const _default: { - startUpload: ({ path, android, ios, ...options }: UploadOptions) => Promise; + initialize: (options: AndroidInitializationOptions) => void; + startUpload: ({ path, ios, ...options }: UploadOptions) => Promise; cancelUpload: (cancelUploadId: string) => Promise; addListener: AddListener; ios: { diff --git a/lib/types.d.ts b/lib/types.d.ts index 085ed05f..2d4fbbb3 100644 --- a/lib/types.d.ts +++ b/lib/types.d.ts @@ -22,10 +22,9 @@ export type UploadOptions = { [index: string]: string; }; wifiOnly?: boolean; - android: AndroidOnlyUploadOptions; ios?: IOSOnlyUploadOptions; } & RawUploadOptions; -type AndroidOnlyUploadOptions = { +export type AndroidInitializationOptions = { notificationId: string; notificationTitle: string; notificationTitleNoWifi: string; diff --git a/src/index.ts b/src/index.ts index 4710e914..acab0e95 100644 --- a/src/index.ts +++ b/src/index.ts @@ -2,7 +2,12 @@ * 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, + AndroidInitializationOptions, + UploadId, + UploadOptions, +} from './types'; export * from './types'; @@ -19,6 +24,18 @@ if (NativeModules.VydiaRNFileUploader) { NativeModule.addListener(eventPrefix + 'completed'); } +let initializedPromise: Promise | undefined; + +/** + * Initializes the module with the given options. + * Must be called at the global level before starting any uploads. + * The notification channel doesn't have to be created beforehand. + * @param options + */ +const initialize = (options: AndroidInitializationOptions) => { + initializedPromise = Promise.resolve(NativeModule.initialize?.(options)); +}; + /** * Starts uploading a file to an HTTP endpoint. * Options object: @@ -35,12 +52,13 @@ if (NativeModules.VydiaRNFileUploader) { * 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. */ -const startUpload = ({ +const startUpload = async ({ path, - android, ios, ...options }: UploadOptions): Promise => { + await initializedPromise; + if (!path.startsWith(fileURIPrefix)) { path = fileURIPrefix + path; } @@ -49,7 +67,7 @@ const startUpload = ({ path = path.replace(fileURIPrefix, ''); } - return NativeModule.startUpload({ ...options, ...android, ...ios, path }); + return NativeModule.startUpload({ ...options, ...ios, path }); }; /** @@ -109,6 +127,7 @@ const android = { }; export default { + initialize, startUpload, cancelUpload, addListener, diff --git a/src/types.ts b/src/types.ts index 2498be65..ca10c9bd 100644 --- a/src/types.ts +++ b/src/types.ts @@ -29,11 +29,10 @@ export type UploadOptions = { }; // Whether the upload should wait for wifi before starting wifiOnly?: boolean; - android: AndroidOnlyUploadOptions; ios?: IOSOnlyUploadOptions; } & RawUploadOptions; -type AndroidOnlyUploadOptions = { +export type AndroidInitializationOptions = { notificationId: string; notificationTitle: string; notificationTitleNoWifi: string; From 0467b424c378a7b7770d8563c81a9e4cf3bb0414 Mon Sep 17 00:00:00 2001 From: thomasvo Date: Thu, 16 Oct 2025 17:36:01 -0700 Subject: [PATCH 07/18] fix --- src/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/index.ts b/src/index.ts index acab0e95..8f789d37 100644 --- a/src/index.ts +++ b/src/index.ts @@ -28,7 +28,7 @@ let initializedPromise: Promise | undefined; /** * Initializes the module with the given options. - * Must be called at the global level before starting any uploads. + * Must be called before starting any uploads. * The notification channel doesn't have to be created beforehand. * @param options */ From 6f402f821332a4c98976e78e51b5d62202726a56 Mon Sep 17 00:00:00 2001 From: thomasvo Date: Thu, 16 Oct 2025 17:47:47 -0700 Subject: [PATCH 08/18] fix --- .../java/com/vydia/RNUploader/Connectivity.kt | 30 +++++++ .../java/com/vydia/RNUploader/Notification.kt | 65 +++++++++++++- .../java/com/vydia/RNUploader/UploadWorker.kt | 87 ++----------------- .../com/vydia/RNUploader/UploaderModule.kt | 2 +- 4 files changed, 103 insertions(+), 81 deletions(-) create mode 100644 android/src/main/java/com/vydia/RNUploader/Connectivity.kt diff --git a/android/src/main/java/com/vydia/RNUploader/Connectivity.kt b/android/src/main/java/com/vydia/RNUploader/Connectivity.kt new file mode 100644 index 00000000..3b3fa823 --- /dev/null +++ b/android/src/main/java/com/vydia/RNUploader/Connectivity.kt @@ -0,0 +1,30 @@ +package com.vydia.RNUploader + +import android.content.Context +import android.net.ConnectivityManager +import android.net.NetworkCapabilities.NET_CAPABILITY_VALIDATED +import android.net.NetworkCapabilities.TRANSPORT_WIFI + +enum class Connectivity { + NoWifi, NoInternet, Ok; + + companion object { + fun fetch(context: Context, wifiOnly: Boolean): Connectivity { + val manager = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager + val network = manager.activeNetwork + val capabilities = manager.getNetworkCapabilities(network) + + val hasInternet = capabilities?.hasCapability(NET_CAPABILITY_VALIDATED) == true + + // not wifiOnly, return early + if (!wifiOnly) return if (hasInternet) Ok else NoInternet + + // handle wifiOnly + return if (hasInternet && capabilities?.hasTransport(TRANSPORT_WIFI) == true) + Ok + else + NoWifi // don't return NoInternet here, more direct to request to join wifi + } + } +} + diff --git a/android/src/main/java/com/vydia/RNUploader/Notification.kt b/android/src/main/java/com/vydia/RNUploader/Notification.kt index 0fdabaf0..cd282bda 100644 --- a/android/src/main/java/com/vydia/RNUploader/Notification.kt +++ b/android/src/main/java/com/vydia/RNUploader/Notification.kt @@ -1,5 +1,13 @@ package com.vydia.RNUploader +import android.app.Notification +import android.app.NotificationManager +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import android.os.Build +import android.widget.RemoteViews +import androidx.core.app.NotificationCompat import com.facebook.react.bridge.ReadableMap @@ -32,7 +40,7 @@ object UploadNotification { if (this.activeUpload?.id == upload.id) this.activeUpload = null } - fun update(opts: ReadableMap) { + fun setOptions(opts: ReadableMap) { id = opts.getString("notificationId")?.hashCode() ?: throw MissingOptionException("notificationId") title = opts.getString("notificationTitle") @@ -44,6 +52,61 @@ object UploadNotification { channel = opts.getString("notificationChannel") ?: throw MissingOptionException("notificationChannel") } + + // builds the notification required to enable Foreground mode + fun build(context: Context): Notification { + // since all workers share the same notification ID, + // get the active upload so we don't overwrite the notification when multiple uploads are running + val wifiOnly = getActiveUpload()?.wifiOnly ?: false + val channel = channel + val progress = UploadProgress.total() + val progress2Decimals = "%.2f".format(progress) + val title = when (Connectivity.fetch(context, wifiOnly)) { + Connectivity.NoWifi -> titleNoWifi + Connectivity.NoInternet -> titleNoInternet + Connectivity.Ok -> title + } + + // Custom layout for progress notification. + // The default hides the % text. This one shows it on the right, + // like most examples in various docs. + val content = RemoteViews(context.packageName, R.layout.notification) + content.setTextViewText(R.id.notification_title, title) + content.setTextViewText(R.id.notification_progress, "${progress2Decimals}%") + content.setProgressBar(R.id.notification_progress_bar, 100, progress.toInt(), false) + + return NotificationCompat.Builder(context, channel).run { + // Starting Android 12, the notification shows up with a confusing delay of 10s. + // This fixes that delay. + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) + foregroundServiceBehavior = Notification.FOREGROUND_SERVICE_IMMEDIATE + + // Required by android. Here we use the system's default upload icon + setSmallIcon(android.R.drawable.stat_sys_upload) + // These prevent the notification from being force-dismissed or dismissed when pressed + setOngoing(true) + setAutoCancel(false) + // These help show the same custom content when the notification collapses and expands + setCustomContentView(content) + setCustomBigContentView(content) + // opens the app when the notification is pressed + setContentIntent(openAppIntent(context)) + build() + } + } + + fun update(context: Context) { + val notification = build(context) + val manager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + manager.notify(id, notification) + } +} + + +private fun openAppIntent(context: Context): PendingIntent? { + val intent = Intent(context, NotificationReceiver::class.java) + val flags = PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + return PendingIntent.getBroadcast(context, "RNFileUpload-notification".hashCode(), intent, flags) } diff --git a/android/src/main/java/com/vydia/RNUploader/UploadWorker.kt b/android/src/main/java/com/vydia/RNUploader/UploadWorker.kt index 63b8bc38..a26d05ac 100644 --- a/android/src/main/java/com/vydia/RNUploader/UploadWorker.kt +++ b/android/src/main/java/com/vydia/RNUploader/UploadWorker.kt @@ -1,17 +1,9 @@ package com.vydia.RNUploader -import android.app.Notification import android.app.NotificationManager -import android.app.PendingIntent import android.content.Context -import android.content.Intent import android.content.pm.ServiceInfo -import android.net.ConnectivityManager -import android.net.NetworkCapabilities.NET_CAPABILITY_VALIDATED -import android.net.NetworkCapabilities.TRANSPORT_WIFI import android.os.Build -import android.widget.RemoteViews -import androidx.core.app.NotificationCompat import androidx.work.CoroutineWorker import androidx.work.ForegroundInfo import androidx.work.WorkerParameters @@ -48,8 +40,6 @@ private val client = OkHttpClient.Builder() .callTimeout(REQUEST_TIMEOUT, REQUEST_TIMEOUT_UNIT) .build() -private enum class Connectivity { NoWifi, NoInternet, Ok } - class UploadWorker(private val context: Context, params: WorkerParameters) : CoroutineWorker(context, params) { @@ -124,19 +114,18 @@ class UploadWorker(private val context: Context, params: WorkerParameters) : UploadProgress.add(upload.id, size) // Don't bother to run on an invalid network - if (getConnectivity(context, upload.wifiOnly) != Connectivity.Ok) return null + if (Connectivity.fetch(context, upload.wifiOnly) != Connectivity.Ok) return null // wait for its turn to run semaphore.acquire() // mark as active upload for notification UploadNotification.setActiveUpload(upload) - notificationManager.notify(UploadNotification.id, buildNotification()) + UploadNotification.update(context) try { return okhttpUpload(client, upload, file) { progress -> handleProgress(progress, size) - notificationManager.notify(UploadNotification.id, buildNotification()) } } catch (error: Throwable) { // reset progress on error @@ -154,16 +143,19 @@ class UploadWorker(private val context: Context, params: WorkerParameters) : private fun handleProgress(bytesSentTotal: Long, fileSize: Long) { UploadProgress.set(upload.id, bytesSentTotal) EventReporter.progress(upload.id, bytesSentTotal, fileSize) + UploadNotification.update(context) } private fun handleSuccess(response: UploadResponse) { UploadProgress.complete(upload.id) EventReporter.success(upload.id, response) + UploadNotification.update(context) } private fun handleError(error: Throwable) { UploadProgress.remove(upload.id) EventReporter.error(upload.id, error) + UploadNotification.update(context) } // Check if cancelled by user or new worker with same ID @@ -173,6 +165,7 @@ class UploadWorker(private val context: Context, params: WorkerParameters) : UploadProgress.remove(upload.id) EventReporter.cancelled(upload.id) + UploadNotification.update(context) return true } @@ -182,7 +175,7 @@ class UploadWorker(private val context: Context, params: WorkerParameters) : // Error was thrown due to unmet network preferences. // Also happens every time you switch from one network to any other - if (getConnectivity(context, upload.wifiOnly) != Connectivity.Ok) unlimitedRetry = true + if (Connectivity.fetch(context, upload.wifiOnly) != Connectivity.Ok) unlimitedRetry = true // Due to the flaky nature of networking, sometimes the network is // valid but the URL is still inaccessible, so keep waiting until // the URL is accessible @@ -207,50 +200,8 @@ class UploadWorker(private val context: Context, params: WorkerParameters) : } - // builds the notification required to enable Foreground mode - fun buildNotification(): Notification { - // since all workers share the same notification ID, - // get the active upload so we don't overwrite the notification when multiple uploads are running - val upload = UploadNotification.getActiveUpload() ?: this.upload - val channel = UploadNotification.channel - val progress = UploadProgress.total() - val progress2Decimals = "%.2f".format(progress) - val title = when (getConnectivity(context, upload.wifiOnly)) { - Connectivity.NoWifi -> UploadNotification.titleNoWifi - Connectivity.NoInternet -> UploadNotification.titleNoInternet - Connectivity.Ok -> UploadNotification.title - } - - // Custom layout for progress notification. - // The default hides the % text. This one shows it on the right, - // like most examples in various docs. - val content = RemoteViews(context.packageName, R.layout.notification) - content.setTextViewText(R.id.notification_title, title) - content.setTextViewText(R.id.notification_progress, "${progress2Decimals}%") - content.setProgressBar(R.id.notification_progress_bar, 100, progress.toInt(), false) - - return NotificationCompat.Builder(context, channel).run { - // Starting Android 12, the notification shows up with a confusing delay of 10s. - // This fixes that delay. - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) - foregroundServiceBehavior = Notification.FOREGROUND_SERVICE_IMMEDIATE - - // Required by android. Here we use the system's default upload icon - setSmallIcon(android.R.drawable.stat_sys_upload) - // These prevent the notification from being force-dismissed or dismissed when pressed - setOngoing(true) - setAutoCancel(false) - // These help show the same custom content when the notification collapses and expands - setCustomContentView(content) - setCustomBigContentView(content) - // opens the app when the notification is pressed - setContentIntent(openAppIntent(context)) - build() - } - } - override suspend fun getForegroundInfo(): ForegroundInfo { - val notification = buildNotification() + val notification = UploadNotification.build(context) val id = UploadNotification.id // Starting Android 14, FOREGROUND_SERVICE_TYPE_DATA_SYNC is mandatory, otherwise app will crash return if (Build.VERSION.SDK_INT > Build.VERSION_CODES.TIRAMISU) @@ -260,26 +211,4 @@ class UploadWorker(private val context: Context, params: WorkerParameters) : } } -private fun getConnectivity(context: Context, wifiOnly: Boolean): Connectivity { - val manager = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager - val network = manager.activeNetwork - val capabilities = manager.getNetworkCapabilities(network) - val hasInternet = capabilities?.hasCapability(NET_CAPABILITY_VALIDATED) == true - - // not wifiOnly, return early - if (!wifiOnly) return if (hasInternet) Connectivity.Ok else Connectivity.NoInternet - - // handle wifiOnly - return if (hasInternet && capabilities?.hasTransport(TRANSPORT_WIFI) == true) - Connectivity.Ok - else - Connectivity.NoWifi // don't return NoInternet here, more direct to request to join wifi -} - - -private fun openAppIntent(context: Context): PendingIntent? { - val intent = Intent(context, NotificationReceiver::class.java) - val flags = PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE - return PendingIntent.getBroadcast(context, "RNFileUpload-notification".hashCode(), intent, flags) -} diff --git a/android/src/main/java/com/vydia/RNUploader/UploaderModule.kt b/android/src/main/java/com/vydia/RNUploader/UploaderModule.kt index a0a4a5d2..54b55ff8 100644 --- a/android/src/main/java/com/vydia/RNUploader/UploaderModule.kt +++ b/android/src/main/java/com/vydia/RNUploader/UploaderModule.kt @@ -77,7 +77,7 @@ class UploaderModule(context: ReactApplicationContext) : @ReactMethod fun initialize(options: ReadableMap, promise: Promise) { try { - UploadNotification.update(options) + UploadNotification.setOptions(options) promise.resolve(true) } catch (exc: Throwable) { if (exc !is MissingOptionException) { From 99c0f34715990af55052f96dd48dc1d29afb868e Mon Sep 17 00:00:00 2001 From: thomasvo Date: Thu, 16 Oct 2025 17:50:09 -0700 Subject: [PATCH 09/18] fix --- android/src/main/java/com/vydia/RNUploader/UploadWorker.kt | 3 --- 1 file changed, 3 deletions(-) diff --git a/android/src/main/java/com/vydia/RNUploader/UploadWorker.kt b/android/src/main/java/com/vydia/RNUploader/UploadWorker.kt index a26d05ac..a216bf99 100644 --- a/android/src/main/java/com/vydia/RNUploader/UploadWorker.kt +++ b/android/src/main/java/com/vydia/RNUploader/UploadWorker.kt @@ -1,6 +1,5 @@ package com.vydia.RNUploader -import android.app.NotificationManager import android.content.Context import android.content.pm.ServiceInfo import android.os.Build @@ -47,8 +46,6 @@ class UploadWorker(private val context: Context, params: WorkerParameters) : private lateinit var upload: Upload private var retries = 0 - private val notificationManager = - context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager override suspend fun doWork(): Result = withContext(Dispatchers.IO) { // Retrieve the upload. If this throws errors, error reporting won't work. From 976621f3ab2600126c0a084f75b867a229c155fa Mon Sep 17 00:00:00 2001 From: thomasvo Date: Thu, 11 Dec 2025 16:42:42 -0800 Subject: [PATCH 10/18] Address Daniel's PR review comments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Name response variable in use block (UploadUtils.kt) - Fix shadowed it in takeIf lambda (UploadUtils.kt) - Rename set() to setIfNotNull() (UploadProgress.kt) - Add complete() method to Progress class (UploadProgress.kt) - Rename clearIfNeeded() to clearIfCompleted() (UploadProgress.kt) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 --- .../com/vydia/RNUploader/UploadProgress.kt | 18 ++++++++++-------- .../java/com/vydia/RNUploader/UploadUtils.kt | 8 ++++---- .../java/com/vydia/RNUploader/UploadWorker.kt | 4 ++-- 3 files changed, 16 insertions(+), 14 deletions(-) diff --git a/android/src/main/java/com/vydia/RNUploader/UploadProgress.kt b/android/src/main/java/com/vydia/RNUploader/UploadProgress.kt index 412f7073..fc9d1e60 100644 --- a/android/src/main/java/com/vydia/RNUploader/UploadProgress.kt +++ b/android/src/main/java/com/vydia/RNUploader/UploadProgress.kt @@ -9,7 +9,12 @@ object UploadProgress { var bytesUploaded: Long, val size: Long, var complete: Boolean = false - ) + ) { + fun complete() { + bytesUploaded = size + complete = true + } + } private val map = mutableMapOf() @@ -19,21 +24,18 @@ object UploadProgress { } @Synchronized - fun set(uploadId: String, bytesUploaded: Long) { + fun setIfNotNull(uploadId: String, bytesUploaded: Long) { map[uploadId]?.bytesUploaded = bytesUploaded } @Synchronized fun complete(uploadId: String) { - map[uploadId]?.let { - it.bytesUploaded = it.size - it.complete = true - } + map[uploadId]?.complete() // Attempt to clear in 2 seconds. This is the simplest way to let the // last worker reset the overall progress. // Clearing progress ensures the notification starts at 0% next time. - Handler(Looper.getMainLooper()).postDelayed({ clearIfNeeded() }, 2000) + Handler(Looper.getMainLooper()).postDelayed({ clearIfCompleted() }, 2000) } @Synchronized @@ -50,7 +52,7 @@ object UploadProgress { } @Synchronized - private fun clearIfNeeded() { + private fun clearIfCompleted() { if (map.values.all { it.complete }) map.clear() } } \ No newline at end of file diff --git a/android/src/main/java/com/vydia/RNUploader/UploadUtils.kt b/android/src/main/java/com/vydia/RNUploader/UploadUtils.kt index 38d4f8b1..e26e2ec1 100644 --- a/android/src/main/java/com/vydia/RNUploader/UploadUtils.kt +++ b/android/src/main/java/com/vydia/RNUploader/UploadUtils.kt @@ -58,11 +58,11 @@ suspend fun okhttpUpload( continuation.resumeWithException(e) override fun onResponse(call: Call, response: Response) { - val result = response.use { // close the response asap + val result = response.use { res -> // close the response asap UploadResponse( - response.code, - response.body?.string().takeIf { !it.isNullOrBlank() } ?: response.message, - response.headers.toMultimap().mapValues { it.value.joinToString(", ") } + res.code, + res.body?.string()?.takeIf { str -> str.isNotEmpty() } ?: res.message, + res.headers.toMultimap().mapValues { it.value.joinToString(", ") } ) } diff --git a/android/src/main/java/com/vydia/RNUploader/UploadWorker.kt b/android/src/main/java/com/vydia/RNUploader/UploadWorker.kt index a3bbec7c..58f1ec0f 100644 --- a/android/src/main/java/com/vydia/RNUploader/UploadWorker.kt +++ b/android/src/main/java/com/vydia/RNUploader/UploadWorker.kt @@ -136,7 +136,7 @@ class UploadWorker(private val context: Context, params: WorkerParameters) : } } catch (error: Throwable) { // reset progress on error - UploadProgress.set(upload.id, 0L) + UploadProgress.setIfNotNull(upload.id, 0L) // pass the error to upper layer for retry decision throw error } finally { @@ -145,7 +145,7 @@ class UploadWorker(private val context: Context, params: WorkerParameters) : } private fun handleProgress(bytesSentTotal: Long, fileSize: Long) { - UploadProgress.set(upload.id, bytesSentTotal) + UploadProgress.setIfNotNull(upload.id, bytesSentTotal) EventReporter.progress(upload.id, bytesSentTotal, fileSize) notificationManager.notify(upload.notificationId, buildNotification()) } From 28382b4c9dab0ce4fde2fec4fa4dd033efc496bf Mon Sep 17 00:00:00 2001 From: thomasvo Date: Thu, 11 Dec 2025 19:00:02 -0800 Subject: [PATCH 11/18] set --- android/src/main/java/com/vydia/RNUploader/UploadProgress.kt | 2 +- android/src/main/java/com/vydia/RNUploader/UploadWorker.kt | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/android/src/main/java/com/vydia/RNUploader/UploadProgress.kt b/android/src/main/java/com/vydia/RNUploader/UploadProgress.kt index fc9d1e60..03b9455b 100644 --- a/android/src/main/java/com/vydia/RNUploader/UploadProgress.kt +++ b/android/src/main/java/com/vydia/RNUploader/UploadProgress.kt @@ -24,7 +24,7 @@ object UploadProgress { } @Synchronized - fun setIfNotNull(uploadId: String, bytesUploaded: Long) { + fun set(uploadId: String, bytesUploaded: Long) { map[uploadId]?.bytesUploaded = bytesUploaded } diff --git a/android/src/main/java/com/vydia/RNUploader/UploadWorker.kt b/android/src/main/java/com/vydia/RNUploader/UploadWorker.kt index 58f1ec0f..a3bbec7c 100644 --- a/android/src/main/java/com/vydia/RNUploader/UploadWorker.kt +++ b/android/src/main/java/com/vydia/RNUploader/UploadWorker.kt @@ -136,7 +136,7 @@ class UploadWorker(private val context: Context, params: WorkerParameters) : } } catch (error: Throwable) { // reset progress on error - UploadProgress.setIfNotNull(upload.id, 0L) + UploadProgress.set(upload.id, 0L) // pass the error to upper layer for retry decision throw error } finally { @@ -145,7 +145,7 @@ class UploadWorker(private val context: Context, params: WorkerParameters) : } private fun handleProgress(bytesSentTotal: Long, fileSize: Long) { - UploadProgress.setIfNotNull(upload.id, bytesSentTotal) + UploadProgress.set(upload.id, bytesSentTotal) EventReporter.progress(upload.id, bytesSentTotal, fileSize) notificationManager.notify(upload.notificationId, buildNotification()) } From ca7748e9bde9fe943e59f35840ea1344f6fe7976 Mon Sep 17 00:00:00 2001 From: thomasvo Date: Thu, 11 Dec 2025 19:01:44 -0800 Subject: [PATCH 12/18] package --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index d66fa806..a32a3e60 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "react-native-background-upload", - "version": "7.5.2", + "version": "7.5.3", "description": "Cross platform http post file uploader with android and iOS background support", "main": "src/index", "typings": "lib/index.d.ts", From d4bef0b9663854ed3ce899a4810a36e1fb816354 Mon Sep 17 00:00:00 2001 From: thomasvo Date: Tue, 20 Jan 2026 17:41:00 -0800 Subject: [PATCH 13/18] doc --- example/RNBGUExample/App.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/example/RNBGUExample/App.tsx b/example/RNBGUExample/App.tsx index 09f88888..306327c1 100644 --- a/example/RNBGUExample/App.tsx +++ b/example/RNBGUExample/App.tsx @@ -79,6 +79,7 @@ const App = () => { importance: AndroidImportance.LOW, }); + // Start multiple uploads to test performance for (let i = 0; i < 100; i++) { const uploadOpts: UploadOptions = { type: 'raw', From 70632632c822a12e2b02a6665b177baa359013e0 Mon Sep 17 00:00:00 2001 From: thomasvo Date: Tue, 20 Jan 2026 17:41:59 -0800 Subject: [PATCH 14/18] bump --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index a32a3e60..06d4cf85 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "react-native-background-upload", - "version": "7.5.3", + "version": "7.6.0", "description": "Cross platform http post file uploader with android and iOS background support", "main": "src/index", "typings": "lib/index.d.ts", From b94890fa4aa33233d55e70c2d6b080b8f9ae596c Mon Sep 17 00:00:00 2001 From: thomasvo Date: Wed, 21 Jan 2026 15:02:25 -0800 Subject: [PATCH 15/18] semaphore fix --- .../src/main/java/com/vydia/RNUploader/UploadWorker.kt | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/android/src/main/java/com/vydia/RNUploader/UploadWorker.kt b/android/src/main/java/com/vydia/RNUploader/UploadWorker.kt index a216bf99..9b0dbf57 100644 --- a/android/src/main/java/com/vydia/RNUploader/UploadWorker.kt +++ b/android/src/main/java/com/vydia/RNUploader/UploadWorker.kt @@ -115,12 +115,11 @@ class UploadWorker(private val context: Context, params: WorkerParameters) : // wait for its turn to run semaphore.acquire() - - // mark as active upload for notification - UploadNotification.setActiveUpload(upload) - UploadNotification.update(context) - try { + // mark as active upload for notification + UploadNotification.setActiveUpload(upload) + UploadNotification.update(context) + return okhttpUpload(client, upload, file) { progress -> handleProgress(progress, size) } From 874935a4ab0f759c253dec146f2adf280aefea83 Mon Sep 17 00:00:00 2001 From: thomasvo Date: Wed, 21 Jan 2026 15:34:08 -0800 Subject: [PATCH 16/18] maxretries --- android/src/main/java/com/vydia/RNUploader/Notification.kt | 3 +++ android/src/main/java/com/vydia/RNUploader/Upload.kt | 2 -- android/src/main/java/com/vydia/RNUploader/UploadWorker.kt | 2 +- 3 files changed, 4 insertions(+), 3 deletions(-) diff --git a/android/src/main/java/com/vydia/RNUploader/Notification.kt b/android/src/main/java/com/vydia/RNUploader/Notification.kt index cd282bda..dd00e451 100644 --- a/android/src/main/java/com/vydia/RNUploader/Notification.kt +++ b/android/src/main/java/com/vydia/RNUploader/Notification.kt @@ -22,6 +22,8 @@ object UploadNotification { private set var channel: String = "File Uploads" private set + var maxRetries: Int = 5 + private set private var activeUpload: Upload? = null @@ -51,6 +53,7 @@ object UploadNotification { ?: throw MissingOptionException("notificationTitleNoWifi") channel = opts.getString("notificationChannel") ?: throw MissingOptionException("notificationChannel") + maxRetries = if (opts.hasKey("maxRetries")) opts.getInt("maxRetries") else 5 } // builds the notification required to enable Foreground mode diff --git a/android/src/main/java/com/vydia/RNUploader/Upload.kt b/android/src/main/java/com/vydia/RNUploader/Upload.kt index 9d22a82b..06906d3d 100644 --- a/android/src/main/java/com/vydia/RNUploader/Upload.kt +++ b/android/src/main/java/com/vydia/RNUploader/Upload.kt @@ -11,7 +11,6 @@ data class Upload( val url: String, val path: String, val method: String, - val maxRetries: Int, val wifiOnly: Boolean, val headers: Map, ) { @@ -21,7 +20,6 @@ data class Upload( url = map.getString(Upload::url.name) ?: throw MissingOptionException(Upload::url.name), path = map.getString(Upload::path.name) ?: throw MissingOptionException(Upload::path.name), 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, headers = map.getMap(Upload::headers.name).let { headers -> if (headers == null) return@let mapOf() diff --git a/android/src/main/java/com/vydia/RNUploader/UploadWorker.kt b/android/src/main/java/com/vydia/RNUploader/UploadWorker.kt index 9b0dbf57..e15dad9e 100644 --- a/android/src/main/java/com/vydia/RNUploader/UploadWorker.kt +++ b/android/src/main/java/com/vydia/RNUploader/UploadWorker.kt @@ -192,7 +192,7 @@ class UploadWorker(private val context: Context, params: WorkerParameters) : } retries = if (unlimitedRetry) 0 else retries + 1 - return retries <= upload.maxRetries + return retries <= UploadNotification.maxRetries } From 488717f61cb040cb5e62a51d705ef2986115baeb Mon Sep 17 00:00:00 2001 From: thomasvo Date: Wed, 21 Jan 2026 17:22:29 -0800 Subject: [PATCH 17/18] Update notification when connectivity check fails Ensures the notification shows correct "Waiting for WiFi/Internet" title when network degrades after initial foreground setup. Co-Authored-By: Claude Opus 4.5 --- android/src/main/java/com/vydia/RNUploader/UploadWorker.kt | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/android/src/main/java/com/vydia/RNUploader/UploadWorker.kt b/android/src/main/java/com/vydia/RNUploader/UploadWorker.kt index e15dad9e..83b34b55 100644 --- a/android/src/main/java/com/vydia/RNUploader/UploadWorker.kt +++ b/android/src/main/java/com/vydia/RNUploader/UploadWorker.kt @@ -111,7 +111,10 @@ class UploadWorker(private val context: Context, params: WorkerParameters) : UploadProgress.add(upload.id, size) // Don't bother to run on an invalid network - if (Connectivity.fetch(context, upload.wifiOnly) != Connectivity.Ok) return null + if (Connectivity.fetch(context, upload.wifiOnly) != Connectivity.Ok) { + UploadNotification.update(context) + return null + } // wait for its turn to run semaphore.acquire() From c2c99220d66cb0f9068dc9e782708183f892ac48 Mon Sep 17 00:00:00 2001 From: thomasvo Date: Wed, 21 Jan 2026 17:48:07 -0800 Subject: [PATCH 18/18] better default wifiOnly --- .../java/com/vydia/RNUploader/Notification.kt | 10 +++++++--- .../java/com/vydia/RNUploader/UploadProgress.kt | 17 +++++++++++++++-- .../java/com/vydia/RNUploader/UploadWorker.kt | 2 +- 3 files changed, 23 insertions(+), 6 deletions(-) diff --git a/android/src/main/java/com/vydia/RNUploader/Notification.kt b/android/src/main/java/com/vydia/RNUploader/Notification.kt index dd00e451..a1e7b371 100644 --- a/android/src/main/java/com/vydia/RNUploader/Notification.kt +++ b/android/src/main/java/com/vydia/RNUploader/Notification.kt @@ -58,9 +58,13 @@ object UploadNotification { // builds the notification required to enable Foreground mode fun build(context: Context): Notification { - // since all workers share the same notification ID, - // get the active upload so we don't overwrite the notification when multiple uploads are running - val wifiOnly = getActiveUpload()?.wifiOnly ?: false + // Determine wifiOnly preference for connectivity check: + // - If an upload is actively running, use its preference + // - Otherwise, check the queue: if ANY upload can proceed with just mobile data (wifiOnly=false), + // we only need internet to make progress. If ALL uploads need WiFi, we need WiFi. + // This ensures the notification ("Waiting for internet" vs "Waiting for WiFi") reflects + // the minimum connectivity required to make progress. + val wifiOnly = getActiveUpload()?.wifiOnly ?: !UploadProgress.hasNonWifiOnlyUploads() val channel = channel val progress = UploadProgress.total() val progress2Decimals = "%.2f".format(progress) diff --git a/android/src/main/java/com/vydia/RNUploader/UploadProgress.kt b/android/src/main/java/com/vydia/RNUploader/UploadProgress.kt index 03b9455b..226cc8bb 100644 --- a/android/src/main/java/com/vydia/RNUploader/UploadProgress.kt +++ b/android/src/main/java/com/vydia/RNUploader/UploadProgress.kt @@ -8,6 +8,7 @@ object UploadProgress { private data class Progress( var bytesUploaded: Long, val size: Long, + val wifiOnly: Boolean, var complete: Boolean = false ) { fun complete() { @@ -19,8 +20,8 @@ object UploadProgress { private val map = mutableMapOf() @Synchronized - fun add(id: String, size: Long) { - map[id] = Progress(bytesUploaded = 0L, size = size) + fun add(id: String, size: Long, wifiOnly: Boolean) { + map[id] = Progress(bytesUploaded = 0L, size = size, wifiOnly = wifiOnly) } @Synchronized @@ -55,4 +56,16 @@ object UploadProgress { private fun clearIfCompleted() { if (map.values.all { it.complete }) map.clear() } + + /** + * Returns true if any incomplete upload can proceed without WiFi (wifiOnly=false). + * Used to determine notification text when no upload is actively running: + * - If true: at least one upload only needs mobile data, so show "Waiting for internet" + * - If false: all uploads need WiFi, so show "Waiting for WiFi" + * This ensures the notification reflects the minimum connectivity needed to make progress. + */ + @Synchronized + fun hasNonWifiOnlyUploads(): Boolean { + return map.values.any { !it.complete && !it.wifiOnly } + } } \ No newline at end of file diff --git a/android/src/main/java/com/vydia/RNUploader/UploadWorker.kt b/android/src/main/java/com/vydia/RNUploader/UploadWorker.kt index 83b34b55..628a2192 100644 --- a/android/src/main/java/com/vydia/RNUploader/UploadWorker.kt +++ b/android/src/main/java/com/vydia/RNUploader/UploadWorker.kt @@ -108,7 +108,7 @@ class UploadWorker(private val context: Context, params: WorkerParameters) : // Register progress asap so the total progress is accurate // This needs to happen before the semaphore wait - UploadProgress.add(upload.id, size) + UploadProgress.add(upload.id, size, upload.wifiOnly) // Don't bother to run on an invalid network if (Connectivity.fetch(context, upload.wifiOnly) != Connectivity.Ok) {