ReviewOS

also looking at this

craft-native/craft

fix(android): an apostrophe in a reply hangs the page forever

#156
Merged glennmichael123 wants to merge fix/android-js-escaping into main
4 files +279 -73
packages/android/templates/CraftBridge.kt.templatemodified+69-55
Changes to packages/android/templates/CraftBridge.kt.template
@@ -166,6 +166,20 @@ class CraftBridge(
166166 )
167167 }
168168
169 /**
170 * Quote a value for interpolation into JavaScript.
171 *
172 * The result carries its own quotes, so write `f($payload)` — leaving the
173 * surrounding `'...'` in place would quote it twice. A Kotlin null renders as
174 * the string "null", which is what plain interpolation produced before; whether
175 * that should instead be a JS null is a separate question from escaping.
176 *
177 * Without this, a value containing an apostrophe ends the JS string early, the
178 * script fails to parse, and `evaluateJavascript` runs nothing at all — so a
179 * hand-built promise with no timeout never settles.
180 */
181 private fun jsQuote(value: String?): String = JSONObject.quote(value ?: "null")
182
169183 fun injectBridge() {
170184 // Before the script, so a native callback arriving while the page is
171185 // still loading has somewhere to go rather than being counted as
@@ -1474,7 +1488,7 @@ class CraftBridge(
14741488
14751489 val event = if (requestCode == REQUEST_CAMERA) "_craftCameraResolve" else "_craftGalleryResolve"
14761490 activity.runOnUiThread {
1477 webView.evaluateJavascript("window.$event && window.$event('$imageUri')", null)
1491 webView.evaluateJavascript("window.$event && window.$event(${jsQuote(imageUri)})", null)
14781492 }
14791493 }
14801494
@@ -1525,7 +1539,7 @@ class CraftBridge(
15251539
15261540 override fun onAuthenticationError(errorCode: Int, errString: CharSequence) {
15271541 webView.evaluateJavascript(
1528 "window._craftBiometricReject && window._craftBiometricReject('$errString')",
1542 "window._craftBiometricReject && window._craftBiometricReject(${jsQuote(errString)})",
15291543 null
15301544 )
15311545 }
@@ -1655,7 +1669,7 @@ class CraftBridge(
16551669 }?.addOnFailureListener { e ->
16561670 activity.runOnUiThread {
16571671 webView.evaluateJavascript(
1658 "window._craftLocationReject && window._craftLocationReject({code: 2, message: '${e.message}'})",
1672 "window._craftLocationReject && window._craftLocationReject({code: 2, message: ${jsQuote(e.message)}})",
16591673 null
16601674 )
16611675 }
@@ -2110,7 +2124,7 @@ class CraftBridge(
21102124 currentAppState = newState
21112125 activity.runOnUiThread {
21122126 webView.evaluateJavascript(
2113 "window._craftAppStateCallback && window._craftAppStateCallback('$currentAppState')",
2127 "window._craftAppStateCallback && window._craftAppStateCallback(${jsQuote(currentAppState)})",
21142128 null
21152129 )
21162130 }
@@ -2247,11 +2261,11 @@ class CraftBridge(
22472261 val contactId = ContentUris.parseId(results[0].uri!!)
22482262
22492263 activity.runOnUiThread {
2250 webView.evaluateJavascript("window._craftAddContactResolve && window._craftAddContactResolve('$contactId')", null)
2264 webView.evaluateJavascript("window._craftAddContactResolve && window._craftAddContactResolve(${jsQuote(contactId)})", null)
22512265 }
22522266 } catch (e: Exception) {
22532267 activity.runOnUiThread {
2254 webView.evaluateJavascript("window._craftAddContactReject && window._craftAddContactReject('${e.message}')", null)
2268 webView.evaluateJavascript("window._craftAddContactReject && window._craftAddContactReject(${jsQuote(e.message)})", null)
22552269 }
22562270 }
22572271 }
@@ -2339,11 +2353,11 @@ class CraftBridge(
23392353 val eventId = uri?.lastPathSegment ?: ""
23402354
23412355 activity.runOnUiThread {
2342 webView.evaluateJavascript("window._craftCreateEventResolve && window._craftCreateEventResolve('$eventId')", null)
2356 webView.evaluateJavascript("window._craftCreateEventResolve && window._craftCreateEventResolve(${jsQuote(eventId)})", null)
23432357 }
23442358 } catch (e: Exception) {
23452359 activity.runOnUiThread {
2346 webView.evaluateJavascript("window._craftCreateEventReject && window._craftCreateEventReject('${e.message}')", null)
2360 webView.evaluateJavascript("window._craftCreateEventReject && window._craftCreateEventReject(${jsQuote(e.message)})", null)
23472361 }
23482362 }
23492363 }
@@ -2362,7 +2376,7 @@ class CraftBridge(
23622376 }
23632377 } catch (e: Exception) {
23642378 activity.runOnUiThread {
2365 webView.evaluateJavascript("window._craftDeleteEventReject && window._craftDeleteEventReject('${e.message}')", null)
2379 webView.evaluateJavascript("window._craftDeleteEventReject && window._craftDeleteEventReject(${jsQuote(e.message)})", null)
23662380 }
23672381 }
23682382 }
@@ -2399,11 +2413,11 @@ class CraftBridge(
23992413 }
24002414
24012415 activity.runOnUiThread {
2402 webView.evaluateJavascript("window._craftNotifResolve && window._craftNotifResolve('$id')", null)
2416 webView.evaluateJavascript("window._craftNotifResolve && window._craftNotifResolve(${jsQuote(id)})", null)
24032417 }
24042418 } catch (e: Exception) {
24052419 activity.runOnUiThread {
2406 webView.evaluateJavascript("window._craftNotifReject && window._craftNotifReject('${e.message}')", null)
2420 webView.evaluateJavascript("window._craftNotifReject && window._craftNotifReject(${jsQuote(e.message)})", null)
24072421 }
24082422 }
24092423 }
@@ -2496,7 +2510,7 @@ class CraftBridge(
24962510 })
24972511 } catch (e: Exception) {
24982512 activity.runOnUiThread {
2499 webView.evaluateJavascript("window._craftProductsReject && window._craftProductsReject('${e.message}')", null)
2513 webView.evaluateJavascript("window._craftProductsReject && window._craftProductsReject(${jsQuote(e.message)})", null)
25002514 }
25012515 }
25022516 }
@@ -2625,14 +2639,14 @@ class CraftBridge(
26252639
26262640 activity.runOnUiThread {
26272641 webView.evaluateJavascript(
2628 "window._craftDownloadResolve && window._craftDownloadResolve('$downloadId')",
2642 "window._craftDownloadResolve && window._craftDownloadResolve(${jsQuote(downloadId)})",
26292643 null
26302644 )
26312645 }
26322646 } catch (e: Exception) {
26332647 activity.runOnUiThread {
26342648 webView.evaluateJavascript(
2635 "window._craftDownloadReject && window._craftDownloadReject('${e.message}')",
2649 "window._craftDownloadReject && window._craftDownloadReject(${jsQuote(e.message)})",
26362650 null
26372651 )
26382652 }
@@ -2658,14 +2672,14 @@ class CraftBridge(
26582672
26592673 activity.runOnUiThread {
26602674 webView.evaluateJavascript(
2661 "window._craftSaveResolve && window._craftSaveResolve('${file.absolutePath}')",
2675 "window._craftSaveResolve && window._craftSaveResolve(${jsQuote(file.absolutePath)})",
26622676 null
26632677 )
26642678 }
26652679 } catch (e: Exception) {
26662680 activity.runOnUiThread {
26672681 webView.evaluateJavascript(
2668 "window._craftSaveReject && window._craftSaveReject('${e.message}')",
2682 "window._craftSaveReject && window._craftSaveReject(${jsQuote(e.message)})",
26692683 null
26702684 )
26712685 }
@@ -2723,7 +2737,7 @@ class CraftBridge(
27232737 }
27242738 } catch (e: Exception) {
27252739 activity.runOnUiThread {
2726 webView.evaluateJavascript("window._craftAudioReject && window._craftAudioReject('${e.message}')", null)
2740 webView.evaluateJavascript("window._craftAudioReject && window._craftAudioReject(${jsQuote(e.message)})", null)
27272741 }
27282742 }
27292743 }
@@ -2739,7 +2753,7 @@ class CraftBridge(
27392753 val bytes = file.readBytes()
27402754 val base64 = "data:audio/m4a;base64," + Base64.encodeToString(bytes, Base64.NO_WRAP)
27412755 activity.runOnUiThread {
2742 webView.evaluateJavascript("window._craftAudioStopResolve && window._craftAudioStopResolve('$base64')", null)
2756 webView.evaluateJavascript("window._craftAudioStopResolve && window._craftAudioStopResolve(${jsQuote(base64)})", null)
27432757 }
27442758 } ?: run {
27452759 activity.runOnUiThread {
@@ -2748,7 +2762,7 @@ class CraftBridge(
27482762 }
27492763 } catch (e: Exception) {
27502764 activity.runOnUiThread {
2751 webView.evaluateJavascript("window._craftAudioStopReject && window._craftAudioStopReject('${e.message}')", null)
2765 webView.evaluateJavascript("window._craftAudioStopReject && window._craftAudioStopReject(${jsQuote(e.message)})", null)
27522766 }
27532767 }
27542768 }
@@ -2835,7 +2849,7 @@ class CraftBridge(
28352849 } catch (e: Exception) {
28362850 activity.runOnUiThread {
28372851 webView.evaluateJavascript(
2838 "window._craftDbExecReject && window._craftDbExecReject('${e.message}')",
2852 "window._craftDbExecReject && window._craftDbExecReject(${jsQuote(e.message)})",
28392853 null
28402854 )
28412855 }
@@ -2875,7 +2889,7 @@ class CraftBridge(
28752889 } catch (e: Exception) {
28762890 activity.runOnUiThread {
28772891 webView.evaluateJavascript(
2878 "window._craftDbQueryReject && window._craftDbQueryReject('${e.message}')",
2892 "window._craftDbQueryReject && window._craftDbQueryReject(${jsQuote(e.message)})",
28792893 null
28802894 )
28812895 }
@@ -2980,9 +2994,9 @@ class CraftBridge(
29802994 bitmap.compress(Bitmap.CompressFormat.PNG, 100, outputStream)
29812995 val base64 = "data:image/png;base64," + Base64.encodeToString(outputStream.toByteArray(), Base64.NO_WRAP)
29822996
2983 webView.evaluateJavascript("window._craftScreenshotResolve && window._craftScreenshotResolve('$base64')", null)
2997 webView.evaluateJavascript("window._craftScreenshotResolve && window._craftScreenshotResolve(${jsQuote(base64)})", null)
29842998 } catch (e: Exception) {
2985 webView.evaluateJavascript("window._craftScreenshotReject && window._craftScreenshotReject('${e.message}')", null)
2999 webView.evaluateJavascript("window._craftScreenshotReject && window._craftScreenshotReject(${jsQuote(e.message)})", null)
29863000 }
29873001 }
29883002 }
@@ -2995,14 +3009,14 @@ class CraftBridge(
29953009 // WorkManager tasks don't require explicit registration, just track them
29963010 activity.runOnUiThread {
29973011 webView.evaluateJavascript(
2998 "window._craftBgTaskResolve && window._craftBgTaskResolve({taskId: '$taskId', registered: true})",
3012 "window._craftBgTaskResolve && window._craftBgTaskResolve({taskId: ${jsQuote(taskId)}, registered: true})",
29993013 null
30003014 )
30013015 }
30023016 } catch (e: Exception) {
30033017 activity.runOnUiThread {
30043018 webView.evaluateJavascript(
3005 "window._craftBgTaskReject && window._craftBgTaskReject('${e.message}')",
3019 "window._craftBgTaskReject && window._craftBgTaskReject(${jsQuote(e.message)})",
30063020 null
30073021 )
30083022 }
@@ -3017,14 +3031,14 @@ class CraftBridge(
30173031 // This is a placeholder that shows the API structure
30183032 activity.runOnUiThread {
30193033 webView.evaluateJavascript(
3020 "window._craftBgTaskResolve && window._craftBgTaskResolve({taskId: '$taskId', scheduled: true, delay: $delay})",
3034 "window._craftBgTaskResolve && window._craftBgTaskResolve({taskId: ${jsQuote(taskId)}, scheduled: true, delay: $delay})",
30213035 null
30223036 )
30233037 }
30243038 } catch (e: Exception) {
30253039 activity.runOnUiThread {
30263040 webView.evaluateJavascript(
3027 "window._craftBgTaskReject && window._craftBgTaskReject('${e.message}')",
3041 "window._craftBgTaskReject && window._craftBgTaskReject(${jsQuote(e.message)})",
30283042 null
30293043 )
30303044 }
@@ -3036,14 +3050,14 @@ class CraftBridge(
30363050 try {
30373051 activity.runOnUiThread {
30383052 webView.evaluateJavascript(
3039 "window._craftBgTaskResolve && window._craftBgTaskResolve({taskId: '$taskId', cancelled: true})",
3053 "window._craftBgTaskResolve && window._craftBgTaskResolve({taskId: ${jsQuote(taskId)}, cancelled: true})",
30403054 null
30413055 )
30423056 }
30433057 } catch (e: Exception) {
30443058 activity.runOnUiThread {
30453059 webView.evaluateJavascript(
3046 "window._craftBgTaskReject && window._craftBgTaskReject('${e.message}')",
3060 "window._craftBgTaskReject && window._craftBgTaskReject(${jsQuote(e.message)})",
30473061 null
30483062 )
30493063 }
@@ -3062,7 +3076,7 @@ class CraftBridge(
30623076 } catch (e: Exception) {
30633077 activity.runOnUiThread {
30643078 webView.evaluateJavascript(
3065 "window._craftBgTaskReject && window._craftBgTaskReject('${e.message}')",
3079 "window._craftBgTaskReject && window._craftBgTaskReject(${jsQuote(e.message)})",
30663080 null
30673081 )
30683082 }
@@ -3099,7 +3113,7 @@ class CraftBridge(
30993113 )
31003114 } catch (e: Exception) {
31013115 webView.evaluateJavascript(
3102 "window._craftPDFReject && window._craftPDFReject('${e.message}')",
3116 "window._craftPDFReject && window._craftPDFReject(${jsQuote(e.message)})",
31033117 null
31043118 )
31053119 }
@@ -3204,7 +3218,7 @@ class CraftBridge(
32043218 } catch (e: Exception) {
32053219 activity.runOnUiThread {
32063220 webView.evaluateJavascript(
3207 "window._craftPickContactReject && window._craftPickContactReject('${e.message}')",
3221 "window._craftPickContactReject && window._craftPickContactReject(${jsQuote(e.message)})",
32083222 null
32093223 )
32103224 }
@@ -3261,7 +3275,7 @@ class CraftBridge(
32613275 } catch (e: Exception) {
32623276 activity.runOnUiThread {
32633277 webView.evaluateJavascript(
3264 "window._craftShortcutsReject && window._craftShortcutsReject('${e.message}')",
3278 "window._craftShortcutsReject && window._craftShortcutsReject(${jsQuote(e.message)})",
32653279 null
32663280 )
32673281 }
@@ -3284,7 +3298,7 @@ class CraftBridge(
32843298 } catch (e: Exception) {
32853299 activity.runOnUiThread {
32863300 webView.evaluateJavascript(
3287 "window._craftShortcutsReject && window._craftShortcutsReject('${e.message}')",
3301 "window._craftShortcutsReject && window._craftShortcutsReject(${jsQuote(e.message)})",
32883302 null
32893303 )
32903304 }
@@ -3310,14 +3324,14 @@ class CraftBridge(
33103324
33113325 activity.runOnUiThread {
33123326 webView.evaluateJavascript(
3313 "window._craftSharedKeychainResolve && window._craftSharedKeychainResolve({success: true, key: '$key'})",
3327 "window._craftSharedKeychainResolve && window._craftSharedKeychainResolve({success: true, key: ${jsQuote(key)}})",
33143328 null
33153329 )
33163330 }
33173331 } catch (e: Exception) {
33183332 activity.runOnUiThread {
33193333 webView.evaluateJavascript(
3320 "window._craftSharedKeychainReject && window._craftSharedKeychainReject('${e.message}')",
3334 "window._craftSharedKeychainReject && window._craftSharedKeychainReject(${jsQuote(e.message)})",
33213335 null
33223336 )
33233337 }
@@ -3334,12 +3348,12 @@ class CraftBridge(
33343348 activity.runOnUiThread {
33353349 if (value != null) {
33363350 webView.evaluateJavascript(
3337 "window._craftSharedKeychainResolve && window._craftSharedKeychainResolve({value: '$value', key: '$key'})",
3351 "window._craftSharedKeychainResolve && window._craftSharedKeychainResolve({value: ${jsQuote(value)}, key: ${jsQuote(key)}})",
33383352 null
33393353 )
33403354 } else {
33413355 webView.evaluateJavascript(
3342 "window._craftSharedKeychainResolve && window._craftSharedKeychainResolve({value: null, key: '$key'})",
3356 "window._craftSharedKeychainResolve && window._craftSharedKeychainResolve({value: null, key: ${jsQuote(key)}})",
33433357 null
33443358 )
33453359 }
@@ -3347,7 +3361,7 @@ class CraftBridge(
33473361 } catch (e: Exception) {
33483362 activity.runOnUiThread {
33493363 webView.evaluateJavascript(
3350 "window._craftSharedKeychainReject && window._craftSharedKeychainReject('${e.message}')",
3364 "window._craftSharedKeychainReject && window._craftSharedKeychainReject(${jsQuote(e.message)})",
33513365 null
33523366 )
33533367 }
@@ -3363,14 +3377,14 @@ class CraftBridge(
33633377
33643378 activity.runOnUiThread {
33653379 webView.evaluateJavascript(
3366 "window._craftSharedKeychainResolve && window._craftSharedKeychainResolve({success: true, key: '$key'})",
3380 "window._craftSharedKeychainResolve && window._craftSharedKeychainResolve({success: true, key: ${jsQuote(key)}})",
33673381 null
33683382 )
33693383 }
33703384 } catch (e: Exception) {
33713385 activity.runOnUiThread {
33723386 webView.evaluateJavascript(
3373 "window._craftSharedKeychainReject && window._craftSharedKeychainReject('${e.message}')",
3387 "window._craftSharedKeychainReject && window._craftSharedKeychainReject(${jsQuote(e.message)})",
33743388 null
33753389 )
33763390 }
@@ -3404,7 +3418,7 @@ class CraftBridge(
34043418 } catch (e: Exception) {
34053419 activity.runOnUiThread {
34063420 webView.evaluateJavascript(
3407 "window._craftAuthPersistReject && window._craftAuthPersistReject('${e.message}')",
3421 "window._craftAuthPersistReject && window._craftAuthPersistReject(${jsQuote(e.message)})",
34083422 null
34093423 )
34103424 }
@@ -3428,7 +3442,7 @@ class CraftBridge(
34283442 } catch (e: Exception) {
34293443 activity.runOnUiThread {
34303444 webView.evaluateJavascript(
3431 "window._craftAuthPersistReject && window._craftAuthPersistReject('${e.message}')",
3445 "window._craftAuthPersistReject && window._craftAuthPersistReject(${jsQuote(e.message)})",
34323446 null
34333447 )
34343448 }
@@ -3448,7 +3462,7 @@ class CraftBridge(
34483462 } catch (e: Exception) {
34493463 activity.runOnUiThread {
34503464 webView.evaluateJavascript(
3451 "window._craftAuthPersistReject && window._craftAuthPersistReject('${e.message}')",
3465 "window._craftAuthPersistReject && window._craftAuthPersistReject(${jsQuote(e.message)})",
34523466 null
34533467 )
34543468 }
@@ -3540,7 +3554,7 @@ class CraftBridge(
35403554 .addOnFailureListener { e ->
35413555 activity.runOnUiThread {
35423556 webView.evaluateJavascript(
3543 "window._craftMLReject && window._craftMLReject('${e.message}')",
3557 "window._craftMLReject && window._craftMLReject(${jsQuote(e.message)})",
35443558 null
35453559 )
35463560 }
@@ -3548,7 +3562,7 @@ class CraftBridge(
35483562 } catch (e: Exception) {
35493563 activity.runOnUiThread {
35503564 webView.evaluateJavascript(
3551 "window._craftMLReject && window._craftMLReject('${e.message}')",
3565 "window._craftMLReject && window._craftMLReject(${jsQuote(e.message)})",
35523566 null
35533567 )
35543568 }
@@ -3607,7 +3621,7 @@ class CraftBridge(
36073621 .addOnFailureListener { e ->
36083622 activity.runOnUiThread {
36093623 webView.evaluateJavascript(
3610 "window._craftMLReject && window._craftMLReject('${e.message}')",
3624 "window._craftMLReject && window._craftMLReject(${jsQuote(e.message)})",
36113625 null
36123626 )
36133627 }
@@ -3615,7 +3629,7 @@ class CraftBridge(
36153629 } catch (e: Exception) {
36163630 activity.runOnUiThread {
36173631 webView.evaluateJavascript(
3618 "window._craftMLReject && window._craftMLReject('${e.message}')",
3632 "window._craftMLReject && window._craftMLReject(${jsQuote(e.message)})",
36193633 null
36203634 )
36213635 }
@@ -3662,7 +3676,7 @@ class CraftBridge(
36623676 .addOnFailureListener { e ->
36633677 activity.runOnUiThread {
36643678 webView.evaluateJavascript(
3665 "window._craftMLReject && window._craftMLReject('${e.message}')",
3679 "window._craftMLReject && window._craftMLReject(${jsQuote(e.message)})",
36663680 null
36673681 )
36683682 }
@@ -3670,7 +3684,7 @@ class CraftBridge(
36703684 } catch (e: Exception) {
36713685 activity.runOnUiThread {
36723686 webView.evaluateJavascript(
3673 "window._craftMLReject && window._craftMLReject('${e.message}')",
3687 "window._craftMLReject && window._craftMLReject(${jsQuote(e.message)})",
36743688 null
36753689 )
36763690 }
@@ -3718,7 +3732,7 @@ class CraftBridge(
37183732 } catch (e: Exception) {
37193733 activity.runOnUiThread {
37203734 webView.evaluateJavascript(
3721 "window._craftWidgetReject && window._craftWidgetReject('${e.message}')",
3735 "window._craftWidgetReject && window._craftWidgetReject(${jsQuote(e.message)})",
37223736 null
37233737 )
37243738 }
@@ -3750,7 +3764,7 @@ class CraftBridge(
37503764
37513765 activity.runOnUiThread {
37523766 webView.evaluateJavascript(
3753 "window._craftVoiceResolve && window._craftVoiceResolve({registered: true, action: '$action', phrase: '$phrase'})",
3767 "window._craftVoiceResolve && window._craftVoiceResolve({registered: true, action: ${jsQuote(action)}, phrase: ${jsQuote(phrase)}})",
37543768 null
37553769 )
37563770 }
@@ -3763,7 +3777,7 @@ class CraftBridge(
37633777
37643778 activity.runOnUiThread {
37653779 webView.evaluateJavascript(
3766 "window._craftVoiceResolve && window._craftVoiceResolve({removed: true, action: '$action'})",
3780 "window._craftVoiceResolve && window._craftVoiceResolve({removed: true, action: ${jsQuote(action)}})",
37673781 null
37683782 )
37693783 }
@@ -3778,7 +3792,7 @@ class CraftBridge(
37783792 if (action != null) {
37793793 activity.runOnUiThread {
37803794 webView.evaluateJavascript(
3781 "window.dispatchEvent(new CustomEvent('craftVoiceAction', {detail: {action: '$action', data: '$data'}}));",
3795 "window.dispatchEvent(new CustomEvent('craftVoiceAction', {detail: {action: ${jsQuote(action)}, data: ${jsQuote(data)}}}));",
37823796 null
37833797 )
37843798 }
@@ -3927,7 +3941,7 @@ class CraftBridge(
39273941 val json = JSONObject(data).toString()
39283942 activity.runOnUiThread {
39293943 webView.evaluateJavascript(
3930 "window.dispatchEvent(new CustomEvent('$event', {detail: $json}));",
3944 "window.dispatchEvent(new CustomEvent(${jsQuote(event)}, {detail: $json}));",
39313945 null
39323946 )
39333947 }
packages/zig/build.zigmodified+45-0
Changes to packages/zig/build.zig
@@ -505,6 +505,49 @@ pub fn build(b: *std.Build) void {
505505 });
506506 const run_android_bridge_tests = b.addRunArtifact(android_bridge_tests);
507507
508 // The reply channel's escaping, across every Kotlin template.
509 //
510 // Every template is imported, not just the four that talk to the WebView
511 // today, so a file starting to emit JavaScript is already covered.
512 //
513 // The list is hand-written. Generating it by reading the directory here was
514 // tried and removed: `zig build` caches this script's evaluation against its
515 // own contents, so the generated list did not change when a template was
516 // added — the completeness check passed by reading a stale list, which is
517 // the exact failure it existed to prevent.
518 const android_escaping_tests = b.addTest(.{
519 .root_module = b.createModule(.{
520 .root_source_file = b.path("test/android_reply_escaping_test.zig"),
521 .target = target,
522 .optimize = optimize,
523 }),
524 });
525 android_escaping_tests.root_module.addAnonymousImport("CraftBridge.kt.template", .{
526 .root_source_file = b.path("../android/templates/CraftBridge.kt.template"),
527 });
528 android_escaping_tests.root_module.addAnonymousImport("CraftBridgeExtensions.kt.template", .{
529 .root_source_file = b.path("../android/templates/CraftBridgeExtensions.kt.template"),
530 });
531 android_escaping_tests.root_module.addAnonymousImport("CraftHealthConnect.kt.template", .{
532 .root_source_file = b.path("../android/templates/CraftHealthConnect.kt.template"),
533 });
534 android_escaping_tests.root_module.addAnonymousImport("CraftHealthConnectStub.kt.template", .{
535 .root_source_file = b.path("../android/templates/CraftHealthConnectStub.kt.template"),
536 });
537 android_escaping_tests.root_module.addAnonymousImport("CraftNative.kt.template", .{
538 .root_source_file = b.path("../android/templates/CraftNative.kt.template"),
539 });
540 android_escaping_tests.root_module.addAnonymousImport("CraftWidgetProvider.kt.template", .{
541 .root_source_file = b.path("../android/templates/CraftWidgetProvider.kt.template"),
542 });
543 android_escaping_tests.root_module.addAnonymousImport("LocationRecordingService.kt.template", .{
544 .root_source_file = b.path("../android/templates/LocationRecordingService.kt.template"),
545 });
546 android_escaping_tests.root_module.addAnonymousImport("MainActivity.kt.template", .{
547 .root_source_file = b.path("../android/templates/MainActivity.kt.template"),
548 });
549 const run_android_escaping_tests = b.addRunArtifact(android_escaping_tests);
550
508551 ios_conformance_tests.root_module.addAnonymousImport("CraftApp.swift", .{
509552 .root_source_file = b.path("../ios/templates/CraftApp.swift"),
510553 });
@@ -1648,6 +1691,7 @@ pub fn build(b: *std.Build) void {
16481691 test_step.dependOn(&run_bridge_surface_tests.step);
16491692 test_step.dependOn(&run_android_conformance_tests.step);
16501693 test_step.dependOn(&run_android_bridge_tests.step);
1694 test_step.dependOn(&run_android_escaping_tests.step);
16511695 test_step.dependOn(&run_menubar_tests.step);
16521696 test_step.dependOn(&run_components_tests.step);
16531697 test_step.dependOn(&run_gpu_tests.step);
@@ -2377,6 +2421,7 @@ pub fn build(b: *std.Build) void {
23772421 test_android_step.dependOn(&run_bridge_surface_tests.step);
23782422 test_android_step.dependOn(&run_android_conformance_tests.step);
23792423 test_android_step.dependOn(&run_android_bridge_tests.step);
2424 test_android_step.dependOn(&run_android_escaping_tests.step);
23802425
23812426 // Add Android tests to the main test step
23822427 test_step.dependOn(&run_android_tests.step);
packages/zig/src/bridge_android_calendar.zigmodified+17-18
Changes to packages/zig/src/bridge_android_calendar.zig
@@ -1,28 +1,26 @@
11//! `deleteCalendarEvent` on Android — the first action here that answers
22//! through the reply channel rather than by returning.
33//!
4//! ## What the shim does, and the one thing this does not reproduce
4//! ## What the shim does
55//!
66//! The Kotlin builds a content URI from the event id, deletes through the
77//! `ContentResolver`, and resolves `true`; on any exception it rejects with
88//! the message. That is all ported.
99//!
10//! What is not ported is how it *sends* the rejection:
10//! The rejection is built as JSON through `bridge_error.appendJsonEscaped`
11//! rather than pasted into a quoted string. When this module was written the
12//! shim did the latter —
1113//!
1214//! "window._craftDeleteEventReject && window._craftDeleteEventReject('${e.message}')"
1315//!
14//! The message goes into a single-quoted JavaScript string with no escaping,
15//! and `eventId.toLong()` puts the caller's own text in that message —
16//! `NumberFormatException: For input string: "1'x"`. A `'` in the id therefore
17//! produces a script that does not parse, `evaluateJavascript` runs nothing,
18//! and **neither the resolve nor the reject fires**. The promise never
19//! settles, and the injected JS builds it by hand with no timeout, so the page
20//! waits forever with nothing in the console.
21//!
22//! Here the payload is built as JSON through `bridge_error.appendJsonEscaped`,
23//! so the same id rejects normally. That is a divergence in the page's favour
24//! and it is deliberate; the shim keeps the bug on the twenty other paths
25//! until #154 is fixed.
16//! — and `eventId.toLong()` puts the caller's own text in that message, so
17//! `craft.calendar.delete("1'x")` produced a script that did not parse:
18//! `evaluateJavascript` ran nothing, neither the resolve nor the reject fired,
19//! and the hand-built promise had no timeout to notice. #154 fixed all
20//! fifty-eight such emissions, and `android_reply_escaping_test` now keeps
21//! them fixed, so this is no longer a divergence — but the escaping stays
22//! here, because `events.settle` takes a payload that is already JSON and this
23//! module is what has to produce it.
2624//!
2725//! ## `toLong()` is reproduced exactly, including what it refuses
2826//!
@@ -114,7 +112,8 @@ pub fn deleteEvent(j: Jni, activity: jobject, id: i64) !void {
114112/// Reject with `message`, escaped.
115113///
116114/// The escaping is the whole point of this function existing rather than the
117/// call being inline — see the module comment and #154.
115/// call being inline — `events.settle` sends the payload as written, so a raw
116/// message would emit a script that does not parse. See the module comment.
118117pub fn rejectWith(allocator: std.mem.Allocator, message: []const u8) !void {
119118 const payload = try rejectPayload(allocator, message);
120119 defer allocator.free(payload);
@@ -171,9 +170,9 @@ test "an id Java would refuse is refused here too" {
171170}
172171
173172test "a rejection message with a quote is escaped rather than emitted raw" {
174 // The bug this action does not reproduce. On the shim, an id of `1'x`
175 // produces `…Reject('For input string: "1'x"')`, which does not parse — so
176 // evaluateJavascript runs nothing and the promise never settles.
173 // The bug that used to be one line away. Before #154 the shim turned an id
174 // of `1'x` into `…Reject('For input string: "1'x"')`, which does not parse
175 // — so evaluateJavascript ran nothing and the promise never settled.
177176 const payload = try rejectPayload(testing.allocator, "For input string: \"1'x\"");
178177 defer testing.allocator.free(payload);
179178
packages/zig/test/android_reply_escaping_test.zigadded+148-0
Changes to packages/zig/test/android_reply_escaping_test.zig
@@ -0,0 +1,148 @@
1//! How the answer gets back, and the one character that stops it.
2//!
3//! Android replies to the page by building JavaScript source and handing it to
4//! `WebView.evaluateJavascript`. Fifty-eight of those emissions interpolated a
5//! Kotlin value straight into a single-quoted JavaScript string:
6//!
7//! ```kotlin
8//! webView.evaluateJavascript(
9//! "window._craftDeleteEventReject && window._craftDeleteEventReject('${e.message}')", null)
10//! ```
11//!
12//! An apostrophe in the value ends the string early, the script does not parse,
13//! and `evaluateJavascript` runs *nothing*. Not the reject, not the resolve —
14//! and because these promises are hand-built rather than made by
15//! `_createCallback`, there is no timeout behind them. The page waits forever
16//! with no console error, since the failure happens inside `evaluateJavascript`
17//! where nothing is watching.
18//!
19//! It is reachable from the page, because Java puts the offending input in the
20//! exception: `craft.calendar.delete("1'x")` throws `NumberFormatException: For
21//! input string: "1'x"`, and that message is what gets interpolated.
22//!
23//! ## Why a scanner and not a review
24//!
25//! The fix is `JSONObject.quote`, which is mechanical — and that is exactly the
26//! kind of fix that comes undone. The next reply path written by hand looks
27//! like the fifty-eight that were there before it, reads naturally, compiles,
28//! and works until someone types an apostrophe. So the rule is checked rather
29//! than remembered.
30//!
31//! ## The rule
32//!
33//! No Kotlin template may contain the two characters `'$`. That is narrower
34//! than "escape your JavaScript" and wider than the reply paths, and both are
35//! deliberate: it is trivially decidable, it has no false positives in this
36//! tree (every occurrence before this file existed was a JavaScript emission),
37//! and a value quoted with `jsQuote` cannot produce it.
38
39const std = ;
40const testing = std.testing;
41
42/// Every Kotlin template in `packages/android/templates`, embedded rather than
43/// read from disk so the test is hermetic.
44///
45/// All eight, not just the four that talk to the WebView today — a file that
46/// starts emitting JavaScript should already be covered rather than needing
47/// someone to notice.
48///
49/// The list is hand-written, and that is a real limit: a ninth template is not
50/// scanned until it is added here. Generating the list from the directory was
51/// tried and removed, because `zig build` caches build.zig's evaluation against
52/// that file's contents — adding a template did not regenerate the list, and the
53/// completeness check passed by reading stale data. A check that cannot fail is
54/// worse than an acknowledged gap.
55const templates = [_]Template{
56 .{ .name = "CraftBridge.kt.template", .source = @embedFile("CraftBridge.kt.template") },
57 .{ .name = "CraftBridgeExtensions.kt.template", .source = @embedFile("CraftBridgeExtensions.kt.template") },
58 .{ .name = "CraftHealthConnect.kt.template", .source = @embedFile("CraftHealthConnect.kt.template") },
59 .{ .name = "CraftHealthConnectStub.kt.template", .source = @embedFile("CraftHealthConnectStub.kt.template") },
60 .{ .name = "CraftNative.kt.template", .source = @embedFile("CraftNative.kt.template") },
61 .{ .name = "CraftWidgetProvider.kt.template", .source = @embedFile("CraftWidgetProvider.kt.template") },
62 .{ .name = "LocationRecordingService.kt.template", .source = @embedFile("LocationRecordingService.kt.template") },
63 .{ .name = "MainActivity.kt.template", .source = @embedFile("MainActivity.kt.template") },
64};
65
66const Template = struct {
67 name: []const u8,
68 source: []const u8,
69};
70
71/// The offset of the first Kotlin interpolation opening immediately inside a
72/// single-quoted JavaScript string, or null if there is none.
73fn firstUnquotedInterpolation(source: []const u8) ?usize {
74 return std.mem.indexOf(u8, source, "'$");
75}
76
77fn sourceOf(name: []const u8) ?[]const u8 {
78 for (templates) |template| {
79 if (std.mem.eql(u8, template.name, name)) return template.source;
80 }
81 return null;
82}
83
84fn lineOf(source: []const u8, offset: usize) usize {
85 return std.mem.count(u8, source[0..offset], "\n") + 1;
86}
87
88test "the scanner recognises the shape it exists to catch" {
89 // Reading the guard below tells you nothing about whether it looks at
90 // anything. Three of this repository's tests have passed for the wrong
91 // reason and only deliberate mutation exposed them, so the scanner is
92 // handed both answers here before it is trusted with the real files.
93 try testing.expect(firstUnquotedInterpolation("f('${e.message}')") != null);
94 try testing.expect(firstUnquotedInterpolation("f('$id')") != null);
95 try testing.expect(firstUnquotedInterpolation("f({key: '$key'})") != null);
96
97 // And the fix must read as clean, or the guard is unsatisfiable.
98 try testing.expect(firstUnquotedInterpolation("f(${jsQuote(e.message)})") == null);
99 try testing.expect(firstUnquotedInterpolation("f($json)") == null);
100 try testing.expect(firstUnquotedInterpolation("window.$callback") == null);
101}
102
103test "no Kotlin template interpolates a value into a single-quoted JavaScript string" {
104 for (templates) |template| {
105 if (firstUnquotedInterpolation(template.source)) |offset| {
106 const line = lineOf(template.source, offset);
107 const start = if (std.mem.lastIndexOfScalar(u8, template.source[0..offset], '\n')) |nl|
108 nl + 1
109 else
110 0;
111 const excerpt = std.mem.trim(
112 u8,
113 std.mem.sliceTo(template.source[start..], '\n'),
114 " \t\r",
115 );
116 std.debug.print(
117 \\
118 \\{s}:{d} interpolates a value into a single-quoted JavaScript string:
119 \\
120 \\ {s}
121 \\
122 \\An apostrophe in that value makes the emitted script a syntax error,
123 \\`evaluateJavascript` runs nothing, and the page's promise never settles.
124 \\Use `jsQuote(value)` — it supplies its own quotes, so the surrounding
125 \\'...' comes out.
126 \\
127 , .{ template.name, line, excerpt });
128 return error.UnquotedInterpolation;
129 }
130 }
131}
132
133test "the templates that answer the page are actually in the scanned set" {
134 // A floor, not a count: `CraftBridge.kt` alone holds 119 of these. If this
135 // drops to zero the embed has gone stale — pointing at the wrong file, or
136 // at a file that no longer talks to the WebView — and the escaping guard
137 // above would pass on bytes nobody replies with.
138 var emissions: usize = 0;
139 for (templates) |template| {
140 emissions += std.mem.count(u8, template.source, "evaluateJavascript(");
141 }
142 try testing.expect(emissions >= 100);
143
144 // And the fix is present rather than the sites merely being deleted.
145 const bridge = sourceOf("CraftBridge.kt.template").?;
146 try testing.expect(std.mem.indexOf(u8, bridge, "private fun jsQuote(") != null);
147 try testing.expect(std.mem.count(u8, bridge, "jsQuote(") >= 50);
148}