Skip to content
Merged
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
25 changes: 19 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,11 +63,14 @@ const options = {
// Optional. Treat these non-2xx statuses as success (e.g. an idempotent
// create that conflicts). Any other non-2xx is an 'error' with errorKind 'http'.
acceptStatus: [409],
// Optional on Android — the library supplies notification defaults and creates
// its own channel. Override any of these to customize.
android: { notificationTitle: 'Uploading…' },
};

// Optional. Call one time at app startup to set the Android notification text.
// The library keeps the text in native storage. Thus a worker relaunched with
// no JS shows the same text. If you do not call configure(), the library uses
// default text and makes its own channel. The call does nothing on iOS.
Upload.configure({ android: { notificationTitle: 'Uploading…' } });

const uploadId = await Upload.startUpload(options);

Upload.addListener('progress', ({ id, progress }) => {});
Expand Down Expand Up @@ -113,6 +116,15 @@ Notes:

All methods are on the default export.

### `configure(options): void`
One-time setup — call at app startup. `options.android` sets the upload
notification's text and identity:
`notificationId/Title/TitleNoWifi/TitleNoInternet/Channel`. The config is
persisted natively, so a worker relaunched by WorkManager with no JS running
shows the same text. Optional: omitted fields keep the library defaults (each
call replaces the whole config). A no-op on iOS, which has no library
notification.

### `startUpload(options): Promise<string>`
Starts an upload; resolves to its id. Rejects only on a bad option (missing/invalid
`url` or `path`) — transport failures and HTTP error responses arrive later as
Expand All @@ -130,7 +142,7 @@ of starting a duplicate.
| `id` | string | Defaults to a generated UUID. |
| `wifiOnly` | boolean | Wait for wifi before/while uploading. |
| `acceptStatus` | number[] | Non-2xx statuses to treat as success. |
| `android` | object | Optional. `notificationId/Title/TitleNoWifi/TitleNoInternet/Channel`, `noNotification` (default false). Sensible defaults + auto-created channel if omitted. |
| `android` | object | Optional. `noNotification` (default false) — see Silent uploads. Notification text is set once via `configure()`, not per upload. |

#### Silent uploads (Android)

Expand All @@ -143,8 +155,9 @@ to defer it, or to stop it mid-flight and let WorkManager re-run it later. Keep
the notification for anything that takes real time to upload; reserve
`noNotification` for small payloads a restart would cost nothing.

Uploads sharing a `notificationId` share one notification, and its progress bar
reports every in-flight upload — silent ones included.
All uploads share one notification (identified by the configured
`notificationId`), and its progress bar reports every in-flight upload — silent
ones included.

### `cancelUpload(uploadId): Promise<boolean>`
Cancels an upload. Fires a `cancelled` event with `cancelReason: 'user'`.
Expand Down
35 changes: 19 additions & 16 deletions android/consumer-rules.pro
Original file line number Diff line number Diff line change
@@ -1,25 +1,28 @@
# Shipped to consumers via consumerProguardFiles, so a minified release build of
# the host app keeps these guarantees.
# These rules go to consumers through consumerProguardFiles. Thus a minified
# release build of the host app keeps these guarantees.
#
# Upload and EventJournal.Entry are persisted with Gson — Upload into WorkManager
# input data, Entry into the on-disk event journal — and read back later, across
# app restarts AND across app updates. Gson resolves fields reflectively by name
# and needs the generic Signature attribute to reconstruct typed collections, so
# R8 renaming either one corrupts persisted state silently:
# Gson persists Upload, NotificationConfig, and EventJournal.Entry. Upload goes
# into WorkManager input data. NotificationConfig goes into SharedPreferences.
# Entry goes into the on-disk event journal. The library reads them back later,
# across app restarts AND across app updates. Gson finds fields by name through
# reflection. Gson also needs the generic Signature attribute to rebuild typed
# collections. Thus, if R8 renames a field or removes Signature, it corrupts the
# persisted state silently:
#
# * Upload.acceptStatus is a List<Int>. Without Signature, Gson deserializes it
# as List<Double>, so acceptStatus.contains(code) never matches and a
# configured accept status (e.g. 409) is reported as an http error instead of
# a completed upload.
# * A journal Entry written by an older build fails to parse if field names
# changed, and is then dropped as malformed — losing exactly the terminal
# outcomes the journal exists to preserve.
# * Upload.acceptStatus is a List<Int>. Without Signature, Gson decodes it as
# List<Double>. Then acceptStatus.contains(code) never matches, and a
# configured accept status (for example 409) is reported as an http error,
# not as a completed upload.
# * A journal Entry from an older build fails to parse if field names changed.
# The library then drops the Entry as malformed. This loses the terminal
# outcomes that the journal exists to keep.
#
# Debug builds are unminified and round-trip symmetrically, so neither failure is
# reproducible without R8; keep these rules.
# Debug builds are not minified and round-trip correctly. Thus neither failure
# is reproducible without R8. Keep these rules.
-keepattributes Signature
-keepattributes *Annotation*

-keep class ai.openspace.backgroundupload.Upload { *; }
-keep class ai.openspace.backgroundupload.Upload$* { *; }
-keep class ai.openspace.backgroundupload.NotificationConfig { *; }
-keep class ai.openspace.backgroundupload.EventJournal$Entry { *; }
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
package ai.openspace.backgroundupload

import android.content.Context
import com.facebook.react.bridge.ReadableMap
import com.google.gson.Gson

/**
* The text and the identity of the upload progress notification. JS sets it
* one time with `configure()`. The class writes it to SharedPreferences. Thus a
* worker that WorkManager relaunches — with no JS, possibly in a process where
* React never initialized — shows the same text. Each field falls back to a
* library default. Thus uploads work when `configure()` was never called.
*/
data class NotificationConfig(
val notificationId: String,
val notificationTitle: String,
val notificationTitleNoInternet: String,
val notificationTitleNoWifi: String,
val notificationChannel: String,
) {
// The id given to NotificationManager. All uploads share the configured id.
// Thus they share one notification, and its progress bar is the total.
val systemNotificationId get() = notificationId.hashCode()

companion object {
const val DEFAULT_NOTIFICATION_CHANNEL = "background-upload"

val DEFAULTS = NotificationConfig(
notificationId = DEFAULT_NOTIFICATION_CHANNEL,
notificationTitle = "Uploading…",
notificationTitleNoInternet = "Waiting for connection…",
notificationTitleNoWifi = "Waiting for Wi-Fi…",
notificationChannel = DEFAULT_NOTIFICATION_CHANNEL,
)

private const val PREFS_NAME = "rnbgupload-config"
private const val PREFS_KEY = "notificationConfig"
private val gson = Gson()

fun fromReadableMap(map: ReadableMap) = NotificationConfig(
notificationId = map.getString(NotificationConfig::notificationId.name)
?: DEFAULTS.notificationId,
notificationTitle = map.getString(NotificationConfig::notificationTitle.name)
?: DEFAULTS.notificationTitle,
notificationTitleNoInternet = map.getString(NotificationConfig::notificationTitleNoInternet.name)
?: DEFAULTS.notificationTitleNoInternet,
notificationTitleNoWifi = map.getString(NotificationConfig::notificationTitleNoWifi.name)
?: DEFAULTS.notificationTitleNoWifi,
notificationChannel = map.getString(NotificationConfig::notificationChannel.name)
?: DEFAULTS.notificationChannel,
)

// Gson does not use the constructor. Thus a blob from a build with
// different fields, or a corrupt blob, can make a non-null field null.
// Each field falls back alone. A bad blob must give default text. It must
// never crash a worker.
@Suppress("SENSELESS_COMPARISON")
fun fromJson(json: String?): NotificationConfig {
val parsed = json?.let {
runCatching { gson.fromJson(it, NotificationConfig::class.java) }.getOrNull()
} ?: return DEFAULTS
return NotificationConfig(
notificationId = parsed.notificationId ?: DEFAULTS.notificationId,
notificationTitle = parsed.notificationTitle ?: DEFAULTS.notificationTitle,
notificationTitleNoInternet = parsed.notificationTitleNoInternet
?: DEFAULTS.notificationTitleNoInternet,
notificationTitleNoWifi = parsed.notificationTitleNoWifi
?: DEFAULTS.notificationTitleNoWifi,
notificationChannel = parsed.notificationChannel ?: DEFAULTS.notificationChannel,
)
}

fun save(context: Context, config: NotificationConfig) {
prefs(context).edit().putString(PREFS_KEY, gson.toJson(config)).apply()
}

fun load(context: Context): NotificationConfig =
fromJson(prefs(context).getString(PREFS_KEY, null))

private fun prefs(context: Context) =
context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
}
}
21 changes: 2 additions & 19 deletions android/src/main/java/ai/openspace/backgroundupload/Upload.kt
Original file line number Diff line number Diff line change
Expand Up @@ -17,11 +17,6 @@ data class Upload(
// terminal http error. Empty by default.
val acceptStatus: List<Int>,
val headers: Map<String, String>,
val notificationId: Int,
val notificationTitle: String,
val notificationTitleNoInternet: String,
val notificationTitleNoWifi: String,
val notificationChannel: String,
/**
* Suppresses the progress notification for this upload.
*
Expand All @@ -43,8 +38,6 @@ data class Upload(
IllegalArgumentException("Missing '$optionName'")

companion object {
const val DEFAULT_NOTIFICATION_CHANNEL = "background-upload"

fun fromReadableMap(map: ReadableMap) = Upload(
id = map.getString(Upload::id.name) ?: UUID.randomUUID().toString(),
url = map.getString(Upload::url.name) ?: throw MissingOptionException(Upload::url.name),
Expand All @@ -62,18 +55,8 @@ data class Upload(
}
return@let map
},
// Notification options are optional: the library supplies sensible defaults
// and creates its own channel, so consumers don't need any notifee plumbing.
notificationId = (map.getString(Upload::notificationId.name)
?: DEFAULT_NOTIFICATION_CHANNEL).hashCode(),
notificationTitle = map.getString(Upload::notificationTitle.name)
?: "Uploading…",
notificationTitleNoInternet = map.getString(Upload::notificationTitleNoInternet.name)
?: "Waiting for connection…",
notificationTitleNoWifi = map.getString(Upload::notificationTitleNoWifi.name)
?: "Waiting for Wi-Fi…",
notificationChannel = map.getString(Upload::notificationChannel.name)
?: DEFAULT_NOTIFICATION_CHANNEL,
// The notification text and identity are not per-upload options. The
// worker reads them from the NotificationConfig that configure() saved.
noNotification = if (map.hasKey(Upload::noNotification.name))
map.getBoolean(Upload::noNotification.name) else false,
)
Expand Down
29 changes: 17 additions & 12 deletions android/src/main/java/ai/openspace/backgroundupload/UploadWorker.kt
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,10 @@ class UploadWorker(private val context: Context, params: WorkerParameters) :
}

private lateinit var upload: Upload
// configure() saved this. The worker can read it when WorkManager relaunched
// the worker with no JS. It is lazy, so the SharedPreferences read occurs on
// the worker's IO dispatcher, not at construction.
private val config by lazy { NotificationConfig.load(context) }
private var retries = 0
private var connectivity = Connectivity.Ok
private val notificationManager =
Expand Down Expand Up @@ -179,7 +183,7 @@ class UploadWorker(private val context: Context, params: WorkerParameters) :
// worker never posted one, and `notify` would create it outside foreground mode.
private fun updateNotification() {
if (!upload.showsNotification) return
notificationManager.notify(upload.notificationId, buildNotification())
notificationManager.notify(config.systemNotificationId, buildNotification())
}

// An HTTP response came back. "completed" only for 2xx or a per-request
Expand Down Expand Up @@ -303,15 +307,16 @@ class UploadWorker(private val context: Context, params: WorkerParameters) :
return this.connectivity == Connectivity.Ok
}

// Ensures the channel used by the foreground notification exists. Only creates
// it when absent, so a channel the consumer registered themselves (with their
// own name/importance) always wins; when they pass nothing we fall back to a
// default LOW-importance channel and no notifee setup is required.
// Makes sure that the channel for the foreground notification exists. It
// makes the channel only when the channel is absent. Thus a channel that the
// consumer registered, with their own name and importance, always wins. When
// configure() never set a channel, we use a default LOW-importance channel,
// and no notifee setup is necessary.
private fun ensureNotificationChannel() {
// minSdk is 29, so NotificationChannel (API 26) is always available.
if (notificationManager.getNotificationChannel(upload.notificationChannel) != null) return
if (notificationManager.getNotificationChannel(config.notificationChannel) != null) return
val channel = NotificationChannel(
upload.notificationChannel,
config.notificationChannel,
"Uploads",
NotificationManager.IMPORTANCE_LOW,
)
Expand All @@ -320,13 +325,13 @@ class UploadWorker(private val context: Context, params: WorkerParameters) :

// builds the notification required to enable Foreground mode
fun buildNotification(): Notification {
val channel = upload.notificationChannel
val channel = config.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
Connectivity.NoWifi -> config.notificationTitleNoWifi
Connectivity.NoInternet -> config.notificationTitleNoInternet
Connectivity.Ok -> config.notificationTitle
}

// Custom layout for progress notification.
Expand Down Expand Up @@ -359,7 +364,7 @@ class UploadWorker(private val context: Context, params: WorkerParameters) :

override suspend fun getForegroundInfo(): ForegroundInfo {
val notification = buildNotification()
val id = upload.notificationId
val id = config.systemNotificationId
// 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 Down
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,17 @@ class UploaderModule(context: ReactApplicationContext) :
}


/**
* Saves the notification configuration (see [NotificationConfig]). Thus a
* worker that WorkManager relaunches with no JS can read it. Each call
* replaces the full configuration. An omitted field goes back to the library
* default.
*/
override fun configure(options: ReadableMap) {
NotificationConfig.save(reactApplicationContext, NotificationConfig.fromReadableMap(options))
}


/*
* Starts a file upload.
* Returns a promise with the string ID of the upload.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
package ai.openspace.backgroundupload

import com.google.gson.Gson
import org.junit.Assert.assertEquals
import org.junit.Test

class NotificationConfigTest {
private val gson = Gson()

private val configured = NotificationConfig(
notificationId = "my-id",
notificationTitle = "Backing up…",
notificationTitleNoInternet = "Offline",
notificationTitleNoWifi = "No wifi",
notificationChannel = "my-channel",
)

@Test
fun `no stored config yields the defaults`() {
assertEquals(NotificationConfig.DEFAULTS, NotificationConfig.fromJson(null))
}

@Test
fun `a configured blob survives a persistence round trip`() {
assertEquals(configured, NotificationConfig.fromJson(gson.toJson(configured)))
}

@Test
fun `a corrupt blob degrades to the defaults`() {
assertEquals(NotificationConfig.DEFAULTS, NotificationConfig.fromJson("]["))
}

// A blob from a build with fewer fields must not make the other fields null.
@Test
fun `fields missing from a stored blob fall back individually`() {
val config = NotificationConfig.fromJson("""{"notificationTitle":"Custom"}""")
assertEquals("Custom", config.notificationTitle)
assertEquals(NotificationConfig.DEFAULTS.notificationChannel, config.notificationChannel)
assertEquals(NotificationConfig.DEFAULTS.notificationTitleNoWifi, config.notificationTitleNoWifi)
assertEquals(
NotificationConfig.DEFAULTS.notificationTitleNoInternet,
config.notificationTitleNoInternet,
)
}

// This is the same derivation that v8 applied to the per-upload option. Thus
// an app that gives its old notificationId to configure() keeps the same
// system notification.
@Test
fun `the system notification id derives from the configured string`() {
assertEquals("my-id".hashCode(), configured.systemNotificationId)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -16,11 +16,6 @@ class UploadTest {
wifiOnly = false,
acceptStatus = listOf(),
headers = mapOf(),
notificationId = 1,
notificationTitle = "Uploading…",
notificationTitleNoInternet = "Waiting for connection…",
notificationTitleNoWifi = "Waiting for Wi-Fi…",
notificationChannel = "background-upload",
noNotification = noNotification,
)

Expand Down
Loading
Loading