diff --git a/android/build.gradle b/android/build.gradle index 2046895e..591768ac 100755 --- a/android/build.gradle +++ b/android/build.gradle @@ -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 { diff --git a/android/consumer-rules.pro b/android/consumer-rules.pro new file mode 100644 index 00000000..3da6e998 --- /dev/null +++ b/android/consumer-rules.pro @@ -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. Without Signature, Gson deserializes it +# as List, 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 { *; } diff --git a/android/src/main/java/ai/openspace/backgroundupload/EventJournal.kt b/android/src/main/java/ai/openspace/backgroundupload/EventJournal.kt new file mode 100644 index 00000000..5bbc0738 --- /dev/null +++ b/android/src/main/java/ai/openspace/backgroundupload/EventJournal.kt @@ -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 .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? = 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 = + 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 = + (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) { + eventIds.forEach { File(dir, "$it.json").delete() } + } +} diff --git a/android/src/main/java/ai/openspace/backgroundupload/EventReporter.kt b/android/src/main/java/ai/openspace/backgroundupload/EventReporter.kt index 8df2c098..a4770c78 100644 --- a/android/src/main/java/ai/openspace/backgroundupload/EventReporter.kt +++ b/android/src/main/java/ai/openspace/backgroundupload/EventReporter.kt @@ -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()) } } diff --git a/android/src/main/java/ai/openspace/backgroundupload/Upload.kt b/android/src/main/java/ai/openspace/backgroundupload/Upload.kt index dba9cfed..f314b1f9 100644 --- a/android/src/main/java/ai/openspace/backgroundupload/Upload.kt +++ b/android/src/main/java/ai/openspace/backgroundupload/Upload.kt @@ -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, val headers: Map, val notificationId: Int, val notificationTitle: String, @@ -24,6 +28,8 @@ 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), @@ -31,6 +37,9 @@ data class Upload( 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() @@ -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, ) } } diff --git a/android/src/main/java/ai/openspace/backgroundupload/UploadOutcome.kt b/android/src/main/java/ai/openspace/backgroundupload/UploadOutcome.kt new file mode 100644 index 00000000..d044ac9e --- /dev/null +++ b/android/src/main/java/ai/openspace/backgroundupload/UploadOutcome.kt @@ -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): 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" + } +} diff --git a/android/src/main/java/ai/openspace/backgroundupload/UploadWorker.kt b/android/src/main/java/ai/openspace/backgroundupload/UploadWorker.kt index 65b61784..b3fd2a1c 100644 --- a/android/src/main/java/ai/openspace/backgroundupload/UploadWorker.kt +++ b/android/src/main/java/ai/openspace/backgroundupload/UploadWorker.kt @@ -1,6 +1,7 @@ package ai.openspace.backgroundupload import android.app.Notification +import android.app.NotificationChannel import android.app.NotificationManager import android.app.PendingIntent import android.content.Context @@ -24,6 +25,7 @@ import okhttp3.OkHttpClient import java.io.File import java.io.IOException import java.net.UnknownHostException +import java.util.UUID import java.util.concurrent.TimeUnit // All workers will start `doWork` immediately but only 1 request is active at a time. @@ -53,7 +55,18 @@ private enum class Connectivity { NoWifi, NoInternet, Ok } class UploadWorker(private val context: Context, params: WorkerParameters) : CoroutineWorker(context, params) { - enum class Input { Params } + companion object { + /** + * Key for the serialized [Upload] in the worker's input data. + * + * A string literal on purpose. This key is persisted in WorkManager's + * database, so the build that runs a job may not be the build that enqueued + * it — a key derived from a symbol name (an enum constant, a property) breaks + * the moment R8 renames it or someone refactors, and the failure looks like + * "No Params" on a job that was queued perfectly well by the previous version. + */ + const val PARAMS_KEY = "params" + } private lateinit var upload: Upload private var retries = 0 @@ -65,11 +78,14 @@ class UploadWorker(private val context: Context, params: WorkerParameters) : // Retrieve the upload. If this throws errors, error reporting won't work. // However, the only way it has errors is the implementation is incorrect, // which can be caught in development - val paramsJson = inputData.getString(Input.Params.name) ?: throw Throwable("No Params") + val paramsJson = inputData.getString(PARAMS_KEY) ?: throw Throwable("No Params") upload = Gson().fromJson(paramsJson, Upload::class.java) // initialization, errors thrown here won't be retried try { + // The foreground notification needs a channel to exist first, or posting + // it silently fails and setForeground can crash on newer Android. + ensureNotificationChannel() // `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. @@ -92,17 +108,17 @@ class UploadWorker(private val context: Context, params: WorkerParameters) : // - "delay" should be within the "try" block to account for worker cancellation, // which cancels the delay immediately and throws CancellationException. // - Linear backoff instead of exponential. One reason for this is we retry on - // invalid connections. Exponential will take too long. If the server flakes and - // returns 500s, we don't retry but consider the request successful. - // This is consistent with iOS behavior. User gets notifications for - // these server issues and can manually retry. Since 500s are currently rare, - // it's likely ok. If they're too frequent, we can consider adding exponential - // backoff for them. + // invalid connections. Exponential will take too long. + // - We only retry transport failures here (no response). Any HTTP response, + // including 4xx/5xx, is terminal at this layer: handleResponse classifies it + // (2xx/acceptStatus -> completed, else http error) and the worker returns + // without retrying. Response-code-based retry policy is the JS queue's job. + // This is consistent with iOS behavior. if (isRetried) delay(RETRY_DELAY) isRetried = true val response = upload() ?: continue - handleSuccess(response) + handleResponse(response) return@withContext Result.success() } catch (error: Throwable) { if (checkAndHandleCancellation()) throw error @@ -150,14 +166,44 @@ class UploadWorker(private val context: Context, params: WorkerParameters) : notificationManager.notify(upload.notificationId, buildNotification()) } - private fun handleSuccess(response: UploadResponse) { + // An HTTP response came back. "completed" only for 2xx or a per-request + // acceptStatus code (axios validateStatus semantics — a 400 is an error, not a + // completion); anything else is a terminal http error carrying the full + // response. Either way the request finished, so the worker does not retry. + private fun handleResponse(response: UploadResponse) { UploadProgress.complete(upload.id) - EventReporter.success(upload.id, response) + val accepted = UploadOutcome.isAccepted(response.code, upload.acceptStatus) + val (body, truncated) = EventJournal.capBody(response.body) + journalAndEmit( + EventJournal.Entry( + eventId = UUID.randomUUID().toString(), + uploadId = upload.id, + type = if (accepted) "completed" else "error", + timestamp = System.currentTimeMillis(), + responseCode = response.code, + responseBody = body, + responseBodyTruncated = truncated, + responseHeaders = response.headers, + errorKind = if (accepted) null else "http", + error = if (accepted) null else "HTTP ${response.code}", + ) + ) } private fun handleError(error: Throwable) { UploadProgress.remove(upload.id) - EventReporter.error(upload.id, error) + // Default fileExists=true so a failed existence probe reads as network, not file. + val fileExists = runCatching { File(upload.path).exists() }.getOrDefault(true) + journalAndEmit( + EventJournal.Entry( + eventId = UUID.randomUUID().toString(), + uploadId = upload.id, + type = "error", + timestamp = System.currentTimeMillis(), + error = error.message ?: "Unknown exception", + errorKind = UploadOutcome.errorKind(error, fileExists), + ) + ) } // Check if cancelled by user or new worker with same ID @@ -166,10 +212,43 @@ class UploadWorker(private val context: Context, params: WorkerParameters) : if (!isStopped) return false UploadProgress.remove(upload.id) - EventReporter.cancelled(upload.id) + + // Only a user cancel is terminal, so only a user cancel is journaled. + // + // WorkManager decides whether to reschedule BEFORE it stops the worker, and + // it ignores the Result we return. cancelUniqueWork marks the row CANCELLED + // first, so a user cancel is genuinely the end. A system stop — a + // foreground-service timeout, quota, or memory pressure — leaves the row + // RUNNING and WorkManager re-runs this same upload. Journaling a terminal + // `cancelled` there would durably tell JS the upload was dead while it was in + // fact about to be retried, so the consumer would settle the transfer and the + // retry would land as a duplicate on the server. + // + // Emitting nothing is the honest answer for a system stop: the upload is + // still in flight as far as anyone should be concerned. If WorkManager ever + // declines to reschedule, `getAllUploads()` is how a consumer notices. + if (!UserCancellations.consume(upload.id)) return true + + journalAndEmit( + EventJournal.Entry( + eventId = UUID.randomUUID().toString(), + uploadId = upload.id, + type = "cancelled", + timestamp = System.currentTimeMillis(), + cancelReason = "user", + ) + ) return true } + // Journal before emitting: the journal is the durable record (survives JS being + // dead); the live emit is best-effort. Both carry the identical payload, so a + // consumer can ack a live event by its eventId. + private fun journalAndEmit(entry: EventJournal.Entry) { + EventJournal.get(context).append(entry) + EventReporter.emit(entry) + } + /** @return whether to retry */ private fun checkRetry(error: Throwable): Boolean { var unlimitedRetry = false @@ -208,6 +287,21 @@ 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. + private fun ensureNotificationChannel() { + // minSdk is 29, so NotificationChannel (API 26) is always available. + if (notificationManager.getNotificationChannel(upload.notificationChannel) != null) return + val channel = NotificationChannel( + upload.notificationChannel, + "Uploads", + NotificationManager.IMPORTANCE_LOW, + ) + notificationManager.createNotificationChannel(channel) + } + // builds the notification required to enable Foreground mode fun buildNotification(): Notification { val channel = upload.notificationChannel diff --git a/android/src/main/java/ai/openspace/backgroundupload/UploaderModule.kt b/android/src/main/java/ai/openspace/backgroundupload/UploaderModule.kt index 87949e41..aed7f4e3 100644 --- a/android/src/main/java/ai/openspace/backgroundupload/UploaderModule.kt +++ b/android/src/main/java/ai/openspace/backgroundupload/UploaderModule.kt @@ -3,44 +3,178 @@ package ai.openspace.backgroundupload import android.util.Log import androidx.work.ExistingWorkPolicy import androidx.work.OneTimeWorkRequestBuilder +import androidx.work.WorkInfo import androidx.work.WorkManager import androidx.work.workDataOf +import com.facebook.react.bridge.Arguments 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.ReadableArray import com.facebook.react.bridge.ReadableMap +import com.facebook.react.bridge.WritableMap import com.google.gson.Gson +import java.util.UUID +/** + * TurboModule (New Architecture). [NativeRNFileUploaderSpec] is generated by + * codegen from `src/NativeRNFileUploader.ts` into this same package (see + * `codegenConfig.android.javaPackageName` in package.json), so it needs no import. + */ class UploaderModule(context: ReactApplicationContext) : - ReactContextBaseJavaModule(context) { + NativeRNFileUploaderSpec(context) { companion object { + const val NAME = "RNFileUploader" const val TAG = "RNFileUploader.UploaderModule" const val WORKER_TAG = "RNFileUploader" - var reactContext: ReactApplicationContext? = null + // WorkInfo exposes tags but not the unique-work name, so the upload id is + // also stored as a prefixed tag to recover it in getAllUploads. + const val ID_TAG_PREFIX = "RNFileUploaderId:" + + // The live module, so EventReporter can reach the codegen emitters — they are + // protected on the generated spec, so only this class may call them. Null + // whenever JS is absent (headless worker, mid-reload); terminal outcomes are + // journaled before being emitted, so a dropped live event is never lost. + // + // Volatile: written on the module-creation thread and read from the + // WorkManager worker, OkHttp callbacks and main, with no other barrier. + @Volatile + var instance: UploaderModule? = null private set } private val workManager = WorkManager.getInstance(context) init { - reactContext = context + instance = this + } + + override fun invalidate() { + // A reload constructs the replacement before tearing this one down, so only + // clear the pointer when it still refers to us. + if (instance === this) instance = null + super.invalidate() + } + + override fun getName(): String = NAME + + + // MARK: - Event emission (called by EventReporter) + + fun emitProgressEvent(params: WritableMap) = safeEmit { emitOnProgress(params) } + + fun emitCompletedEvent(params: WritableMap) = safeEmit { emitOnCompleted(params) } + + fun emitErrorEvent(params: WritableMap) = safeEmit { emitOnError(params) } + + fun emitCancelledEvent(params: WritableMap) = safeEmit { emitOnCancelled(params) } + + fun emitNotificationEvent(params: WritableMap) = safeEmit { emitOnNotification(params) } + + private inline fun safeEmit(emit: () -> Unit) { + try { + emit() + } catch (exc: NullPointerException) { + // The generated spec's emitter callback is only installed when the C++ + // TurboModule is constructed, and is gone once the runtime tears down, so a + // null callback is expected in both gaps. It is ALSO null for the whole + // process on the old architecture, where this module still registers and its + // methods work but no event can ever be delivered — hence warn, not debug, + // so that case is diagnosable instead of silent. + Log.w(TAG, "live event dropped (no event emitter — New Architecture required)") + } catch (exc: Throwable) { + // Anything else is a real bridging or payload failure worth seeing. + Log.e(TAG, "failed to emit live event", exc) + } + } + + + /** + * Returns terminal events (completed/error/cancelled) that JS has not yet + * acknowledged, including ones that fired while JS was dead. Read these on + * startup, process them, then call ackEvents to remove them. + */ + override fun getUnacknowledgedEvents(promise: Promise) { + try { + val events = EventJournal.get(reactApplicationContext).unacknowledged() + val arr = Arguments.createArray() + events.forEach { arr.pushMap(it.toWritableMap()) } + promise.resolve(arr) + } catch (exc: Throwable) { + Log.e(TAG, exc.message, exc) + promise.reject(exc) + } + } + + + /** + * Removes journaled events by eventId once JS has processed them. + */ + override fun ackEvents(ids: ReadableArray, promise: Promise) { + try { + val eventIds = (0 until ids.size()).mapNotNull { ids.getString(it) } + EventJournal.get(reactApplicationContext).ack(eventIds) + promise.resolve(true) + } catch (exc: Throwable) { + Log.e(TAG, exc.message, exc) + promise.reject(exc) + } + } + + + /** + * Enumerates uploads WorkManager still knows about, as [{ id, state }]. + * WorkManager auto-prunes finished work after roughly a day, so this is for + * reconciling live/recent uploads — terminal outcomes must be read from + * getUnacknowledgedEvents, which is durable until acknowledged. + */ + override fun getAllUploads(promise: Promise) { + try { + val infos = workManager.getWorkInfosByTag(WORKER_TAG).get() + val arr = Arguments.createArray() + for (info in infos) { + val id = info.tags.firstOrNull { it.startsWith(ID_TAG_PREFIX) } + ?.removePrefix(ID_TAG_PREFIX) ?: continue + arr.pushMap(Arguments.createMap().apply { + putString("id", id) + putString( + "state", + when (info.state) { + WorkInfo.State.ENQUEUED, WorkInfo.State.BLOCKED -> "pending" + WorkInfo.State.RUNNING -> "running" + WorkInfo.State.SUCCEEDED -> "completed" + WorkInfo.State.FAILED -> "error" + WorkInfo.State.CANCELLED -> "cancelled" + }, + ) + }) + } + promise.resolve(arr) + } catch (exc: Throwable) { + Log.e(TAG, exc.message, exc) + promise.reject(exc) + } } - override fun getName(): String = "RNFileUploader" + /** + * iOS-only: there is no per-task byte counter to read on Android, where uploads + * are WorkManager jobs rather than URLSession tasks. Use getAllUploads for + * liveness and the progress event for bytes. + */ + override fun getUploadStatus(id: String, promise: Promise) { + promise.resolve(null) + } /* * Starts a file upload. * Returns a promise with the string ID of the upload. */ - @ReactMethod - fun startUpload(rawOptions: ReadableMap, promise: Promise) { + override fun startUpload(options: ReadableMap, promise: Promise) { try { - val id = startUpload(rawOptions) + val id = enqueueUpload(options) promise.resolve(id) } catch (exc: Throwable) { if (exc !is Upload.MissingOptionException) { @@ -52,15 +186,22 @@ class UploaderModule(context: ReactApplicationContext) : } /** - * @return whether the upload was started + * @return the id of the enqueued upload */ - private fun startUpload(options: ReadableMap): String { + private fun enqueueUpload(options: ReadableMap): String { val upload = Upload.fromReadableMap(options) val data = Gson().toJson(upload) + // Clear any stale user-cancel mark for this (possibly reused customUploadId) + // from a prior life, so a later system stop of this fresh upload isn't + // misreported as a user cancel. Done here (before enqueue), never in the + // worker, so a real cancel arriving as the worker starts can't be erased. + UserCancellations.consume(upload.id) + val request = OneTimeWorkRequestBuilder() .addTag(WORKER_TAG) - .setInputData(workDataOf(UploadWorker.Input.Params.name to data)) + .addTag(ID_TAG_PREFIX + upload.id) + .setInputData(workDataOf(UploadWorker.PARAMS_KEY to data)) .build() workManager @@ -80,26 +221,42 @@ class UploaderModule(context: ReactApplicationContext) : * 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) { + override fun cancelUpload(id: String, promise: Promise) { try { - workManager.cancelUniqueWork(uploadId) - promise.resolve(true) - } catch (exc: Throwable) { - exc.printStackTrace() - Log.e(TAG, exc.message, exc) - promise.reject(exc) - } - } + val active = workManager.getWorkInfosForUniqueWork(id).get() + .firstOrNull { !it.state.isFinished } + if (active == null) { + // Nothing to cancel. Drop any mark so a later upload reusing this + // customUploadId can't be misreported as a user cancel. + UserCancellations.consume(id) + promise.resolve(false) + + return + } + + // Record intent BEFORE cancelling so the worker's stop handler can tell + // this apart from a system stop and report cancelReason 'user'. + UserCancellations.mark(id) + workManager.cancelUniqueWork(id) + + if (active.state == WorkInfo.State.ENQUEUED) { + // The worker never started, so it will never run its own stop handler and + // nothing else would ever report this cancellation — leaving a consumer + // awaiting this upload's outcome forever. Report it here instead, and + // consume the mark so it cannot leak. + UserCancellations.consume(id) + val entry = EventJournal.Entry( + eventId = UUID.randomUUID().toString(), + uploadId = id, + type = "cancelled", + timestamp = System.currentTimeMillis(), + cancelReason = "user", + ) + EventJournal.get(reactApplicationContext).append(entry) + EventReporter.emit(entry) + } - /* - * Cancels all file uploads - */ - @ReactMethod - fun stopAllUploads(promise: Promise) { - try { - workManager.cancelAllWorkByTag(WORKER_TAG) promise.resolve(true) } catch (exc: Throwable) { exc.printStackTrace() @@ -107,7 +264,4 @@ class UploaderModule(context: ReactApplicationContext) : promise.reject(exc) } } - - } - diff --git a/android/src/main/java/ai/openspace/backgroundupload/UploaderReactPackage.java b/android/src/main/java/ai/openspace/backgroundupload/UploaderReactPackage.java deleted file mode 100644 index 4fc726ad..00000000 --- a/android/src/main/java/ai/openspace/backgroundupload/UploaderReactPackage.java +++ /dev/null @@ -1,35 +0,0 @@ -package ai.openspace.backgroundupload; - -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 UploaderReactPackage 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 UploaderModule(reactContext)); - return modules; - } -} diff --git a/android/src/main/java/ai/openspace/backgroundupload/UploaderReactPackage.kt b/android/src/main/java/ai/openspace/backgroundupload/UploaderReactPackage.kt new file mode 100644 index 00000000..96193a35 --- /dev/null +++ b/android/src/main/java/ai/openspace/backgroundupload/UploaderReactPackage.kt @@ -0,0 +1,29 @@ +package ai.openspace.backgroundupload + +import com.facebook.react.BaseReactPackage +import com.facebook.react.bridge.NativeModule +import com.facebook.react.bridge.ReactApplicationContext +import com.facebook.react.module.model.ReactModuleInfo +import com.facebook.react.module.model.ReactModuleInfoProvider + +class UploaderReactPackage : BaseReactPackage() { + + override fun getModule(name: String, reactContext: ReactApplicationContext): NativeModule? = + if (name == UploaderModule.NAME) UploaderModule(reactContext) else null + + override fun getReactModuleInfoProvider() = ReactModuleInfoProvider { + mapOf( + UploaderModule.NAME to ReactModuleInfo( + name = UploaderModule.NAME, + className = UploaderModule.NAME, + canOverrideExistingModule = false, + // Created on first JS access. Uploads outlive the module (WorkManager + // runs them, the journal records their outcomes), so nothing is lost by + // not constructing it at startup. + needsEagerInit = false, + isCxxModule = false, + isTurboModule = true, + ), + ) + } +} diff --git a/android/src/main/java/ai/openspace/backgroundupload/UserCancellations.kt b/android/src/main/java/ai/openspace/backgroundupload/UserCancellations.kt new file mode 100644 index 00000000..5b19cea6 --- /dev/null +++ b/android/src/main/java/ai/openspace/backgroundupload/UserCancellations.kt @@ -0,0 +1,17 @@ +package ai.openspace.backgroundupload + +// Upload ids the JS side explicitly cancelled. Consulted by the worker to +// distinguish user cancels from system kills (WorkManager 2.8.1 has no +// getStopReason). Same-process only: a user cancel always originates from live +// JS, so the set never needs to persist across process death. +object UserCancellations { + private val ids = mutableSetOf() + + @Synchronized + fun mark(id: String) { + ids.add(id) + } + + @Synchronized + fun consume(id: String): Boolean = ids.remove(id) +} diff --git a/android/src/test/java/ai/openspace/backgroundupload/EventJournalTest.kt b/android/src/test/java/ai/openspace/backgroundupload/EventJournalTest.kt new file mode 100644 index 00000000..811f1bd2 --- /dev/null +++ b/android/src/test/java/ai/openspace/backgroundupload/EventJournalTest.kt @@ -0,0 +1,104 @@ +package ai.openspace.backgroundupload + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import java.io.File + +class EventJournalTest { + @get:Rule + val tmp = TemporaryFolder() + + private fun entry(id: String, uploadId: String = "u1") = EventJournal.Entry( + eventId = id, + uploadId = uploadId, + type = "completed", + timestamp = System.currentTimeMillis(), + responseCode = 200, + responseBody = "ok", + responseHeaders = mapOf("x-a" to "b"), + ) + + @Test + fun `append then read returns the entry`() { + val journal = EventJournal(tmp.newFolder()) + journal.append(entry("e1")) + val events = journal.unacknowledged() + assertEquals(1, events.size) + assertEquals("e1", events[0].eventId) + assertEquals(200, events[0].responseCode) + assertEquals("ok", events[0].responseBody) + } + + @Test + fun `ack removes only the acked entry`() { + val journal = EventJournal(tmp.newFolder()) + journal.append(entry("e1")) + journal.append(entry("e2")) + journal.ack(listOf("e1")) + assertEquals(listOf("e2"), journal.unacknowledged().map { it.eventId }) + } + + @Test + fun `entries survive a new journal instance over the same dir`() { + val dir = tmp.newFolder() + EventJournal(dir).append(entry("e1")) + assertEquals(1, EventJournal(dir).unacknowledged().size) + } + + @Test + fun `oversized body is truncated and flagged`() { + val journal = EventJournal(tmp.newFolder()) + val big = "x".repeat(EventJournal.MAX_BODY_CHARS + 100) + journal.append(entry("e1").copy(responseBody = big)) + val read = journal.unacknowledged()[0] + assertTrue(read.responseBodyTruncated) + assertTrue(read.responseBody!!.length <= EventJournal.MAX_BODY_CHARS) + } + + @Test + fun `corrupt file is skipped, not fatal`() { + val dir = tmp.newFolder() + val journal = EventJournal(dir) + journal.append(entry("e1")) + java.io.File(dir, "garbage.json").writeText("{not json") + assertEquals(1, journal.unacknowledged().size) + } + + @Test + fun `entries are ordered by timestamp`() { + val journal = EventJournal(tmp.newFolder()) + journal.append(entry("late").copy(timestamp = 2000)) + journal.append(entry("early").copy(timestamp = 1000)) + assertEquals(listOf("early", "late"), journal.unacknowledged().map { it.eventId }) + } + + @Test + fun `append does not throw when the directory is unwritable`() { + // A regular file where a directory is expected: mkdirs() and every write fail. + val notADir = tmp.newFile() + val journal = EventJournal(notADir) + journal.append(entry("e1")) // must not throw + assertEquals(emptyList(), journal.unacknowledged().map { it.eventId }) + } + + @Test + fun `prunes the oldest entries beyond the cap`() { + val dir = tmp.newFolder() + val journal = EventJournal(dir, maxEntries = 3) + // Stamp increasing mtimes so pruning order is deterministic. Each mtime is + // set before the next append, which is when pruning reads it. + journal.append(entry("e1")); File(dir, "e1.json").setLastModified(1000) + journal.append(entry("e2")); File(dir, "e2.json").setLastModified(2000) + journal.append(entry("e3")); File(dir, "e3.json").setLastModified(3000) + journal.append(entry("e4")) // 4th write trips the cap; oldest (e1) is dropped + + val ids = journal.unacknowledged().map { it.eventId } + assertEquals(3, ids.size) + assertFalse(ids.contains("e1")) + assertTrue(ids.contains("e4")) + } +} diff --git a/android/src/test/java/ai/openspace/backgroundupload/UploadOutcomeTest.kt b/android/src/test/java/ai/openspace/backgroundupload/UploadOutcomeTest.kt new file mode 100644 index 00000000..b9375ce0 --- /dev/null +++ b/android/src/test/java/ai/openspace/backgroundupload/UploadOutcomeTest.kt @@ -0,0 +1,51 @@ +package ai.openspace.backgroundupload + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import java.io.IOException + +class UploadOutcomeTest { + + @Test + fun `2xx is accepted`() { + assertTrue(UploadOutcome.isAccepted(200, listOf())) + assertTrue(UploadOutcome.isAccepted(204, listOf())) + assertTrue(UploadOutcome.isAccepted(299, listOf())) + } + + @Test + fun `non-2xx is not accepted by default`() { + assertFalse(UploadOutcome.isAccepted(199, listOf())) + assertFalse(UploadOutcome.isAccepted(300, listOf())) + assertFalse(UploadOutcome.isAccepted(404, listOf())) + assertFalse(UploadOutcome.isAccepted(500, listOf())) + } + + @Test + fun `non-2xx listed in acceptStatus is accepted`() { + assertTrue(UploadOutcome.isAccepted(409, listOf(409))) + assertTrue(UploadOutcome.isAccepted(404, listOf(404, 409))) + } + + @Test + fun `acceptStatus does not accept unlisted codes`() { + assertFalse(UploadOutcome.isAccepted(500, listOf(409))) + } + + @Test + fun `IOException with a missing file is a file error`() { + assertEquals("file", UploadOutcome.errorKind(IOException("gone"), fileExists = false)) + } + + @Test + fun `IOException with the file present is a network error`() { + assertEquals("network", UploadOutcome.errorKind(IOException("reset"), fileExists = true)) + } + + @Test + fun `a non-IO error is unknown`() { + assertEquals("unknown", UploadOutcome.errorKind(RuntimeException("boom"), fileExists = true)) + } +}