diff --git a/android/build.gradle b/android/build.gradle index dee87982..64b59248 100755 --- a/android/build.gradle +++ b/android/build.gradle @@ -1,19 +1,17 @@ buildscript { ext { - kotlinVersion = '1.6.20' + kotlinVersion = '2.2.20' buildToolsVersion = '29.0.2' - compileSdkVersion = 31 // this helps us use the latest Worker version - targetSdkVersion = 29 - minSdkVersion = 18 + minSdkVersion = 29 + compileSdkVersion = 35 + targetSdkVersion = 35 } - ext.detoxKotlinVersion = ext.kotlinVersion - repositories { mavenCentral() google() } dependencies { - classpath 'com.android.tools.build:gradle:3.5.4' + classpath 'com.android.tools.build:gradle:8.13.0' classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlinVersion" } } @@ -21,28 +19,17 @@ buildscript { apply plugin: 'com.android.library' apply plugin: 'kotlin-android' -def DEFAULT_COMPILE_SDK_VERSION = 28 -def DEFAULT_BUILD_TOOLS_VERSION = "28.0.3" -def DEFAULT_TARGET_SDK_VERSION = 28 - -def safeExtGet(prop, fallback) { - rootProject.ext.has(prop) ? rootProject.ext.get(prop) : fallback +def safeExtGet(prop) { + rootProject.ext.has(prop) ? rootProject.ext.get(prop) : ext.get(prop) } android { - buildFeatures { - /* - This is a replacement for the deprecated 'kotlin-android-extensions' library. More - information can be found here: https://developer.android.com/topic/libraries/view-binding/migration - */ - viewBinding = true - } - compileSdkVersion safeExtGet('compileSdkVersion', DEFAULT_COMPILE_SDK_VERSION) - buildToolsVersion safeExtGet('buildToolsVersion', DEFAULT_BUILD_TOOLS_VERSION) + compileSdkVersion safeExtGet('compileSdkVersion') + buildToolsVersion safeExtGet('buildToolsVersion') defaultConfig { - minSdkVersion 24 - targetSdkVersion safeExtGet('targetSdkVersion', DEFAULT_TARGET_SDK_VERSION) + minSdkVersion safeExtGet('minSdkVersion') + targetSdkVersion safeExtGet('targetSdkVersion') versionCode 1 versionName "1.0" ndk { @@ -65,25 +52,13 @@ repositories { mavenCentral() } -def _ext = ext - -def _kotlinVersion = _ext.has('detoxKotlinVersion') ? _ext.detoxKotlinVersion : '1.3.10' -def _kotlinStdlib = _ext.has('detoxKotlinStdlib') ? _ext.detoxKotlinStdlib : 'kotlin-stdlib-jdk8' - dependencies { - implementation "androidx.core:core-ktx:1.7.0" - + implementation "androidx.core:core-ktx:1.17.0" implementation 'com.facebook.react:react-native:+' - - implementation "org.jetbrains.kotlin:$_kotlinStdlib:$_kotlinVersion" - - implementation("com.squareup.okhttp3:okhttp:4.10.0") - - implementation 'com.google.code.gson:gson:2.8.9' - - implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-core:1.6.4' - - implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.6.4' - - implementation "androidx.work:work-runtime-ktx:2.8.1" -} + implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlinVersion" + implementation("com.squareup.okhttp3:okhttp:5.2.1") + implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-core:1.10.2' + implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.10.2' + implementation "androidx.work:work-runtime-ktx:2.10.5" + implementation 'com.google.code.gson:gson:2.13.2' +} \ No newline at end of file diff --git a/android/src/main/java/com/vydia/RNUploader2/EventReporter.kt b/android/src/main/java/com/vydia/RNUploader2/EventReporter.kt new file mode 100644 index 00000000..9c36cea9 --- /dev/null +++ b/android/src/main/java/com/vydia/RNUploader2/EventReporter.kt @@ -0,0 +1,67 @@ +package com.vydia.RNUploader2 + +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 + +// 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") + }) + + // TODO expose via JS + fun globalError(origin: String, exception: Throwable) = + sendEvent("globalError", Arguments.createMap().apply { + putString("origin", origin) + putString("error", exception.message ?: "Unknown exception") + }) + + fun success(uploadId: String, response: UploadResponse) = + CoroutineScope(Dispatchers.IO).launch { + sendEvent("completed", Arguments.createMap().apply { + putString("id", uploadId) + putInt("responseCode", response.statusCode) + putString("responseBody", response.body) + putMap("responseHeaders", Arguments.makeNativeMap(response.headers)) + }) + } + + fun progress(uploadId: String) = + sendEvent("progress", Arguments.createMap().apply { + putString("id", uploadId) + putDouble("progress", UploadQueue.progressPercentage().toDouble()) + }) + + fun notification() = sendEvent("notification") + + /** Sends an event to the JS module */ + private fun sendEvent(eventName: String, params: WritableMap = Arguments.createMap()) { + val reactContext = UploaderModule2.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/RNUploader2/NotificationConfigs.kt b/android/src/main/java/com/vydia/RNUploader2/NotificationConfigs.kt new file mode 100644 index 00000000..b374e840 --- /dev/null +++ b/android/src/main/java/com/vydia/RNUploader2/NotificationConfigs.kt @@ -0,0 +1,31 @@ +package com.vydia.RNUploader2 + +import com.facebook.react.bridge.ReadableMap + + +object NotificationConfigs { + 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 + + 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/RNUploader2/NotificationReceiver.kt b/android/src/main/java/com/vydia/RNUploader2/NotificationReceiver.kt new file mode 100644 index 00000000..c10c8e23 --- /dev/null +++ b/android/src/main/java/com/vydia/RNUploader2/NotificationReceiver.kt @@ -0,0 +1,17 @@ +package com.vydia.RNUploader2 + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent + +// there's no way to directly open the app from the notification without reloading it, +// so we use a BroadcastReceiver to listen to the notification intent +class NotificationReceiver : BroadcastReceiver() { + override fun onReceive(context: Context?, intent: Intent?) { + context ?: return + val packageName = context.packageName + val launchIntent = context.packageManager.getLaunchIntentForPackage(packageName) + context.startActivity(launchIntent) + EventReporter.notification() + } +} diff --git a/android/src/main/java/com/vydia/RNUploader2/Upload.kt b/android/src/main/java/com/vydia/RNUploader2/Upload.kt new file mode 100644 index 00000000..7ee60cb9 --- /dev/null +++ b/android/src/main/java/com/vydia/RNUploader2/Upload.kt @@ -0,0 +1,40 @@ +package com.vydia.RNUploader2 + +import com.facebook.react.bridge.ReadableMap +import java.util.UUID + +// Data model of a single upload +// Can be created from RN's ReadableMap +// Can be used for JSON deserialization +data class Upload( + val id: String, + val url: String, + val path: String, + val method: String, + val maxRetries: Int, + val wifiOnly: Boolean, + val headers: Map, +) { + // Progress tracking properties + var bytesUploaded: Long = 0L + var size: Long = 0L + + companion object { + fun fromRawOptions(map: ReadableMap) = Upload( + id = map.getString("customUploadId") ?: UUID.randomUUID().toString(), + 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() + val map = mutableMapOf() + for (entry in headers.entryIterator) { + map[entry.key] = entry.value.toString() + } + return@let map + }, + ) + } +} diff --git a/android/src/main/java/com/vydia/RNUploader2/UploadQueue.kt b/android/src/main/java/com/vydia/RNUploader2/UploadQueue.kt new file mode 100644 index 00000000..41ff9362 --- /dev/null +++ b/android/src/main/java/com/vydia/RNUploader2/UploadQueue.kt @@ -0,0 +1,87 @@ +package com.vydia.RNUploader2 + +import java.io.File +import java.io.FileNotFoundException + +object UploadQueue { + private val queue = ArrayDeque() + + /** + * Keeps track of the total bytes of completed uploads to report overall progress correctly. + */ + private var completedBytes = 0L + + /** + * Returns the overall progress percentage of all uploads in the queue. + * 0 - 100 + */ + @Synchronized + fun progressPercentage(): Float { + if (queue.isEmpty()) return 0f + + val totalBytes = queue.sumOf { it.size } + completedBytes + if (totalBytes == 0L) return 0f + val uploadedBytes = (current()?.bytesUploaded ?: 0L) + completedBytes + + return uploadedBytes.toFloat() / totalBytes.toFloat() * 100 + } + + @Synchronized + fun add(upload: Upload) { + if (queue.any { it.id == upload.id }) return + + val file = File(upload.path) + if (file.exists()) + upload.size = file.length() + else + throw FileNotFoundException("File at path ${upload.path} does not exist") + + queue.add(upload) + } + + @Synchronized + fun progress(bytesUploaded: Long) { + current()?.bytesUploaded = bytesUploaded + } + + @Synchronized + fun complete() { + // Extract size immediately and allow upload object to be garbage collected + completedBytes += queue.removeFirst().size + } + + @Synchronized + fun pop() = queue.removeFirst() + + @Synchronized + fun cancel(uploadId: String) = queue.removeIf { it.id == uploadId } + + + @Synchronized + fun skipWifiOnly(): Boolean { + // Find index instead of element to avoid double search + val index = queue.indexOfFirst { !it.wifiOnly } + if (index == -1) return false + if (index == 0) return true // Already at front + + // Remove by index (still O(n) but avoids the find step) + val upload = queue.removeAt(index) + queue.addFirst(upload) + return true + } + + @Synchronized + fun isAllWifiOnly() = !queue.isEmpty() && queue.all { it.wifiOnly } + + @Synchronized + fun current() = queue.firstOrNull() + + @Synchronized + fun clear() { + queue.clear() + completedBytes = 0L + } + + @Synchronized + fun isEmpty() = queue.isEmpty() +} \ No newline at end of file diff --git a/android/src/main/java/com/vydia/RNUploader2/UploadUtils.kt b/android/src/main/java/com/vydia/RNUploader2/UploadUtils.kt new file mode 100644 index 00000000..6b1eef50 --- /dev/null +++ b/android/src/main/java/com/vydia/RNUploader2/UploadUtils.kt @@ -0,0 +1,201 @@ +package com.vydia.RNUploader2 + +import android.R +import android.app.Notification +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 kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.suspendCancellableCoroutine +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 +import okio.buffer +import java.io.File +import java.io.IOException +import kotlin.coroutines.resumeWithException + +// Throttling interval of progress reports +private const val PROGRESS_INTERVAL = 500 // milliseconds + +// Data class to hold the important response information +data class UploadResponse( + val statusCode: Int, + val statusMessage: String, + val headers: Map, + val body: String? = null +) + +// make an upload request using okhttp +suspend fun okhttpUpload( + client: OkHttpClient, + upload: Upload, + onProgress: (Long) -> Unit, + isCancelled: () -> Boolean +): UploadResponse = + suspendCancellableCoroutine { continuation -> + val requestBody = File(upload.path).asRequestBody() + + // Throttle progress reports + var lastProgressReport = 0L + fun throttled(): Boolean { + val now = System.currentTimeMillis() + if (now - lastProgressReport < PROGRESS_INTERVAL) return true + lastProgressReport = now + return false + } + + // Build the request + val request = Request.Builder() + .url(upload.url) + .headers(upload.headers.toHeaders()) + .method(upload.method, withProgressListener(requestBody) { progress -> + if (!throttled()) onProgress(progress) + }) + .build() + + // Create the call + val call = client.newCall(request) + + // Start a polling coroutine to check for cancellation + val cancellationCheck = CoroutineScope(continuation.context).launch { + while (!call.isCanceled()) { + if (isCancelled()) { + call.cancel() + continuation.resumeWithException(CancellationException("Upload cancelled externally")) + break + } + delay(100) // Poll every 100ms + } + } + + // cancel everything if the coroutine is cancelled + continuation.invokeOnCancellation { + call.cancel() + cancellationCheck.cancel() + } + + // enqueue the call and add the callbacks + call.enqueue(object : Callback { + override fun onFailure(call: Call, e: IOException) { + cancellationCheck.cancel() + if (isCancelled()) return + continuation.resumeWithException(e) + } + + override fun onResponse(call: Call, response: Response) { + cancellationCheck.cancel() + response.use { // Automatically closes the response + if (isCancelled()) return + + val uploadResponse = UploadResponse( + statusCode = response.code, + statusMessage = response.message, + headers = response.headers.toMap(), + body = response.body.string().let { + it.ifBlank { response.message } + } + ) + continuation.resumeWith(Result.success(uploadResponse)) + } + } + }) + } + +// create a request body that allows us to listen to progress. +// okhttp has no built-in way of reporting progress +private fun withProgressListener( + body: RequestBody, + onProgress: (Long) -> Unit +) = object : RequestBody() { + override fun contentType() = body.contentType() + override fun contentLength() = body.contentLength() + override fun writeTo(sink: BufferedSink) { + val countingSink = object : ForwardingSink(sink) { + var bytesWritten = 0L + + override fun write(source: Buffer, byteCount: Long) { + super.write(source, byteCount) + bytesWritten += byteCount + onProgress(bytesWritten) + } + } + + val bufferedSink = countingSink.buffer() + body.writeTo(bufferedSink) + bufferedSink.flush() + } +} + +class MissingOptionException(optionName: String) : + IllegalArgumentException("Missing '$optionName'") + + +data class Connection(val wifi: Boolean, val connected: Boolean) + +enum class NotificationConnectivity { + NoWifi, NoInternet, Ok +} + +fun buildNotification( + context: Context, + connectivity: NotificationConnectivity +): Pair { + val progress = UploadQueue.progressPercentage() + val progress2Decimals = "%.2f".format(progress) + val title = when (connectivity) { + NotificationConnectivity.NoWifi -> NotificationConfigs.titleNoWifi + NotificationConnectivity.NoInternet -> NotificationConfigs.titleNoInternet + NotificationConnectivity.Ok -> NotificationConfigs.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) + + val notification = NotificationCompat.Builder(context, NotificationConfigs.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) + NotificationCompat.Builder.setForegroundServiceBehavior = + Notification.FOREGROUND_SERVICE_IMMEDIATE + + // Required by android. Here we use the system's default upload icon + setSmallIcon(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() + } + + return Pair(NotificationConfigs.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/RNUploader2/UploadWorker.kt b/android/src/main/java/com/vydia/RNUploader2/UploadWorker.kt new file mode 100644 index 00000000..2bfe30ca --- /dev/null +++ b/android/src/main/java/com/vydia/RNUploader2/UploadWorker.kt @@ -0,0 +1,250 @@ +package com.vydia.RNUploader2 + +import android.app.NotificationManager +import android.content.Context +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 androidx.work.CoroutineWorker +import androidx.work.ForegroundInfo +import androidx.work.WorkerParameters +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import okhttp3.OkHttpClient +import java.io.File +import java.io.IOException +import java.net.UnknownHostException +import java.util.concurrent.TimeUnit +import kotlin.math.pow + + +// Max total time for a single request to complete +// This is 24hrs so plenty of time for large uploads +// Worst case is the time maxes out and the upload gets restarted. +// Not using unlimited time to prevent unexpected behaviors. +private const val REQUEST_TIMEOUT = 24L +private val REQUEST_TIMEOUT_UNIT = TimeUnit.HOURS + + +// Use Okhttp as it provides the most standard behaviors even though it's not coroutine friendly +private val client = OkHttpClient.Builder() + .callTimeout(REQUEST_TIMEOUT, REQUEST_TIMEOUT_UNIT) + .build() + +class UploadWorker(private val context: Context, params: WorkerParameters) : + CoroutineWorker(context, params) { + + override suspend fun doWork(): Result = withContext(Dispatchers.IO) { + try { + // `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. + // ⚠️ This should be called in the foreground + setForeground(getForegroundInfo()) + } catch (error: Throwable) { + // If we fail to start foreground service, the worker will be stopped shortly after, + // which makes it impossible to report errors for all uploads in the queue. + // If we report errors here, the client can decide when to retry. + handleUnableToStartForeground(error) + throw error + } + + // Update notification periodically + var notificationJob: Job? = null + try { + notificationJob = startNotificationUpdateJob() + } catch (error: Throwable) { + // Should not block the worker if notification job setup fails + EventReporter.globalError( + "UploadWorker.startNotificationUpdateJob", + Error("Failed to start notification job: ${error.message}") + ) + } + + try { + // Keep processing uploads until the queue is empty + while (UploadQueue.current() != null) uploadCurrent() + + return@withContext Result.success() + } finally { + notificationJob?.cancel() // Cancel the notification updates when work is done + } + } + + private suspend fun uploadCurrent() { + val upload = UploadQueue.current() ?: return + + // We don't let WorkManager manage retries and network constraints as it's very buggy. + // i.e. we'd occasionally get BackgroundServiceStartNotAllowedException, + // or ForegroundServiceStartNotAllowedException, or workers getting cancelled for no reason. + var retries = 0 + while (true) { + try { + // delay needs to be part of the try block + if (retries > 0) exponentialBackoffDelay(retries) + + // If there's no internet, wait until there is + val connection = waitForInternet() + + // If upload requires wifi and we're not on wifi, try to switch to a non-wifi upload + if (!connection.wifi && upload.wifiOnly) { + if (UploadQueue.skipWifiOnly()) return + continue + } + + // Start the upload + val response = okhttpUpload( + client, upload, + onProgress = { bytesSentTotal -> + UploadQueue.progress(bytesSentTotal) + EventReporter.progress(upload.id) + }, + isCancelled = { + if (UploadQueue.current() != upload) true + else if (upload.wifiOnly && !checkConnection().wifi) true + else false + } + ) + + // Mark upload as completed + UploadQueue.complete() + EventReporter.success(upload.id, response) + return + } catch (error: Throwable) { + try { + // If the current upload has changed, it means it was cancelled externally + if (UploadQueue.current() != upload) return + + // Worker stopped externally. This is unexpected so we need to report errors + if (isStopped) return handleUnexpectedStop(error) + + // High chance error was thrown due to network connection issue + // There's a bunch of different errors that can be thrown here, + // so just check if the network is connected. + if (!checkConnection().connected) continue + + // 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 + if (error is UnknownHostException) continue + + // There are many errors here that come from non-existent files + // so we can't check using class, so we just check if the file exists + // If the file doesn't exist, no point retrying + if (!File(upload.path).exists()) + return handleError(upload, IOException("File at path ${upload.path} does not exist")) + + // Only penalize retries for other types of errors + retries++ + + // If we've retried too many times, give up + if (retries > upload.maxRetries) return handleError(upload, error) + } catch (_: Throwable) { + continue + } + } + } + } + + private suspend fun exponentialBackoffDelay(retries: Int) { + var time = 5000L // 5 seconds + time = (time * (2.toDouble().pow(retries - 1))).toLong() + time = time.coerceAtMost(60_000L) // max 5 minutes + delay(time) + } + + private suspend fun waitForInternet(): Connection { + while (true) { + if (UploadQueue.isEmpty()) throw CancellationException() + val connectivity = checkConnection() + if (connectivity.connected) return connectivity + delay(1000L) + } + } + + private fun handleError(upload: Upload, error: Throwable) { + UploadQueue.pop() + EventReporter.error(upload.id, error) + } + + private fun handleUnexpectedStop(error: Throwable) { + val stopReason = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) this.stopReason else "unknown" + + val error = + CancellationException("Worker stopped due to: $stopReason. Original error: ${error.message}") + + while (!UploadQueue.isEmpty()) { + val upload = UploadQueue.pop() + EventReporter.error(upload.id, error) + } + } + + private fun handleUnableToStartForeground(error: Throwable) { + val error = + Error("Failed to start foreground service. Original error: ${error.message}") + + while (!UploadQueue.isEmpty()) { + val upload = UploadQueue.pop() + EventReporter.error(upload.id, error) + } + } + + + private fun startNotificationUpdateJob() = CoroutineScope(Dispatchers.IO).launch { + while (true) { + try { + delay(1000L) + + val connection = checkConnection() + + val notificationConnectivity = + if (!connection.connected) NotificationConnectivity.NoInternet + else if (UploadQueue.isAllWifiOnly() && !connection.wifi) NotificationConnectivity.NoWifi + else NotificationConnectivity.Ok + + val (id, notification) = buildNotification(context, notificationConnectivity) + notificationManager.notify(id, notification) + } catch (error: Throwable) { + if (isStopped) return@launch + EventReporter.globalError("UploadWorker.updateNotification", error) + } + } + } + + + // builds the notification required to enable Foreground mode + override suspend fun getForegroundInfo(): ForegroundInfo { + val (id, notification) = buildNotification(context, NotificationConnectivity.Ok) + + // 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) + else + ForegroundInfo(id, notification) + } + + private fun checkConnection(): Connection { + val network = connectivityManager.activeNetwork + val capabilities = connectivityManager.getNetworkCapabilities(network) + val connected = capabilities?.hasCapability(NET_CAPABILITY_VALIDATED) == true + val wifi = capabilities?.hasTransport(TRANSPORT_WIFI) == true + + return Connection(wifi = wifi, connected = connected) + } + + + val notificationManager: NotificationManager + get() = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + + val connectivityManager: ConnectivityManager + get() = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager +} + diff --git a/android/src/main/java/com/vydia/RNUploader2/Uploader2ReactPackage.java b/android/src/main/java/com/vydia/RNUploader2/Uploader2ReactPackage.java new file mode 100644 index 00000000..a7e2cf44 --- /dev/null +++ b/android/src/main/java/com/vydia/RNUploader2/Uploader2ReactPackage.java @@ -0,0 +1,35 @@ +package com.vydia.RNUploader2; + +import com.facebook.react.ReactPackage; +import com.facebook.react.bridge.JavaScriptModule; +import com.facebook.react.bridge.NativeModule; +import com.facebook.react.bridge.ReactApplicationContext; +import com.facebook.react.uimanager.ViewManager; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +/** + * Created by stephen on 12/8/16. + */ +public class Uploader2ReactPackage implements ReactPackage { + + // Deprecated in RN 0.47, @todo remove after < 0.47 support remove + public List> createJSModules() { + return Collections.emptyList(); + } + + @Override + public List createViewManagers(ReactApplicationContext reactContext) { + return Collections.emptyList(); + } + + @Override + public List createNativeModules( + ReactApplicationContext reactContext) { + List modules = new ArrayList<>(); + modules.add(new UploaderModule2(reactContext)); + return modules; + } +} diff --git a/android/src/main/java/com/vydia/RNUploader2/UploaderModule2.kt b/android/src/main/java/com/vydia/RNUploader2/UploaderModule2.kt new file mode 100644 index 00000000..0957c202 --- /dev/null +++ b/android/src/main/java/com/vydia/RNUploader2/UploaderModule2.kt @@ -0,0 +1,119 @@ +package com.vydia.RNUploader2 + +import android.util.Log +import androidx.work.ExistingWorkPolicy +import androidx.work.OneTimeWorkRequestBuilder +import androidx.work.WorkManager +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 kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch + + +class UploaderModule2(context: ReactApplicationContext) : + ReactContextBaseJavaModule(context) { + + companion object { + const val TAG = "RNFileUploader.UploaderModule" + const val WORKER_ID = "RNFileUploader" + var reactContext: ReactApplicationContext? = null + private set + } + + private val workManager = WorkManager.getInstance(context) + + init { + reactContext = context + } + + + override fun getName(): String = "RNFileUploader2" + + + @ReactMethod + fun initialize(opts: ReadableMap, promise: Promise) = + CoroutineScope(Dispatchers.IO).launch { + try { + NotificationConfigs.update(opts) + promise.resolve(true) + } catch (exc: Throwable) { + if (exc !is MissingOptionException) { + exc.printStackTrace() + Log.e(TAG, exc.message, exc) + } + promise.reject(exc) + } + } + + + /* + * Starts a file upload. + * Returns a promise with the string ID of the upload. + */ + @ReactMethod + fun startUpload(rawOptions: ReadableMap, promise: Promise) { + try { + val upload = Upload.fromRawOptions(rawOptions) + UploadQueue.add(upload) + + val request = OneTimeWorkRequestBuilder().build() + + // TODO check if cancelling and starting will keep the queue + workManager + .beginUniqueWork(WORKER_ID, ExistingWorkPolicy.KEEP, request) + .enqueue() + + // TODO retry in a bit if the id has never been started and then clear the map + // don't use the upload ID since it can be duplicated + + promise.resolve(upload.id) + } catch (exc: Throwable) { + if (exc !is MissingOptionException) { + exc.printStackTrace() + Log.e(TAG, exc.message, exc) + } + promise.reject(exc) + } + } + + /* + * Cancels file upload + * Accepts upload ID as a first argument, this upload will be cancelled + * Event "cancelled" will be fired when upload is cancelled. + */ + @ReactMethod + fun cancelUpload(uploadId: String, promise: Promise) { + try { + UploadQueue.cancel(uploadId) + EventReporter.cancelled(uploadId) + promise.resolve(true) + } catch (exc: Throwable) { + exc.printStackTrace() + Log.e(TAG, exc.message, exc) + promise.reject(exc) + } + } + + /* + * Cancels all file uploads + */ + @ReactMethod + fun stopAllUploads(promise: Promise) { + try { + while (!UploadQueue.isEmpty()) { + val upload = UploadQueue.pop() + EventReporter.cancelled(upload.id) + } + promise.resolve(true) + } catch (exc: Throwable) { + exc.printStackTrace() + Log.e(TAG, exc.message, exc) + promise.reject(exc) + } + } + +} diff --git a/example/RNBGUExample/App.tsx b/example/RNBGUExample/App.tsx index e7e44655..048eff4f 100644 --- a/example/RNBGUExample/App.tsx +++ b/example/RNBGUExample/App.tsx @@ -8,12 +8,13 @@ import React, {useEffect, useState} from 'react'; import { + Button, SafeAreaView, - StyleSheet, ScrollView, - View, - Text, StatusBar, + StyleSheet, + Text, + View, Button, } from 'react-native'; import notifee, {AndroidImportance} from '@notifee/react-native'; @@ -27,6 +28,15 @@ 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 ANDROID_NOTIFICATION_CHANNEL = 'RNBGUExample'; + +Upload.initialize({ + notificationId: ANDROID_NOTIFICATION_CHANNEL, + notificationTitle: ANDROID_NOTIFICATION_CHANNEL, + notificationTitleNoWifi: 'No wifi', + notificationTitleNoInternet: 'No internet', + notificationChannel: ANDROID_NOTIFICATION_CHANNEL, +}); const App = () => { const [uploadId, setUploadId] = useState(); @@ -37,6 +47,7 @@ const App = () => { useEffect(() => { Upload.addListener('progress', null, data => { + setUploadId(data.uploadId); setProgress(data.progress); console.log(`Progress: ${data.progress}%`); }); @@ -49,6 +60,14 @@ const App = () => { }, []); useEffect(() => { + notifee.requestPermission({alert: true, sound: true}); + + notifee.createChannel({ + id: ANDROID_NOTIFICATION_CHANNEL, + name: ANDROID_NOTIFICATION_CHANNEL, + importance: AndroidImportance.LOW, + }); + RNFS.exists('file://' + TEST_FILE) .then(exists => { if (exists) return; @@ -60,24 +79,12 @@ const App = () => { .then(() => setTestFileDownload('downloaded')); }, []); - const onPressUpload = async () => { - await notifee.requestPermission({alert: true, sound: true}); - - const channelId = 'RNBGUExample'; - await notifee.createChannel({ - id: channelId, - name: channelId, - importance: AndroidImportance.LOW, - }); + const doUpload = async (id?: string) => { + const customUploadId = id ?? Date.now().toString() + Math.random(); + console.log('---starting', customUploadId); const uploadOpts: UploadOptions = { - android: { - notificationId: channelId, - notificationTitle: channelId, - notificationTitleNoWifi: 'No wifi', - notificationTitleNoInternet: 'No internet', - notificationChannel: channelId, - }, + customUploadId, type: 'raw', url: UPLOAD_URL, path: TEST_FILE, @@ -100,6 +107,12 @@ const App = () => { }); }; + const onPressUpload = () => { + for (let i = 0; i < 100; i++) { + doUpload(); + } + }; + 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/package.json b/package.json index d66fa806..a1e349f8 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "react-native-background-upload", - "version": "7.5.2", + "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", 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;