From bbc7bd2134fe9aed1890d18bb57f42494685c5c4 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 1 Oct 2026 12:00:13 +0000 Subject: [PATCH 01/12] refactor(android): replace Gson with org.json in SQLHTTPServer Parse execute/batch request bodies with org.json and centralize param conversion so numeric truncation matches former Gson getAsLong behavior. Add unit tests and drop the gson dependency. Co-authored-by: Martin DONADIEU --- android/build.gradle | 1 - .../fastsql/HttpRequestJsonParams.java | 91 ++++++++++++++++++ .../capacitor/fastsql/SQLHTTPServer.java | 76 +++------------ .../fastsql/HttpRequestJsonParamsTest.java | 94 +++++++++++++++++++ 4 files changed, 196 insertions(+), 66 deletions(-) create mode 100644 android/src/main/java/app/capgo/capacitor/fastsql/HttpRequestJsonParams.java create mode 100644 android/src/test/java/app/capgo/capacitor/fastsql/HttpRequestJsonParamsTest.java diff --git a/android/build.gradle b/android/build.gradle index ed7030e..9eee2e2 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -52,7 +52,6 @@ dependencies { implementation project(':capacitor-android') implementation "androidx.appcompat:appcompat:$androidxAppCompatVersion" implementation 'androidx.sqlite:sqlite:2.7.1' - implementation 'com.google.code.gson:gson:2.14.0' implementation 'org.nanohttpd:nanohttpd:2.3.1' compileOnly 'net.zetetic:sqlcipher-android:4.19.1' testImplementation "junit:junit:$junitVersion" diff --git a/android/src/main/java/app/capgo/capacitor/fastsql/HttpRequestJsonParams.java b/android/src/main/java/app/capgo/capacitor/fastsql/HttpRequestJsonParams.java new file mode 100644 index 0000000..27f2247 --- /dev/null +++ b/android/src/main/java/app/capgo/capacitor/fastsql/HttpRequestJsonParams.java @@ -0,0 +1,91 @@ +package app.capgo.capacitor.fastsql; + +import com.getcapacitor.JSArray; +import java.math.BigDecimal; +import java.util.Iterator; +import org.json.JSONArray; +import org.json.JSONException; +import org.json.JSONObject; + +/** + * Converts HTTP request JSON parameter arrays to {@link JSArray} values with the same + * semantics as the previous Gson-based parsing in {@link SQLHTTPServer}. + */ +final class HttpRequestJsonParams { + + private HttpRequestJsonParams() {} + + static void populateJsArrayFromParamsJson(JSONArray paramsJson, JSArray params) throws JSONException { + for (int i = 0; i < paramsJson.length(); i++) { + appendParam(params, paramsJson.get(i)); + } + } + + static void appendParam(JSArray params, Object param) throws JSONException { + if (param == JSONObject.NULL) { + params.put(JSONObject.NULL); + } else if (param instanceof Boolean) { + params.put(param); + } else if (param instanceof Number) { + putNumberLikeGson(params, (Number) param); + } else if (param instanceof String) { + params.put(param); + } else if (param instanceof JSONObject) { + JSONObject obj = (JSONObject) param; + JSONObject jsonObj = new JSONObject(); + Iterator keys = obj.keys(); + while (keys.hasNext()) { + String key = keys.next(); + jsonObj.put(key, elementGetAsString(obj.get(key))); + } + params.put(jsonObj); + } + } + + /** Mirrors Gson {@code JsonElement#getAsString()} for object field values. */ + static String elementGetAsString(Object value) { + if (value == null || value == JSONObject.NULL) { + throw new IllegalStateException("Cannot get string from null"); + } + if (value instanceof String) { + return (String) value; + } + return String.valueOf(value); + } + + /** + * Mirrors Gson {@code JsonPrimitive#getAsLong()} with {@code getAsDouble()} fallback on + * {@link NumberFormatException}, using LazilyParsedNumber-style parsing on the number's text. + */ + static void putNumberLikeGson(JSArray params, Number number) throws JSONException { + String s = numberToJsonNumberString(number); + try { + params.put(gsonStyleLongFromNumberString(s)); + } catch (NumberFormatException e) { + params.put(gsonStyleDoubleFromNumberString(s, number)); + } + } + + static long gsonStyleLongFromNumberString(String s) { + try { + return Long.parseLong(s); + } catch (NumberFormatException e) { + return (long) Double.parseDouble(s); + } + } + + static double gsonStyleDoubleFromNumberString(String s, Number number) { + try { + return Double.parseDouble(s); + } catch (NumberFormatException e) { + return number.doubleValue(); + } + } + + static String numberToJsonNumberString(Number number) { + if (number instanceof BigDecimal) { + return ((BigDecimal) number).toPlainString(); + } + return number.toString(); + } +} diff --git a/android/src/main/java/app/capgo/capacitor/fastsql/SQLHTTPServer.java b/android/src/main/java/app/capgo/capacitor/fastsql/SQLHTTPServer.java index 2912be8..4edd986 100644 --- a/android/src/main/java/app/capgo/capacitor/fastsql/SQLHTTPServer.java +++ b/android/src/main/java/app/capgo/capacitor/fastsql/SQLHTTPServer.java @@ -2,11 +2,6 @@ import com.getcapacitor.JSArray; import com.getcapacitor.JSObject; -import com.google.gson.Gson; -import com.google.gson.JsonArray; -import com.google.gson.JsonElement; -import com.google.gson.JsonObject; -import com.google.gson.JsonParser; import fi.iki.elonen.NanoHTTPD; // Note: org.nanohttpd:nanohttpd:2.3.1 still uses fi.iki.elonen package import java.io.DataInputStream; import java.io.IOException; @@ -29,7 +24,6 @@ public class SQLHTTPServer extends NanoHTTPD { private final String token; private final Map databases; - private final Gson gson = new Gson(); private final ExecutorService requestExecutor = Executors.newSingleThreadExecutor((runnable) -> { Thread thread = new Thread(runnable); thread.setName("FastSQL-DB"); @@ -120,37 +114,13 @@ private Response routeRequest(IHTTPSession session, DatabaseConnection db) throw private Response handleExecute(IHTTPSession session, DatabaseConnection db) throws Exception { // Read request body String body = readRequestBody(session); - JsonObject request = JsonParser.parseString(body).getAsJsonObject(); + JSONObject request = new JSONObject(body); - String statement = request.get("statement").getAsString(); - JsonArray paramsJson = request.has("params") ? request.getAsJsonArray("params") : new JsonArray(); + String statement = request.getString("statement"); + JSONArray paramsJson = request.has("params") ? request.getJSONArray("params") : new JSONArray(); - // Convert to JSArray JSArray params = new JSArray(); - for (JsonElement param : paramsJson) { - if (param.isJsonNull()) { - params.put(JSONObject.NULL); - } else if (param.isJsonPrimitive()) { - if (param.getAsJsonPrimitive().isNumber()) { - try { - params.put(param.getAsLong()); - } catch (NumberFormatException e) { - params.put(param.getAsDouble()); - } - } else if (param.getAsJsonPrimitive().isBoolean()) { - params.put(param.getAsBoolean()); - } else { - params.put(param.getAsString()); - } - } else if (param.isJsonObject()) { - JsonObject obj = param.getAsJsonObject(); - JSONObject jsonObj = new JSONObject(); - for (String key : obj.keySet()) { - jsonObj.put(key, obj.get(key).getAsString()); - } - params.put(jsonObj); - } - } + HttpRequestJsonParams.populateJsArrayFromParamsJson(paramsJson, params); // Execute query JSObject result = db.execute(statement, params); @@ -163,41 +133,17 @@ private Response handleExecute(IHTTPSession session, DatabaseConnection db) thro private Response handleBatch(IHTTPSession session, DatabaseConnection db) throws Exception { // Read request body String body = readRequestBody(session); - JsonObject request = JsonParser.parseString(body).getAsJsonObject(); - JsonArray operations = request.getAsJsonArray("operations"); + JSONObject request = new JSONObject(body); + JSONArray operations = request.getJSONArray("operations"); JSONArray results = new JSONArray(); - for (JsonElement opElement : operations) { - JsonObject operation = opElement.getAsJsonObject(); - String statement = operation.get("statement").getAsString(); - JsonArray paramsJson = operation.has("params") ? operation.getAsJsonArray("params") : new JsonArray(); + for (int i = 0; i < operations.length(); i++) { + JSONObject operation = operations.getJSONObject(i); + String statement = operation.getString("statement"); + JSONArray paramsJson = operation.has("params") ? operation.getJSONArray("params") : new JSONArray(); - // Convert to JSArray JSArray params = new JSArray(); - for (JsonElement param : paramsJson) { - if (param.isJsonNull()) { - params.put(JSONObject.NULL); - } else if (param.isJsonPrimitive()) { - if (param.getAsJsonPrimitive().isNumber()) { - try { - params.put(param.getAsLong()); - } catch (NumberFormatException e) { - params.put(param.getAsDouble()); - } - } else if (param.getAsJsonPrimitive().isBoolean()) { - params.put(param.getAsBoolean()); - } else { - params.put(param.getAsString()); - } - } else if (param.isJsonObject()) { - JsonObject obj = param.getAsJsonObject(); - JSONObject jsonObj = new JSONObject(); - for (String key : obj.keySet()) { - jsonObj.put(key, obj.get(key).getAsString()); - } - params.put(jsonObj); - } - } + HttpRequestJsonParams.populateJsArrayFromParamsJson(paramsJson, params); JSObject result = db.execute(statement, params); results.put(new JSONObject(result.toString())); diff --git a/android/src/test/java/app/capgo/capacitor/fastsql/HttpRequestJsonParamsTest.java b/android/src/test/java/app/capgo/capacitor/fastsql/HttpRequestJsonParamsTest.java new file mode 100644 index 0000000..cdd59ff --- /dev/null +++ b/android/src/test/java/app/capgo/capacitor/fastsql/HttpRequestJsonParamsTest.java @@ -0,0 +1,94 @@ +package app.capgo.capacitor.fastsql; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + +import com.getcapacitor.JSArray; +import org.json.JSONArray; +import org.json.JSONObject; +import org.junit.Test; + +public class HttpRequestJsonParamsTest { + + @Test + public void populateParams_handlesNullBooleanStringAndNumbers() throws Exception { + JSONArray input = new JSONArray(); + input.put(JSONObject.NULL); + input.put(true); + input.put("text"); + input.put(42); + input.put(3.14); + + JSArray params = new JSArray(); + HttpRequestJsonParams.populateJsArrayFromParamsJson(input, params); + + assertEquals(5, params.length()); + assertEquals(JSONObject.NULL, params.get(0)); + assertEquals(true, params.get(1)); + assertEquals("text", params.get(2)); + assertEquals(42L, ((Number) params.get(3)).longValue()); + assertEquals(3L, ((Number) params.get(4)).longValue()); + } + + @Test + public void populateParams_truncatesDecimalLikeGsonGetAsLong() throws Exception { + JSArray params = new JSArray(); + HttpRequestJsonParams.appendParam(params, 9.99); + assertEquals(9L, params.get(0)); + } + + @Test + public void populateParams_jsonObjectValuesUseGetAsStringSemantics() throws Exception { + JSONObject obj = new JSONObject(); + obj.put("s", "hello"); + obj.put("n", 7); + obj.put("b", false); + + JSArray params = new JSArray(); + HttpRequestJsonParams.appendParam(params, obj); + + JSONObject out = (JSONObject) params.get(0); + assertEquals("hello", out.getString("s")); + assertEquals("7", out.getString("n")); + assertEquals("false", out.getString("b")); + } + + @Test + public void elementGetAsString_rejectsNull() { + try { + HttpRequestJsonParams.elementGetAsString(JSONObject.NULL); + assertTrue("expected IllegalStateException", false); + } catch (IllegalStateException expected) { + // Gson-compatible + } + } + + @Test + public void gsonStyleLongFromNumberString_parsesIntegerAndTruncatesDecimal() { + assertEquals(100L, HttpRequestJsonParams.gsonStyleLongFromNumberString("100")); + assertEquals(3L, HttpRequestJsonParams.gsonStyleLongFromNumberString("3.14")); + } + + @Test + public void malformedRequestJson_throws() { + try { + new JSONObject("{not json"); + assertTrue("expected JSONException", false); + } catch (org.json.JSONException expected) { + // Same error path as invalid Gson bodies surfacing as request failures + } + } + + @Test + public void populateParams_skipsUnsupportedArrayElements() throws Exception { + JSONArray input = new JSONArray(); + input.put(new JSONArray().put(1)); + input.put("kept"); + + JSArray params = new JSArray(); + HttpRequestJsonParams.populateJsArrayFromParamsJson(input, params); + + assertEquals(1, params.length()); + assertEquals("kept", params.get(0)); + } +} From f1febb88bbdba550b3256b796ead82b784dad67e Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 1 Oct 2026 12:05:02 +0000 Subject: [PATCH 02/12] test(android): unit-test param conversion without JSArray on JVM Co-authored-by: Martin DONADIEU --- .../fastsql/HttpRequestJsonParams.java | 47 +++++++++++----- .../fastsql/HttpRequestJsonParamsTest.java | 56 ++++++++----------- 2 files changed, 54 insertions(+), 49 deletions(-) diff --git a/android/src/main/java/app/capgo/capacitor/fastsql/HttpRequestJsonParams.java b/android/src/main/java/app/capgo/capacitor/fastsql/HttpRequestJsonParams.java index 27f2247..8ab0351 100644 --- a/android/src/main/java/app/capgo/capacitor/fastsql/HttpRequestJsonParams.java +++ b/android/src/main/java/app/capgo/capacitor/fastsql/HttpRequestJsonParams.java @@ -17,29 +17,46 @@ private HttpRequestJsonParams() {} static void populateJsArrayFromParamsJson(JSONArray paramsJson, JSArray params) throws JSONException { for (int i = 0; i < paramsJson.length(); i++) { - appendParam(params, paramsJson.get(i)); + Object converted = convertRequestParam(paramsJson.get(i)); + if (converted != null) { + params.put(converted); + } } } - static void appendParam(JSArray params, Object param) throws JSONException { + /** + * Converts one JSON param value for {@link JSArray#put(Object)}. + * + * @return converted value, or {@code null} to skip unsupported values (e.g. nested arrays) + */ + static Object convertRequestParam(Object param) { if (param == JSONObject.NULL) { - params.put(JSONObject.NULL); - } else if (param instanceof Boolean) { - params.put(param); - } else if (param instanceof Number) { - putNumberLikeGson(params, (Number) param); - } else if (param instanceof String) { - params.put(param); - } else if (param instanceof JSONObject) { + return JSONObject.NULL; + } + if (param instanceof Boolean) { + return param; + } + if (param instanceof Number) { + return convertNumberLikeGson((Number) param); + } + if (param instanceof String) { + return param; + } + if (param instanceof JSONObject) { JSONObject obj = (JSONObject) param; JSONObject jsonObj = new JSONObject(); Iterator keys = obj.keys(); while (keys.hasNext()) { String key = keys.next(); - jsonObj.put(key, elementGetAsString(obj.get(key))); + try { + jsonObj.put(key, elementGetAsString(obj.get(key))); + } catch (JSONException e) { + throw new IllegalStateException(e); + } } - params.put(jsonObj); + return jsonObj; } + return null; } /** Mirrors Gson {@code JsonElement#getAsString()} for object field values. */ @@ -57,12 +74,12 @@ static String elementGetAsString(Object value) { * Mirrors Gson {@code JsonPrimitive#getAsLong()} with {@code getAsDouble()} fallback on * {@link NumberFormatException}, using LazilyParsedNumber-style parsing on the number's text. */ - static void putNumberLikeGson(JSArray params, Number number) throws JSONException { + static Number convertNumberLikeGson(Number number) { String s = numberToJsonNumberString(number); try { - params.put(gsonStyleLongFromNumberString(s)); + return gsonStyleLongFromNumberString(s); } catch (NumberFormatException e) { - params.put(gsonStyleDoubleFromNumberString(s, number)); + return gsonStyleDoubleFromNumberString(s, number); } } diff --git a/android/src/test/java/app/capgo/capacitor/fastsql/HttpRequestJsonParamsTest.java b/android/src/test/java/app/capgo/capacitor/fastsql/HttpRequestJsonParamsTest.java index cdd59ff..0182424 100644 --- a/android/src/test/java/app/capgo/capacitor/fastsql/HttpRequestJsonParamsTest.java +++ b/android/src/test/java/app/capgo/capacitor/fastsql/HttpRequestJsonParamsTest.java @@ -1,9 +1,9 @@ package app.capgo.capacitor.fastsql; import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNull; import static org.junit.Assert.assertTrue; -import com.getcapacitor.JSArray; import org.json.JSONArray; import org.json.JSONObject; import org.junit.Test; @@ -11,43 +11,27 @@ public class HttpRequestJsonParamsTest { @Test - public void populateParams_handlesNullBooleanStringAndNumbers() throws Exception { - JSONArray input = new JSONArray(); - input.put(JSONObject.NULL); - input.put(true); - input.put("text"); - input.put(42); - input.put(3.14); - - JSArray params = new JSArray(); - HttpRequestJsonParams.populateJsArrayFromParamsJson(input, params); - - assertEquals(5, params.length()); - assertEquals(JSONObject.NULL, params.get(0)); - assertEquals(true, params.get(1)); - assertEquals("text", params.get(2)); - assertEquals(42L, ((Number) params.get(3)).longValue()); - assertEquals(3L, ((Number) params.get(4)).longValue()); + public void convertRequestParam_handlesNullBooleanStringAndNumbers() throws Exception { + assertEquals(JSONObject.NULL, HttpRequestJsonParams.convertRequestParam(JSONObject.NULL)); + assertEquals(true, HttpRequestJsonParams.convertRequestParam(true)); + assertEquals("text", HttpRequestJsonParams.convertRequestParam("text")); + assertEquals(42L, HttpRequestJsonParams.convertRequestParam(42)); + assertEquals(3L, HttpRequestJsonParams.convertRequestParam(3.14)); } @Test - public void populateParams_truncatesDecimalLikeGsonGetAsLong() throws Exception { - JSArray params = new JSArray(); - HttpRequestJsonParams.appendParam(params, 9.99); - assertEquals(9L, params.get(0)); + public void convertRequestParam_truncatesDecimalLikeGsonGetAsLong() { + assertEquals(9L, HttpRequestJsonParams.convertRequestParam(9.99)); } @Test - public void populateParams_jsonObjectValuesUseGetAsStringSemantics() throws Exception { + public void convertRequestParam_jsonObjectValuesUseGetAsStringSemantics() throws Exception { JSONObject obj = new JSONObject(); obj.put("s", "hello"); obj.put("n", 7); obj.put("b", false); - JSArray params = new JSArray(); - HttpRequestJsonParams.appendParam(params, obj); - - JSONObject out = (JSONObject) params.get(0); + JSONObject out = (JSONObject) HttpRequestJsonParams.convertRequestParam(obj); assertEquals("hello", out.getString("s")); assertEquals("7", out.getString("n")); assertEquals("false", out.getString("b")); @@ -72,7 +56,7 @@ public void gsonStyleLongFromNumberString_parsesIntegerAndTruncatesDecimal() { @Test public void malformedRequestJson_throws() { try { - new JSONObject("{not json"); + new JSONObject(""); assertTrue("expected JSONException", false); } catch (org.json.JSONException expected) { // Same error path as invalid Gson bodies surfacing as request failures @@ -80,15 +64,19 @@ public void malformedRequestJson_throws() { } @Test - public void populateParams_skipsUnsupportedArrayElements() throws Exception { + public void convertRequestParam_skipsUnsupportedArrayElements() throws Exception { + assertNull(HttpRequestJsonParams.convertRequestParam(new JSONArray().put(1))); + JSONArray input = new JSONArray(); input.put(new JSONArray().put(1)); input.put("kept"); - JSArray params = new JSArray(); - HttpRequestJsonParams.populateJsArrayFromParamsJson(input, params); - - assertEquals(1, params.length()); - assertEquals("kept", params.get(0)); + int converted = 0; + for (int i = 0; i < input.length(); i++) { + if (HttpRequestJsonParams.convertRequestParam(input.get(i)) != null) { + converted++; + } + } + assertEquals(1, converted); } } From c76e0a109dcd9e310413662080ff570a821a3a07 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 1 Oct 2026 12:07:18 +0000 Subject: [PATCH 03/12] ci: pin GitHub Actions to latest major tags Co-authored-by: Martin DONADIEU --- .github/workflows/build.yml | 6 +++--- .github/workflows/bump_version.yml | 4 ++-- .github/workflows/deploy_example_app.yml | 4 ++-- .github/workflows/github-releases-to-discord.yml | 4 ++-- .github/workflows/pr_beta_prompt.yml | 4 ++-- .github/workflows/pr_beta_publish.yml | 14 +++++++------- .github/workflows/test.yml | 14 +++++++------- 7 files changed, 25 insertions(+), 25 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 0a377cb..c1e788e 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -21,7 +21,7 @@ jobs: id-token: write steps: - name: Check out - uses: actions/checkout@v6 + uses: actions/checkout@v7 with: fetch-depth: 0 filter: blob:none @@ -42,7 +42,7 @@ jobs: CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} run: bun run changelog:ai - name: Setup Node.js for npm publish - uses: actions/setup-node@v6 + uses: actions/setup-node@v7 with: node-version: 24.x registry-url: 'https://registry.npmjs.org' @@ -74,7 +74,7 @@ jobs: -f "client_payload[package]=$(node -p "require('./package.json').name")" - name: Create GitHub release id: create_release - uses: softprops/action-gh-release@v2 + uses: softprops/action-gh-release@v3 with: body: | ## 🆕 Changelog diff --git a/.github/workflows/bump_version.yml b/.github/workflows/bump_version.yml index c9fcb95..61e045f 100644 --- a/.github/workflows/bump_version.yml +++ b/.github/workflows/bump_version.yml @@ -26,11 +26,11 @@ jobs: name: "Bump version and create tag" steps: - name: Setup Node.js - uses: actions/setup-node@v6 + uses: actions/setup-node@v7 with: node-version: 24.x - name: Check out - uses: actions/checkout@v6 + uses: actions/checkout@v7 with: fetch-depth: 0 filter: blob:none diff --git a/.github/workflows/deploy_example_app.yml b/.github/workflows/deploy_example_app.yml index 99a915c..7bc9ab6 100644 --- a/.github/workflows/deploy_example_app.yml +++ b/.github/workflows/deploy_example_app.yml @@ -19,7 +19,7 @@ jobs: timeout-minutes: 10 steps: - name: Checkout - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd + uses: actions/checkout@v7 with: ref: ${{ github.event.release.tag_name || github.ref }} fetch-depth: 1 @@ -27,7 +27,7 @@ jobs: persist-credentials: false - name: Setup bun - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 + uses: oven-sh/setup-bun@v2 with: bun-version: 1.4.2 no-cache: true diff --git a/.github/workflows/github-releases-to-discord.yml b/.github/workflows/github-releases-to-discord.yml index cae7011..22c7cdc 100644 --- a/.github/workflows/github-releases-to-discord.yml +++ b/.github/workflows/github-releases-to-discord.yml @@ -11,11 +11,11 @@ jobs: timeout-minutes: 10 steps: - name: Setup Node.js - uses: actions/setup-node@v6 + uses: actions/setup-node@v7 with: node-version: 24.x - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@v7 - name: GitHub Releases to Discord uses: SethCohen/github-releases-to-discord@v1 with: diff --git a/.github/workflows/pr_beta_prompt.yml b/.github/workflows/pr_beta_prompt.yml index b918fed..961d0d9 100644 --- a/.github/workflows/pr_beta_prompt.yml +++ b/.github/workflows/pr_beta_prompt.yml @@ -19,11 +19,11 @@ jobs: timeout-minutes: 10 steps: - name: Setup Node.js - uses: actions/setup-node@v6.5.0 + uses: actions/setup-node@v7 with: node-version: 24.x - name: Upsert beta publish comment - uses: actions/github-script@v9.0.0 + uses: actions/github-script@v9 env: IS_SAME_REPO: ${{ github.event.pull_request.head.repo.full_name == github.repository && 'true' || 'false' }} with: diff --git a/.github/workflows/pr_beta_publish.yml b/.github/workflows/pr_beta_publish.yml index 7756366..bf714e5 100644 --- a/.github/workflows/pr_beta_publish.yml +++ b/.github/workflows/pr_beta_publish.yml @@ -30,7 +30,7 @@ jobs: steps: - name: Resolve PR and access checks id: pr - uses: actions/github-script@v9.0.0 + uses: actions/github-script@v9 with: script: | const prNumber = context.eventName === 'workflow_dispatch' @@ -108,7 +108,7 @@ jobs: core.setOutput('head_sha', pr.head.sha); - name: Mark publish as running - uses: actions/github-script@v9.0.0 + uses: actions/github-script@v9 with: script: | const marker = ''; @@ -155,14 +155,14 @@ jobs: }); - name: Check out PR head - uses: actions/checkout@v6.1.0 + uses: actions/checkout@v7 with: ref: ${{ steps.pr.outputs.head_sha }} fetch-depth: 0 filter: blob:none persist-credentials: false - - uses: oven-sh/setup-bun@v2.2.0 + - uses: oven-sh/setup-bun@v2 - name: Install dependencies run: bun i @@ -196,7 +196,7 @@ jobs: run: bun run build - name: Setup Node.js for npm publish - uses: actions/setup-node@v6.5.0 + uses: actions/setup-node@v7 with: node-version: 24.x registry-url: 'https://registry.npmjs.org' @@ -224,7 +224,7 @@ jobs: -f "client_payload[package]=${{ steps.version.outputs.package_name }}" - name: Publish success comment if: ${{ success() }} - uses: actions/github-script@v9.0.0 + uses: actions/github-script@v9 with: script: | const marker = ''; @@ -281,7 +281,7 @@ jobs: - name: Publish failure comment if: ${{ failure() && steps.pr.outputs.pr_number != '' }} - uses: actions/github-script@v9.0.0 + uses: actions/github-script@v9 with: script: | const marker = ''; diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 3a96939..815e78a 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -15,7 +15,7 @@ jobs: timeout-minutes: 10 steps: - name: Check out - uses: actions/checkout@v6 + uses: actions/checkout@v7 - name: Enforce capacitor-swift-pm version run: | set -euo pipefail @@ -29,10 +29,10 @@ jobs: timeout-minutes: 10 steps: - name: Check out - uses: actions/checkout@v6 + uses: actions/checkout@v7 - uses: oven-sh/setup-bun@v2 - name: Setup Node.js - uses: actions/setup-node@v6 + uses: actions/setup-node@v7 with: node-version: 24.x - name: Install dependencies @@ -43,7 +43,7 @@ jobs: - name: Check Cap 9 deprecated APIs run: bun run check:cap9-deprecated - name: Setup java - uses: actions/setup-java@v5 + uses: actions/setup-java@v6 with: distribution: 'zulu' java-version: '21' @@ -59,10 +59,10 @@ jobs: timeout-minutes: 10 steps: - name: Check out - uses: actions/checkout@v6 + uses: actions/checkout@v7 - uses: oven-sh/setup-bun@v2 - name: Setup Node.js - uses: actions/setup-node@v6 + uses: actions/setup-node@v7 with: node-version: 24.x - name: Install dependencies @@ -85,7 +85,7 @@ jobs: name: 'Build code and test' steps: - name: Check out - uses: actions/checkout@v6 + uses: actions/checkout@v7 - uses: oven-sh/setup-bun@v2 - name: Install dependencies id: install_code From cade80f1f981d99d52f5ffc0b8b08e82052288cd Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 1 Oct 2026 12:13:30 +0000 Subject: [PATCH 04/12] test(android): fix org.json unit tests on Android runtime Co-authored-by: Martin DONADIEU --- .../fastsql/HttpRequestJsonParamsTest.java | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/android/src/test/java/app/capgo/capacitor/fastsql/HttpRequestJsonParamsTest.java b/android/src/test/java/app/capgo/capacitor/fastsql/HttpRequestJsonParamsTest.java index 0182424..c6812d0 100644 --- a/android/src/test/java/app/capgo/capacitor/fastsql/HttpRequestJsonParamsTest.java +++ b/android/src/test/java/app/capgo/capacitor/fastsql/HttpRequestJsonParamsTest.java @@ -5,7 +5,9 @@ import static org.junit.Assert.assertTrue; import org.json.JSONArray; +import org.json.JSONException; import org.json.JSONObject; +import org.json.JSONTokener; import org.junit.Test; public class HttpRequestJsonParamsTest { @@ -26,10 +28,7 @@ public void convertRequestParam_truncatesDecimalLikeGsonGetAsLong() { @Test public void convertRequestParam_jsonObjectValuesUseGetAsStringSemantics() throws Exception { - JSONObject obj = new JSONObject(); - obj.put("s", "hello"); - obj.put("n", 7); - obj.put("b", false); + JSONObject obj = new JSONObject("{\"s\":\"hello\",\"n\":7,\"b\":false}"); JSONObject out = (JSONObject) HttpRequestJsonParams.convertRequestParam(obj); assertEquals("hello", out.getString("s")); @@ -56,19 +55,23 @@ public void gsonStyleLongFromNumberString_parsesIntegerAndTruncatesDecimal() { @Test public void malformedRequestJson_throws() { try { - new JSONObject(""); + new JSONTokener("{").nextValue(); assertTrue("expected JSONException", false); - } catch (org.json.JSONException expected) { + } catch (JSONException expected) { // Same error path as invalid Gson bodies surfacing as request failures } } @Test public void convertRequestParam_skipsUnsupportedArrayElements() throws Exception { - assertNull(HttpRequestJsonParams.convertRequestParam(new JSONArray().put(1))); + JSONArray nested = new JSONArray(); + nested.put(1); + assertNull(HttpRequestJsonParams.convertRequestParam(nested)); JSONArray input = new JSONArray(); - input.put(new JSONArray().put(1)); + JSONArray nestedInInput = new JSONArray(); + nestedInInput.put(1); + input.put(nestedInInput); input.put("kept"); int converted = 0; From e467baf884c8a714468010c33dc3cf5cc197e5d9 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 1 Oct 2026 12:18:12 +0000 Subject: [PATCH 05/12] test(android): run HttpRequestJsonParams tests with Robolectric Co-authored-by: Martin DONADIEU --- android/build.gradle | 4 ++++ .../capgo/capacitor/fastsql/HttpRequestJsonParamsTest.java | 5 +++++ 2 files changed, 9 insertions(+) diff --git a/android/build.gradle b/android/build.gradle index 9eee2e2..d04484e 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -40,6 +40,9 @@ android { sourceCompatibility JavaVersion.VERSION_21 targetCompatibility JavaVersion.VERSION_21 } + testOptions { + unitTests.includeAndroidResources = true + } } repositories { @@ -55,6 +58,7 @@ dependencies { implementation 'org.nanohttpd:nanohttpd:2.3.1' compileOnly 'net.zetetic:sqlcipher-android:4.19.1' testImplementation "junit:junit:$junitVersion" + testImplementation 'org.robolectric:robolectric:4.16' androidTestImplementation "androidx.test.ext:junit:$androidxJunitVersion" androidTestImplementation "androidx.test.espresso:espresso-core:$androidxEspressoCoreVersion" } diff --git a/android/src/test/java/app/capgo/capacitor/fastsql/HttpRequestJsonParamsTest.java b/android/src/test/java/app/capgo/capacitor/fastsql/HttpRequestJsonParamsTest.java index c6812d0..d2bac1a 100644 --- a/android/src/test/java/app/capgo/capacitor/fastsql/HttpRequestJsonParamsTest.java +++ b/android/src/test/java/app/capgo/capacitor/fastsql/HttpRequestJsonParamsTest.java @@ -9,7 +9,12 @@ import org.json.JSONObject; import org.json.JSONTokener; import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.annotation.Config; +@RunWith(RobolectricTestRunner.class) +@Config(sdk = 28) public class HttpRequestJsonParamsTest { @Test From d0392039863c466e23fc063e75de09db811b0ffb Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 1 Oct 2026 12:27:03 +0000 Subject: [PATCH 06/12] fix(android): align org.json param parsing with Gson edge cases Co-authored-by: Martin DONADIEU --- .../fastsql/HttpRequestJsonParams.java | 11 +++++++++- .../fastsql/HttpRequestJsonParamsTest.java | 22 ++++++++++++++----- 2 files changed, 26 insertions(+), 7 deletions(-) diff --git a/android/src/main/java/app/capgo/capacitor/fastsql/HttpRequestJsonParams.java b/android/src/main/java/app/capgo/capacitor/fastsql/HttpRequestJsonParams.java index 8ab0351..5a4b9b0 100644 --- a/android/src/main/java/app/capgo/capacitor/fastsql/HttpRequestJsonParams.java +++ b/android/src/main/java/app/capgo/capacitor/fastsql/HttpRequestJsonParams.java @@ -67,6 +67,15 @@ static String elementGetAsString(Object value) { if (value instanceof String) { return (String) value; } + if (value instanceof Number) { + return JSONObject.numberToString((Number) value); + } + if (value instanceof Boolean) { + return value.toString(); + } + if (value instanceof JSONObject || value instanceof JSONArray) { + throw new IllegalStateException("Cannot get string from non-primitive"); + } return String.valueOf(value); } @@ -87,7 +96,7 @@ static long gsonStyleLongFromNumberString(String s) { try { return Long.parseLong(s); } catch (NumberFormatException e) { - return (long) Double.parseDouble(s); + return new BigDecimal(s).longValue(); } } diff --git a/android/src/test/java/app/capgo/capacitor/fastsql/HttpRequestJsonParamsTest.java b/android/src/test/java/app/capgo/capacitor/fastsql/HttpRequestJsonParamsTest.java index d2bac1a..72e6400 100644 --- a/android/src/test/java/app/capgo/capacitor/fastsql/HttpRequestJsonParamsTest.java +++ b/android/src/test/java/app/capgo/capacitor/fastsql/HttpRequestJsonParamsTest.java @@ -4,6 +4,7 @@ import static org.junit.Assert.assertNull; import static org.junit.Assert.assertTrue; +import com.getcapacitor.JSArray; import org.json.JSONArray; import org.json.JSONException; import org.json.JSONObject; @@ -55,6 +56,7 @@ public void elementGetAsString_rejectsNull() { public void gsonStyleLongFromNumberString_parsesIntegerAndTruncatesDecimal() { assertEquals(100L, HttpRequestJsonParams.gsonStyleLongFromNumberString("100")); assertEquals(3L, HttpRequestJsonParams.gsonStyleLongFromNumberString("3.14")); + assertEquals(Long.MAX_VALUE, HttpRequestJsonParams.gsonStyleLongFromNumberString("1e20")); } @Test @@ -79,12 +81,20 @@ public void convertRequestParam_skipsUnsupportedArrayElements() throws Exception input.put(nestedInInput); input.put("kept"); - int converted = 0; - for (int i = 0; i < input.length(); i++) { - if (HttpRequestJsonParams.convertRequestParam(input.get(i)) != null) { - converted++; - } + JSArray params = new JSArray(); + HttpRequestJsonParams.populateJsArrayFromParamsJson(input, params); + assertEquals(1, params.length()); + assertEquals("kept", params.get(0)); + } + + @Test + public void convertRequestParam_rejectsNestedObjectFieldsLikeGson() throws Exception { + JSONObject obj = new JSONObject("{\"meta\":{\"a\":1}}"); + try { + HttpRequestJsonParams.convertRequestParam(obj); + assertTrue("expected IllegalStateException", false); + } catch (IllegalStateException expected) { + // Gson getAsString() on nested objects throws } - assertEquals(1, converted); } } From fc02566d18e730d60183c233feb33d3373a4d31c Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 1 Oct 2026 12:34:43 +0000 Subject: [PATCH 07/12] fix(android): handle JSONException from JSONObject.numberToString Co-authored-by: Martin DONADIEU --- .../app/capgo/capacitor/fastsql/HttpRequestJsonParams.java | 6 +++++- .../capgo/capacitor/fastsql/HttpRequestJsonParamsTest.java | 2 +- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/android/src/main/java/app/capgo/capacitor/fastsql/HttpRequestJsonParams.java b/android/src/main/java/app/capgo/capacitor/fastsql/HttpRequestJsonParams.java index 5a4b9b0..52bb013 100644 --- a/android/src/main/java/app/capgo/capacitor/fastsql/HttpRequestJsonParams.java +++ b/android/src/main/java/app/capgo/capacitor/fastsql/HttpRequestJsonParams.java @@ -68,7 +68,11 @@ static String elementGetAsString(Object value) { return (String) value; } if (value instanceof Number) { - return JSONObject.numberToString((Number) value); + try { + return JSONObject.numberToString((Number) value); + } catch (JSONException e) { + throw new IllegalStateException(e); + } } if (value instanceof Boolean) { return value.toString(); diff --git a/android/src/test/java/app/capgo/capacitor/fastsql/HttpRequestJsonParamsTest.java b/android/src/test/java/app/capgo/capacitor/fastsql/HttpRequestJsonParamsTest.java index 72e6400..2d01df5 100644 --- a/android/src/test/java/app/capgo/capacitor/fastsql/HttpRequestJsonParamsTest.java +++ b/android/src/test/java/app/capgo/capacitor/fastsql/HttpRequestJsonParamsTest.java @@ -70,7 +70,7 @@ public void malformedRequestJson_throws() { } @Test - public void convertRequestParam_skipsUnsupportedArrayElements() throws Exception { + public void populateJsArrayFromParamsJson_skipsUnsupportedArrayElements() throws Exception { JSONArray nested = new JSONArray(); nested.put(1); assertNull(HttpRequestJsonParams.convertRequestParam(nested)); From 542b00806ff8f55ed4249248a330aba6a2a994fa Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 1 Oct 2026 12:40:26 +0000 Subject: [PATCH 08/12] fix(android): match Gson long parsing via double cast fallback Co-authored-by: Martin DONADIEU --- .../app/capgo/capacitor/fastsql/HttpRequestJsonParams.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/android/src/main/java/app/capgo/capacitor/fastsql/HttpRequestJsonParams.java b/android/src/main/java/app/capgo/capacitor/fastsql/HttpRequestJsonParams.java index 52bb013..ea00f83 100644 --- a/android/src/main/java/app/capgo/capacitor/fastsql/HttpRequestJsonParams.java +++ b/android/src/main/java/app/capgo/capacitor/fastsql/HttpRequestJsonParams.java @@ -100,7 +100,8 @@ static long gsonStyleLongFromNumberString(String s) { try { return Long.parseLong(s); } catch (NumberFormatException e) { - return new BigDecimal(s).longValue(); + // Gson LazilyParsedNumber#longValue() uses a double parse + cast, not BigDecimal. + return (long) Double.parseDouble(s); } } From 5f92142a6ecb2ff5af2f70ab39546ff061ff9a74 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 1 Oct 2026 12:59:11 +0000 Subject: [PATCH 09/12] fix(android): preserve raw JSON literals and match Gson 2.14 long parsing Co-authored-by: Martin DONADIEU --- .../fastsql/HttpRequestJsonParams.java | 255 +++++++++++++++++- .../capacitor/fastsql/SQLHTTPServer.java | 10 +- .../fastsql/HttpRequestJsonParamsTest.java | 46 ++-- 3 files changed, 282 insertions(+), 29 deletions(-) diff --git a/android/src/main/java/app/capgo/capacitor/fastsql/HttpRequestJsonParams.java b/android/src/main/java/app/capgo/capacitor/fastsql/HttpRequestJsonParams.java index ea00f83..ed60b58 100644 --- a/android/src/main/java/app/capgo/capacitor/fastsql/HttpRequestJsonParams.java +++ b/android/src/main/java/app/capgo/capacitor/fastsql/HttpRequestJsonParams.java @@ -2,10 +2,13 @@ import com.getcapacitor.JSArray; import java.math.BigDecimal; +import java.util.ArrayList; import java.util.Iterator; +import java.util.List; import org.json.JSONArray; import org.json.JSONException; import org.json.JSONObject; +import org.json.JSONTokener; /** * Converts HTTP request JSON parameter arrays to {@link JSArray} values with the same @@ -13,17 +16,40 @@ */ final class HttpRequestJsonParams { + private static final int MAX_NUMBER_STRING_LENGTH = 10_000; + private HttpRequestJsonParams() {} static void populateJsArrayFromParamsJson(JSONArray paramsJson, JSArray params) throws JSONException { + populateJsArrayFromParamsJson(paramsJson, params, null); + } + + static void populateJsArrayFromParamsJson(JSONArray paramsJson, JSArray params, String rawParamsArrayJson) throws JSONException { + List rawElements = rawParamsArrayJson != null ? splitTopLevelJsonArrayElements(rawParamsArrayJson) : null; for (int i = 0; i < paramsJson.length(); i++) { - Object converted = convertRequestParam(paramsJson.get(i)); + String rawElement = rawElements != null && i < rawElements.size() ? rawElements.get(i) : null; + Object converted = + rawElement != null ? convertRequestParamFromRawJson(rawElement, paramsJson.get(i)) : convertRequestParam(paramsJson.get(i)); if (converted != null) { params.put(converted); } } } + static String extractJsonArrayAfterKey(String json, String key) throws JSONException { + String search = "\"" + key + "\""; + int idx = json.indexOf(search); + if (idx < 0) { + throw new JSONException("No key: " + key); + } + idx = json.indexOf('[', idx + search.length()); + if (idx < 0) { + throw new JSONException("No array for key: " + key); + } + int end = indexOfMatchingBracket(json, idx, '[', ']'); + return json.substring(idx, end + 1); + } + /** * Converts one JSON param value for {@link JSArray#put(Object)}. * @@ -59,6 +85,30 @@ static Object convertRequestParam(Object param) { return null; } + static Object convertRequestParamFromRawJson(String rawElement, Object parsedFallback) { + String raw = rawElement.trim(); + if (raw.startsWith("{")) { + return convertFlatObjectParamFromRawJson(raw); + } + if (raw.startsWith("[")) { + return null; + } + if (raw.equals("null")) { + return JSONObject.NULL; + } + if (raw.equals("true") || raw.equals("false")) { + return Boolean.parseBoolean(raw); + } + if (raw.startsWith("\"")) { + try { + return new JSONTokener(raw).nextValue(); + } catch (JSONException e) { + throw new IllegalStateException(e); + } + } + return convertNumberLikeGsonFromLiteral(raw); + } + /** Mirrors Gson {@code JsonElement#getAsString()} for object field values. */ static String elementGetAsString(Object value) { if (value == null || value == JSONObject.NULL) { @@ -83,25 +133,52 @@ static String elementGetAsString(Object value) { return String.valueOf(value); } + static String elementGetAsStringFromRawToken(String token) { + String raw = token.trim(); + if (raw.equals("null")) { + throw new IllegalStateException("Cannot get string from null"); + } + if (raw.startsWith("{") || raw.startsWith("[")) { + throw new IllegalStateException("Cannot get string from non-primitive"); + } + if (raw.startsWith("\"")) { + try { + Object value = new JSONTokener(raw).nextValue(); + if (value instanceof String) { + return (String) value; + } + } catch (JSONException e) { + throw new IllegalStateException(e); + } + } + if (raw.equals("true") || raw.equals("false")) { + return raw; + } + return raw; + } + /** * Mirrors Gson {@code JsonPrimitive#getAsLong()} with {@code getAsDouble()} fallback on * {@link NumberFormatException}, using LazilyParsedNumber-style parsing on the number's text. */ static Number convertNumberLikeGson(Number number) { - String s = numberToJsonNumberString(number); + return convertNumberLikeGsonFromLiteral(numberToJsonNumberString(number)); + } + + static Number convertNumberLikeGsonFromLiteral(String literal) { try { - return gsonStyleLongFromNumberString(s); + return gsonStyleLongFromNumberString(literal); } catch (NumberFormatException e) { - return gsonStyleDoubleFromNumberString(s, number); + return gsonStyleDoubleFromNumberString(literal, Double.parseDouble(literal)); } } static long gsonStyleLongFromNumberString(String s) { + checkNumberStringLength(s); try { return Long.parseLong(s); } catch (NumberFormatException e) { - // Gson LazilyParsedNumber#longValue() uses a double parse + cast, not BigDecimal. - return (long) Double.parseDouble(s); + return parseBigDecimalLikeGson(s).longValue(); } } @@ -119,4 +196,170 @@ static String numberToJsonNumberString(Number number) { } return number.toString(); } + + static JSONObject convertFlatObjectParamFromRawJson(String raw) { + JSONObject out = new JSONObject(); + int i = skipWhitespace(raw, 1); + while (true) { + i = skipWhitespace(raw, i); + if (raw.charAt(i) == '}') { + break; + } + int keyEnd = endOfJsonString(raw, i); + String key; + try { + key = (String) new JSONTokener(raw.substring(i, keyEnd)).nextValue(); + } catch (JSONException e) { + throw new IllegalStateException(e); + } + i = skipWhitespace(raw, keyEnd); + if (raw.charAt(i) != ':') { + throw new IllegalStateException("Expected ':' after object key"); + } + i = skipWhitespace(raw, i + 1); + int valueEnd = endOfJsonValue(raw, i); + String valueToken = raw.substring(i, valueEnd); + try { + out.put(key, elementGetAsStringFromRawToken(valueToken)); + } catch (JSONException e) { + throw new IllegalStateException(e); + } + i = skipWhitespace(raw, valueEnd); + if (i < raw.length() && raw.charAt(i) == ',') { + i++; + } + } + return out; + } + + static List splitTopLevelJsonArrayElements(String arrayJson) { + String trimmed = arrayJson.trim(); + if (trimmed.length() < 2 || trimmed.charAt(0) != '[') { + throw new IllegalStateException("Not a JSON array"); + } + List elements = new ArrayList<>(); + int i = skipWhitespace(trimmed, 1); + while (true) { + i = skipWhitespace(trimmed, i); + if (trimmed.charAt(i) == ']') { + break; + } + int end = endOfJsonValue(trimmed, i); + elements.add(trimmed.substring(i, end)); + i = skipWhitespace(trimmed, end); + if (trimmed.charAt(i) == ',') { + i++; + } + } + return elements; + } + + static int endOfJsonValue(String s, int start) { + start = skipWhitespace(s, start); + char c = s.charAt(start); + if (c == '"') { + return endOfJsonString(s, start); + } + if (c == '{') { + return indexOfMatchingBracket(s, start, '{', '}') + 1; + } + if (c == '[') { + return indexOfMatchingBracket(s, start, '[', ']') + 1; + } + if (s.startsWith("null", start)) { + return start + 4; + } + if (s.startsWith("true", start)) { + return start + 4; + } + if (s.startsWith("false", start)) { + return start + 5; + } + int i = start; + while (i < s.length()) { + char ch = s.charAt(i); + if (ch == ',' || ch == ']' || ch == '}' || Character.isWhitespace(ch)) { + break; + } + i++; + } + return i; + } + + static int endOfJsonString(String s, int start) { + if (s.charAt(start) != '"') { + throw new IllegalStateException("Expected string"); + } + boolean escape = false; + for (int i = start + 1; i < s.length(); i++) { + char c = s.charAt(i); + if (escape) { + escape = false; + continue; + } + if (c == '\\') { + escape = true; + continue; + } + if (c == '"') { + return i + 1; + } + } + throw new IllegalStateException("Unterminated string"); + } + + static int indexOfMatchingBracket(String s, int openIdx, char open, char close) { + int depth = 0; + boolean inString = false; + boolean escape = false; + for (int i = openIdx; i < s.length(); i++) { + char c = s.charAt(i); + if (escape) { + escape = false; + continue; + } + if (inString && c == '\\') { + escape = true; + continue; + } + if (c == '"') { + inString = !inString; + continue; + } + if (inString) { + continue; + } + if (c == open) { + depth++; + } else if (c == close) { + depth--; + if (depth == 0) { + return i; + } + } + } + throw new IllegalStateException("Unbalanced brackets"); + } + + static int skipWhitespace(String s, int index) { + while (index < s.length() && Character.isWhitespace(s.charAt(index))) { + index++; + } + return index; + } + + private static void checkNumberStringLength(String s) { + if (s.length() > MAX_NUMBER_STRING_LENGTH) { + throw new NumberFormatException("Number string too large"); + } + } + + private static BigDecimal parseBigDecimalLikeGson(String s) { + checkNumberStringLength(s); + BigDecimal decimal = new BigDecimal(s); + if (Math.abs((long) decimal.scale()) >= 10_000) { + throw new NumberFormatException("Number has unsupported scale"); + } + return decimal; + } } diff --git a/android/src/main/java/app/capgo/capacitor/fastsql/SQLHTTPServer.java b/android/src/main/java/app/capgo/capacitor/fastsql/SQLHTTPServer.java index 4edd986..098aaf9 100644 --- a/android/src/main/java/app/capgo/capacitor/fastsql/SQLHTTPServer.java +++ b/android/src/main/java/app/capgo/capacitor/fastsql/SQLHTTPServer.java @@ -7,6 +7,7 @@ import java.io.IOException; import java.nio.charset.StandardCharsets; import java.security.SecureRandom; +import java.util.List; import java.util.Map; import java.util.concurrent.ExecutionException; import java.util.concurrent.ExecutorService; @@ -120,7 +121,8 @@ private Response handleExecute(IHTTPSession session, DatabaseConnection db) thro JSONArray paramsJson = request.has("params") ? request.getJSONArray("params") : new JSONArray(); JSArray params = new JSArray(); - HttpRequestJsonParams.populateJsArrayFromParamsJson(paramsJson, params); + String rawParamsArray = request.has("params") ? HttpRequestJsonParams.extractJsonArrayAfterKey(body, "params") : null; + HttpRequestJsonParams.populateJsArrayFromParamsJson(paramsJson, params, rawParamsArray); // Execute query JSObject result = db.execute(statement, params); @@ -137,13 +139,17 @@ private Response handleBatch(IHTTPSession session, DatabaseConnection db) throws JSONArray operations = request.getJSONArray("operations"); JSONArray results = new JSONArray(); + String rawOperationsArray = HttpRequestJsonParams.extractJsonArrayAfterKey(body, "operations"); + List rawOperations = HttpRequestJsonParams.splitTopLevelJsonArrayElements(rawOperationsArray); for (int i = 0; i < operations.length(); i++) { JSONObject operation = operations.getJSONObject(i); String statement = operation.getString("statement"); JSONArray paramsJson = operation.has("params") ? operation.getJSONArray("params") : new JSONArray(); JSArray params = new JSArray(); - HttpRequestJsonParams.populateJsArrayFromParamsJson(paramsJson, params); + String rawOperation = rawOperations.get(i); + String rawParamsArray = operation.has("params") ? HttpRequestJsonParams.extractJsonArrayAfterKey(rawOperation, "params") : null; + HttpRequestJsonParams.populateJsArrayFromParamsJson(paramsJson, params, rawParamsArray); JSObject result = db.execute(statement, params); results.put(new JSONObject(result.toString())); diff --git a/android/src/test/java/app/capgo/capacitor/fastsql/HttpRequestJsonParamsTest.java b/android/src/test/java/app/capgo/capacitor/fastsql/HttpRequestJsonParamsTest.java index 2d01df5..07dab72 100644 --- a/android/src/test/java/app/capgo/capacitor/fastsql/HttpRequestJsonParamsTest.java +++ b/android/src/test/java/app/capgo/capacitor/fastsql/HttpRequestJsonParamsTest.java @@ -8,7 +8,6 @@ import org.json.JSONArray; import org.json.JSONException; import org.json.JSONObject; -import org.json.JSONTokener; import org.junit.Test; import org.junit.runner.RunWith; import org.robolectric.RobolectricTestRunner; @@ -33,13 +32,10 @@ public void convertRequestParam_truncatesDecimalLikeGsonGetAsLong() { } @Test - public void convertRequestParam_jsonObjectValuesUseGetAsStringSemantics() throws Exception { - JSONObject obj = new JSONObject("{\"s\":\"hello\",\"n\":7,\"b\":false}"); - - JSONObject out = (JSONObject) HttpRequestJsonParams.convertRequestParam(obj); + public void convertRequestParamFromRawJson_preservesObjectNumericLiterals() throws Exception { + JSONObject out = (JSONObject) HttpRequestJsonParams.convertRequestParamFromRawJson("{\"n\":1.0,\"s\":\"hello\"}", null); + assertEquals("1.0", out.getString("n")); assertEquals("hello", out.getString("s")); - assertEquals("7", out.getString("n")); - assertEquals("false", out.getString("b")); } @Test @@ -53,19 +49,19 @@ public void elementGetAsString_rejectsNull() { } @Test - public void gsonStyleLongFromNumberString_parsesIntegerAndTruncatesDecimal() { + public void gsonStyleLongFromNumberString_matchesGson214() { assertEquals(100L, HttpRequestJsonParams.gsonStyleLongFromNumberString("100")); assertEquals(3L, HttpRequestJsonParams.gsonStyleLongFromNumberString("3.14")); - assertEquals(Long.MAX_VALUE, HttpRequestJsonParams.gsonStyleLongFromNumberString("1e20")); + assertEquals(7766279631452241920L, HttpRequestJsonParams.gsonStyleLongFromNumberString("1e20")); } @Test - public void malformedRequestJson_throws() { + public void extractJsonArrayAfterKey_throwsOnMalformedBody() { try { - new JSONTokener("{").nextValue(); + HttpRequestJsonParams.extractJsonArrayAfterKey("{", "params"); assertTrue("expected JSONException", false); } catch (JSONException expected) { - // Same error path as invalid Gson bodies surfacing as request failures + // Same error path as invalid request bodies in SQLHTTPServer } } @@ -75,26 +71,34 @@ public void populateJsArrayFromParamsJson_skipsUnsupportedArrayElements() throws nested.put(1); assertNull(HttpRequestJsonParams.convertRequestParam(nested)); - JSONArray input = new JSONArray(); - JSONArray nestedInInput = new JSONArray(); - nestedInInput.put(1); - input.put(nestedInInput); - input.put("kept"); + String rawParams = "[ [1], \"kept\" ]"; + JSONArray input = new JSONArray(rawParams); JSArray params = new JSArray(); - HttpRequestJsonParams.populateJsArrayFromParamsJson(input, params); + HttpRequestJsonParams.populateJsArrayFromParamsJson(input, params, rawParams); assertEquals(1, params.length()); assertEquals("kept", params.get(0)); } @Test - public void convertRequestParam_rejectsNestedObjectFieldsLikeGson() throws Exception { - JSONObject obj = new JSONObject("{\"meta\":{\"a\":1}}"); + public void convertRequestParamFromRawJson_rejectsNestedObjectFieldsLikeGson() { try { - HttpRequestJsonParams.convertRequestParam(obj); + HttpRequestJsonParams.convertRequestParamFromRawJson("{\"meta\":{\"a\":1}}", null); assertTrue("expected IllegalStateException", false); } catch (IllegalStateException expected) { // Gson getAsString() on nested objects throws } } + + @Test + public void populateJsArrayFromParamsJson_usesRawNumberLiterals() throws Exception { + String rawParams = "[1e20]"; + JSONArray input = new JSONArray("[100000000000000000000]"); + + JSArray params = new JSArray(); + HttpRequestJsonParams.populateJsArrayFromParamsJson(input, params, rawParams); + + assertEquals(1, params.length()); + assertEquals(7766279631452241920L, params.get(0)); + } } From becda5dd4525b0e456a98a9c78d36e5b1e062fc8 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 1 Oct 2026 13:07:41 +0000 Subject: [PATCH 10/12] fix(android): scan JSON objects when extracting raw params arrays Co-authored-by: Martin DONADIEU --- .../fastsql/HttpRequestJsonParams.java | 49 +++++++++++++++---- .../fastsql/HttpRequestJsonParamsTest.java | 21 +++++++- 2 files changed, 58 insertions(+), 12 deletions(-) diff --git a/android/src/main/java/app/capgo/capacitor/fastsql/HttpRequestJsonParams.java b/android/src/main/java/app/capgo/capacitor/fastsql/HttpRequestJsonParams.java index ed60b58..0404f1c 100644 --- a/android/src/main/java/app/capgo/capacitor/fastsql/HttpRequestJsonParams.java +++ b/android/src/main/java/app/capgo/capacitor/fastsql/HttpRequestJsonParams.java @@ -37,17 +37,38 @@ static void populateJsArrayFromParamsJson(JSONArray paramsJson, JSArray params, } static String extractJsonArrayAfterKey(String json, String key) throws JSONException { - String search = "\"" + key + "\""; - int idx = json.indexOf(search); - if (idx < 0) { - throw new JSONException("No key: " + key); - } - idx = json.indexOf('[', idx + search.length()); - if (idx < 0) { - throw new JSONException("No array for key: " + key); + int i = skipWhitespace(json, 0); + if (i >= json.length() || json.charAt(i) != '{') { + throw new JSONException("Expected JSON object"); + } + i++; + while (i < json.length()) { + i = skipWhitespace(json, i); + if (json.charAt(i) == '}') { + throw new JSONException("No key: " + key); + } + int keyEnd = endOfJsonString(json, i); + String memberKey = parseQuotedStringToken(json, i, keyEnd); + i = skipWhitespace(json, keyEnd); + if (json.charAt(i) != ':') { + throw new JSONException("Expected ':' after object key"); + } + i = skipWhitespace(json, i + 1); + if (memberKey.equals(key)) { + i = skipWhitespace(json, i); + if (i >= json.length() || json.charAt(i) != '[') { + throw new JSONException("No array for key: " + key); + } + int end = indexOfMatchingBracket(json, i, '[', ']'); + return json.substring(i, end + 1); + } + i = endOfJsonValue(json, i); + i = skipWhitespace(json, i); + if (i < json.length() && json.charAt(i) == ',') { + i++; + } } - int end = indexOfMatchingBracket(json, idx, '[', ']'); - return json.substring(idx, end + 1); + throw new JSONException("No key: " + key); } /** @@ -348,6 +369,14 @@ static int skipWhitespace(String s, int index) { return index; } + static String parseQuotedStringToken(String json, int start, int end) throws JSONException { + try { + return (String) new JSONTokener(json.substring(start, end)).nextValue(); + } catch (JSONException e) { + throw new JSONException("Invalid object key", e); + } + } + private static void checkNumberStringLength(String s) { if (s.length() > MAX_NUMBER_STRING_LENGTH) { throw new NumberFormatException("Number string too large"); diff --git a/android/src/test/java/app/capgo/capacitor/fastsql/HttpRequestJsonParamsTest.java b/android/src/test/java/app/capgo/capacitor/fastsql/HttpRequestJsonParamsTest.java index 07dab72..ef4ce40 100644 --- a/android/src/test/java/app/capgo/capacitor/fastsql/HttpRequestJsonParamsTest.java +++ b/android/src/test/java/app/capgo/capacitor/fastsql/HttpRequestJsonParamsTest.java @@ -56,15 +56,32 @@ public void gsonStyleLongFromNumberString_matchesGson214() { } @Test - public void extractJsonArrayAfterKey_throwsOnMalformedBody() { + public void extractJsonArrayAfterKey_throwsWhenKeyMissing() { + try { + HttpRequestJsonParams.extractJsonArrayAfterKey("{}", "params"); + assertTrue("expected JSONException", false); + } catch (JSONException expected) { + // Missing params key + } + } + + @Test + public void extractJsonArrayAfterKey_throwsOnMalformedObject() { try { HttpRequestJsonParams.extractJsonArrayAfterKey("{", "params"); assertTrue("expected JSONException", false); } catch (JSONException expected) { - // Same error path as invalid request bodies in SQLHTTPServer + // Truncated JSON } } + @Test + public void extractJsonArrayAfterKey_ignoresParamsSubstringInsideStringValue() throws Exception { + String body = "{\"statement\":\"see \\\"params\\\": [99]\",\"params\":[42]}"; + String rawParams = HttpRequestJsonParams.extractJsonArrayAfterKey(body, "params"); + assertEquals("[42]", rawParams); + } + @Test public void populateJsArrayFromParamsJson_skipsUnsupportedArrayElements() throws Exception { JSONArray nested = new JSONArray(); From 5bc46db29834cea7dfdac3b784c1d12159f5c6e8 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 4 Oct 2026 22:09:54 +0000 Subject: [PATCH 11/12] fix(android): use last duplicate params array in raw JSON scan extractJsonArrayAfterKey now keeps scanning and returns the last matching array so raw literal extraction matches org.json duplicate-key semantics. Add unit test for duplicate params keys. Co-authored-by: Martin DONADIEU --- .../capacitor/fastsql/HttpRequestJsonParams.java | 13 +++++++++++-- .../fastsql/HttpRequestJsonParamsTest.java | 13 +++++++++++++ 2 files changed, 24 insertions(+), 2 deletions(-) diff --git a/android/src/main/java/app/capgo/capacitor/fastsql/HttpRequestJsonParams.java b/android/src/main/java/app/capgo/capacitor/fastsql/HttpRequestJsonParams.java index 0404f1c..d38140c 100644 --- a/android/src/main/java/app/capgo/capacitor/fastsql/HttpRequestJsonParams.java +++ b/android/src/main/java/app/capgo/capacitor/fastsql/HttpRequestJsonParams.java @@ -42,9 +42,13 @@ static String extractJsonArrayAfterKey(String json, String key) throws JSONExcep throw new JSONException("Expected JSON object"); } i++; + String found = null; while (i < json.length()) { i = skipWhitespace(json, i); if (json.charAt(i) == '}') { + if (found != null) { + return found; + } throw new JSONException("No key: " + key); } int keyEnd = endOfJsonString(json, i); @@ -60,14 +64,19 @@ static String extractJsonArrayAfterKey(String json, String key) throws JSONExcep throw new JSONException("No array for key: " + key); } int end = indexOfMatchingBracket(json, i, '[', ']'); - return json.substring(i, end + 1); + found = json.substring(i, end + 1); + i = end + 1; + } else { + i = endOfJsonValue(json, i); } - i = endOfJsonValue(json, i); i = skipWhitespace(json, i); if (i < json.length() && json.charAt(i) == ',') { i++; } } + if (found != null) { + return found; + } throw new JSONException("No key: " + key); } diff --git a/android/src/test/java/app/capgo/capacitor/fastsql/HttpRequestJsonParamsTest.java b/android/src/test/java/app/capgo/capacitor/fastsql/HttpRequestJsonParamsTest.java index ef4ce40..0cda231 100644 --- a/android/src/test/java/app/capgo/capacitor/fastsql/HttpRequestJsonParamsTest.java +++ b/android/src/test/java/app/capgo/capacitor/fastsql/HttpRequestJsonParamsTest.java @@ -82,6 +82,19 @@ public void extractJsonArrayAfterKey_ignoresParamsSubstringInsideStringValue() t assertEquals("[42]", rawParams); } + @Test + public void extractJsonArrayAfterKey_usesLastDuplicateKeyLikeJSONObject() throws Exception { + String body = "{\"params\":[1,2],\"params\":[\"a\"]}"; + String rawParams = HttpRequestJsonParams.extractJsonArrayAfterKey(body, "params"); + assertEquals("[\"a\"]", rawParams); + + JSONArray paramsJson = new JSONObject(body).getJSONArray("params"); + JSArray params = new JSArray(); + HttpRequestJsonParams.populateJsArrayFromParamsJson(paramsJson, params, rawParams); + assertEquals(1, params.length()); + assertEquals("a", params.get(0)); + } + @Test public void populateJsArrayFromParamsJson_skipsUnsupportedArrayElements() throws Exception { JSONArray nested = new JSONArray(); From cb0fd88dfc86d0e12ee354893f6920bd613f94c7 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 4 Oct 2026 23:27:39 +0000 Subject: [PATCH 12/12] fix(android): honor last duplicate params value type in raw scan When duplicate keys include non-array values, skip them and let the final match decide, matching org.json. Reject when the last match is not an array. Co-authored-by: Martin DONADIEU --- .../fastsql/HttpRequestJsonParams.java | 20 ++++++++++++++----- .../fastsql/HttpRequestJsonParamsTest.java | 16 +++++++++++++++ 2 files changed, 31 insertions(+), 5 deletions(-) diff --git a/android/src/main/java/app/capgo/capacitor/fastsql/HttpRequestJsonParams.java b/android/src/main/java/app/capgo/capacitor/fastsql/HttpRequestJsonParams.java index d38140c..1f0e20e 100644 --- a/android/src/main/java/app/capgo/capacitor/fastsql/HttpRequestJsonParams.java +++ b/android/src/main/java/app/capgo/capacitor/fastsql/HttpRequestJsonParams.java @@ -43,12 +43,16 @@ static String extractJsonArrayAfterKey(String json, String key) throws JSONExcep } i++; String found = null; + boolean sawMatchingKey = false; while (i < json.length()) { i = skipWhitespace(json, i); if (json.charAt(i) == '}') { if (found != null) { return found; } + if (sawMatchingKey) { + throw new JSONException("No array for key: " + key); + } throw new JSONException("No key: " + key); } int keyEnd = endOfJsonString(json, i); @@ -59,13 +63,16 @@ static String extractJsonArrayAfterKey(String json, String key) throws JSONExcep } i = skipWhitespace(json, i + 1); if (memberKey.equals(key)) { + sawMatchingKey = true; i = skipWhitespace(json, i); - if (i >= json.length() || json.charAt(i) != '[') { - throw new JSONException("No array for key: " + key); + if (i < json.length() && json.charAt(i) == '[') { + int end = indexOfMatchingBracket(json, i, '[', ']'); + found = json.substring(i, end + 1); + i = end + 1; + } else { + found = null; + i = endOfJsonValue(json, i); } - int end = indexOfMatchingBracket(json, i, '[', ']'); - found = json.substring(i, end + 1); - i = end + 1; } else { i = endOfJsonValue(json, i); } @@ -77,6 +84,9 @@ static String extractJsonArrayAfterKey(String json, String key) throws JSONExcep if (found != null) { return found; } + if (sawMatchingKey) { + throw new JSONException("No array for key: " + key); + } throw new JSONException("No key: " + key); } diff --git a/android/src/test/java/app/capgo/capacitor/fastsql/HttpRequestJsonParamsTest.java b/android/src/test/java/app/capgo/capacitor/fastsql/HttpRequestJsonParamsTest.java index 0cda231..458eb52 100644 --- a/android/src/test/java/app/capgo/capacitor/fastsql/HttpRequestJsonParamsTest.java +++ b/android/src/test/java/app/capgo/capacitor/fastsql/HttpRequestJsonParamsTest.java @@ -95,6 +95,22 @@ public void extractJsonArrayAfterKey_usesLastDuplicateKeyLikeJSONObject() throws assertEquals("a", params.get(0)); } + @Test + public void extractJsonArrayAfterKey_usesLastDuplicateWhenEarlierValueNotArray() throws Exception { + String body = "{\"params\":null,\"params\":[1]}"; + assertEquals("[1]", HttpRequestJsonParams.extractJsonArrayAfterKey(body, "params")); + } + + @Test + public void extractJsonArrayAfterKey_throwsWhenLastDuplicateValueNotArray() { + try { + HttpRequestJsonParams.extractJsonArrayAfterKey("{\"params\":[1],\"params\":null}", "params"); + assertTrue("expected JSONException", false); + } catch (JSONException expected) { + assertTrue(expected.getMessage().contains("No array for key")); + } + } + @Test public void populateJsArrayFromParamsJson_skipsUnsupportedArrayElements() throws Exception { JSONArray nested = new JSONArray();