Skip to content
Closed
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
3 changes: 3 additions & 0 deletions android/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,9 @@ android {
defaultConfig {
minSdkVersion safeExtGet('minSdkVersion', 29)
targetSdkVersion safeExtGet('targetSdkVersion', 36)
// Keeps the Gson-persisted models intact under the host app's R8. See the
// file for why a minified build breaks silently without it.
consumerProguardFiles 'consumer-rules.pro'
}

compileOptions {
Expand Down
25 changes: 25 additions & 0 deletions android/consumer-rules.pro
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
# Shipped to consumers via consumerProguardFiles, so 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:
#
# * 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.
#
# Debug builds are unminified and round-trip symmetrically, so 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.EventJournal$Entry { *; }
140 changes: 140 additions & 0 deletions android/src/main/java/ai/openspace/backgroundupload/EventJournal.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
package ai.openspace.backgroundupload

import android.content.Context
import com.google.gson.Gson
import java.io.File

// Durable record of terminal upload events (completed / error / cancelled).
// Written BEFORE the event is emitted to JS; deleted only when JS acknowledges.
// One JSON file per event named <eventId>.json — tmp+rename keeps each write
// self-contained so a crash mid-append can never corrupt other entries.
//
// `maxEntries` is a runaway guard: the design assumes JS drains the journal via
// ack() on every boot, but if that loop breaks (or a consumer hasn't adopted it
// yet) the directory would grow without bound. When exceeded we drop the OLDEST
// entries. Set high enough that legitimate heavy offline use won't hit it — this
// only fires in the pathological "nothing ever acks" case.
class EventJournal(
private val dir: File,
private val maxEntries: Int = MAX_ENTRIES,
) {

data class Entry(
val eventId: String,
val uploadId: String,
val type: String, // completed | error | cancelled
val timestamp: Long,
val responseCode: Int? = null,
val responseBody: String? = null,
val responseBodyTruncated: Boolean = false,
val responseHeaders: Map<String, String>? = null,
val error: String? = null,
val errorKind: String? = null, // http | network | file | unknown
val cancelReason: String? = null, // user | system
) {
fun toWritableMap(): com.facebook.react.bridge.WritableMap =
com.facebook.react.bridge.Arguments.createMap().apply {
putString("eventId", eventId)
putString("id", uploadId)
putString("type", type)
putDouble("timestamp", timestamp.toDouble())
responseCode?.let { putInt("responseCode", it) }
responseBody?.let { putString("responseBody", it) }
if (responseBodyTruncated) putBoolean("responseBodyTruncated", true)
responseHeaders?.let {
putMap("responseHeaders", com.facebook.react.bridge.Arguments.makeNativeMap(it))
}
error?.let { putString("error", it) }
errorKind?.let { putString("errorKind", it) }
cancelReason?.let { putString("cancelReason", it) }
}
}

companion object {
const val MAX_BODY_CHARS = 64 * 1024
const val MAX_ENTRIES = 1000
private val gson = Gson()

// Char-count cap (not byte-accurate: splitting on a byte boundary risks
// cutting a surrogate pair; a slightly loose cap is fine as a safety limit).
// Returns the (possibly truncated) body and whether truncation occurred.
// Single source of truth so the journaled copy and the live-emitted copy match.
fun capBody(body: String?): Pair<String?, Boolean> =
if (body != null && body.length > MAX_BODY_CHARS)
body.substring(0, MAX_BODY_CHARS) to true
else body to false

@Volatile
private var instance: EventJournal? = null

// The worker may run in a process where React never initialized, so the
// journal must be reachable from a bare Context, not the module.
fun get(context: Context): EventJournal =
instance ?: synchronized(this) {
instance
?: EventJournal(File(context.filesDir, "rnbgupload-events")).also { instance = it }
}
}

init {
dir.mkdirs()
}

@Synchronized
fun append(entry: Entry) {
// Defensive cap in case a caller didn't pre-cap; idempotent when it did.
val (body, truncated) = capBody(entry.responseBody)
val bounded =
if (truncated) entry.copy(responseBody = body, responseBodyTruncated = true) else entry
// A journal write must NEVER throw into the caller. The worker calls this
// right after a successful upload; a propagated IOException (e.g. disk full)
// would be classified as a retryable error and re-run the upload, sending
// duplicate data to the server. Losing one journal entry is the lesser evil.
try {
val tmp = File(dir, "${entry.eventId}.tmp")
tmp.writeText(gson.toJson(bounded))
tmp.renameTo(File(dir, "${entry.eventId}.json"))
} catch (t: Throwable) {
t.printStackTrace()
return
}
pruneToMax()
}

// Keep the directory bounded. Prune by file modification time (no parsing)
// rather than the entry's own timestamp — cheaper, and close enough since a
// file's mtime is when it was journaled. Guarded: a prune failure must not
// propagate for the same reason append() must not.
private fun pruneToMax() {
try {
// Sweep orphaned .tmp files (writeText succeeded but rename failed).
dir.listFiles { f -> f.extension == "tmp" }?.forEach { it.delete() }
val files = dir.listFiles { f -> f.extension == "json" } ?: return
if (files.size <= maxEntries) return
files.sortedBy { it.lastModified() }
.take(files.size - maxEntries)
.forEach { it.delete() }
} catch (t: Throwable) {
t.printStackTrace()
}
}

@Synchronized
@Suppress("SENSELESS_COMPARISON") // Gson can inject null into a non-null field
fun unacknowledged(): List<Entry> =
(dir.listFiles { f -> f.extension == "json" } ?: emptyArray())
.mapNotNull { f ->
runCatching { gson.fromJson(f.readText(), Entry::class.java) }.getOrNull()
}
// Gson bypasses the constructor, so a file missing a field yields null
// despite the non-null Kotlin type. Check every field JS relies on being
// present, not just eventId — an entry reaching JS with a null `type`
// would fall silently through a `switch (event.type)`.
.filter { it.eventId != null && it.uploadId != null && it.type != null }
.sortedBy { it.timestamp }

@Synchronized
fun ack(eventIds: List<String>) {
eventIds.forEach { File(dir, "$it.json").delete() }
}
}
Original file line number Diff line number Diff line change
@@ -1,54 +1,39 @@
package ai.openspace.backgroundupload

import android.util.Log
import com.facebook.react.bridge.Arguments
import com.facebook.react.bridge.WritableMap
import com.facebook.react.modules.core.DeviceEventManagerModule.RCTDeviceEventEmitter

// Sends events to React Native
// Sends live events to JS through the module's codegen event emitters. Terminal
// outcomes are journaled before they reach here, so when JS is absent (headless
// worker, mid-reload) dropping the live event costs nothing — the consumer picks
// it up from getUnacknowledgedEvents instead.
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))
})

// Emit a terminal event from its journal entry, so the live event carries the
// exact same payload (incl. eventId) as the journaled copy — letting a consumer
// ackEvents([eventId]) right after handling a live event, and keeping iOS/Android
// event shapes identical.
fun emit(entry: EventJournal.Entry) {
val module = UploaderModule.instance ?: return
val params = entry.toWritableMap()
when (entry.type) {
"completed" -> module.emitCompletedEvent(params)
"cancelled" -> module.emitCancelledEvent(params)
else -> module.emitErrorEvent(params)
}
}

fun progress(uploadId: String, bytesSentTotal: Long, contentLength: Long) =
sendEvent("progress", Arguments.createMap().apply {
fun progress(uploadId: String, bytesSentTotal: Long, contentLength: Long) {
val module = UploaderModule.instance ?: return
module.emitProgressEvent(Arguments.createMap().apply {
putString("id", uploadId)
putDouble("progress", (bytesSentTotal.toDouble() * 100 / contentLength)) //0-100
// Guard against a zero-byte file (contentLength == 0) producing NaN.
val pct = if (contentLength <= 0) 0.0 else bytesSentTotal.toDouble() * 100 / contentLength
putDouble("progress", pct) // 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)
}
fun notification() {
val module = UploaderModule.instance ?: return
module.emitNotificationEvent(Arguments.createMap())
}
}
23 changes: 17 additions & 6 deletions android/src/main/java/ai/openspace/backgroundupload/Upload.kt
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,10 @@ data class Upload(
val method: String,
val maxRetries: Int,
val wifiOnly: Boolean,
// Non-2xx statuses to treat as a successful completion (e.g. [409] when
// duplicate-create conflicts are expected). Everything else non-2xx is a
// terminal http error. Empty by default.
val acceptStatus: List<Int>,
val headers: Map<String, String>,
val notificationId: Int,
val notificationTitle: String,
Expand All @@ -24,13 +28,18 @@ data class Upload(
IllegalArgumentException("Missing '$optionName'")

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

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,
acceptStatus = map.getArray(Upload::acceptStatus.name)?.let { arr ->
(0 until arr.size()).map { i -> arr.getInt(i) }
} ?: listOf(),
headers = map.getMap(Upload::headers.name).let { headers ->
if (headers == null) return@let mapOf()
val map = mutableMapOf<String, String>()
Expand All @@ -39,16 +48,18 @@ data class Upload(
}
return@let map
},
notificationId = map.getString(Upload::notificationId.name)?.hashCode()
?: throw MissingOptionException(Upload::notificationId.name),
// 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)
?: throw MissingOptionException(Upload::notificationTitle.name),
?: "Uploading…",
notificationTitleNoInternet = map.getString(Upload::notificationTitleNoInternet.name)
?: throw MissingOptionException(Upload::notificationTitleNoInternet.name),
?: "Waiting for connection…",
notificationTitleNoWifi = map.getString(Upload::notificationTitleNoWifi.name)
?: throw MissingOptionException(Upload::notificationTitleNoWifi.name),
?: "Waiting for Wi-Fi…",
notificationChannel = map.getString(Upload::notificationChannel.name)
?: throw MissingOptionException(Upload::notificationChannel.name),
?: DEFAULT_NOTIFICATION_CHANNEL,
)
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
package ai.openspace.backgroundupload

import java.io.IOException

// Pure classification of terminal upload outcomes. Kept free of Android/React
// types so it can be unit-tested on a plain JVM — this is the highest-consequence
// logic in the uploader (it decides success vs failure), so it's covered directly.
object UploadOutcome {

// Whether an HTTP response counts as a successful completion. 2xx always, plus
// any per-request acceptStatus codes (axios validateStatus semantics). Anything
// else — including 4xx/5xx — is a terminal http error, not a completion.
fun isAccepted(code: Int, acceptStatus: List<Int>): Boolean =
code in 200..299 || acceptStatus.contains(code)

// Classify a thrown error into a stable kind for the JS layer. `fileExists`
// is passed in (not read here) to keep this pure; callers should default it to
// true when the existence check itself fails, so a flaky file probe reads as a
// retryable network error rather than a terminal "file gone".
fun errorKind(error: Throwable, fileExists: Boolean): String = when {
error is IOException && !fileExists -> "file"
error is IOException -> "network"
else -> "unknown"
}
}
Loading
Loading