CraftBridge.saveFile writes only when the data URL has exactly one comma, and resolves unconditionally:
if (data.startsWith("data:")) {
val parts = data.split(",")
if (parts.size == 2) {
val bytes = Base64.decode(parts[1], Base64.DEFAULT)
file.writeBytes(bytes)
}
// no else
} else {
file.writeText(data)
}
activity.runOnUiThread {
webView.evaluateJavascript(
"window._craftSaveResolve && window._craftSaveResolve(${jsQuote(file.absolutePath)})", null)
}String.split(",") splits on every comma, so parts.size == 2 means "exactly one comma in the whole string". Anything else falls through the if with no else, nothing is written, and the page is handed file.absolutePath as though it had been.
What reaches it
data:text/plain,a,b— a legal data URL whose payload contains a comma. Three parts, no write.data:image/png;base64— a caller who built the URL wrong. One part, no write.data:— one part, no write.
Base64 output has no commas, so the common case is safe. It is the plain-text data URLs and the malformed ones that fail silently.
Why it costs something
The reply is a path. A page that saves a file and then tells the user where it is, or hands the path to openPDF, or reads it back, gets a path to a file that does not exist — and the failure surfaces later, somewhere else, as a missing file rather than as a failed save.
_craftSaveReject is already assigned by the injected JS, so there is somewhere honest to send this.
Suggested fix
Give the if an else that rejects:
if (parts.size == 2) {
file.writeBytes(Base64.decode(parts[1], Base64.DEFAULT))
} else {
activity.runOnUiThread {
webView.evaluateJavascript(
"window._craftSaveReject && window._craftSaveReject(${jsQuote("Malformed data URL")})", null)
}
return
}Splitting with a limit — data.split(",", limit = 2) — would instead make data:text/plain,a,b write a,b as base64, which throws and rejects. Either is honest; the first says what happened.
Zig ports it as-is
bridge_android_files.zig reproduces the branch, with the skipped case named Plan.nothing rather than left as an absent else, and a test that says what it does. It is the same behaviour in both languages until this is decided.
Related: #127, #148, #154, #157, #159 — replies that are sent but do not mean what they appear to.