From b85d713bfae0b0044bdd67869a2745c4f86efdc4 Mon Sep 17 00:00:00 2001 From: Dylan Murphy Date: Mon, 27 Jul 2026 10:21:13 -0400 Subject: [PATCH] feat(android)!: durable event journal and TurboModule conversion MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit UploaderModule now extends the generated NativeRNFileUploaderSpec and the package registers it through BaseReactPackage with isTurboModule = true, so it runs as a real TurboModule instead of a legacy module behind the interop shim. Events go out through the generated emitters rather than RCTDeviceEventEmitter. Terminal outcomes are journaled to disk before they are emitted, so an outcome that lands while JS is dead is not lost — getUnacknowledgedEvents replays it and ackEvents drains it. EventJournal is the store; UploadOutcome classifies a response into a typed errorKind ('http', 'network', 'file') and honors a request's acceptStatus instead of treating every non-2xx as a failure. Two correctness fixes worth reviewing closely: WorkManager decides whether to reschedule before it stops a worker and ignores the Result the worker returns. The old code journaled a terminal cancel on every stop, so a system stop — doze, low memory, constraint loss — looked identical to a user cancel, and the rescheduled work then uploaded the same file again. checkAndHandleCancellation now journals only when UserCancellations.consume confirms the cancel came from JS. cancelUpload used to resolve true unconditionally, having thrown a fire-and-forget cancel at WorkManager. It now queries getWorkInfosForUniqueWork, resolves false when nothing is active, and when the work was still ENQUEUED — never started, so no worker will ever run to report it — journals and emits the cancel itself. Also: @Volatile on the module instance, an identity check in invalidate so a replacement module is not torn down by its predecessor, safeEmit distinguishing a missing emitter from a real failure, a null guard on decoded journal entries, and consumer ProGuard rules keeping the Signature attribute the Gson-persisted models need under a host app's R8. Co-Authored-By: Claude Opus 5 --- android/build.gradle | 3 + android/consumer-rules.pro | 25 ++ .../backgroundupload/EventJournal.kt | 140 +++++++++++ .../backgroundupload/EventReporter.kt | 69 +++--- .../ai/openspace/backgroundupload/Upload.kt | 23 +- .../backgroundupload/UploadOutcome.kt | 25 ++ .../backgroundupload/UploadWorker.kt | 120 ++++++++-- .../backgroundupload/UploaderModule.kt | 218 +++++++++++++++--- .../UploaderReactPackage.java | 35 --- .../backgroundupload/UploaderReactPackage.kt | 29 +++ .../backgroundupload/UserCancellations.kt | 17 ++ .../backgroundupload/EventJournalTest.kt | 104 +++++++++ .../backgroundupload/UploadOutcomeTest.kt | 51 ++++ 13 files changed, 731 insertions(+), 128 deletions(-) create mode 100644 android/consumer-rules.pro create mode 100644 android/src/main/java/ai/openspace/backgroundupload/EventJournal.kt create mode 100644 android/src/main/java/ai/openspace/backgroundupload/UploadOutcome.kt delete mode 100644 android/src/main/java/ai/openspace/backgroundupload/UploaderReactPackage.java create mode 100644 android/src/main/java/ai/openspace/backgroundupload/UploaderReactPackage.kt create mode 100644 android/src/main/java/ai/openspace/backgroundupload/UserCancellations.kt create mode 100644 android/src/test/java/ai/openspace/backgroundupload/EventJournalTest.kt create mode 100644 android/src/test/java/ai/openspace/backgroundupload/UploadOutcomeTest.kt 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)) + } +}