also looking at this
fix(android): an apostrophe in a reply hangs the page forever
#156
4 files
+279
-73
Review threads live on the whole diff, not on one commit, so none are shown here - a thread's line means something in the branch's final form, and painting it into an intermediate step would put it on code it is not about.
| @@ -166,6 +166,20 @@ class CraftBridge( | ||
| 166 | 166 | ) |
| 167 | 167 | } |
| 168 | 168 | |
| 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 | ||
| 169 | 183 | fun injectBridge() { |
| 170 | 184 | // Before the script, so a native callback arriving while the page is |
| 171 | 185 | // still loading has somewhere to go rather than being counted as |
| @@ -1474,7 +1488,7 @@ class CraftBridge( | ||
| 1474 | 1488 | |
| 1475 | 1489 | val event = if (requestCode == REQUEST_CAMERA) "_craftCameraResolve" else "_craftGalleryResolve" |
| 1476 | 1490 | activity.runOnUiThread { |
| 1477 | webView.evaluateJavascript("window.$event && window.$event('$imageUri')", null) | |
| 1491 | webView.evaluateJavascript("window.$event && window.$event(${jsQuote(imageUri)})", null) | |
| 1478 | 1492 | } |
| 1479 | 1493 | } |
| 1480 | 1494 | |
| @@ -1525,7 +1539,7 @@ class CraftBridge( | ||
| 1525 | 1539 | |
| 1526 | 1540 | override fun onAuthenticationError(errorCode: Int, errString: CharSequence) { |
| 1527 | 1541 | webView.evaluateJavascript( |
| 1528 | "window._craftBiometricReject && window._craftBiometricReject('$errString')", | |
| 1542 | "window._craftBiometricReject && window._craftBiometricReject(${jsQuote(errString)})", | |
| 1529 | 1543 | null |
| 1530 | 1544 | ) |
| 1531 | 1545 | } |
| @@ -1655,7 +1669,7 @@ class CraftBridge( | ||
| 1655 | 1669 | }?.addOnFailureListener { e -> |
| 1656 | 1670 | activity.runOnUiThread { |
| 1657 | 1671 | webView.evaluateJavascript( |
| 1658 | "window._craftLocationReject && window._craftLocationReject({code: 2, message: '${e.message}'})", | |
| 1672 | "window._craftLocationReject && window._craftLocationReject({code: 2, message: ${jsQuote(e.message)}})", | |
| 1659 | 1673 | null |
| 1660 | 1674 | ) |
| 1661 | 1675 | } |
| @@ -2110,7 +2124,7 @@ class CraftBridge( | ||
| 2110 | 2124 | currentAppState = newState |
| 2111 | 2125 | activity.runOnUiThread { |
| 2112 | 2126 | webView.evaluateJavascript( |
| 2113 | "window._craftAppStateCallback && window._craftAppStateCallback('$currentAppState')", | |
| 2127 | "window._craftAppStateCallback && window._craftAppStateCallback(${jsQuote(currentAppState)})", | |
| 2114 | 2128 | null |
| 2115 | 2129 | ) |
| 2116 | 2130 | } |
| @@ -2247,11 +2261,11 @@ class CraftBridge( | ||
| 2247 | 2261 | val contactId = ContentUris.parseId(results[0].uri!!) |
| 2248 | 2262 | |
| 2249 | 2263 | activity.runOnUiThread { |
| 2250 | webView.evaluateJavascript("window._craftAddContactResolve && window._craftAddContactResolve('$contactId')", null) | |
| 2264 | webView.evaluateJavascript("window._craftAddContactResolve && window._craftAddContactResolve(${jsQuote(contactId)})", null) | |
| 2251 | 2265 | } |
| 2252 | 2266 | } catch (e: Exception) { |
| 2253 | 2267 | activity.runOnUiThread { |
| 2254 | webView.evaluateJavascript("window._craftAddContactReject && window._craftAddContactReject('${e.message}')", null) | |
| 2268 | webView.evaluateJavascript("window._craftAddContactReject && window._craftAddContactReject(${jsQuote(e.message)})", null) | |
| 2255 | 2269 | } |
| 2256 | 2270 | } |
| 2257 | 2271 | } |
| @@ -2339,11 +2353,11 @@ class CraftBridge( | ||
| 2339 | 2353 | val eventId = uri?.lastPathSegment ?: "" |
| 2340 | 2354 | |
| 2341 | 2355 | activity.runOnUiThread { |
| 2342 | webView.evaluateJavascript("window._craftCreateEventResolve && window._craftCreateEventResolve('$eventId')", null) | |
| 2356 | webView.evaluateJavascript("window._craftCreateEventResolve && window._craftCreateEventResolve(${jsQuote(eventId)})", null) | |
| 2343 | 2357 | } |
| 2344 | 2358 | } catch (e: Exception) { |
| 2345 | 2359 | activity.runOnUiThread { |
| 2346 | webView.evaluateJavascript("window._craftCreateEventReject && window._craftCreateEventReject('${e.message}')", null) | |
| 2360 | webView.evaluateJavascript("window._craftCreateEventReject && window._craftCreateEventReject(${jsQuote(e.message)})", null) | |
| 2347 | 2361 | } |
| 2348 | 2362 | } |
| 2349 | 2363 | } |
| @@ -2362,7 +2376,7 @@ class CraftBridge( | ||
| 2362 | 2376 | } |
| 2363 | 2377 | } catch (e: Exception) { |
| 2364 | 2378 | activity.runOnUiThread { |
| 2365 | webView.evaluateJavascript("window._craftDeleteEventReject && window._craftDeleteEventReject('${e.message}')", null) | |
| 2379 | webView.evaluateJavascript("window._craftDeleteEventReject && window._craftDeleteEventReject(${jsQuote(e.message)})", null) | |
| 2366 | 2380 | } |
| 2367 | 2381 | } |
| 2368 | 2382 | } |
| @@ -2399,11 +2413,11 @@ class CraftBridge( | ||
| 2399 | 2413 | } |
| 2400 | 2414 | |
| 2401 | 2415 | activity.runOnUiThread { |
| 2402 | webView.evaluateJavascript("window._craftNotifResolve && window._craftNotifResolve('$id')", null) | |
| 2416 | webView.evaluateJavascript("window._craftNotifResolve && window._craftNotifResolve(${jsQuote(id)})", null) | |
| 2403 | 2417 | } |
| 2404 | 2418 | } catch (e: Exception) { |
| 2405 | 2419 | activity.runOnUiThread { |
| 2406 | webView.evaluateJavascript("window._craftNotifReject && window._craftNotifReject('${e.message}')", null) | |
| 2420 | webView.evaluateJavascript("window._craftNotifReject && window._craftNotifReject(${jsQuote(e.message)})", null) | |
| 2407 | 2421 | } |
| 2408 | 2422 | } |
| 2409 | 2423 | } |
| @@ -2496,7 +2510,7 @@ class CraftBridge( | ||
| 2496 | 2510 | }) |
| 2497 | 2511 | } catch (e: Exception) { |
| 2498 | 2512 | activity.runOnUiThread { |
| 2499 | webView.evaluateJavascript("window._craftProductsReject && window._craftProductsReject('${e.message}')", null) | |
| 2513 | webView.evaluateJavascript("window._craftProductsReject && window._craftProductsReject(${jsQuote(e.message)})", null) | |
| 2500 | 2514 | } |
| 2501 | 2515 | } |
| 2502 | 2516 | } |
| @@ -2625,14 +2639,14 @@ class CraftBridge( | ||
| 2625 | 2639 | |
| 2626 | 2640 | activity.runOnUiThread { |
| 2627 | 2641 | webView.evaluateJavascript( |
| 2628 | "window._craftDownloadResolve && window._craftDownloadResolve('$downloadId')", | |
| 2642 | "window._craftDownloadResolve && window._craftDownloadResolve(${jsQuote(downloadId)})", | |
| 2629 | 2643 | null |
| 2630 | 2644 | ) |
| 2631 | 2645 | } |
| 2632 | 2646 | } catch (e: Exception) { |
| 2633 | 2647 | activity.runOnUiThread { |
| 2634 | 2648 | webView.evaluateJavascript( |
| 2635 | "window._craftDownloadReject && window._craftDownloadReject('${e.message}')", | |
| 2649 | "window._craftDownloadReject && window._craftDownloadReject(${jsQuote(e.message)})", | |
| 2636 | 2650 | null |
| 2637 | 2651 | ) |
| 2638 | 2652 | } |
| @@ -2658,14 +2672,14 @@ class CraftBridge( | ||
| 2658 | 2672 | |
| 2659 | 2673 | activity.runOnUiThread { |
| 2660 | 2674 | webView.evaluateJavascript( |
| 2661 | "window._craftSaveResolve && window._craftSaveResolve('${file.absolutePath}')", | |
| 2675 | "window._craftSaveResolve && window._craftSaveResolve(${jsQuote(file.absolutePath)})", | |
| 2662 | 2676 | null |
| 2663 | 2677 | ) |
| 2664 | 2678 | } |
| 2665 | 2679 | } catch (e: Exception) { |
| 2666 | 2680 | activity.runOnUiThread { |
| 2667 | 2681 | webView.evaluateJavascript( |
| 2668 | "window._craftSaveReject && window._craftSaveReject('${e.message}')", | |
| 2682 | "window._craftSaveReject && window._craftSaveReject(${jsQuote(e.message)})", | |
| 2669 | 2683 | null |
| 2670 | 2684 | ) |
| 2671 | 2685 | } |
| @@ -2723,7 +2737,7 @@ class CraftBridge( | ||
| 2723 | 2737 | } |
| 2724 | 2738 | } catch (e: Exception) { |
| 2725 | 2739 | activity.runOnUiThread { |
| 2726 | webView.evaluateJavascript("window._craftAudioReject && window._craftAudioReject('${e.message}')", null) | |
| 2740 | webView.evaluateJavascript("window._craftAudioReject && window._craftAudioReject(${jsQuote(e.message)})", null) | |
| 2727 | 2741 | } |
| 2728 | 2742 | } |
| 2729 | 2743 | } |
| @@ -2739,7 +2753,7 @@ class CraftBridge( | ||
| 2739 | 2753 | val bytes = file.readBytes() |
| 2740 | 2754 | val base64 = "data:audio/m4a;base64," + Base64.encodeToString(bytes, Base64.NO_WRAP) |
| 2741 | 2755 | activity.runOnUiThread { |
| 2742 | webView.evaluateJavascript("window._craftAudioStopResolve && window._craftAudioStopResolve('$base64')", null) | |
| 2756 | webView.evaluateJavascript("window._craftAudioStopResolve && window._craftAudioStopResolve(${jsQuote(base64)})", null) | |
| 2743 | 2757 | } |
| 2744 | 2758 | } ?: run { |
| 2745 | 2759 | activity.runOnUiThread { |
| @@ -2748,7 +2762,7 @@ class CraftBridge( | ||
| 2748 | 2762 | } |
| 2749 | 2763 | } catch (e: Exception) { |
| 2750 | 2764 | activity.runOnUiThread { |
| 2751 | webView.evaluateJavascript("window._craftAudioStopReject && window._craftAudioStopReject('${e.message}')", null) | |
| 2765 | webView.evaluateJavascript("window._craftAudioStopReject && window._craftAudioStopReject(${jsQuote(e.message)})", null) | |
| 2752 | 2766 | } |
| 2753 | 2767 | } |
| 2754 | 2768 | } |
| @@ -2835,7 +2849,7 @@ class CraftBridge( | ||
| 2835 | 2849 | } catch (e: Exception) { |
| 2836 | 2850 | activity.runOnUiThread { |
| 2837 | 2851 | webView.evaluateJavascript( |
| 2838 | "window._craftDbExecReject && window._craftDbExecReject('${e.message}')", | |
| 2852 | "window._craftDbExecReject && window._craftDbExecReject(${jsQuote(e.message)})", | |
| 2839 | 2853 | null |
| 2840 | 2854 | ) |
| 2841 | 2855 | } |
| @@ -2875,7 +2889,7 @@ class CraftBridge( | ||
| 2875 | 2889 | } catch (e: Exception) { |
| 2876 | 2890 | activity.runOnUiThread { |
| 2877 | 2891 | webView.evaluateJavascript( |
| 2878 | "window._craftDbQueryReject && window._craftDbQueryReject('${e.message}')", | |
| 2892 | "window._craftDbQueryReject && window._craftDbQueryReject(${jsQuote(e.message)})", | |
| 2879 | 2893 | null |
| 2880 | 2894 | ) |
| 2881 | 2895 | } |
| @@ -2980,9 +2994,9 @@ class CraftBridge( | ||
| 2980 | 2994 | bitmap.compress(Bitmap.CompressFormat.PNG, 100, outputStream) |
| 2981 | 2995 | val base64 = "data:image/png;base64," + Base64.encodeToString(outputStream.toByteArray(), Base64.NO_WRAP) |
| 2982 | 2996 | |
| 2983 | webView.evaluateJavascript("window._craftScreenshotResolve && window._craftScreenshotResolve('$base64')", null) | |
| 2997 | webView.evaluateJavascript("window._craftScreenshotResolve && window._craftScreenshotResolve(${jsQuote(base64)})", null) | |
| 2984 | 2998 | } catch (e: Exception) { |
| 2985 | webView.evaluateJavascript("window._craftScreenshotReject && window._craftScreenshotReject('${e.message}')", null) | |
| 2999 | webView.evaluateJavascript("window._craftScreenshotReject && window._craftScreenshotReject(${jsQuote(e.message)})", null) | |
| 2986 | 3000 | } |
| 2987 | 3001 | } |
| 2988 | 3002 | } |
| @@ -2995,14 +3009,14 @@ class CraftBridge( | ||
| 2995 | 3009 | // WorkManager tasks don't require explicit registration, just track them |
| 2996 | 3010 | activity.runOnUiThread { |
| 2997 | 3011 | webView.evaluateJavascript( |
| 2998 | "window._craftBgTaskResolve && window._craftBgTaskResolve({taskId: '$taskId', registered: true})", | |
| 3012 | "window._craftBgTaskResolve && window._craftBgTaskResolve({taskId: ${jsQuote(taskId)}, registered: true})", | |
| 2999 | 3013 | null |
| 3000 | 3014 | ) |
| 3001 | 3015 | } |
| 3002 | 3016 | } catch (e: Exception) { |
| 3003 | 3017 | activity.runOnUiThread { |
| 3004 | 3018 | webView.evaluateJavascript( |
| 3005 | "window._craftBgTaskReject && window._craftBgTaskReject('${e.message}')", | |
| 3019 | "window._craftBgTaskReject && window._craftBgTaskReject(${jsQuote(e.message)})", | |
| 3006 | 3020 | null |
| 3007 | 3021 | ) |
| 3008 | 3022 | } |
| @@ -3017,14 +3031,14 @@ class CraftBridge( | ||
| 3017 | 3031 | // This is a placeholder that shows the API structure |
| 3018 | 3032 | activity.runOnUiThread { |
| 3019 | 3033 | 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})", | |
| 3021 | 3035 | null |
| 3022 | 3036 | ) |
| 3023 | 3037 | } |
| 3024 | 3038 | } catch (e: Exception) { |
| 3025 | 3039 | activity.runOnUiThread { |
| 3026 | 3040 | webView.evaluateJavascript( |
| 3027 | "window._craftBgTaskReject && window._craftBgTaskReject('${e.message}')", | |
| 3041 | "window._craftBgTaskReject && window._craftBgTaskReject(${jsQuote(e.message)})", | |
| 3028 | 3042 | null |
| 3029 | 3043 | ) |
| 3030 | 3044 | } |
| @@ -3036,14 +3050,14 @@ class CraftBridge( | ||
| 3036 | 3050 | try { |
| 3037 | 3051 | activity.runOnUiThread { |
| 3038 | 3052 | webView.evaluateJavascript( |
| 3039 | "window._craftBgTaskResolve && window._craftBgTaskResolve({taskId: '$taskId', cancelled: true})", | |
| 3053 | "window._craftBgTaskResolve && window._craftBgTaskResolve({taskId: ${jsQuote(taskId)}, cancelled: true})", | |
| 3040 | 3054 | null |
| 3041 | 3055 | ) |
| 3042 | 3056 | } |
| 3043 | 3057 | } catch (e: Exception) { |
| 3044 | 3058 | activity.runOnUiThread { |
| 3045 | 3059 | webView.evaluateJavascript( |
| 3046 | "window._craftBgTaskReject && window._craftBgTaskReject('${e.message}')", | |
| 3060 | "window._craftBgTaskReject && window._craftBgTaskReject(${jsQuote(e.message)})", | |
| 3047 | 3061 | null |
| 3048 | 3062 | ) |
| 3049 | 3063 | } |
| @@ -3062,7 +3076,7 @@ class CraftBridge( | ||
| 3062 | 3076 | } catch (e: Exception) { |
| 3063 | 3077 | activity.runOnUiThread { |
| 3064 | 3078 | webView.evaluateJavascript( |
| 3065 | "window._craftBgTaskReject && window._craftBgTaskReject('${e.message}')", | |
| 3079 | "window._craftBgTaskReject && window._craftBgTaskReject(${jsQuote(e.message)})", | |
| 3066 | 3080 | null |
| 3067 | 3081 | ) |
| 3068 | 3082 | } |
| @@ -3099,7 +3113,7 @@ class CraftBridge( | ||
| 3099 | 3113 | ) |
| 3100 | 3114 | } catch (e: Exception) { |
| 3101 | 3115 | webView.evaluateJavascript( |
| 3102 | "window._craftPDFReject && window._craftPDFReject('${e.message}')", | |
| 3116 | "window._craftPDFReject && window._craftPDFReject(${jsQuote(e.message)})", | |
| 3103 | 3117 | null |
| 3104 | 3118 | ) |
| 3105 | 3119 | } |
| @@ -3204,7 +3218,7 @@ class CraftBridge( | ||
| 3204 | 3218 | } catch (e: Exception) { |
| 3205 | 3219 | activity.runOnUiThread { |
| 3206 | 3220 | webView.evaluateJavascript( |
| 3207 | "window._craftPickContactReject && window._craftPickContactReject('${e.message}')", | |
| 3221 | "window._craftPickContactReject && window._craftPickContactReject(${jsQuote(e.message)})", | |
| 3208 | 3222 | null |
| 3209 | 3223 | ) |
| 3210 | 3224 | } |
| @@ -3261,7 +3275,7 @@ class CraftBridge( | ||
| 3261 | 3275 | } catch (e: Exception) { |
| 3262 | 3276 | activity.runOnUiThread { |
| 3263 | 3277 | webView.evaluateJavascript( |
| 3264 | "window._craftShortcutsReject && window._craftShortcutsReject('${e.message}')", | |
| 3278 | "window._craftShortcutsReject && window._craftShortcutsReject(${jsQuote(e.message)})", | |
| 3265 | 3279 | null |
| 3266 | 3280 | ) |
| 3267 | 3281 | } |
| @@ -3284,7 +3298,7 @@ class CraftBridge( | ||
| 3284 | 3298 | } catch (e: Exception) { |
| 3285 | 3299 | activity.runOnUiThread { |
| 3286 | 3300 | webView.evaluateJavascript( |
| 3287 | "window._craftShortcutsReject && window._craftShortcutsReject('${e.message}')", | |
| 3301 | "window._craftShortcutsReject && window._craftShortcutsReject(${jsQuote(e.message)})", | |
| 3288 | 3302 | null |
| 3289 | 3303 | ) |
| 3290 | 3304 | } |
| @@ -3310,14 +3324,14 @@ class CraftBridge( | ||
| 3310 | 3324 | |
| 3311 | 3325 | activity.runOnUiThread { |
| 3312 | 3326 | webView.evaluateJavascript( |
| 3313 | "window._craftSharedKeychainResolve && window._craftSharedKeychainResolve({success: true, key: '$key'})", | |
| 3327 | "window._craftSharedKeychainResolve && window._craftSharedKeychainResolve({success: true, key: ${jsQuote(key)}})", | |
| 3314 | 3328 | null |
| 3315 | 3329 | ) |
| 3316 | 3330 | } |
| 3317 | 3331 | } catch (e: Exception) { |
| 3318 | 3332 | activity.runOnUiThread { |
| 3319 | 3333 | webView.evaluateJavascript( |
| 3320 | "window._craftSharedKeychainReject && window._craftSharedKeychainReject('${e.message}')", | |
| 3334 | "window._craftSharedKeychainReject && window._craftSharedKeychainReject(${jsQuote(e.message)})", | |
| 3321 | 3335 | null |
| 3322 | 3336 | ) |
| 3323 | 3337 | } |
| @@ -3334,12 +3348,12 @@ class CraftBridge( | ||
| 3334 | 3348 | activity.runOnUiThread { |
| 3335 | 3349 | if (value != null) { |
| 3336 | 3350 | webView.evaluateJavascript( |
| 3337 | "window._craftSharedKeychainResolve && window._craftSharedKeychainResolve({value: '$value', key: '$key'})", | |
| 3351 | "window._craftSharedKeychainResolve && window._craftSharedKeychainResolve({value: ${jsQuote(value)}, key: ${jsQuote(key)}})", | |
| 3338 | 3352 | null |
| 3339 | 3353 | ) |
| 3340 | 3354 | } else { |
| 3341 | 3355 | webView.evaluateJavascript( |
| 3342 | "window._craftSharedKeychainResolve && window._craftSharedKeychainResolve({value: null, key: '$key'})", | |
| 3356 | "window._craftSharedKeychainResolve && window._craftSharedKeychainResolve({value: null, key: ${jsQuote(key)}})", | |
| 3343 | 3357 | null |
| 3344 | 3358 | ) |
| 3345 | 3359 | } |
| @@ -3347,7 +3361,7 @@ class CraftBridge( | ||
| 3347 | 3361 | } catch (e: Exception) { |
| 3348 | 3362 | activity.runOnUiThread { |
| 3349 | 3363 | webView.evaluateJavascript( |
| 3350 | "window._craftSharedKeychainReject && window._craftSharedKeychainReject('${e.message}')", | |
| 3364 | "window._craftSharedKeychainReject && window._craftSharedKeychainReject(${jsQuote(e.message)})", | |
| 3351 | 3365 | null |
| 3352 | 3366 | ) |
| 3353 | 3367 | } |
| @@ -3363,14 +3377,14 @@ class CraftBridge( | ||
| 3363 | 3377 | |
| 3364 | 3378 | activity.runOnUiThread { |
| 3365 | 3379 | webView.evaluateJavascript( |
| 3366 | "window._craftSharedKeychainResolve && window._craftSharedKeychainResolve({success: true, key: '$key'})", | |
| 3380 | "window._craftSharedKeychainResolve && window._craftSharedKeychainResolve({success: true, key: ${jsQuote(key)}})", | |
| 3367 | 3381 | null |
| 3368 | 3382 | ) |
| 3369 | 3383 | } |
| 3370 | 3384 | } catch (e: Exception) { |
| 3371 | 3385 | activity.runOnUiThread { |
| 3372 | 3386 | webView.evaluateJavascript( |
| 3373 | "window._craftSharedKeychainReject && window._craftSharedKeychainReject('${e.message}')", | |
| 3387 | "window._craftSharedKeychainReject && window._craftSharedKeychainReject(${jsQuote(e.message)})", | |
| 3374 | 3388 | null |
| 3375 | 3389 | ) |
| 3376 | 3390 | } |
| @@ -3404,7 +3418,7 @@ class CraftBridge( | ||
| 3404 | 3418 | } catch (e: Exception) { |
| 3405 | 3419 | activity.runOnUiThread { |
| 3406 | 3420 | webView.evaluateJavascript( |
| 3407 | "window._craftAuthPersistReject && window._craftAuthPersistReject('${e.message}')", | |
| 3421 | "window._craftAuthPersistReject && window._craftAuthPersistReject(${jsQuote(e.message)})", | |
| 3408 | 3422 | null |
| 3409 | 3423 | ) |
| 3410 | 3424 | } |
| @@ -3428,7 +3442,7 @@ class CraftBridge( | ||
| 3428 | 3442 | } catch (e: Exception) { |
| 3429 | 3443 | activity.runOnUiThread { |
| 3430 | 3444 | webView.evaluateJavascript( |
| 3431 | "window._craftAuthPersistReject && window._craftAuthPersistReject('${e.message}')", | |
| 3445 | "window._craftAuthPersistReject && window._craftAuthPersistReject(${jsQuote(e.message)})", | |
| 3432 | 3446 | null |
| 3433 | 3447 | ) |
| 3434 | 3448 | } |
| @@ -3448,7 +3462,7 @@ class CraftBridge( | ||
| 3448 | 3462 | } catch (e: Exception) { |
| 3449 | 3463 | activity.runOnUiThread { |
| 3450 | 3464 | webView.evaluateJavascript( |
| 3451 | "window._craftAuthPersistReject && window._craftAuthPersistReject('${e.message}')", | |
| 3465 | "window._craftAuthPersistReject && window._craftAuthPersistReject(${jsQuote(e.message)})", | |
| 3452 | 3466 | null |
| 3453 | 3467 | ) |
| 3454 | 3468 | } |
| @@ -3540,7 +3554,7 @@ class CraftBridge( | ||
| 3540 | 3554 | .addOnFailureListener { e -> |
| 3541 | 3555 | activity.runOnUiThread { |
| 3542 | 3556 | webView.evaluateJavascript( |
| 3543 | "window._craftMLReject && window._craftMLReject('${e.message}')", | |
| 3557 | "window._craftMLReject && window._craftMLReject(${jsQuote(e.message)})", | |
| 3544 | 3558 | null |
| 3545 | 3559 | ) |
| 3546 | 3560 | } |
| @@ -3548,7 +3562,7 @@ class CraftBridge( | ||
| 3548 | 3562 | } catch (e: Exception) { |
| 3549 | 3563 | activity.runOnUiThread { |
| 3550 | 3564 | webView.evaluateJavascript( |
| 3551 | "window._craftMLReject && window._craftMLReject('${e.message}')", | |
| 3565 | "window._craftMLReject && window._craftMLReject(${jsQuote(e.message)})", | |
| 3552 | 3566 | null |
| 3553 | 3567 | ) |
| 3554 | 3568 | } |
| @@ -3607,7 +3621,7 @@ class CraftBridge( | ||
| 3607 | 3621 | .addOnFailureListener { e -> |
| 3608 | 3622 | activity.runOnUiThread { |
| 3609 | 3623 | webView.evaluateJavascript( |
| 3610 | "window._craftMLReject && window._craftMLReject('${e.message}')", | |
| 3624 | "window._craftMLReject && window._craftMLReject(${jsQuote(e.message)})", | |
| 3611 | 3625 | null |
| 3612 | 3626 | ) |
| 3613 | 3627 | } |
| @@ -3615,7 +3629,7 @@ class CraftBridge( | ||
| 3615 | 3629 | } catch (e: Exception) { |
| 3616 | 3630 | activity.runOnUiThread { |
| 3617 | 3631 | webView.evaluateJavascript( |
| 3618 | "window._craftMLReject && window._craftMLReject('${e.message}')", | |
| 3632 | "window._craftMLReject && window._craftMLReject(${jsQuote(e.message)})", | |
| 3619 | 3633 | null |
| 3620 | 3634 | ) |
| 3621 | 3635 | } |
| @@ -3662,7 +3676,7 @@ class CraftBridge( | ||
| 3662 | 3676 | .addOnFailureListener { e -> |
| 3663 | 3677 | activity.runOnUiThread { |
| 3664 | 3678 | webView.evaluateJavascript( |
| 3665 | "window._craftMLReject && window._craftMLReject('${e.message}')", | |
| 3679 | "window._craftMLReject && window._craftMLReject(${jsQuote(e.message)})", | |
| 3666 | 3680 | null |
| 3667 | 3681 | ) |
| 3668 | 3682 | } |
| @@ -3670,7 +3684,7 @@ class CraftBridge( | ||
| 3670 | 3684 | } catch (e: Exception) { |
| 3671 | 3685 | activity.runOnUiThread { |
| 3672 | 3686 | webView.evaluateJavascript( |
| 3673 | "window._craftMLReject && window._craftMLReject('${e.message}')", | |
| 3687 | "window._craftMLReject && window._craftMLReject(${jsQuote(e.message)})", | |
| 3674 | 3688 | null |
| 3675 | 3689 | ) |
| 3676 | 3690 | } |
| @@ -3718,7 +3732,7 @@ class CraftBridge( | ||
| 3718 | 3732 | } catch (e: Exception) { |
| 3719 | 3733 | activity.runOnUiThread { |
| 3720 | 3734 | webView.evaluateJavascript( |
| 3721 | "window._craftWidgetReject && window._craftWidgetReject('${e.message}')", | |
| 3735 | "window._craftWidgetReject && window._craftWidgetReject(${jsQuote(e.message)})", | |
| 3722 | 3736 | null |
| 3723 | 3737 | ) |
| 3724 | 3738 | } |
| @@ -3750,7 +3764,7 @@ class CraftBridge( | ||
| 3750 | 3764 | |
| 3751 | 3765 | activity.runOnUiThread { |
| 3752 | 3766 | 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)}})", | |
| 3754 | 3768 | null |
| 3755 | 3769 | ) |
| 3756 | 3770 | } |
| @@ -3763,7 +3777,7 @@ class CraftBridge( | ||
| 3763 | 3777 | |
| 3764 | 3778 | activity.runOnUiThread { |
| 3765 | 3779 | webView.evaluateJavascript( |
| 3766 | "window._craftVoiceResolve && window._craftVoiceResolve({removed: true, action: '$action'})", | |
| 3780 | "window._craftVoiceResolve && window._craftVoiceResolve({removed: true, action: ${jsQuote(action)}})", | |
| 3767 | 3781 | null |
| 3768 | 3782 | ) |
| 3769 | 3783 | } |
| @@ -3778,7 +3792,7 @@ class CraftBridge( | ||
| 3778 | 3792 | if (action != null) { |
| 3779 | 3793 | activity.runOnUiThread { |
| 3780 | 3794 | 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)}}}));", | |
| 3782 | 3796 | null |
| 3783 | 3797 | ) |
| 3784 | 3798 | } |
| @@ -3927,7 +3941,7 @@ class CraftBridge( | ||
| 3927 | 3941 | val json = JSONObject(data).toString() |
| 3928 | 3942 | activity.runOnUiThread { |
| 3929 | 3943 | webView.evaluateJavascript( |
| 3930 | "window.dispatchEvent(new CustomEvent('$event', {detail: $json}));", | |
| 3944 | "window.dispatchEvent(new CustomEvent(${jsQuote(event)}, {detail: $json}));", | |
| 3931 | 3945 | null |
| 3932 | 3946 | ) |
| 3933 | 3947 | } |
| @@ -505,6 +505,49 @@ pub fn build(b: *std.Build) void { | ||
| 505 | 505 | }); |
| 506 | 506 | const run_android_bridge_tests = b.addRunArtifact(android_bridge_tests); |
| 507 | 507 | |
| 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 | ||
| 508 | 551 | ios_conformance_tests.root_module.addAnonymousImport("CraftApp.swift", .{ |
| 509 | 552 | .root_source_file = b.path("../ios/templates/CraftApp.swift"), |
| 510 | 553 | }); |
| @@ -1648,6 +1691,7 @@ pub fn build(b: *std.Build) void { | ||
| 1648 | 1691 | test_step.dependOn(&run_bridge_surface_tests.step); |
| 1649 | 1692 | test_step.dependOn(&run_android_conformance_tests.step); |
| 1650 | 1693 | test_step.dependOn(&run_android_bridge_tests.step); |
| 1694 | test_step.dependOn(&run_android_escaping_tests.step); | |
| 1651 | 1695 | test_step.dependOn(&run_menubar_tests.step); |
| 1652 | 1696 | test_step.dependOn(&run_components_tests.step); |
| 1653 | 1697 | test_step.dependOn(&run_gpu_tests.step); |
| @@ -2377,6 +2421,7 @@ pub fn build(b: *std.Build) void { | ||
| 2377 | 2421 | test_android_step.dependOn(&run_bridge_surface_tests.step); |
| 2378 | 2422 | test_android_step.dependOn(&run_android_conformance_tests.step); |
| 2379 | 2423 | test_android_step.dependOn(&run_android_bridge_tests.step); |
| 2424 | test_android_step.dependOn(&run_android_escaping_tests.step); | |
| 2380 | 2425 | |
| 2381 | 2426 | // Add Android tests to the main test step |
| 2382 | 2427 | test_step.dependOn(&run_android_tests.step); |
| @@ -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 | ||
| 39 | const std = ; | |
| 40 | const 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. | |
| 55 | const 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 | ||
| 66 | const 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. | |
| 73 | fn firstUnquotedInterpolation(source: []const u8) ?usize { | |
| 74 | return std.mem.indexOf(u8, source, "'$"); | |
| 75 | } | |
| 76 | ||
| 77 | fn 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 | ||
| 84 | fn lineOf(source: []const u8, offset: usize) usize { | |
| 85 | return std.mem.count(u8, source[0..offset], "\n") + 1; | |
| 86 | } | |
| 87 | ||
| 88 | test "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 | ||
| 103 | test "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 | ||
| 133 | test "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 | } | |