Neither needs the main thread, a listener, or an Activity result — the download is queued with the system's own manager and the save is ordinary file I/O — so both are served whole rather than in halves.
saveFile can report a path it never wrote
The shim's data-URL branch has no else:
if (parts.size == 2) {
file.writeBytes(Base64.decode(parts[1], Base64.DEFAULT))
}
// ...then resolve(file.absolutePath) regardlessString.split(",") splits on every comma, so parts.size == 2 means "exactly one comma in the whole string". data:text/plain,a,b — a legal data URL — gives three parts, nothing is written, and the resolve still hands the page a path to a file that may not exist.
Base64 output contains no commas, so the common case is safe. It is the plain-text and malformed data URLs that fail silently.
Reproduced rather than corrected, because the reply is what a page acts on. The skipped case is named Plan.nothing rather than left as an absent else, and has a test that says what it does. Filed as #175 with a suggested fix.
The decoding is Java's
android.util.Base64.decode rather than a decoder here, for the reason bridge_android_notifications hashes ids in Java: what matters is not decoding base64 but decoding it the way the shim does, including which inputs it throws on. A decoder here would be a second implementation whose disagreements only ever show up on a device.
Four constants are read through JNI rather than written down — Base64.DEFAULT, VISIBILITY_VISIBLE_NOTIFY_COMPLETED, DIRECTORY_DOWNLOADS, DIRECTORY_DOCUMENTS. None of them throw when wrong: the first decodes with a different alphabet, the second silently hides the download from the user, and the last two write to a different directory. Same asymmetry that made addContact read its MIME types.
The stream closes on the error path too
A FileOutputStream left open holds its file descriptor until the GC gets to it, which on a device is long enough to matter. The close is a defer, and its method id is resolved before the write — which is what lets it be one, since a defer cannot return.
Testing
zig build test:android passes with the ratchet at 67. The mutation fired: dropping the second-comma check makes data:text/plain,a,b decode a,b as base64 instead of writing nothing, and the named test catches it. Both libcraft.so targets still build with no NDK.