Twenty-one reply paths in CraftBridge.kt build JavaScript by interpolating a value into a single-quoted string with no escaping:
webView.evaluateJavascript(
"window._craftDeleteEventReject && window._craftDeleteEventReject('${e.message}')", null)When the interpolated text contains a ', the emitted script is a syntax error. evaluateJavascript then runs nothing at all — so neither the resolve nor the reject fires, and the page's promise never settles.
It is reachable from the page, not just from odd device state
Several of these messages contain caller-supplied text, because Java puts the input in the exception:
craft.calendar.delete("1'x")
→ "1'x".toLong() throws NumberFormatException: For input string: "1'x"
→ window._craftDeleteEventReject && window._craftDeleteEventReject('For input string: "1'x"')
^ string ends here
→ SyntaxError, nothing runs, the promise hangs foreverThe same shape applies wherever the failing input is echoed back — dbExecute and dbQuery put the caller's SQL in the message, addContact and createCalendarEvent echo field values.
Why a hang rather than an error
The injected JS builds these promises by hand rather than through _createCallback, so there is no timeout on this path — the same property that made the original ota* bug a permanent hang rather than a slow failure. A page that hits this waits forever with no console error, because the failure is inside evaluateJavascript where nothing is watching.
Not primarily a security issue
Worth saying plainly, because the shape looks like injection. The script runs in the page's own context, and the page can already call eval — so a page injecting into itself gains nothing. The cost is the hang, and it is a correctness bug rather than a privilege boundary.
The one caveat is a WebView loading untrusted remote content, where "the page" and "the app author" are different parties. That is a broader question about the bridge being exposed to remote origins at all, and not specific to these 21 lines.
The affected paths
_craftAddContactReject · _craftAudioReject · _craftAudioStopReject · _craftAuthPersistReject · _craftBgTaskReject · _craftCreateEventReject · _craftDbExecReject · _craftDbQueryReject · _craftDeleteEventReject · _craftDownloadReject · _craftMLReject · _craftNotifReject — and nine more.
The fix
JSONObject.quote(...) is already imported and used elsewhere in the same file (registerPush uses it for exactly this). It produces a correctly escaped, quoted JSON string:
val payload = JSONObject.quote(e.message ?: "")
webView.evaluateJavascript("window._craftDeleteEventReject && window._craftDeleteEventReject($payload)", null)Note the quotes move out of the template — quote supplies them, and leaving the surrounding '…' in place would double them.
Zig's side
android_events.settle takes the payload as pre-formed JSON rather than a bare string, so a Zig-served action escapes through bridge_error.appendJsonEscaped and cannot reproduce this. The first migrated action to hit a reject path documents the divergence explicitly; the shim keeps the bug until it is fixed here.