diff --git a/android/src/main/java/com/vydia/RNUploader/EventReporter.kt b/android/src/main/java/com/vydia/RNUploader/EventReporter.kt index 32daf999..3604a137 100644 --- a/android/src/main/java/com/vydia/RNUploader/EventReporter.kt +++ b/android/src/main/java/com/vydia/RNUploader/EventReporter.kt @@ -4,64 +4,51 @@ 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 { - 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: 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 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) } } - } 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..03b9455b 100644 --- a/android/src/main/java/com/vydia/RNUploader/UploadProgress.kt +++ b/android/src/main/java/com/vydia/RNUploader/UploadProgress.kt @@ -1,64 +1,58 @@ 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 // Stores and aggregates total progress from all workers -class UploadProgress { - - companion object { - private fun storage(context: Context) = - context.getSharedPreferences("RNFileUpload-Progress", Context.MODE_PRIVATE) - - @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) +object UploadProgress { + private data class Progress( + var bytesUploaded: Long, + val size: Long, + var complete: Boolean = false + ) { + fun complete() { + bytesUploaded = size + complete = true + } + } - val totalBytesUploaded = storage.all.keys - .filter { it.endsWith("-uploaded") } - .sumOf { storage.getLong(it, 0L) } + private val map = mutableMapOf() - val totalFileSize = storage.all.keys - .filter { it.endsWith("-size") } - .sumOf { storage.getLong(it, 0L) } + @Synchronized + fun add(id: String, size: Long) { + map[id] = Progress(bytesUploaded = 0L, size = size) + } - if (totalFileSize == 0L) return 0.0 - return (totalBytesUploaded.toDouble() * 100 / totalFileSize) - } + @Synchronized + fun set(uploadId: String, bytesUploaded: Long) { + map[uploadId]?.bytesUploaded = bytesUploaded + } - private val handler = Handler(Looper.getMainLooper()) + @Synchronized + fun complete(uploadId: String) { + 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. - fun scheduleClearing(context: Context) = - handler.postDelayed({ clearIfNeeded(context) }, 2000) + Handler(Looper.getMainLooper()).postDelayed({ clearIfCompleted() }, 2000) + } - @Synchronized - fun clearIfNeeded(context: Context) { - val workManager = WorkManager.getInstance(context) - val works = workManager.getWorkInfosByTag(WORKER_TAG).get() - if (works.any { !it.state.isFinished }) return + @Synchronized + fun remove(uploadId: String) { + map.remove(uploadId) + } - val storage = storage(context) - storage.edit().clear().apply() - } + @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) + } + + @Synchronized + 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 57da5aac..e26e2ec1 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 { @@ -47,8 +57,17 @@ suspend fun okhttpUpload( override fun onFailure(call: Call, e: IOException) = continuation.resumeWithException(e) - override fun onResponse(call: Call, response: Response) = - continuation.resumeWith(Result.success(response)) + override fun onResponse(call: Call, response: Response) { + val result = response.use { res -> // close the response asap + UploadResponse( + res.code, + res.body?.string()?.takeIf { str -> str.isNotEmpty() } ?: res.message, + res.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..a3bbec7c 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,47 @@ 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) { + UploadProgress.complete(upload.id) 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 +165,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 +201,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 +227,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 +245,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" 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",