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'")
187 changes: 187 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,187 @@
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 androidx.core.content.edit
import com.facebook.react.bridge.ReadableMap


object UploadNotification {
private var activeUpload: Upload? = null

@Synchronized
fun setOptions(opts: ReadableMap, context: Context) {
NotificationOptions.set(opts, context)
}

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

// builds the notification required to enable Foreground mode
@Synchronized
fun build(context: Context): Pair<Int, Notification> {
val opts = NotificationOptions.get(context)

// 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 progress = UploadProgress.total()
val progress2Decimals = "%.2f".format(progress)
val title = when (Connectivity.fetch(context, wifiOnly)) {
Connectivity.NoWifi -> opts.titleNoWifi
Connectivity.NoInternet -> opts.titleNoInternet
Connectivity.Ok -> opts.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, opts.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()
}

return Pair(opts.id, notification)
}

@Synchronized
fun update(context: Context) {
val (id, 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)
}


private data class Options(
val id: Int,
val title: String,
val titleNoInternet: String,
val titleNoWifi: String,
val channel: String
)

/**
* Manages notification options with persistence to SharedPreferences.
* Options are loaded lazily: from memory if set, otherwise from SharedPreferences,
* otherwise falls back to defaults.
*/
private object NotificationOptions {
private const val PREFS_NAME = "UploadNotificationPrefs"
private const val PREF_ID = "id"
private const val PREF_TITLE = "title"
private const val PREF_TITLE_NO_INTERNET = "titleNoInternet"
private const val PREF_TITLE_NO_WIFI = "titleNoWifi"
private const val PREF_CHANNEL = "channel"

private var cached: Options? = null

@Synchronized
fun get(context: Context): Options {
cached?.let { return it }
loadFromPrefs(context)?.let { cached = it; return it }
return Options(
id = "default-upload-notification".hashCode(),
title = "Uploading files",
titleNoInternet = "Waiting for internet connection",
titleNoWifi = "Waiting for WiFi connection",
channel = "File Uploads"
)
}

@Synchronized
fun set(opts: ReadableMap, context: Context) {
val options = 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")
)
cached = options
// In rare cases, the Worker might be killed and restarted by the operating system,
// so we need to persist the options
saveToPrefs(context, options)
}

private fun loadFromPrefs(context: Context): Options? {
val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
val id = prefs.getInt(PREF_ID, 0)
if (id == 0) return null

return Options(
id = id,
title = prefs.getString(PREF_TITLE, "Uploading files") ?: "Uploading files",
titleNoInternet = prefs.getString(PREF_TITLE_NO_INTERNET, "Waiting for internet connection")
?: "Waiting for internet connection",
titleNoWifi = prefs.getString(PREF_TITLE_NO_WIFI, "Waiting for WiFi connection")
?: "Waiting for WiFi connection",
channel = prefs.getString(PREF_CHANNEL, "File Uploads") ?: "File Uploads"
)
}

private fun saveToPrefs(context: Context, options: Options) {
context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
.edit {
putInt(PREF_ID, options.id)
.putString(PREF_TITLE, options.title)
.putString(PREF_TITLE_NO_INTERNET, options.titleNoInternet)
.putString(PREF_TITLE_NO_WIFI, options.titleNoWifi)
.putString(PREF_CHANNEL, options.channel)
}
}
}

22 changes: 2 additions & 20 deletions android/src/main/java/com/vydia/RNUploader/Upload.kt
Original file line number Diff line number Diff line change
Expand Up @@ -11,26 +11,18 @@ data class Upload(
val url: String,
val path: String,
val method: String,
val maxRetries: Int,
Copy link

Choose a reason for hiding this comment

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

Resumed workers use uninitialized notification settings after app restart

Medium Severity

The notification settings (notificationChannel, notificationId, etc.) were removed from the Upload class, so they're no longer serialized with WorkManager input data. When workers resume after an app restart (process death), they depend on UploadNotification singleton state which hasn't been initialized yet—initialize() is called by JS code that may not have executed. The worker uses the default channel "File Uploads" which likely doesn't exist, causing the foreground notification to fail. This could result in missing notifications or setForeground() failures that prevent the worker from running properly.

Additional Locations (1)

Fix in Cursor Fix in Web

val wifiOnly: Boolean,
val maxRetries: Int,
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(),
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,
maxRetries = if (map.hasKey(Upload::maxRetries.name)) map.getInt(Upload::maxRetries.name) else 5,
headers = map.getMap(Upload::headers.name).let { headers ->
if (headers == null) return@let mapOf()
val map = mutableMapOf<String, String>()
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
17 changes: 15 additions & 2 deletions android/src/main/java/com/vydia/RNUploader/UploadProgress.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand All @@ -19,8 +20,8 @@ object UploadProgress {
private val map = mutableMapOf<String, Progress>()

@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
Expand Down Expand Up @@ -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 }
}
}
Loading
Loading