diff --git a/.github/cspell.json b/.github/cspell.json index bdf826f..d1b3bf3 100644 --- a/.github/cspell.json +++ b/.github/cspell.json @@ -1,7 +1,10 @@ { "version": "0.2", "$schema": "https://raw.githubusercontent.com/streetsidesoftware/cspell/main/cspell.schema.json", - "dictionaries": ["vgv_allowed", "vgv_forbidden"], + "dictionaries": [ + "vgv_allowed", + "vgv_forbidden" + ], "dictionaryDefinitions": [ { "name": "vgv_allowed", @@ -16,6 +19,13 @@ ], "useGitignore": true, "words": [ - "fluttium" + "androidx", + "fluttium", + "inspectable", + "javascriptengine", + "jetpack", + "jscore", + "logcat", + "thenables" ] } diff --git a/.github/dependabot.yaml b/.github/dependabot.yaml index 43c7ecf..d5088df 100644 --- a/.github/dependabot.yaml +++ b/.github/dependabot.yaml @@ -1,43 +1,21 @@ version: 2 -enable-beta-ecosystems: true updates: - package-ecosystem: "github-actions" directory: "/" schedule: - interval: "daily" + interval: "weekly" - package-ecosystem: "pub" - directory: "/javascript" + directories: + - "/packages/javascript" + - "/packages/javascript/example" + - "/packages/javascript_platform_interface" + - "/packages/javascript_android" + - "/packages/javascript_darwin" schedule: - interval: "daily" - - package-ecosystem: "pub" - directory: "/javascript/example" - schedule: - interval: "daily" - - package-ecosystem: "pub" - directory: "/javascript_platform_interface" - schedule: - interval: "daily" - - package-ecosystem: "pub" - directory: "/javascript_android" - schedule: - interval: "daily" - - package-ecosystem: "pub" - directory: "/javascript_ios" - schedule: - interval: "daily" - - package-ecosystem: "pub" - directory: "/javascript_linux" - schedule: - interval: "daily" - - package-ecosystem: "pub" - directory: "/javascript_macos" - schedule: - interval: "daily" - - package-ecosystem: "pub" - directory: "/javascript_web" - schedule: - interval: "daily" - - package-ecosystem: "pub" - directory: "/javascript_windows" + interval: "weekly" + - package-ecosystem: "gradle" + directories: + - "/packages/javascript_android/android" + - "/packages/javascript/example/android" schedule: - interval: "daily" \ No newline at end of file + interval: "weekly" diff --git a/.github/workflows/ci-diag.yaml b/.github/workflows/ci-diag.yaml new file mode 100644 index 0000000..9a48fb5 --- /dev/null +++ b/.github/workflows/ci-diag.yaml @@ -0,0 +1,54 @@ +name: ci-diag + +on: + push: + branches: + - 'ci-diag/**' + workflow_dispatch: + +jobs: + android: + strategy: + fail-fast: false + matrix: + include: + - api-level: "37.0" + target: google_apis + - api-level: "37.1" + target: google_apis_ps16k + - api-level: "37.2" + target: google_apis_ps16k + name: android-${{ matrix.api-level }}-${{ matrix.target }} + runs-on: ubuntu-latest + timeout-minutes: 40 + defaults: + run: + working-directory: packages/javascript/example + steps: + - uses: actions/checkout@v5 + - uses: actions/setup-java@v5 + with: + distribution: temurin + java-version: "17" + - uses: subosito/flutter-action@v2 + with: + channel: stable + cache: true + - run: flutter pub get + - name: Enable KVM + run: | + echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules + sudo udevadm control --reload-rules + sudo udevadm trigger --name-match=kvm + - name: Integration tests (emulator) + uses: reactivecircus/android-emulator-runner@v2 + with: + api-level: ${{ matrix.api-level }} + target: ${{ matrix.target }} + arch: x86_64 + emulator-options: -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none + disable-animations: true + working-directory: packages/javascript/example + script: | + adb shell dumpsys webviewupdate | grep -iE 'Current WebView package|versionName' | head -3 + flutter test integration_test/android_transports_test.dart -d emulator-5554 diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 112f425..f221b0c 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -1,10 +1,148 @@ name: ci +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + on: pull_request: branches: - main + push: + branches: + - main jobs: - build: + semantic-pull-request: + if: github.event_name == 'pull_request' uses: VeryGoodOpenSource/very_good_workflows/.github/workflows/semantic_pull_request.yml@v1 + + spell-check: + uses: VeryGoodOpenSource/very_good_workflows/.github/workflows/spell_check.yml@v1 + with: + includes: | + **/*.md + !**/third_party/** + modified_files_only: false + + packages: + name: ${{ matrix.package }} + strategy: + fail-fast: false + matrix: + include: + - package: javascript + os: ubuntu-latest + - package: javascript_platform_interface + os: ubuntu-latest + - package: javascript_android + os: ubuntu-latest + # The darwin tests exercise the real JavaScriptCore runtime, which + # only exists on macOS hosts. + - package: javascript_darwin + os: macos-latest + runs-on: ${{ matrix.os }} + timeout-minutes: 15 + defaults: + run: + working-directory: packages/${{ matrix.package }} + steps: + - uses: actions/checkout@v5 + - uses: subosito/flutter-action@v2 + with: + channel: stable + cache: true + - run: flutter pub get + - run: dart format --output=none --set-exit-if-changed . + - run: flutter analyze + - run: flutter test + + example-android: + runs-on: ubuntu-latest + timeout-minutes: 40 + defaults: + run: + working-directory: packages/javascript/example + steps: + - uses: actions/checkout@v5 + - uses: actions/setup-java@v5 + with: + distribution: temurin + java-version: "17" + - uses: subosito/flutter-action@v2 + with: + channel: stable + cache: true + - run: flutter pub get + - run: flutter analyze + - run: flutter test + - run: flutter build apk --debug + + - name: Enable KVM + run: | + echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules + sudo udevadm control --reload-rules + sudo udevadm trigger --name-match=kvm + + - name: Integration tests (emulator) + uses: reactivecircus/android-emulator-runner@v2 + with: + # A recent image so the WebView is new enough for message ports and + # promise results; the transport test skips what the device lacks. + api-level: 36 + target: google_apis + arch: x86_64 + emulator-options: -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none + disable-animations: true + working-directory: packages/javascript/example + script: flutter test integration_test -d emulator-5554 + + example-ios: + runs-on: macos-latest + timeout-minutes: 30 + defaults: + run: + working-directory: packages/javascript/example + steps: + - uses: actions/checkout@v5 + - uses: subosito/flutter-action@v2 + with: + channel: stable + cache: true + - run: flutter pub get + + - name: Start simulator + id: simulator + run: | + UDID=$(xcrun simctl list devices available -j | python3 -c 'import json,sys; d=json.load(sys.stdin)["devices"]; print(next(x["udid"] for k in sorted(d, reverse=True) for x in d[k] if x["name"].startswith("iPhone")))') + echo "udid=$UDID" >> "$GITHUB_OUTPUT" + xcrun simctl boot "$UDID" + xcrun simctl bootstatus "$UDID" -b + # A simulator booted only via simctl never surfaces the app's + # "Dart VM Service is listening" log line on the hosted macOS + # runners, so `flutter test -d` waits forever after the Xcode + # build. Opening Simulator.app on the booted device fixes it. + open -a Simulator --args -CurrentDeviceUDID "$UDID" + sleep 10 + + - name: Integration tests (simulator) + # android_transports_test.dart is Android-only; launching a second app + # in one run is also flaky on Apple targets, so run the contract file. + run: flutter test integration_test/javascript_test.dart -d ${{ steps.simulator.outputs.udid }} + + example-macos: + runs-on: macos-latest + timeout-minutes: 20 + defaults: + run: + working-directory: packages/javascript/example + steps: + - uses: actions/checkout@v5 + - uses: subosito/flutter-action@v2 + with: + channel: stable + cache: true + - run: flutter config --enable-macos-desktop + - run: flutter pub get + - name: Integration tests (macOS desktop) + run: flutter test integration_test/javascript_test.dart -d macos diff --git a/.github/workflows/javascript.yaml b/.github/workflows/javascript.yaml deleted file mode 100644 index 1c6e601..0000000 --- a/.github/workflows/javascript.yaml +++ /dev/null @@ -1,182 +0,0 @@ -name: javascript - -concurrency: - group: ${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true - -on: - pull_request: - paths: - - ".github/workflows/javascript.yaml" - - "javascript/**" - push: - branches: - - main - paths: - - ".github/workflows/javascript.yaml" - - "javascript/**" - -jobs: - spell-check: - uses: VeryGoodOpenSource/very_good_workflows/.github/workflows/spell_check.yml@v1 - with: - includes: | - **/*.md - !brick/**/*.md - .*/**/*.md - modified_files_only: false - - build: - uses: VeryGoodOpenSource/very_good_workflows/.github/workflows/flutter_package.yml@v1 - with: - flutter_channel: stable - flutter_version: 3.32.0 - working_directory: javascript - - - android: - runs-on: macos-latest - - defaults: - run: - working-directory: javascript/example - - steps: - - uses: actions/checkout@v5 - - uses: actions/setup-java@v5 - with: - distribution: "temurin" - java-version: "11" - - - uses: subosito/flutter-action@v2 - - - name: Install Fluttium - run: flutter pub global activate fluttium_cli - - - name: AVD Cache - uses: actions/cache@v4 - id: avd-cache - with: - path: | - ~/.android/avd/* - ~/.android/adb* - key: avd-29 - - - name: Cache AVD Snapshot - if: steps.avd-cache.outputs.cache-hit != 'true' - uses: reactivecircus/android-emulator-runner@v2 - with: - api-level: 29 - force-avd-creation: false - emulator-options: -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none - disable-animations: false - script: echo "Generated AVD snapshot for caching." - - - name: E2E Tests - uses: reactivecircus/android-emulator-runner@v2 - with: - api-level: 29 - script: fluttium test flows/test_platform_name.yaml -d android - working-directory: javascript/example - - ios: - runs-on: macos-latest - - defaults: - run: - working-directory: javascript/example - - steps: - - uses: actions/checkout@v5 - - - uses: subosito/flutter-action@v2 - - - name: Install Fluttium - run: flutter pub global activate fluttium_cli - - - name: Start Simulator - # Start an iPhone simulator - run: | - UDID=$(xcrun xctrace list devices | grep "^iPhone" | awk '{gsub(/[()]/,""); print $NF}' | head -n 1) - echo $UDID - xcrun simctl boot "${UDID:?No Simulator with this name found}" - - - name: E2E Tests - run: fluttium test flows/test_platform_name.yaml -d iPhone - - linux: - runs-on: ubuntu-latest - if: false # todo(renancaraujo): https://github.com/wolfenrain/fluttium/issues/345 - defaults: - run: - working-directory: javascript/example - - steps: - - uses: actions/checkout@v5 - - - uses: subosito/flutter-action@v2 - - - name: Install Dependencies - run: | - sudo apt-get update - sudo apt-get install -y libgtk-3-dev libx11-dev pkg-config cmake ninja-build libblkid-dev liblzma-dev - - - name: Install Fluttium - run: flutter pub global activate fluttium_cli - - - name: E2E Tests - run: xvfb-run fluttium test flows/test_platform_name.yaml -d linux - - macos: - runs-on: macos-13 - - defaults: - run: - working-directory: javascript/example - - steps: - - uses: actions/checkout@v5 - - - uses: subosito/flutter-action@v2 - - - name: Install Fluttium - run: flutter pub global activate fluttium_cli - - - name: E2E Tests - run: fluttium test flows/test_platform_name.yaml -d macos - - web: - runs-on: ubuntu-latest - - defaults: - run: - working-directory: javascript/example - - steps: - - uses: actions/checkout@v5 - - - uses: subosito/flutter-action@v2 - - - name: Install Fluttium - run: flutter pub global activate fluttium_cli - - - name: E2E Tests - run: xvfb-run fluttium test flows/test_platform_name.yaml -d chrome - - windows: - runs-on: windows-2019 - - defaults: - run: - working-directory: javascript/example - - steps: - - uses: actions/checkout@v5 - - - uses: subosito/flutter-action@v2 - - - name: Install Fluttium - run: flutter pub global activate fluttium_cli - - - name: E2E Tests - run: fluttium test flows/test_platform_name.yaml -d windows diff --git a/.github/workflows/javascript_android.yaml b/.github/workflows/javascript_android.yaml deleted file mode 100644 index 612d1cb..0000000 --- a/.github/workflows/javascript_android.yaml +++ /dev/null @@ -1,26 +0,0 @@ -name: javascript_android - -concurrency: - group: ${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true - -on: - pull_request: - paths: - - ".github/workflows/javascript_android.yaml" - - "javascript_android/**" - push: - branches: - - main - paths: - - ".github/workflows/javascript_android.yaml" - - "javascript_android/**" - -jobs: - build: - uses: VeryGoodOpenSource/very_good_workflows/.github/workflows/flutter_package.yml@v1 - with: - flutter_channel: stable - flutter_version: 3.32.0 - working_directory: javascript_android - diff --git a/.github/workflows/javascript_ios.yaml b/.github/workflows/javascript_ios.yaml deleted file mode 100644 index 2b629db..0000000 --- a/.github/workflows/javascript_ios.yaml +++ /dev/null @@ -1,26 +0,0 @@ -name: javascript_ios - -concurrency: - group: ${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true - -on: - pull_request: - paths: - - ".github/workflows/javascript_ios.yaml" - - "javascript_ios/**" - push: - branches: - - main - paths: - - ".github/workflows/javascript_ios.yaml" - - "javascript_ios/**" - -jobs: - build: - uses: VeryGoodOpenSource/very_good_workflows/.github/workflows/flutter_package.yml@v1 - with: - flutter_channel: stable - flutter_version: 3.32.0 - working_directory: javascript_ios - diff --git a/.github/workflows/javascript_linux.yaml b/.github/workflows/javascript_linux.yaml deleted file mode 100644 index 8808ed0..0000000 --- a/.github/workflows/javascript_linux.yaml +++ /dev/null @@ -1,26 +0,0 @@ -name: javascript_linux - -concurrency: - group: ${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true - -on: - pull_request: - paths: - - ".github/workflows/javascript_linux.yaml" - - "javascript_linux/**" - push: - branches: - - main - paths: - - ".github/workflows/javascript_linux.yaml" - - "javascript_linux/**" - -jobs: - build: - uses: VeryGoodOpenSource/very_good_workflows/.github/workflows/flutter_package.yml@v1 - with: - flutter_channel: stable - flutter_version: 3.32.0 - working_directory: javascript_linux - diff --git a/.github/workflows/javascript_macos.yaml b/.github/workflows/javascript_macos.yaml deleted file mode 100644 index 1661421..0000000 --- a/.github/workflows/javascript_macos.yaml +++ /dev/null @@ -1,26 +0,0 @@ -name: javascript_macos - -concurrency: - group: ${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true - -on: - pull_request: - paths: - - ".github/workflows/javascript_macos.yaml" - - "javascript_macos/**" - push: - branches: - - main - paths: - - ".github/workflows/javascript_macos.yaml" - - "javascript_macos/**" - -jobs: - build: - uses: VeryGoodOpenSource/very_good_workflows/.github/workflows/flutter_package.yml@v1 - with: - flutter_channel: stable - flutter_version: 3.32.0 - working_directory: javascript_macos - diff --git a/.github/workflows/javascript_platform_interface.yaml b/.github/workflows/javascript_platform_interface.yaml deleted file mode 100644 index e17a158..0000000 --- a/.github/workflows/javascript_platform_interface.yaml +++ /dev/null @@ -1,26 +0,0 @@ -name: javascript_platform_interface - -concurrency: - group: ${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true - -on: - pull_request: - paths: - - ".github/workflows/javascript_platform_interface.yaml" - - "javascript_platform_interface/**" - push: - branches: - - main - paths: - - ".github/workflows/javascript_platform_interface.yaml" - - "javascript_platform_interface/**" - -jobs: - build: - uses: VeryGoodOpenSource/very_good_workflows/.github/workflows/flutter_package.yml@v1 - with: - flutter_channel: stable - flutter_version: 3.32.0 - working_directory: javascript_platform_interface - diff --git a/.github/workflows/javascript_web.yaml b/.github/workflows/javascript_web.yaml deleted file mode 100644 index f3e84d6..0000000 --- a/.github/workflows/javascript_web.yaml +++ /dev/null @@ -1,26 +0,0 @@ -name: javascript_web - -concurrency: - group: ${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true - -on: - pull_request: - paths: - - ".github/workflows/javascript_web.yaml" - - "javascript_web/**" - push: - branches: - - main - paths: - - ".github/workflows/javascript_web.yaml" - - "javascript_web/**" - -jobs: - build: - uses: VeryGoodOpenSource/very_good_workflows/.github/workflows/flutter_package.yml@v1 - with: - flutter_channel: stable - flutter_version: 3.32.0 - working_directory: javascript_web - diff --git a/.github/workflows/javascript_windows.yaml b/.github/workflows/javascript_windows.yaml deleted file mode 100644 index 507bf4f..0000000 --- a/.github/workflows/javascript_windows.yaml +++ /dev/null @@ -1,26 +0,0 @@ -name: javascript_windows - -concurrency: - group: ${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true - -on: - pull_request: - paths: - - ".github/workflows/javascript_windows.yaml" - - "javascript_windows/**" - push: - branches: - - main - paths: - - ".github/workflows/javascript_windows.yaml" - - "javascript_windows/**" - -jobs: - build: - uses: VeryGoodOpenSource/very_good_workflows/.github/workflows/flutter_package.yml@v1 - with: - flutter_channel: stable - flutter_version: 3.32.0 - working_directory: javascript_windows - diff --git a/packages/javascript/.gitignore b/packages/javascript/.gitignore deleted file mode 100644 index 671e1c9..0000000 --- a/packages/javascript/.gitignore +++ /dev/null @@ -1 +0,0 @@ -pubspec_overrides.yaml diff --git a/packages/javascript/CHANGELOG.md b/packages/javascript/CHANGELOG.md index 1a5be17..d78ad7c 100644 --- a/packages/javascript/CHANGELOG.md +++ b/packages/javascript/CHANGELOG.md @@ -1,3 +1,30 @@ +# 1.3.0 + +Fixes [#11](https://github.com/predatorx7/javascript_flutter/issues/11). + +**Behavior change: `runJavaScriptReturningResult` and `runJavaScriptFromFileReturningResult` now follow one string-in, string-out contract on every platform.** + +- The value the script evaluates to (its completion value, i.e. the last expression statement; no top level `return`), or the value a returned `Promise` resolves to, is returned to Dart as a `String` exactly as-is. There is no automatic JSON decoding, number or boolean parsing, or type detection. `""` is `""`. +- If that value is not a JavaScript string (number, boolean, `null`, `undefined`, object, array, bigint, function, `NaN`, `Infinity`, a script ending in a declaration, an empty script, ...) the `Future` resolves to the empty string `''` on every platform. +- Promises are awaited (on Android this needs a WebView with `JS_FEATURE_PROMISE_RETURN`; WebViews without it resolve a Promise result to `''`). A rejected promise, a thrown error, or a parse failure throws a `JavaScriptExecutionException` carrying the rejection reason or the JavaScript error message. +- The declared return type stays `Future` for API compatibility; it always resolves to a `String` and may be narrowed to `Future` in a future major version. +- Channels: `sendMessage(channelName, message)` takes a string and the Dart handler receives it verbatim in `JavaScriptMessage.message`; `JavaScriptReply.message` is delivered to JavaScript verbatim as the promise's resolved string; calling `sendMessage` with an unregistered channel name throws in JavaScript. + +**What changed and how to migrate** + +- Previously Android auto-decoded results: the JS string `"42"` became Dart `int 42`, `"true"` became a `bool`, and `'{"a":1}'` became a `Map`. Darwin returned typed values for numbers, booleans and objects and double-encoded promise results. Now both platforms return the string verbatim and non-strings become `''`. +- Where you relied on auto-decoding, wrap the result with `jsonDecode(result as String)`. +- Where you relied on typed results, end your script with `JSON.stringify(...)` and decode it with `jsonDecode` in Dart. +- Root cause of #11: Android's `androidx.javascriptengine` `evaluateJavaScriptAsync` only returns strings and coerces every other type to `""`, which the plugin passed through, while Darwin had its own inconsistent typed conversion. + +**Other changes** + +- Android now uses message ports for channel communication (see `javascript_android` 2.2.0). +- Modernized toolchain and CI. Requires Flutter 3.44 or newer; Android requires `minSdk` 26 and builds with Java 17. +- `JavaScriptReply(message: null)` resolves the JavaScript promise with `null` on both platforms (Android previously delivered the string `"null"`), and passing a non-string to `sendMessage` throws on both platforms. +- Update dependency of `javascript_platform_interface` to `^2.1.0`, `javascript_android` to `^2.2.0`, and `javascript_darwin` to `^2.1.0`. +- Add unit tests for the public `JavaScript` API. + # 1.2.0 - Deprecate `engineId` and introduce `instanceId` as an alternative diff --git a/packages/javascript/README.md b/packages/javascript/README.md index 21359d1..3412691 100644 --- a/packages/javascript/README.md +++ b/packages/javascript/README.md @@ -23,7 +23,7 @@ For applications requiring non-interactive JavaScript evaluation, using this Jav The Android plugin [javascript_android](https://pub.dev/packages/javascript_android) depends on [Android Jetpack JavaScript Engine](https://developer.android.com/reference/kotlin/androidx/javascriptengine) library for its [JavaScriptIsolate](https://developer.android.com/reference/kotlin/androidx/javascriptengine/JavaScriptIsolate) API. -The implementation uses method channel for communication generated with [pub.dev:pigeon](https://pub.dev/packages/pigeon) library. +The implementation uses method channel (and message ports for channel messages) for communication generated with [pub.dev:pigeon](https://pub.dev/packages/pigeon) library. ### iOS/MacOS @@ -31,6 +31,51 @@ The iOS, & MacOS plugin [javascript_darwin](https://pub.dev/packages/javascript_ The implementation uses FFI for communication, taken from [pub.dev:flutter_js](https://pub.dev/packages/flutter_js), and [pub.dev:flutter_jscore](https://pub.dev/packages/flutter_jscore) flutter packages (With some additional improvements). +## Requirements + +- **Flutter**: 3.44 or newer (the Android plugin builds with the Flutter 3.44+ Gradle integration and Java 17). +- **Android**: `minSdk` 26 or newer (required by `androidx.javascriptengine`), and a device whose WebView/Chromium supports the JavaScript sandbox feature. Awaiting a `Promise` result additionally needs a WebView with `JS_FEATURE_PROMISE_RETURN`; on WebViews without it a script that evaluates to a `Promise` resolves to `''` and a warning is logged. +- **iOS/MacOS**: A platform version that supports JavaScriptCore (see [javascript_darwin](https://pub.dev/packages/javascript_darwin)). + +## Return values: string in, string out + +`runJavaScriptReturningResult` and `runJavaScriptFromFileReturningResult` follow one rule on every platform: + +1. The value the script evaluates to (its completion value, i.e. the last expression statement; no `return` at top level), or the value a returned `Promise` resolves to, is returned to Dart as a `String` **exactly as-is**. No automatic JSON decoding, no number/boolean parsing, no type detection. `""` is `""`. +2. If that value is **not a JS string** (number, boolean, `null`, `undefined`, object, array, bigint, function, `NaN`, `Infinity`, a script ending in a declaration, an empty script, ...), the `Future` resolves to the empty string `''` on every platform. +3. Promises are awaited (on Android this needs a WebView with `JS_FEATURE_PROMISE_RETURN`, see Requirements). A rejected promise throws a `JavaScriptExecutionException` carrying the rejection reason; a script that throws or fails to parse throws a `JavaScriptExecutionException` carrying the JS error message. +4. The declared return type stays `Future` for API compatibility, but it always resolves to a `String`. It may be narrowed to `Future` in a future major version. +5. Channels: `sendMessage(channelName, message)` in JS takes a string and the Dart handler receives it verbatim in `JavaScriptMessage.message`; `JavaScriptReply.message` is delivered to JS verbatim as the promise's resolved string; calling `sendMessage` with an unregistered channel name throws in JS. + +To get a number or an object back, end your script with `JSON.stringify(...)` and call `jsonDecode` on the Dart side: + +```dart +final result = await javascript.runJavaScriptReturningResult( + 'JSON.stringify({total: 1 + 2, items: [1, 2, 3]})', +); +final data = jsonDecode(result as String) as Map; +print(data['total']); // 3 + +// Promises work the same way. +final fromPromise = await javascript.runJavaScriptReturningResult( + 'Promise.resolve().then(() => JSON.stringify([1, 2, 3]))', +); +print(jsonDecode(fromPromise as String)); // [1, 2, 3] + +// A non-string value becomes an empty string. +print(await javascript.runJavaScriptReturningResult('2 + 2')); // '' +``` + +If you want "return the last expression, whatever its type" behavior, implement it in your own JavaScript. This package deliberately does not parse or wrap your script (scripts can be multiple megabytes). + +> **Behavior change in 1.3.0 (fixes [#11](https://github.com/predatorx7/javascript_flutter/issues/11))** +> +> Previously, Android auto-decoded results (a JS string `"42"` became Dart `int 42`, `"true"` became a `bool`, `'{"a":1}'` became a `Map`), and Darwin returned typed values for numbers, booleans and objects and double-encoded promise results. Now both platforms return the string verbatim and non-strings become `''`. +> +> **Migration:** wrap with `jsonDecode(result as String)` where you relied on auto-decoding, and end scripts with `JSON.stringify(...)` where you relied on typed results. +> +> Root cause: Android's `androidx.javascriptengine` `evaluateJavaScriptAsync` only returns strings and coerces every other type to `""`, which the plugin passed through, while Darwin had its own inconsistent typed conversion. + ## Using The easiest way to use this library is via the high-level interface defined by [JavaScript] class. @@ -60,17 +105,21 @@ void example() async { const sum = (a, b) => a + b; /// Send a message to the platform and return the result - const platformSum = (a, b) => sendMessage('Sum', JSON.stringify({a: 1, b: 2})).then(result => JSON.parse(result)); + const platformSum = (a, b) => sendMessage('Sum', JSON.stringify({a, b})).then(result => JSON.parse(result)); const addSum = async (a, b) => { return sum(a, b) + (await platformSum(a, b)); } - /// Return the result as a string or a promise of a string. + /// The result of the last expression (or the value of a returned Promise) is + /// handed to Dart. It must be a string: use JSON.stringify for anything else. /// No return statement should be used at this top level evaluation. - addSum(1, 2).then(result => result.toString()); + addSum(1, 2).then(result => JSON.stringify({sum: result})); '''); - print(result); + + /// The result is always a String. Decode it yourself. + final data = jsonDecode(result as String) as Map; + print(data['sum']); // 6 /// Dispose of the javascript environment to clear up resources await javascript.dispose(); @@ -182,39 +231,41 @@ Evaluates the given JavaScript code in the context of the current JavaScript ins **Parameters:** - `javaScript`: The JavaScript code to evaluate -**Returns:** `Future` - The result of the JavaScript evaluation +**Returns:** `Future` - always a `String` (see [Return values](#return-values-string-in-string-out)) **Behavior:** -The method has specific behavior based on the output of the JavaScript expression: - -1. **JSON String or Promise of JSON String**: Returns a `Map`, `List`, `String`, `num`, `bool`, or `null` if the string can be decoded as JSON, otherwise returns the string. -2. **Other Data Types**: Returns an empty `String` on some platforms. -3. **JavaScript Error**: The Future completes with an error. +1. **String or Promise of a String**: the string is returned exactly as-is, with no JSON decoding or type detection. `""` is `""`. +2. **Any other value** (number, boolean, `null`, `undefined`, object, array, ...): the empty string `''` on every platform. Use `JSON.stringify(...)` in JavaScript and `jsonDecode` in Dart for structured data. +3. **JavaScript error, parse failure or rejected Promise**: the Future completes with a `JavaScriptExecutionException`. Global variables set by one evaluation are visible for later evaluations, similar to adding multiple `