Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions android/src/main/java/com/vydia/RNUploader/Connectivity.kt
Original file line number Diff line number Diff line change
@@ -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 {
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

moved from UploadWorker.ts no logical change

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
}
}
}

Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
package com.vydia.RNUploader

class MissingOptionException(optionName: String) :
IllegalArgumentException("Missing '$optionName'")
112 changes: 112 additions & 0 deletions android/src/main/java/com/vydia/RNUploader/Notification.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
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


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 setOptions(opts: ReadableMap) {
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

these options are moved over from the Upload class, so they are now global options instead of per-Upload options

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")
}

// builds the notification required to enable Foreground mode
fun build(context: Context): Notification {
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

moved from UploadWorker.ts. Not much has changed except for val wifiOnly = getActiveUpload()?.wifiOnly ?: false

// 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)
}


18 changes: 0 additions & 18 deletions android/src/main/java/com/vydia/RNUploader/Upload.kt
Original file line number Diff line number Diff line change
Expand Up @@ -14,15 +14,7 @@ data class Upload(
val maxRetries: Int,
val wifiOnly: Boolean,
val headers: Map<String, String>,
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(),
Expand All @@ -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),
)
}
}
Expand Down
104 changes: 15 additions & 89 deletions android/src/main/java/com/vydia/RNUploader/UploadWorker.kt
Original file line number Diff line number Diff line change
@@ -1,17 +1,8 @@
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
Expand Down Expand Up @@ -48,18 +39,13 @@ 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) {

enum class Input { Params }

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.
Expand Down Expand Up @@ -125,11 +111,15 @@ 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 (Connectivity.fetch(context, upload.wifiOnly) != Connectivity.Ok) return null
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Notification not updated when connectivity check fails

Medium Severity

When the connectivity check fails in upload(), the code returns null without updating the notification. Previously, validateAndReportConnectivity() always updated the notification to reflect connectivity state. Now, a wifi-only upload waiting for wifi shows "Uploading" instead of "Waiting for WiFi" because UploadNotification.build() uses the active upload's wifiOnly (defaulting to false when no active upload), and the notification is never updated when connectivity fails pre-semaphore.

Fix in Cursor Fix in Web


// wait for its turn to run
semaphore.acquire()

// mark as active upload for notification
UploadNotification.setActiveUpload(upload)
UploadNotification.update(context)

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Semaphore leak if notification update throws exception

High Severity

If UploadNotification.setActiveUpload() or UploadNotification.update() throws an exception after semaphore.acquire() but before entering the try block, the finally block containing semaphore.release() will never execute. With MAX_CONCURRENCY = 1, this would permanently leak the only permit, causing all subsequent uploads to hang indefinitely waiting for the semaphore. The old code had semaphore.acquire() immediately followed by try, avoiding this window.

Fix in Cursor Fix in Web

try {
return okhttpUpload(client, upload, file) { progress ->
handleProgress(progress, size)
Expand All @@ -141,23 +131,28 @@ 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())
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
Expand All @@ -167,6 +162,7 @@ class UploadWorker(private val context: Context, params: WorkerParameters) :

UploadProgress.remove(upload.id)
EventReporter.cancelled(upload.id)
UploadNotification.update(context)
return true
}

Expand All @@ -176,7 +172,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 (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
Expand All @@ -200,56 +196,10 @@ 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
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
}

// 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 id = upload.notificationId
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)
ForegroundInfo(id, notification, ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC)
Expand All @@ -258,28 +208,4 @@ 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 {
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)
}
Loading
Loading