Skip to content

v8 stack 3/5: Android durable journal and TurboModule conversion - #25

Closed
dmurphy5 wants to merge 1 commit into
dylan/v8-stack-2-js-apifrom
dylan/v8-stack-3-android
Closed

v8 stack 3/5: Android durable journal and TurboModule conversion#25
dmurphy5 wants to merge 1 commit into
dylan/v8-stack-2-js-apifrom
dylan/v8-stack-3-android

Conversation

@dmurphy5

@dmurphy5 dmurphy5 commented Jul 27, 2026

Copy link
Copy Markdown

The stack

Five PRs, each based on the one before it. This is 3/5.

PR Contents Base
1 #23 toolchain, dependencies, and the ai.openspace rename master
2 #24 TurboModule spec and the JS surface #23
3 #25 Android durable journal and TurboModule 👈 #24
4 #26 iOS Swift TurboModule rewrite #25
5 #27 docs and example app #26

#22 stays open and untouched as the single-diff view of the same change.
Its tree and this stack's tip are byte-identical (tree f8404b0), so merging
either one produces exactly the same master. Review whichever is easier to
follow; merge one and close the other.


Converts the Android module to a real TurboModule and makes terminal outcomes
durable.

UploaderModule now extends the generated NativeRNFileUploaderSpec, and the
package registers it through BaseReactPackage with isTurboModule = true, so
it runs as a TurboModule rather than a legacy module behind the interop shim.
Events go out through the generated emitters instead of RCTDeviceEventEmitter.

Terminal outcomes are journaled to disk before being emitted, so an outcome
that lands while JS is dead isn't 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.

The two fixes worth reviewing closely

A system stop was journaling a terminal cancel — and that caused duplicate
uploads. WorkManager decides whether to reschedule before it stops a worker,
and it ignores the Result the worker returns. The old code journaled a cancel
on every stop, so doze, low memory, or constraint loss looked identical to a user
cancel; the rescheduled work then uploaded the same file again.
checkAndHandleCancellation now journals only when UserCancellations.consume
confirms the cancel came from JS.

cancelUpload always resolved true, 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.

Smaller hardening

@Volatile on the module instance; an identity check in invalidate so a
replacement module isn't 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 — without which a minified build fails
silently.

Verified

:react-native-background-upload:testDebugUnitTest — 15/15 pass (7 EventJournal,
8 UploadOutcome). Exercised on a physical Android device inside Diana: field note
create online and offline, and update.

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 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant